SwarmOps

Page kind: Operate

Serve over ssh#

Describes stable at aa87870d, ; latest release v0.11.0what changedLast changed View as Markdown

After this page people and CI reach a store with stock git alone: clone, fetch, pull and push over ssh, through swarmops serve as the forced command of an authorized_keys line.

You need: SwarmOps 0.7.0 or newer on Linux (swarmops --version); Stores; git 2.39 or newer on the server (git --version); an OpenSSH server (ssh -V).

Commands and outputs here follow the conventions of the operations guide; the client outputs come from a run with git 2.43.0. Over HTTPS instead: Serve over HTTP, which reuses this page's access file and refusals.

Before you start#

  • swarmops and git 2.39 or later on the server (Install and upgrade), an OpenSSH server, and any git on the clients.
  • A store under one directory, here /srv/so, owned by the account that serves it (Stores). A store's name is its directory's name: one segment of [a-z0-9][a-z0-9._-]{0,62}, here acme.
  • A FORMAT 2 store for branches beyond main and release/<name>, and for tags (swarmops init --format 2, or swarmops upgrade).
  • A filesystem that lets the serving account run files: git runs the hooks serve writes in the store (git/serve-hooks/pre-receive and git/serve-hooks/proc-receive), so a store mounted noexec refuses every push.

How it works#

Text
git (client) ──ssh──▶ sshd ──forced command──▶ swarmops serve --root /srv/so --as person/ada
                                                 │ 1 the caller: --as alone
                                                 │ 2 the store named by the client's path
                                                 │ 3 access.toml: may the caller read?
                                                 │ 4 git/serve.git refreshed from the store,
                                                 │   unless git/serve.state shows nothing moved
                                                 ▼
                                    git upload-pack | git receive-pack on serve.git
                                                 │ a fetch: swarmops serve --hook
                                                 │ pack-objects sends the pack, from
                                                 │ git/serve-cache/ when an identical
                                                 │ request was answered before
                                                 │ a push: git checks the pack in its
                                                 │ quarantine, then runs
                                                 ▼
                                    swarmops serve --hook pre-receive: per ref, access,
                                    forward or force, import; one refused ref refuses the
                                    push, and git drops the quarantine
                                                 ▼
                                    swarmops serve --hook proc-receive: per ref, the same,
                                    then compare-and-swap of the store ref, or a land

serve runs once per connection and ends with it; there is no daemon. The pack protocol is the host git's, so every git client that talks to GitHub talks to it. What git advertises is the export of each store ref's committed head, made again before every connection, so a client never fetches what a crash could take back. git/serve.state names the store ref, the changeset and the commit of each export; a connection whose store refs and serve.git refs both match it advertises serve.git as it is, taking neither the store's writer lock nor git/serve.lock, so a fetch with nothing new does not wait behind the agents' lands. Anything else (a ref moved, the file missing or damaged, a serve.git ref moved by hand) refreshes first, as before.

Refreshes coalesce. git/serve.state (version 2) also says covered: how far into the store's journal the last full refresh read. A connection that started before that point finds serve.git current even when the store has moved since, because that refresh already holds every land acknowledged before the connection started; a refresh that finds such a newer export once it has the lock writes nothing. A refresh lets git/serve.lock go while it waits for the store's writer lock, and a preparation lets it go between its rounds, so connections and pushes do not wait behind the agents' lands. A state of version 1 (an older swarmops) is read as not current: the next connection refreshes and writes version 2.

git hands each fetch's pack to serve --hook pack-objects (its uploadpack.packObjectsHook, which serve sets in git's environment). It sends only commits a served ref is or reaches: a want of anything else, such as the commit of a refused push that git kept in serve.git, is refused before any byte, with either protocol. Identical requests (the same wants, haves, depth and options, the same git) share one pack in git/serve-cache/: the first produces it, and the others are sent it, after its header's BLAKE3 digest is checked against its bytes; an entry that fails the check is removed and the pack produced again. The cache keeps at most 4 GiB a store, the least recently used removed first.

git/serve.git, git/SERVE, git/serve.lock, git/serve.state, git/serve-hooks/ and git/serve-cache/ in the store are a cache of that export: deleted, the next connection makes them again, and a backup may leave them out. git/serve-records.jsonl, its older file and git/serve-split/ are records of the requests served, which a backup may leave out too.

The serving account#

One Unix account runs serve for every key: the account that owns the stores, since their files are readable by their owner alone. Its environment does not name the caller: serve ignores SWARMOPS_AGENT and SWARMOPS_RUN_ID, so a key line without --as is refused rather than given the account's identity.

