Artifact 6c8e0635234581e716bd99cf5c1e00662c4d5143f5618382a28e4a9da109a565:
- File
cmd/quarry/main.go
— part of check-in
[2fd88bc87a]
at
2026-08-21 19:17:01
on branch quarry-forum
— forum votes: OLTP vote store, forum.vote op, arrows + derived scores
Score is always derived from vote rows (no counter); votes are forge-local 0001 §8 state, never synced. Umbrella plan: ticket aa351dec. (user: dmestas, size: 44560) [annotate] [blame] [check-ins using]
// quarry is a multi-repository forge for zit. See docs/rfcs/. package main import ( "context" "craftdesign.group/quarry/internal/errlog" "encoding/json" "errors" "flag" "fmt" "log" "net/http" "os" "path/filepath" "regexp" "sort" "strings" "sync" "time" "craftdesign.group/quarry/internal/api" "craftdesign.group/quarry/internal/archive" "craftdesign.group/quarry/internal/chat" "craftdesign.group/quarry/internal/ci" "craftdesign.group/quarry/internal/events" "craftdesign.group/quarry/internal/index" "craftdesign.group/quarry/internal/ingest" "craftdesign.group/quarry/internal/lease" "craftdesign.group/quarry/internal/notify" "craftdesign.group/quarry/internal/oltp" "craftdesign.group/quarry/internal/ops" "craftdesign.group/quarry/internal/web" "craftdesign.group/quarry/internal/webhook" "craftdesign.group/quarry/internal/work" "craftdesign.group/quarry/internal/xfer" "craftdesign.group/quarry/internal/zitcli" ) // version is stamped at build time; every page footer and the API report it // (0001 Principle 1). var version = "0.0.0-dev" func main() { if len(os.Args) > 1 { switch os.Args[1] { case "version": fmt.Println("quarry", version) return case "spec": if err := printSpec(os.Stdout); err != nil { fmt.Fprintln(os.Stderr, "quarry:", err) os.Exit(1) } return case "serve": if err := serveCmd(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "quarry:", err) os.Exit(1) } return case "reindex": if err := reindexCmd(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "quarry:", err) os.Exit(1) } return case "docs": r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: version}); err != nil { fmt.Fprintln(os.Stderr, "quarry:", err) os.Exit(1) } os.Stdout.Write(ops.Docs(r, "quarry")) return case "user": if err := userCmd(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "quarry:", err) os.Exit(1) } return case "webhook": if err := webhookCmd(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "quarry:", err) os.Exit(1) } return case "accounts": if err := accountAPICmd(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "quarry:", err) os.Exit(1) } return case "repo": if err := repoCmd(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "quarry:", err) os.Exit(1) } return case "account": if err := accountCmd(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "quarry:", err) os.Exit(1) } return } } fmt.Fprintln(os.Stderr, "usage: quarry version|spec|serve|reindex|repo|accounts|user|account|webhook|docs") os.Exit(2) } // userCmd manages sync users (0001 §7 v1 auth): `quarry user add` // derives the RFC §6.3 shared secret from the store's project code and // stores it in forge OLTP — plaintext is never persisted. A repo with // at least one sync user requires authenticated pushes. func userCmd(args []string) error { if len(args) < 1 || args[0] != "add" { return fmt.Errorf("usage: quarry user add --repo name=path --user U [--password P] [--data DIR]") } fs := flag.NewFlagSet("user add", flag.ContinueOnError) repos := repoFlags{} fs.Var(repos, "repo", "repository as name=path") user := fs.String("user", "", "sync login name") password := fs.String("password", "", "password (omit to read from QUARRY_PASSWORD)") data := fs.String("data", "", "derived-data directory (default: .quarry beside the repo)") if err := fs.Parse(args[1:]); err != nil { return err } if len(repos) != 1 || *user == "" { return fmt.Errorf("user add wants exactly one --repo and a --user") } pw := *password if pw == "" { pw = os.Getenv("QUARRY_PASSWORD") } if pw == "" { return fmt.Errorf("no password: use --password or QUARRY_PASSWORD") } var name, path string for n, p := range repos { name, path = n, p } dir := *data if dir == "" { dir = filepath.Join(path, ".quarry") } if err := os.MkdirAll(dir, 0o755); err != nil { return err } code, err := xfer.ProjectCode(path) if err != nil { return fmt.Errorf("project code of %s: %w", name, err) } db, err := oltp.Open(filepath.Join(dir, "oltp.db")) if err != nil { return err } defer db.Close() if err := db.SetSyncUser(name, *user, xfer.SharedSecret(code, *user, pw)); err != nil { return err } fmt.Printf("sync user %q added to %s; pushes now require authentication\n", *user, name) return nil } // reindexCmd is 0001 §5's full rebuild as a verb: for each --repo, drop // the derived tables and cursor, then replay the store. Run it with the // same --repo/--data flags as serve, while serve is stopped (the // embedded NATS store directory admits one process). func reindexCmd(args []string) error { fs := flag.NewFlagSet("reindex", flag.ContinueOnError) repos := repoFlags{} fs.Var(repos, "repo", "repository as name=path (repeatable; bare path uses its basename)") data := fs.String("data", "", "derived-data directory (default: .quarry beside a SINGLE repo; required for a fleet)") natsURL := fs.String("nats", "", "external NATS URL (multi-node); reindex then publishes a cluster-wide reset that every node's fold absorbs") if err := fs.Parse(args); err != nil { return err } if len(repos) == 0 { return fmt.Errorf("reindex wants at least one --repo") } dir, err := dataDir(*data, repos) if err != nil { return err } // On external NATS the reset republishes the census to the shared // stream, so every node re-folds (idempotent) — reindex is a // cluster-wide operation there, not a local-only one. Embedded is the // offline single-node rebuild (serve must be stopped: one process per // store dir). ev, err := startEvents(*natsURL, dir) if err != nil { return fmt.Errorf("%w (is serve still running against this data dir?)", err) } defer ev.Close() for name, path := range repos { if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil { return err } ix, err := index.Open(filepath.Join(dir, name, "index.db")) if err != nil { return err } n, err := ingest.NewPipeline(zitcli.New(path), ev, ix, name, "reindex").Reindex() ix.Close() if err != nil { return fmt.Errorf("reindex %s: %w", name, err) } fmt.Printf("reindexed %s: %d artifacts replayed\n", name, n) } return nil } // repoFlags collects repeatable --repo name=path values (bare paths take // their directory basename as the repository name). type repoFlags map[string]string func (r repoFlags) String() string { return fmt.Sprint(map[string]string(r)) } func (r repoFlags) Set(v string) error { name, path, found := strings.Cut(v, "=") if !found { path = v name = filepath.Base(strings.TrimSuffix(v, "/")) } if name == "" || path == "" { return fmt.Errorf("--repo wants name=path or path, got %q", v) } if !repoName.MatchString(name) { return fmt.Errorf("repository name %q: use letters, digits, dot, dash, underscore", name) } if _, dup := r[name]; dup { return fmt.Errorf("repository %q registered twice", name) } r[name] = path return nil } var repoName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) // serveCmd runs the HTTP surface: the op-generated API plus the HTML // pages, both consumers of one registry. Each --repo gets its own index // and watcher pipeline; one embedded NATS carries every store's events // under zit.evt.<name>.* (0001 §4: a watcher per registered store). // dataDir resolves the derived-data directory. With one repository the // default is .quarry beside it — deterministic and obviously derived. // With several there IS no principled default: the old rule took // "the first --repo", which for a Go map was a coin flip per boot, and // on 2026-08-16 it planted indexes, NATS state and the OLTP database // inside a corpus meant to hold only source repositories. 0001 §11 // says derived data is disposable; that only helps if you can tell // which directory is the derived one — so a fleet must say. func dataDir(explicit string, repos map[string]string) (string, error) { if explicit != "" { return explicit, nil } if len(repos) == 1 { for _, p := range repos { return filepath.Join(p, ".quarry"), nil } } names := make([]string, 0, len(repos)) for n := range repos { names = append(names, n) } sort.Strings(names) return "", fmt.Errorf("serving %d repositories needs an explicit --data: "+ "there is no principled default, and the old rule silently wrote "+ "indexes, event-stream state and the account database into an "+ "arbitrary one of %v", len(repos), names) } func serveCmd(args []string) error { fs := flag.NewFlagSet("serve", flag.ContinueOnError) addr := fs.String("addr", "127.0.0.1:8080", "listen address") repos := repoFlags{} fs.Var(repos, "repo", "repository to serve as name=path (repeatable; bare path uses its basename)") data := fs.String("data", "", "derived-data directory (default: .quarry beside a SINGLE repo; required for a fleet)") interval := fs.Duration("sync-interval", 2*time.Second, "store poll interval") leaseTTL := fs.Duration("lease-ttl", lease.DefaultTTL, "single-writer lease lifetime") natsURL := fs.String("nats", "", "external NATS URL for multi-node (default: embedded, single node); the server must allow max_payload >= 8MiB") notifyURL := fs.String("notify", "", "notify companion base URL for mentions and activity (default: notifications disabled)") dagnatsURL := fs.String("dagnats", "", "dagnats control-plane URL for CI (0001 §6); empty disables CI") ciVerdicts := fs.Bool("ci-verdicts", true, "record ci-pass/ci-fail as tag artifacts on the check-in, so CI history clones with the repository") ciPoll := fs.Duration("ci-poll", 10*time.Second, "how often to refresh unfinished CI runs") // How often DUE-ness is evaluated, not how often anything is pulled: // each mirror carries its own interval. A minute is fine because the // loop does nothing when no mirror is due. mirrorTick := fs.Duration("mirror-tick", time.Minute, "how often to check for due mirrors") // Bulk work is bounded by memory, not cores: each archive buffers its // whole output. Two at a time with a short queue keeps the worst case // near half a gigabyte instead of unbounded. bulkLimit := fs.Int("bulk-limit", 2, "concurrent bulk operations (archive)") // Where repositories created or cloned through the API are placed. // Without it an API caller must supply a filesystem path, which only // the machine's operator can meaningfully choose. reposRoot := fs.String("repos-root", "", "directory for repositories created through the API") bulkQueue := fs.Int("bulk-queue", 8, "bulk operations allowed to wait before refusing") archiveCache := fs.Int64("archive-cache", 2<<30, "bytes of built archives to keep on disk (0 disables)") // Ingest is bounded because it is the one path that streams a WHOLE // store: `zit frames` on sqlite peaks at 507 MB and tcl is larger. // One goroutine per repository with nothing between them means a // cold start, or a mirror round that lands artifacts in several big // stores at once, runs that many censuses simultaneously and the // memory adds. Observed: zit spiking to gigabytes after pulling // sqlite and tcl together. ingestLimit := fs.Int("ingest-limit", 3, "stores that may ingest at once") errorlogPath := fs.String("errorlog", "", "append server errors to this file (the in-process journal restarts with the server; this survives)") tracker := fs.String("tracker", "", "repository whose tickets are the forge's own; enables /fleet's file-ticket buttons") notifyKey := fs.String("notify-key", "", "notify console API key (or QUARRY_NOTIFY_KEY); notify's /v1 routes answer 401 without one") if err := fs.Parse(args); err != nil { return err } // Refuse leftovers. Go's flag package stops at the first non-flag // argument and hands the rest back, so a malformed invocation — one // unquoted shell variable is enough — silently drops every flag past // that point. Doing this cost twelve --repo flags once, and quarry // started and served the remaining eleven without a word. if rest := fs.Args(); len(rest) > 0 { return fmt.Errorf("unexpected argument %q: serve takes only flags "+ "(a shell variable holding flags must be word-split, e.g. \"${args[@]}\")", rest[0]) } deps := api.Deps{Version: version, Repos: api.NewRepos(nil), Errors: errlog.Default} var notifier web.Notifier var chatLive web.ChatLive var verify api.Verify var sessions web.Sessions var inbox web.Inbox var engine *ci.Engine var ciCloneBase string var fronter *xfer.Fronter if len(repos) > 0 { fronter = xfer.New() defer fronter.Close() // Registration happens in the registrar below, which owns the // whole lifetime; registering here too would mean two places // that must agree about which repositories exist. } if len(repos) > 0 { dir, err := dataDir(*data, repos) if err != nil { return err } if err := os.MkdirAll(dir, 0o755); err != nil { return err } odb, err := oltp.Open(filepath.Join(dir, "oltp.db")) if err != nil { return err } defer odb.Close() fronter.SetAuth(odb) // Two credentials, on purpose (0001 §8). Accounts are the forge // identity and carry per-repo roles; the RFC §6.3 shared secret // stays the SYNC credential, because the protocol fixes its // derivation and the server must be able to recompute it. Once // any account exists it governs web/API writes; before that, // v1's per-repo secret still does, so an existing deployment // keeps working and a fresh local one stays open. // Cookie sessions for the browser; Basic stays for the API. sessions = sessionPlane{db: odb} var codeMu sync.Mutex codes := map[string]string{} verify = func(repo, user, pass string, need ops.Auth) api.Decision { if odb.HasAccounts() { acct, ok := odb.Authenticate(user, pass) if !ok { return api.Denied } // Authenticated. From here a refusal is about capability, // not identity, and saying so is the difference between a // client that stops and one that retries forever. if odb.RoleOf(acct, repo) >= requiredRole(need) { return api.Allowed } return api.Insufficient } if !odb.Gated(repo) { return api.Allowed } secret, ok := odb.Secret(repo, user) if !ok || pass == "" { return api.Denied } codeMu.Lock() code, cached := codes[repo] codeMu.Unlock() if !cached { path, ok := repos[repo] if !ok { return api.Denied } c, err := xfer.ProjectCode(path) if err != nil { log.Printf("auth: project code of %s: %v", repo, err) return api.Denied } codeMu.Lock() codes[repo], code = c, c codeMu.Unlock() } if secret == xfer.SharedSecret(code, user, pass) { return api.Allowed } return api.Denied } ev, err := startEvents(*natsURL, dir) if err != nil { return err } defer ev.Close() notifier = ev // Single-writer coordination (0001 §3). node identifies this // process; one embedded NATS means one node, but the machinery // and its failure posture (coordinator down → writes refuse) run // the same on one node as on N. leases, err := lease.New(ev.JS(), *addr, *leaseTTL) if err != nil { return err } defer leases.Close() // Per-repo chat plane (0001 §5): messages fan out over the same // NATS the events ride, with a 7-day JetStream window. room, err := chat.New(ev.Conn(), ev.JS()) if err != nil { return err } deps.Chat = room chatLive = &chatAdapter{room: room, codes: codes, mu: &codeMu} // Notification triggers (0001 §5): fired best-effort to the notify // companion; disabled when --notify is unset. if *notifyURL != "" { key := *notifyKey if key == "" { key = os.Getenv("QUARRY_NOTIFY_KEY") } if key == "" { // Better to say so at startup than to discover it as 401s // in the log after a write nobody was told about. log.Printf("notify: no API key (--notify-key or QUARRY_NOTIFY_KEY); triggers will be refused") } nclient := notify.New(*notifyURL, key) deps.Notify = ¬ifyAdapter{client: nclient} inbox = inboxPlane{client: nclient} fmt.Println("quarry: notifications ->", *notifyURL) // A server error's FIRST sighting notifies; repeats only // count in the journal — an alert channel that repeats // itself gets muted. Signature dedup makes this cheap. np := deps.Notify errlog.Default.OnFirst = func(e errlog.Entry) { _ = np.Trigger(context.Background(), api.NotifyEvent{ Workflow: "quarry-error", To: "admins", Payload: map[string]any{ "surface": e.Surface, "path": e.Path, "summary": e.Message, }, TxnID: "err:" + e.First.Format(time.RFC3339Nano), }) } } // CI (0001 §6): dagnats is the engine, quarry the trigger and // display. Disabled unless --dagnats names one, so a forge with // no engine simply has no CI rather than a broken one. engine = ci.New(*dagnatsURL) if engine.Enabled() { // The clone URL handed to a pipeline: quarry's own sync // fronting, on the address quarry was told to listen on. ciCloneBase = "http://" + *addr + "/r/" deps.CI = ciRuns{db: odb} fmt.Println("quarry: CI ->", *dagnatsURL) go func() { for range time.Tick(*ciPoll) { pollCI(engine, odb, deps.Repos, *ciVerdicts) } }() } // Mirroring: always available, because it costs nothing until an // admin configures an upstream — the scheduler wakes, finds no // rows, and sleeps. The tick is how often DUE-ness is checked, // not how often anything is pulled; each mirror's own interval // decides that. // One bulk lane for the whole process, not per repository: the // resource being protected is this machine's memory, and eleven // per-repo lanes of two would be a bound of twenty-two. deps.Bulk = work.NewLane("bulk", *bulkLimit, *bulkQueue) // Archives are reproducible, so a cached one is never stale and // the budget is purely a disk-space choice. Without it every // reader of a popular tag pays the same seconds of compression. if *archiveCache > 0 { ac, err := archive.New(filepath.Join(dir, "archives"), *archiveCache) if err != nil { return err } deps.Archives = ac } // The queue is per-repository and generous: ingest must WAIT, // never be shed. Dropping a round would leave an index behind // its store until the next poll, which is a correctness surprise // in exchange for a memory saving the limit already delivers. ingestLane := work.NewLane("ingest", *ingestLimit, len(repos)+1) deps.Mirrors = mirrorStore{db: odb} go mirrorScheduler(&deps, odb, *mirrorTick) // Webhook dispatch (Principle 6): always on, because it costs // nothing until somebody subscribes — Raise finds no endpoints and // returns. Delivery is durable, so an endpoint that is down during // a restart still gets its events. dispatcher, err := webhook.New(ev.JS(), hookSubs{db: odb}) if err != nil { return err } hookCtx, stopHooks := context.WithCancel(context.Background()) defer stopHooks() if err := dispatcher.Start(hookCtx); err != nil { return err } deps.Hooks = hookPlane{d: dispatcher, at: func() string { return time.Now().UTC().Format(time.RFC3339) }} // The project codes learned below (shared with the auth verifier's // cache) key both the lease and the push guard. fronter.SetGuard(func(repo string, proxy func() error) error { codeMu.Lock() code, ok := codes[repo] codeMu.Unlock() if !ok { return fmt.Errorf("unknown repo %q", repo) } return leases.Guard(code, proxy) }) rg := ®istrar{ dir: dir, reposRoot: *reposRoot, node: *addr, interval: *interval, ciCloneBase: ciCloneBase, ev: ev, leases: leases, deps: &deps, fronter: fronter, ingestLane: ingestLane, bulk: deps.Bulk, engine: engine, odb: odb, codes: codes, codeMu: &codeMu, } defer rg.CloseAll() deps.Registry = rg deps.Accounts = accountStore{db: odb} deps.Votes = odb deps.CanRead = rg.CanRead deps.Deletable = rg.Deletable deps.MirrorFreshness = func() map[string]string { rows, err := odb.Mirrors() if err != nil { log.Printf("mirror freshness: %v", err) return nil } m := make(map[string]string, len(rows)) for _, r := range rows { if r.Enabled && r.LastOK != "" { m[r.Repo] = r.LastOK } } return m } rg.refreshPrivate() // The sync path takes the SAME visibility answer as the pages. // A clone reaching artifacts the web refuses would not be a // smaller hole than an open web page — it would be a bigger one, // because it hands over everything at once. fronter.SetReadGate(func(req *http.Request, repo string) bool { return rg.CanRead(identityCtx(req, odb), repo) }) deps.Private = func() map[string]bool { set, err := odb.PrivateRepos() if err != nil { log.Printf("visibility: %v", err) return nil } return set } deps.Descriptions = func() map[string]string { rows, err := odb.Repos() if err != nil { log.Printf("repo descriptions: %v", err) return nil } m := make(map[string]string, len(rows)) for _, r := range rows { m[r.Name] = r.Description } return m } deps.RepoMeta = func() map[string]api.RepoMeta { rows, err := odb.Repos() if err != nil { log.Printf("repo meta: %v", err) return nil } m := make(map[string]api.RepoMeta, len(rows)) for _, r := range rows { m[r.Name] = api.RepoMeta{Homepage: r.Homepage, Topics: strings.Fields(r.Topics)} } return m } // Persisted registrations first, then --repo. The flag stays as // bootstrap and override — it is how the first repository gets // in, and how an operator recovers a registry they cannot read — // but it is no longer the only source of truth. if odb != nil { stored, rerr := odb.Repos() if rerr != nil { return rerr } for _, r := range stored { if _, dup := repos[r.Name]; dup { continue // --repo wins: an explicit flag is the override } if err := rg.Register(r.Name, r.Path); err != nil { // One unreadable stored repository must not stop the // server: the rest are fine and the operator needs a // running forge to fix this one. log.Printf("registered repository %s (%s): %v", r.Name, r.Path, err) } } } for name, path := range repos { if err := rg.Register(name, path); err != nil { return err } // A flag-named repository still belongs in the registry, or // it cannot be described, taken private, or managed at all — // it would be served and simultaneously invisible to every // admin surface. if odb != nil { if err := odb.EnsureRepo(name, path); err != nil { log.Printf("registry: recording %s: %v", name, err) } } } rg.refreshPrivate() } if *errorlogPath != "" { f, ferr := os.OpenFile(*errorlogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if ferr != nil { return fmt.Errorf("errorlog: %w", ferr) } errlog.Default.SetOutput(f) } mux, err := buildHandler(deps, notifier, chatLive, verify, sessions, inbox, *tracker) if err != nil { return err } if fronter != nil { mount := http.NewServeMux() // Sync fronting (0001 §7): zit clients POST the repo URL // verbatim; /xfer is the alias zit serve itself answers on. mount.Handle("POST /r/{repo}", fronter) mount.Handle("POST /r/{repo}/xfer", fronter) mount.Handle("/", mux) mux = mount } fmt.Println("quarry", version, "listening on", *addr) return http.ListenAndServe(*addr, mux) } // chatAdapter bridges the chat data plane to web.ChatLive: it maps a // repo name to its project code and converts messages to the view type, // keeping web free of the data-plane package. type chatAdapter struct { room *chat.Room codes map[string]string mu *sync.Mutex } func (a *chatAdapter) SubscribeChat(repo string) (<-chan web.ChatMessage, func()) { a.mu.Lock() code, ok := a.codes[repo] a.mu.Unlock() if !ok { ch := make(chan web.ChatMessage) close(ch) return ch, func() {} } src, cancel := a.room.Subscribe(code) out := make(chan web.ChatMessage, 32) go func() { defer close(out) for m := range src { select { case out <- web.ChatMessage{User: m.User, Text: m.Text, At: m.At}: default: } } }() return out, cancel } // notifyAdapter bridges api.NotifyPlane to the notify client, mapping a // quarry event to a Novu-shaped trigger. The event's To is the // subscriber id (a user or a "repo:<name>" topic key). type notifyAdapter struct{ client *notify.Client } func (a *notifyAdapter) Trigger(ctx context.Context, e api.NotifyEvent) error { // NotifyEvent.To is documented as "a user, or a repo:<name> topic". // notify addresses those through different fields — subscriberId // names one person, topicKey fans out — so the convention has to be // honoured here or an activity event reaches nobody: it lands on an // invented subscriber literally called "repo:synthetic" while every // watcher of that repository hears nothing. to := notify.Recipient{SubscriberID: e.To} if repo, ok := strings.CutPrefix(e.To, "repo:"); ok && repo != "" { to = notify.Recipient{TopicKey: repoTopicKey(repo)} } return a.client.Trigger(ctx, e.Workflow, to, e.Payload, e.TxnID) } // startEvents opens the event plane: an external NATS when a URL is // given (multi-node), else an embedded server under dir (single node). func startEvents(url, dir string) (*events.Server, error) { if url != "" { fmt.Println("quarry: connecting to external NATS", url) return events.Connect(url) } return events.Start(filepath.Join(dir, "nats")) } // buildHandler wires the two registry consumers onto one mux. notifier // may be nil (no live pages). func buildHandler(deps api.Deps, notifier web.Notifier, chatLive web.ChatLive, verify api.Verify, sessions web.Sessions, inbox web.Inbox, tracker string) (http.Handler, error) { r := ops.NewRegistry() if err := api.RegisterAll(r, deps); err != nil { return nil, err } apiRouter, err := api.Router(r, deps.Version, verify) if err != nil { return nil, err } mux := http.NewServeMux() mux.Handle("/api/", apiRouter) var opts []web.Option if sessions != nil { opts = append(opts, web.WithSessions(sessions)) } if tracker != "" { opts = append(opts, web.WithTracker(tracker)) } if inbox != nil { opts = append(opts, web.WithInbox(inbox)) } mux.Handle("/", web.HandlerWith(r, notifier, chatLive, opts, web.Verify(verify))) return mux, nil } // printSpec emits the OpenAPI document generated from the full registry — // the same registry serve will run, so the spec cannot drift (0001 §9). func printSpec(w *os.File) error { r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: version}); err != nil { return err } spec, err := ops.Spec(r, "quarry", version) if err != nil { return err } enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(spec) } // requiredRole maps an op's declared capability onto the role an account // must hold on that repo. This is the only place the ops vocabulary and // the ACL vocabulary meet, so adding a level is a change here and // nowhere else. func requiredRole(need ops.Auth) oltp.Role { switch need { case ops.Admin: return oltp.RepoAdmin case ops.Token: return oltp.Write } return oltp.NoRole } // accountCmd manages forge accounts and per-repo roles (0001 §8's v2 // tables). Distinct from `quarry user add`, which writes the SYNC // credential: that one is the RFC §6.3 shared secret a push presents, // this one is the login a person uses. --repo-name names a REGISTERED // repository, because roles are held against served names, not paths. func accountCmd(args []string) error { const usage = `usage: quarry account add --user U [--password P] [--admin] quarry account passwd --user U [--password P] quarry account grant --user U --repo-name N --role read|write|admin quarry account revoke --user U --repo-name N quarry account delete --user U quarry account list every form also takes --data DIR, or one --repo name=path to locate it` if len(args) < 1 { return errors.New(usage) } sub := args[0] fs := flag.NewFlagSet("account "+sub, flag.ContinueOnError) repos := repoFlags{} fs.Var(repos, "repo", "a registered repository as name=path, used only to locate the data directory") data := fs.String("data", "", "derived-data directory (default: .quarry beside the repo)") user := fs.String("user", "", "account name") password := fs.String("password", "", "password (omit to read from QUARRY_PASSWORD)") repoTarget := fs.String("repo-name", "", "repository the role applies to") role := fs.String("role", "", "read, write or admin") admin := fs.Bool("admin", false, "site admin: every repository, every capability") if err := fs.Parse(args[1:]); err != nil { return err } dir := *data if dir == "" { if len(repos) != 1 { return errors.New("need --data DIR, or one --repo name=path to locate it\n" + usage) } for _, p := range repos { dir = filepath.Join(p, ".quarry") } } db, err := oltp.Open(filepath.Join(dir, "oltp.db")) if err != nil { return err } defer db.Close() // Password sources in order: the flag, then the environment, so a // password need not reach the process table or shell history. readPassword := func() (string, error) { if *password != "" { return *password, nil } if pw := os.Getenv("QUARRY_PASSWORD"); pw != "" { return pw, nil } return "", errors.New("no password: use --password or QUARRY_PASSWORD") } switch sub { case "add": if *user == "" { return errors.New("account add wants --user") } pw, err := readPassword() if err != nil { return err } first := !db.HasAccounts() if err := db.AddAccount(*user, pw, *admin); err != nil { return err } if *admin { fmt.Printf("account %q created (site admin)\n", *user) } else { fmt.Printf("account %q created\n", *user) } if first { fmt.Println("this is the first account: web and API writes now require one") } return nil case "passwd": if *user == "" { return errors.New("account passwd wants --user") } pw, err := readPassword() if err != nil { return err } if err := db.SetPassword(*user, pw); err != nil { return err } fmt.Printf("password changed for %q\n", *user) return nil case "grant": if *user == "" || *repoTarget == "" || *role == "" { return errors.New("account grant wants --user, --repo-name and --role") } r, err := oltp.ParseRole(*role) if err != nil { return err } if err := db.Grant(*user, *repoTarget, r); err != nil { return err } fmt.Printf("%q now has %s on %s\n", *user, r, *repoTarget) return nil case "revoke": if *user == "" || *repoTarget == "" { return errors.New("account revoke wants --user and --repo-name") } if err := db.Revoke(*user, *repoTarget); err != nil { return err } fmt.Printf("%q no longer has a role on %s\n", *user, *repoTarget) return nil case "delete": if *user == "" { return errors.New("account delete wants --user") } if err := db.DeleteAccount(*user); err != nil { return err } fmt.Printf("account %q deleted, with every role it held\n", *user) return nil case "list": accts, err := db.Accounts() if err != nil { return err } if len(accts) == 0 { fmt.Println("no accounts: web and API writes are open (single-user local serving)") return nil } for _, a := range accts { line := a.Name if a.Admin { line += " (site admin)" } grants, err := db.GrantsFor(a.Name) if err != nil { return err } for _, repo := range oltp.SortedRepos(grants) { line += fmt.Sprintf(" %s=%s", repo, grants[repo]) } fmt.Println(line) } return nil } return errors.New(usage) } // hookSubs adapts the OLTP database to the dispatcher's Subscriptions // view. The two packages keep their own types on purpose: oltp owns the // stored row (secrets, timestamps, paused flag), webhook owns what a // delivery needs, and this is the only place they meet. type hookSubs struct{ db *oltp.DB } func (h hookSubs) ActiveWebhooks(repo string) ([]webhook.Sub, error) { rows, err := h.db.ActiveWebhooks(repo) if err != nil { return nil, err } out := make([]webhook.Sub, 0, len(rows)) for _, w := range rows { out = append(out, webhook.Sub{ID: w.ID, URL: w.URL, Secret: w.Secret, Active: true}) } return out, nil } func (h hookSubs) SubByID(id int64) (webhook.Sub, bool) { w, ok := h.db.WebhookByID(id) if !ok { return webhook.Sub{}, false } return webhook.Sub{ID: w.ID, URL: w.URL, Secret: w.Secret, Active: w.Active}, true } // hookPlane adapts the dispatcher to the ops layer's WebhookPlane, which // speaks primitives so the ops never import the delivery machinery. type hookPlane struct { d *webhook.Dispatcher at func() string } func (h hookPlane) Raise(id, repo, kind, actor, summary, link string) error { return h.d.Raise(webhook.Event{ ID: id, Repo: repo, Kind: kind, Actor: actor, Summary: summary, Link: link, At: h.at(), }) } // webhookCmd manages subscriptions to quarry's outbound event surface // (Principle 6). The signing secret is printed once at creation and // never again — a key a listing can read back is a key that leaks // through logs, screenshots and shoulders. func webhookCmd(args []string) error { const usage = `usage: quarry webhook add --repo-name N --url https://... quarry webhook list --repo-name N quarry webhook pause --id N quarry webhook resume --id N quarry webhook delete --id N every form also takes --data DIR, or one --repo name=path to locate it` if len(args) < 1 { return errors.New(usage) } sub := args[0] fs := flag.NewFlagSet("webhook "+sub, flag.ContinueOnError) repos := repoFlags{} fs.Var(repos, "repo", "a registered repository as name=path, used only to locate the data directory") data := fs.String("data", "", "derived-data directory") repoTarget := fs.String("repo-name", "", "repository whose events to deliver") endpoint := fs.String("url", "", "endpoint to POST events to") id := fs.Int64("id", 0, "subscription id") if err := fs.Parse(args[1:]); err != nil { return err } dir := *data if dir == "" { if len(repos) != 1 { return errors.New("need --data DIR, or one --repo name=path to locate it\n" + usage) } for _, p := range repos { dir = filepath.Join(p, ".quarry") } } db, err := oltp.Open(filepath.Join(dir, "oltp.db")) if err != nil { return err } defer db.Close() switch sub { case "add": if *repoTarget == "" || *endpoint == "" { return errors.New("webhook add wants --repo-name and --url") } w, err := db.AddWebhook(*repoTarget, *endpoint) if err != nil { return err } fmt.Printf("webhook %d: %s -> %s\n", w.ID, w.Repo, w.URL) fmt.Printf("signing secret (shown once): %s\n", w.Secret) fmt.Println("verify X-Quarry-Signature as sha256=HMAC-SHA256(secret, body)") return nil case "list": if *repoTarget == "" { return errors.New("webhook list wants --repo-name") } hooks, err := db.Webhooks(*repoTarget) if err != nil { return err } if len(hooks) == 0 { fmt.Printf("no webhooks on %s\n", *repoTarget) return nil } for _, w := range hooks { state := "active" if !w.Active { state = "paused" } fmt.Printf("%d %-7s %s (added %s)\n", w.ID, state, w.URL, w.Created) } return nil case "pause", "resume": if *id == 0 { return fmt.Errorf("webhook %s wants --id", sub) } if err := db.SetWebhookActive(*id, sub == "resume"); err != nil { return err } fmt.Printf("webhook %d %sd\n", *id, sub) return nil case "delete": if *id == 0 { return errors.New("webhook delete wants --id") } if err := db.DeleteWebhook(*id); err != nil { return err } fmt.Printf("webhook %d deleted\n", *id) return nil } return errors.New(usage) } // sessionPlane adapts the OLTP database to the web's Sessions seam. The // authorization half deliberately reuses the same role lookup the Basic // path uses, so a cookie can never be worth more than a password. type sessionPlane struct{ db *oltp.DB } func (s sessionPlane) Login(user, password string) (string, time.Time, bool) { return s.db.NewSession(user, password) } func (s sessionPlane) Resolve(token string) (string, bool) { acct, ok := s.db.SessionAccount(token) return acct.Name, ok } func (s sessionPlane) Logout(token string) { s.db.EndSession(token) } func (s sessionPlane) Allow(user, repo string, need ops.Auth) bool { if !s.db.HasAccounts() { return true } // The caller has already proven the cookie, so this is authorization, // not authentication — but it runs the SAME role lookup the password // path does, so a cookie is never worth more than a password. a, found := s.db.AccountByName(user) if !found { return false } return s.db.RoleOf(a, repo) >= requiredRole(need) } // inboxPlane adapts the notify client to the web's Inbox seam, keeping // notify's message shape out of the page layer. type inboxPlane struct{ client *notify.Client } func (i inboxPlane) Messages(ctx context.Context, subscriber string) ([]web.InboxMessage, error) { msgs, err := i.client.Inbox(ctx, subscriber) if err != nil { return nil, err } out := make([]web.InboxMessage, 0, len(msgs)) for _, m := range msgs { out = append(out, web.InboxMessage{ Subject: m.Subject, Content: m.Content, CreatedAt: m.CreatedAt, Read: m.Read, }) } return out, nil } // topicUnsafe matches everything notify refuses in a topic key: it // accepts lowercase letters, digits, hyphens and underscores only. var topicUnsafe = regexp.MustCompile(`[^a-z0-9_-]+`) // repoTopicKey turns a repository name into a topic key notify accepts. // quarry's internal convention is "repo:<name>" (0001 §5) and its repo // names allow dots, dashes, underscores and uppercase — none of which a // colon-bearing key can carry into notify, which rejects the whole // trigger rather than mangling it. // // Two names that differ only in punctuation or case collapse to one key // ("My.Repo" and "my-repo" both give repo-my-repo), so two repositories // could share an activity topic. That is a real limitation, accepted // because the alternative — hashing — produces keys no operator can // read when subscribing someone to a repository's activity. func repoTopicKey(repo string) string { return "repo-" + topicUnsafe.ReplaceAllString(strings.ToLower(repo), "-") } // ciStore adapts the OLTP database to the CI trigger's record-keeping. // mirrorStore adapts the OLTP mirror rows to the ops layer's view. The // interval crosses as seconds because that is the JSON surface's shape; // the duration is reconstituted here rather than leaked outward. type mirrorStore struct{ db *oltp.DB } func (m mirrorStore) Mirrors() ([]api.Mirror, error) { rows, err := m.db.Mirrors() if err != nil { return nil, err } out := make([]api.Mirror, 0, len(rows)) for _, r := range rows { out = append(out, toAPIMirror(r)) } return out, nil } func (m mirrorStore) MirrorFor(repo string) (api.Mirror, bool, error) { row, ok, err := m.db.MirrorFor(repo) if err != nil || !ok { return api.Mirror{}, ok, err } return toAPIMirror(row), true, nil } func (m mirrorStore) SetMirror(repo, url string, everySeconds int) error { return m.db.SetMirror(repo, url, time.Duration(everySeconds)*time.Second) } func (m mirrorStore) DeleteMirror(repo string) error { return m.db.DeleteMirror(repo) } func (m mirrorStore) RecordMirrorRun(repo string, err error) error { return m.db.RecordMirrorRun(repo, time.Now(), err) } func toAPIMirror(r oltp.Mirror) api.Mirror { return api.Mirror{ Repo: r.Repo, URL: r.URL, Every: int(r.Every.Seconds()), Enabled: r.Enabled, LastAt: r.LastAt, LastOK: r.LastOK, LastErr: r.LastErr, Failures: r.Failures, } } // mirrorScheduler pulls every due mirror on a tick. It is one goroutine // for the whole fleet rather than one per store on purpose: pulls are // network-bound against OTHER PEOPLE'S servers, and eleven goroutines // each free to hit fossil-scm.org whenever their own timer fires is a // worse neighbour than a single loop that walks the list. func mirrorScheduler(deps *api.Deps, odb *oltp.DB, tick time.Duration) { for range time.Tick(tick) { rows, err := odb.Mirrors() if err != nil { log.Printf("mirror: listing: %v", err) continue } now := time.Now() for _, row := range rows { if !row.Due(now) { continue } changed, err := deps.PullMirror(toAPIMirror(row)) switch { case err != nil: log.Printf("mirror: %s from %s: %v", row.Repo, row.URL, err) case changed: log.Printf("mirror: %s pulled new artifacts from %s", row.Repo, row.URL) } // A quiet pull is not logged. Almost every one is quiet, and // a log line per store per interval buries the ones that matter. } } } type ciStore struct{ db *oltp.DB } // ciRuns adapts the OLTP rows to the ops layer's view. type ciRuns struct{ db *oltp.DB } func (c ciRuns) CIRuns(repo string, limit int) ([]api.CIRun, error) { rows, err := c.db.CIRuns(repo, limit) return toAPIRuns(rows), err } func (c ciRuns) CIRunsFor(repo, checkin string) ([]api.CIRun, error) { rows, err := c.db.CIRunsFor(repo, checkin) return toAPIRuns(rows), err } func toAPIRuns(rows []oltp.CIRun) []api.CIRun { out := make([]api.CIRun, 0, len(rows)) for _, r := range rows { out = append(out, api.CIRun{RunID: r.RunID, Checkin: r.Checkin, Workflow: r.Workflow, Pipeline: r.Pipeline, Status: r.Status, Started: r.Started}) } return out } func (s ciStore) CIWatermark(repo string) (string, bool) { return s.db.CIWatermark(repo) } func (s ciStore) SetCIWatermark(repo, c string) error { return s.db.SetCIWatermark(repo, c) } func (s ciStore) AddRun(runID, repo, checkin, workflow, pipeline, status string) error { return s.db.AddCIRun(oltp.CIRun{RunID: runID, Repo: repo, Checkin: checkin, Workflow: workflow, Pipeline: pipeline, Status: status}) } // ciRepo adapts one repository's index and store to the trigger's read // side: the index answers "what is new", zit answers "what is in it". type ciRepo struct { ix *index.Index zit *zitcli.Client } func (r ciRepo) NewCheckins(after string, limit int) ([]ci.Checkin, error) { entries, err := r.ix.CheckinsAfter(after, limit) if err != nil { return nil, err } out := make([]ci.Checkin, 0, len(entries)) for _, e := range entries { out = append(out, ci.Checkin{Hash: e.Hash, Branch: e.Branch, Date: e.Date, User: e.User, Comment: e.Comment}) } return out, nil } func (r ciRepo) Files(version string) ([]string, error) { entries, err := r.zit.Ls(context.Background(), version) if err != nil { return nil, err } out := make([]string, 0, len(entries)) for _, e := range entries { out = append(out, e.Name) } return out, nil } func (r ciRepo) Read(path, version string) ([]byte, error) { return r.zit.Cat(context.Background(), path, version) } // pollCI refreshes the status of runs quarry still thinks are going and // writes the durable verdict when one finishes. // // dagnats owns the run; quarry caches only the last status it saw, so a // missed poll costs nothing but freshness. The verdict is the part that // must not be lost: it becomes a tag artifact on the check-in, which // clones with the repository (0001 §6's no-hostage rule). func pollCI(engine *ci.Engine, db *oltp.DB, repos *api.Repos, verdicts bool) { runs, err := db.UnfinishedCIRuns() if err != nil { log.Printf("ci: poll: %v", err) return } for _, r := range runs { run, err := engine.Status(r.RunID) if err != nil { continue // the engine may be restarting; try again next tick } if run.Status == r.Status { continue } if err := db.SetCIStatus(r.RunID, run.Status); err != nil { log.Printf("ci: status %s: %v", r.RunID, err) continue } if !run.Terminal() || !verdicts { continue } repo, ok := repos.Get(r.Repo) if !ok { continue } verdict := "ci-fail" if run.Passed() { verdict = "ci-pass" } // Written through the lease holder, like every other artifact // quarry mints: a verdict is a write, and two nodes must not // both record one. if err := repo.WriteTag(verdict, r.Checkin, r.RunID); err != nil { log.Printf("ci: verdict %s on %.12s: %v", verdict, r.Checkin, err) continue } log.Printf("ci: %s %.12s %s (run %s)", r.Repo, r.Checkin, verdict, r.RunID) } }