Artifact bc3c26aae16fd903cd89e76ebff0f5484ad1ae48ee570793be64758d9cce6cfd:
- File
cmd/quarry/registrar.go
— part of check-in
[86880118d5]
at
2026-08-18 02:09:54
on branch trunk
— account: manage identity through the API, and stop lying about why
Six ops (account.list/add/passwd/delete/grant/revoke, admin) plus quarry accounts on top of them. Creating a login or granting someone read on a repository no longer needs shell access on the server.
quarry account still opens OLTP directly and has to: the FIRST account must exist before any server runs or any admin exists to authenticate as. Everything after that is the API, so the CLI has no privileged path.
The verifier never crosses the seam in either direction, and account.add /passwd scrub the password out of any error before it travels — an error is the one value that reaches a log, an HTTP body and a terminal at once.
Verify returns a three-valued Decision instead of a bool. Denied and Insufficient are not the same thing and the wire has separate codes for them: an authenticated client short of a capability was being told 401 with a challenge, which invites it to retry the same password forever. Now 403, and deliberately without a challenge. A harness test pinned the old behaviour and was updated with the reason. (user: dmestas, size: 13403) [annotate] [blame] [check-ins using]
package main import ( "context" "fmt" "log" "os" "path/filepath" "sync" "sync/atomic" "time" "craftdesign.group/quarry/internal/api" "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/oltp" "craftdesign.group/quarry/internal/ops" "craftdesign.group/quarry/internal/work" "craftdesign.group/quarry/internal/xfer" "craftdesign.group/quarry/internal/zitcli" "errors" ) // registrar owns a served repository's whole lifetime: its index handle, // its ingest pipeline, its sync backend, and the goroutine polling it. // // This was the body of serve's startup loop. It is a type now because a // repository can be registered while the server runs, and the loop's // shape did not survive that: it relied on `defer ix.Close()` reaching // serve's return, and on a poll goroutine that never stops. Neither is // available to an admin call, and both are leaks if a repository is ever // removed. type registrar struct { dir string // derived-data root reposRoot string // where repositories created through the API are placed node string // this node's id, for durable consumer names interval time.Duration ciCloneBase string ev *events.Server leases *lease.Manager deps *api.Deps fronter *xfer.Fronter ingestLane *work.Lane bulk *work.Lane engine *ci.Engine odb *oltp.DB // codes is shared with the auth verifier's cache, so a project code // learned here is the one the push gate checks. codes map[string]string codeMu *sync.Mutex mu sync.Mutex running map[string]*served } // served is what a registration leaves behind that must be released. type served struct { ix *index.Index stop context.CancelFunc done chan struct{} // closed when the poll goroutine has exited } func (rg *registrar) track(name string, s *served) { rg.mu.Lock() defer rg.mu.Unlock() if rg.running == nil { rg.running = map[string]*served{} } rg.running[name] = s } // Register opens a repository and starts serving it. Safe to call while // the server is running. func (rg *registrar) Register(name, path string) error { if _, exists := rg.deps.Repos.Get(name); exists { return fmt.Errorf("repository %q is already served: %w", name, ops.ErrInvalid) } if err := os.MkdirAll(filepath.Join(rg.dir, name), 0o755); err != nil { return err } ix, err := index.Open(filepath.Join(rg.dir, name, "index.db")) if err != nil { return err } cli := zitcli.New(path) // The lease keys on the store's project code, learned via the same // boundary-clean handshake user-add uses. Doing it before anything // is published means a path that is not a repository fails here, // with the index closed again, rather than half-registering. code, cerr := xfer.ProjectCode(path) if cerr != nil { ix.Close() // A path that is not a repository is a bad REQUEST, not a server // fault. Without this it surfaced as "internal error", which // tells an operator to read the server log for what is in fact // their own typo. return fmt.Errorf("%w: %s is not a zit repository: %v", ops.ErrInvalid, path, cerr) } rg.codeMu.Lock() rg.codes[name] = code rg.codeMu.Unlock() indexing := new(atomic.Bool) indexing.Store(true) entry := &api.Repo{ Index: ix, Zit: cli, Code: code, Indexing: indexing.Load, Guard: func(fn func() error) error { err := rg.leases.Guard(code, fn) if errors.Is(err, lease.ErrNotHolder) || errors.Is(err, lease.ErrUnavailable) { return fmt.Errorf("%w: %v", ops.ErrUnavailable, err) } return err }, } entry.Mirror = func(url string) (bool, error) { var changed bool err := entry.Guard(func() error { var perr error changed, perr = cli.Pull(url) return perr }) return changed, err } pipe := ingest.NewPipeline(cli, rg.ev, ix, name, rg.node) entry.Sync = pipe.Sync entry.Health = func() api.RepoHealth { s := pipe.Stats() h := api.RepoHealth{ RoundSeconds: s.LastRound.Seconds(), LastCount: s.LastCount, Total: s.Total, Fallbacks: s.Fallbacks, LastErr: s.LastErr, Failures: s.Failures, } if !s.LastOK.IsZero() { h.LastOK = s.LastOK.Format(time.RFC3339) } if !s.LastErrAt.IsZero() { h.LastErrAt = s.LastErrAt.Format(time.RFC3339) } return h } rg.fronter.Register(name, path) // Published complete or not at all. rg.deps.Repos.Put(name, entry) ctx, stop := context.WithCancel(context.Background()) done := make(chan struct{}) rg.track(name, &served{ix: ix, stop: stop, done: done}) go rg.poll(ctx, done, name, path, cli, ix, pipe, indexing) return nil } // poll runs the store's initial ingest and then its round loop until the // context is cancelled. func (rg *registrar) poll(ctx context.Context, done chan struct{}, name, path string, cli *zitcli.Client, ix *index.Index, pipe *ingest.Pipeline, indexing *atomic.Bool) { defer close(done) start := time.Now() var n int err := rg.ingestLane.Do(ctx, func() error { var serr error n, serr = pipe.Sync() return serr }) indexing.Store(false) if err != nil { log.Printf("initial ingest of %s: %v", name, err) } else { log.Printf("ingested %d artifacts from %s (%s) in %s", n, name, path, time.Since(start).Round(time.Second)) } // time.Tick leaks its ticker; a Ticker can be stopped, which matters // now that a repository can be unregistered. tick := time.NewTicker(rg.interval) defer tick.Stop() for { select { case <-ctx.Done(): return case <-tick.C: } if err := rg.ingestLane.Do(ctx, func() error { _, serr := pipe.Sync() return serr }); err != nil { if ctx.Err() != nil { return } log.Printf("ingest %s: %v", name, err) continue } // Warm the tip's listing so the repo home is never the request // that pays for it: `ls --long` costs ~4ms per file, which is // 8.5s on a 2,198-path tree. The listing is immutable per // version, so it is computed once and reused until the tip moves. if b, ok, err := ix.TipOf("trunk"); err == nil && ok { go cli.Ls(context.Background(), b.Tip) } // CI looks right after a fold, which is the moment a new // check-in becomes visible to quarry whatever brought it in. if rg.engine.Enabled() { if err := ci.Trigger(rg.engine, ciStore{db: rg.odb}, name, ciRepo{ix: ix, zit: cli}, rg.ciCloneBase+name); err != nil { log.Printf("ci: %s: %v", name, err) } } } } // Unregister stops serving a repository and releases what it held. // // Order matters: drop it from the registry FIRST so no new request can // find it, then stop the poll loop and wait for it to exit, then close // the index. Closing while a fold is mid-transaction is how a "removed" // repository takes the process down with it. func (rg *registrar) Unregister(name string) error { if _, ok := rg.deps.Repos.Get(name); !ok { return fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound) } rg.deps.Repos.Delete(name) rg.fronter.Unregister(name) rg.mu.Lock() s := rg.running[name] delete(rg.running, name) rg.mu.Unlock() if s != nil { s.stop() <-s.done s.ix.Close() } rg.codeMu.Lock() delete(rg.codes, name) rg.codeMu.Unlock() return nil } // Add creates or adopts a repository, records it, and starts serving it. // // The order is deliberate: make the store, then persist, then serve. A // recorded repository that failed to open is visible and fixable; one // that serves without being recorded vanishes at the next restart and // leaves no trace of why. func (rg *registrar) Add(spec api.RepoSpec) (string, error) { if rg.odb == nil { return "", fmt.Errorf("no forge database: %w", ops.ErrUnavailable) } if _, exists := rg.deps.Repos.Get(spec.Name); exists { return "", fmt.Errorf("repository %q is already served: %w", spec.Name, ops.ErrInvalid) } // The name is validated BEFORE it is joined to a path, because that // is the only thing standing between a name and the filesystem. if !oltp.RepoName.MatchString(spec.Name) { return "", fmt.Errorf("%w: repository name %q: use letters, digits, dot, dash, underscore", ops.ErrInvalid, spec.Name) } if spec.Path == "" { if rg.reposRoot == "" { return "", fmt.Errorf("%w: this node has no repositories root, so path is required "+ "(start it with --repos-root DIR)", ops.ErrInvalid) } if !spec.Create && spec.Clone == "" { return "", fmt.Errorf("%w: path is required when adopting an existing store", ops.ErrInvalid) } spec.Path = filepath.Join(rg.reposRoot, spec.Name) } // Making a store is the one step that leaves something behind if a // later one fails, so it is undone explicitly rather than left for // an operator to find. made := false switch { case spec.Create: if err := rg.mustBeEmpty(spec.Path); err != nil { return "", err } if err := zitcli.Init(context.Background(), spec.Path); err != nil { return "", fmt.Errorf("%w: %v", ops.ErrInvalid, err) } made = true case spec.Clone != "": if err := rg.mustBeEmpty(spec.Path); err != nil { return "", err } // A clone is minutes of network for a large repository, so it // takes the bulk lane like any other long, memory-hungry job // rather than running unbounded because an admin asked. err := rg.bulk.Do(context.Background(), func() error { return zitcli.Clone(context.Background(), spec.Clone, spec.Path) }) if errors.Is(err, work.ErrBusy) { return "", fmt.Errorf("too many clones in flight, retry shortly: %w", ops.ErrUnavailable) } if err != nil { os.RemoveAll(spec.Path) return "", fmt.Errorf("%w: %v", ops.ErrInvalid, err) } made = true } undo := func() { if made { os.RemoveAll(spec.Path) } } if err := rg.odb.AddRepo(spec.Name, spec.Path, spec.Description); err != nil { undo() return "", fmt.Errorf("%w: %v", ops.ErrInvalid, err) } if err := rg.Register(spec.Name, spec.Path); err != nil { if derr := rg.odb.DeleteRepo(spec.Name); derr != nil { log.Printf("repo.add %s: rollback failed: %v", spec.Name, derr) } undo() return "", err } if spec.Mirror && spec.Clone != "" { every := time.Duration(spec.MirrorEvery) * time.Second if every == 0 { every = 24 * time.Hour } // A failed mirror record is not worth unwinding a working // repository over — it is one admin call to fix, and the // repository is already serving. if err := rg.odb.SetMirror(spec.Name, spec.Clone, every); err != nil { log.Printf("repo.add %s: mirror not recorded: %v", spec.Name, err) } } return spec.Path, nil } // mustBeEmpty refuses to create or clone over existing content. Both zit // verbs create the directory themselves, so anything already there is // somebody else's, and the failure mode of guessing wrong is destroying // it on the rollback path. func (rg *registrar) mustBeEmpty(path string) error { if path == "" { return fmt.Errorf("%w: empty path", ops.ErrInvalid) } entries, err := os.ReadDir(path) if os.IsNotExist(err) { return nil } if err != nil { return fmt.Errorf("%w: %v", ops.ErrInvalid, err) } if len(entries) > 0 { return fmt.Errorf("%w: %s already exists and is not empty", ops.ErrInvalid, path) } return nil } // Remove stops serving a repository and forgets it. The store on disk // and its derived index are left alone: "stop serving this" and // "destroy this" are different intentions and only one is reversible. func (rg *registrar) Remove(name string) error { if err := rg.Unregister(name); err != nil { return err } if rg.odb != nil { if err := rg.odb.DeleteRepo(name); err != nil { return err } } return nil } // CloseAll releases every registration — serve's shutdown path. func (rg *registrar) CloseAll() { for _, name := range rg.deps.Repos.Names() { if err := rg.Unregister(name); err != nil { log.Printf("unregister %s: %v", name, err) } } } // accountStore adapts forge OLTP to the ops layer's account seam. The // role strings cross as text because that is the API's shape; parsing // them here keeps oltp.Role out of the op surface. type accountStore struct{ db *oltp.DB } func (a accountStore) Accounts() ([]api.Account, error) { rows, err := a.db.Accounts() if err != nil { return nil, err } out := make([]api.Account, 0, len(rows)) for _, r := range rows { out = append(out, api.Account{Name: r.Name, Admin: r.Admin, Created: r.Created}) } return out, nil } func (a accountStore) AddAccount(name, password string, admin bool) error { return a.db.AddAccount(name, password, admin) } func (a accountStore) SetPassword(name, password string) error { return a.db.SetPassword(name, password) } func (a accountStore) DeleteAccount(name string) error { return a.db.DeleteAccount(name) } func (a accountStore) Grant(account, repo, role string) error { r, err := oltp.ParseRole(role) if err != nil { return err } return a.db.Grant(account, repo, r) } func (a accountStore) Revoke(account, repo string) error { return a.db.Revoke(account, repo) } func (a accountStore) GrantsFor(account string) (map[string]string, error) { grants, err := a.db.GrantsFor(account) if err != nil { return nil, err } out := make(map[string]string, len(grants)) for repo, role := range grants { out[repo] = role.String() } return out, nil }