Its state directory. The account keeps one memo key per store, $XDG_STATE_HOME/swarmops/memo-keys/<repo_id>.key (~/.local/state without XDG_STATE_HOME; SWARMOPS_MEMO_KEY_DIR moves the directory), made at its first connection. With it, the refresh before each connection stops its walk of the history at what it exported before; without a home directory it stays correct but walks the history every time. Give the account a home directory it can write (Backup and restore says what the key is).

Its first connection exports the store's whole history into serve.git, as loose objects that git gc --auto packs later (known limitations). Prepare a large store before people clone it, as the serving account:

Shell
swarmops --repo /srv/so/acme serve --prepare --seed-from /srv/import/acme.git
Output
{"ok":true,"op":"serve_prepare","api":1,"result":{"refreshed":{"moved":1,"removed":0,"objects":0},"rounds":1,"seeded":1,"skipped":null,"warnings":[]},...}

--seed-from names the repository git import read: serve --prepare fetches the imported commits' objects from it into serve.git, exports (finding every commit there, so writing none), and packs the whole history with a bitmap. Without --seed-from it exports what serve.git lacks. It needs no --root; it waits for git/serve.lock as a connection does (--prepare-wait zero gives up at once and marks the lock's holder to run again), and exits 0 when it finds nothing to do. It helps only when it runs as the account that serves the store and that the agents run as: it writes serve.git and uses that account's memo key. Without the memo key, on a store whose main head is no imported commit, it exports nothing and says so in warnings; the next connection then refreshes as before.

After every ref move of a store that holds access.toml (land, commit --advance, ref set, ref delete, op undo, sync, fetch, git import and ws land, from the command line or as an MCP tool) swarmops starts swarmops serve --prepare --prepare-wait zero for that store once its response is out (after git import, with --seed-from the repository it read). That run is detached: its own process group, no stdin, stdout or stderr, and nobody waits for it; the MCP server reaps the ones it started. When another refresh holds git/serve.lock it leaves git/serve.dirty for that one and exits, so a burst of lands costs one preparation running at a time. A preparation that fails changes nothing the operation returned; the next connection refreshes as before. It helps only when the agents run as the same account as serve.

Write access.toml#

The store grants nothing until its root holds an access.toml, read on every connection. Write it as the serving account, beside FORMAT and REPO; no command writes it, and a change applies to the next connection. A push to refs/land/<target> also reads it before each land attempt: a land grant removed while a land retries after a lost race stops it before its next attempt, with the refusal the connection would get and nothing written. An attempt already checked is not stopped by the check: it can still land, lose its race or conflict.

TOML
# Who may do what through `swarmops serve`.
# Read on every connection and before each land attempt.
version = 1

[[grant]]
who = ["person/ada", "ci/*"]          # an identity, or <family>/* for every instance of a family
read = true                            # clone, fetch, pull
push = ["release/*", "feature/**", "tags/**"]   # refs a push may create and fast-forward
force = ["feature/**"]                 # of those, refs a push may also move backwards or delete
land = ["main", "release/*"]           # targets a push to refs/land/<target> may land onto

[[grant]]
who = ["person/bob"]
read = true
  • Names are store ref names: main, release/1.4, feature/x, tags/v1.4.0. A pattern is an exact name, * for one segment without /, or a trailing ** for every depth below.
  • Grants add up, and there are no deny rules: what any grant naming the caller allows, the caller may do, whatever their order.
  • push, force and land each imply read. force acts only on refs push also covers, and a deletion needs force.
  • main never moves backwards and is never deleted: a force that covers main makes the file invalid. The example lets people change main only by a land; a grant with push = ["main"] lets a push move it forward along first parents as well.
  • The file holds at most 1 MiB (Access file).
The fileEvery caller gets
MissingE_AUTH, details.reason no_access_file
Not TOML, an unknown key, version other than 1, a bad pattern, a force covering main, more than 1 MiBE_POLICY_DENIED, details.reason access_file_invalid, with the line
Text
fatal: remote error: E_POLICY_DENIED: access.toml has force "**", which covers main (line 22)

Add a key line#

In ~/.ssh/authorized_keys of the serving account, one line per person or tool, each naming its own identity with --as (Identities):

Text
command="swarmops serve --root /srv/so --as person/ada",restrict ssh-ed25519 AAAA… ada@laptop
command="swarmops serve --root /srv/so --as ci/runner-1",restrict ssh-ed25519 AAAA… ci

restrict turns off forwarding, the pty and ~/.ssh/rc; the forced command runs whatever the client asks, and serve reads the request from SSH_ORIGINAL_COMMAND without a shell. It runs git-upload-pack and git-receive-pack (or git upload-pack, git receive-pack) on a path that names one store under --root, and refuses anything else. sshd starts the command through the account's login shell, so swarmops must be on that shell's PATH, or named by its full path.

Let protocol v2 through#

git sends the protocol it wants in GIT_PROTOCOL, which sshd drops unless it accepts it. In sshd_config, then reload sshd:

Text
AcceptEnv GIT_PROTOCOL

Without it, clients fall back to protocol v0, which serve also serves; v2 sends only the refs a fetch asks for.

What a client types#

Nothing but stock git, with the store's name as the repository (.git is optional):

Shell
git clone ssh://so-host/acme.git              # main, the branches and the tags
git pull --rebase                             # fetch, then rebase onto main
git push origin HEAD:refs/land/main           # land: squashed onto main, as an agent lands
git push origin feature/x                     # a branch of its own (FORMAT 2)
git push --force origin feature/x             # with force granted
git push origin v1.4.0                        # a lightweight tag (FORMAT 2)
git push origin main                          # with push granted on main: forward only

A clone's commits keep their ids: the store's history imported from git exports back as itself.

A land squashes the pushed commits into one changeset on main, as swarmops land does, and names the commit it made; nothing is left under refs/land/:

Text
remote: landed onto main as 211c210a39dba59a1f0897b5205bd65678ef79b8: git fetch brings it in
To ssh://so-host/acme.git
 * [new reference]   HEAD -> refs/land/main

The landed commit is new to the client, whose author is the pushed commit's and whose committer is the caller; git pull --rebase drops the local commit it replaces:

Text
warning: skipped previously applied commit e6abb43
…
Successfully rebased and updated refs/heads/main.

The same push again (a dropped connection, a CI retry) carries the same land key, finds the land on main and writes nothing (remote: landed already onto main as 211c210…). The same commit pushed by another identity is a land of its own: a change of its own, or a refusal with E_NOTHING_TO_LAND when main holds its changes already.

A conflict writes nothing; the report comes on git's remote: lines, at most 50 paths (Land conflict lines):

Text
remote: E_CONFLICT: 1 path conflicts with main at b62a907
remote:   README.md: both changed lines 1; main's change by unknown/unknown
remote: resolve it on top of main, then push again
To ssh://so-host/acme.git
 ! [remote rejected] HEAD -> refs/land/main (conflict)

unknown/unknown names a change that came in from git, which carries no agent; a change an agent committed in the store names that agent. Fetch, rebase onto origin/main, resolve, push again.

Refusals a client sees#

A refusal before git starts is one line, fatal: remote error: <CODE>: <message>, and the envelope after it, which ssh passes on from serve's stderr:

Text
fatal: remote error: E_AUTH: the store has no access.toml: serve grants nothing without one
{"ok":false,"op":"serve","api":1,"error":{"code":"E_AUTH","message":"the store has no access.toml: serve grants nothing without one","retryable":false,"exit":5,"details":{"reason":"no_access_file"},"hint":"the operator writes access.toml in the store's root: version = 1, then one [[grant]] per set of callers (who, read, push, force, land)"},"meta":{"elapsed_ms":1,"resolved":{},"auth":"trust"}}

A push with one refused ref is refused whole, before any of its objects enters serve.git: every ref is ! [remote rejected] <ref> (pre-receive hook declined), and a remote: line per ref gives the reason, <ref> <CODE>: <message> (a ref refused only with the others reads accepted alone). Push refs that must land independently in separate pushes. A ref that loses a race after that check, to another push or a land conflict, is refused alone: ! [remote rejected] <ref> (<reason>). No text names a path of the server.

The client seesWhyWhat to do
E_AUTH: serve names no caller: --as is not given, …The key line has no --asOperator: add --as to the line
E_AUTH: no grant of access.toml names person/eveNo grant names the callerOperator: add a grant, or the caller uses its own key
E_AUTH: the store has no access.toml: …Write access.tomlOperator
E_POLICY_DENIED: access.toml … (line N)The file is invalidOperator: fix line N
E_POLICY_DENIED: person/bob may not push feature/bThe caller's grants do not cover the action and refPush to a ref it covers, or land; or the operator widens the grant
E_INVALID_ARG: the path names no storeThe URL's path is not one store namessh://<host>/<name>.git
E_NOT_A_REPO: no store named nope is servedNo such store under --rootCheck the name with the operator
E_INVALID_ARG: serve runs git-upload-pack and git-receive-pack alonessh so-host <command>, or git archive --remoteClone, fetch, pull or push with git
E_BUSY: …Another connection's refresh held git/serve.lock for 10 sConnect again
remote: {"ok":false,…"E_POLICY_DENIED",…"not_served"…}, then git's bad pack headerA fetch by id of an object no served ref reachesFetch a branch or a tag
non-fast-forward: main moves along first parents; push to refs/land/mainA push to main that is not forward along first parents, such as the merge git pull --no-rebase makesgit pull --rebase, or push to refs/land/main
! [rejected] … (non-fast-forward)A branch moved backwards without --force--force, if force covers the branch
stale: refs/heads/feature/y moved; fetch firstAnother push moved the ref after this client's fetchFetch, then push again
E_REF_PROTECTED: ref "main" is protected: it is never deletedA deletion of mainNone: main stays
E_TAG_IMMUTABLE: tag "tags/v0.1.0" cannot move: …A tag pushed again at another commitDelete it (with force), then push it again
E_INVALID_ARG: … is an annotated tag, which the store does not hold: …git tag -aPush a lightweight tag
E_REF_NAME_INVALID: refs/notes/x is not served: …A ref outside refs/heads/, refs/tags/ and refs/land/Push a branch or a tag
E_REF_NAME_INVALID: … names a ref the store reservesA branch under remote/Another name
E_FORMAT_REQUIRED: refs/heads/feature/z needs a store of format 2: …A branch or tag in a FORMAT 1 storeOperator: swarmops upgrade
E_GIT_IMPORT_LOSSY: …The pushed history holds what the store cannot keep, such as a submodulePush a history without it

Records#

serve appends one JSON line to the store's git/serve-records.jsonl for each request it served from the store on: a clone, a fetch, a push, or a refusal after the store was found. Each line is a ServeRecord (swarmops schema ServeRecord):

Shell
tail -n 1 /srv/so/acme/git/serve-records.jsonl
Output
{"at":1794048398609,"identity":"person/ada","service":"clone","prepare_ms":50,"send_ms":6335,"bytes":6293595,"exit":0}

at is when the connection started (Unix milliseconds) and identity the caller serve resolved. service is clone for a fetch that excludes nothing (no have, no excluded commit, no shallow client), else fetch (a fetch with nothing new too), or push. For a fetch, prepare_ms runs to the pack's first byte (the refresh, the negotiation, the pack produced or found in the cache) and send_ms from there to the end, so a slow client shows as send_ms; for a push, send_ms runs to the start of the push's check (receiving it and git's own checks) and prepare_ms from there to the end (judging, importing, landing, exporting). A request that sent no pack, or a push that never reached its check, is all prepare_ms. bytes counts the pack sent or received, exit is git's (128 + signal for a git a signal ended) or the refusal's, and code names a refusal's error code, a push's first refused ref among them. A push the hooks refuse records the refusal's code and that code's exit, though git exits 0: E_REF_PROTECTED for a move of main off its first parents, E_CAS_MISMATCH for a stale push, E_CONFLICT for a land that conflicts.

The hooks pass their split to the parent serve in git/serve-split/<connection>, a line per hook run (a second pack on one connection adds its bytes), which it removes after reading; a split older than a day, which a serve that died left, is removed by the next connection. Lines are not synced: the records are diagnostics, a reader skips a torn last line, and nothing reads them for correctness. A record that cannot be written is a warning: line on stderr; the request stands. Records hold no path and nothing of the serving account's environment. Past 64 MiB the file is renamed serve-records.1.jsonl, replacing the one older file.

Limits#

  • A push sends at most 2 GiB (receive.maxInputSize, Push size); git refuses a bigger pack before serve decides anything.
  • A push names at most 1,000 refs (Push commands); past that, every ref is refused with too_many_commands.
  • The serve lock waits 10 s, the store's writer lock wait; a refresh moves each served ref at most 8 times against a concurrent writer before the connection is refused.
  • The pack cache holds at most 4 GiB a store (Serve pack cache); a larger pack is sent and not kept. A fetch waits at most 10 s for another connection producing the same pack, then produces its own.
  • The records of a store take at most 64 MiB before they move to the one older file (Serve records).

Not served#

Partial clone filters (the client warns and gets everything), git push --atomic, push options, signed pushes, annotated tag objects, SHA-256 repositories, git LFS, submodules, server-side user hooks, git daemon, a web interface, and swarmops serve --stdio, the store-to-store sync, which is refused with not_served. A push to main moves it; it does not land: land through refs/land/main.

Next#

Next: Serve over HTTP: the same store over HTTPS, with the same access file.

On this page