Files
Maven/deploy
kami 810076451f update: roll back what the restart actually deploys
On the deployment deploy/README.md documents, source_dir and install_dir are
the same tree and the restart command rebuilds the image from it. The
Dockerfile builds from cmd/ and internal/ and .dockerignore keeps the host
binaries out, so restoring the snapshotted binaries restored bytes nothing
reads. A bad commit therefore cost two health timeouts and two image builds
and ended in ErrRollbackFailed with an instruction to copy files back by hand,
which would not have helped either.

A deployment that rebuilds from source now has to say how the source is put
back. source_rollback "git" records the commit before the update and checks it
back out before the rollback restart. It refuses a dirty tree, because the
recorded commit does not describe one and a forced checkout would delete his
work. A build-from-source config that says nothing is refused by Validate, at
startup, rather than at the one rollback that mattered.

Also in this change, all from the same review:

  - MethodPing, the one method a locked daemon answers. Preflight passed on an
    unlocked daemon and the post-restart Presence read failed on a locked one,
    so a good update read as SHE IS PROBABLY DOWN once the env key is gone.
  - A dial failure is reported apart from a read failure. The documented
    socket is under /var/lib/docker, which a non-root operator cannot
    traverse, and "she is not answering" was the wrong diagnosis.
  - Verify refuses to run as root over a tree owned by someone else. It runs
    make build and make test in place, and root-owned artifacts break his next
    ordinary make.
  - A rollback no longer reverts config_files. That undid every config edit
    since the last apply, phraser.model_path among them.
  - The verify-failure path no longer reports rolled_back for a compile error.
  - waitHealthy caps each attempt at the remaining budget, so a 90s timeout
    cannot run to 99s.
  - tail cuts on a rune boundary. Russian test names showed the seam.
  - The claim that mavend does not import internal/update is replaced with
    what is enforced: mavend constructs no Updater and nothing can call Apply.
  - snapshot_dir inside source_dir is refused. It landed in the build context.

Found in review of #69.
2026-08-01 14:06:00 +04:00
..

Maven — Docker deployment

One image, one container per daemon (docker-compose.yml). Core (mavend) holds the encryption key and the db; the modules mount only the shared socket dir and read-only models.

First run

# 1. generate the at-rest db key (32 bytes, base64) — keep it safe, losing it loses the db
cp deploy/db_key.env.example deploy/db_key.env
printf 'MAVEN_DB_KEY=%s\n' "$(openssl rand 32 | base64 -w0)" > deploy/db_key.env

# 2. build + start
docker compose build
docker compose up -d

# 3. logs
docker compose logs -f mavend

models/ and deps/ are bind-mounted / baked from the host — they are NOT in git (fetched via make deps + downloaded models). The build context needs deps/lib, deps/piper, deps/include, and deps/whisper.cpp/ggml/include present (see .dockerignore).

Layout

Path (in container) What
/opt/maven/bin the six daemons
/opt/maven/lib native .so (whisper+vulkan, onnxruntime)
/opt/maven/piper piper binary + espeak data
/opt/maven/models (ro) bind-mount of ./models
/run/maven (volume) shared IPC sockets
/var/lib/maven (volume) encrypted db at rest
/dev/shm (tmpfs) decrypted db working copy (RAM only)

Reading the outside world (off by default)

mavend.json ships without a feeds block, which means no RSS/Atom feed is fetched and no outbound request is made. Switching it on is adding the block:

"feeds": {
  "poll_interval": "30m",
  "max_items": 5,
  "max_age": "24h",
  "sources": [
    { "name": "habr", "url": "https://habr.com/ru/rss/best/daily/",
      "category": "технологии", "exclude": ["реклама"] }
  ]
}

What it does and does not do:

  • items are written as notes with source rss:<name>, visible on /dash;
  • nothing is announced. She reads them back when asked — "что нового в лентах?", "что нового по технологиям?" — and never on arrival. There is no severity or channel knob here on purpose;
  • the fetcher is allowlisted to the hosts of the configured feeds, plus any allow_hosts. It refuses non-http(s) schemes and every private address (loopback, the LAN, the 10.42.0.0/24 wg range, cloud metadata). It caps the response at 2 MiB and redirects at 3, and makes at most one request per host per second. See internal/webfetch;
  • how far each feed was read is stored as a config fact rss:latest:<name>, so a restart does not re-note yesterday's headlines.

Reading a page (crawl, also off by default)

There is no crawl block either, so no page is fetched. Two halves, separately switched:

"crawl": {
  "on_demand": true,
  "interval": "6h",
  "max_runes": 4000,
  "watches": [
    { "name": "changelog", "url": "https://example.org/changelog", "interval": "12h" }
  ]
}
  • on_demand lets her read a page he names in the utterance: "посмотри https://example.org/x — что там?". The page becomes context for his question, and only the URL leaves the box. Without a URL nothing is fetched, so this is a fallback and not a habit;
  • watches re-reads a fixed list on its interval and writes a note when the text changed. Like the feeds, it announces nothing;
  • the answer path sits last in the query chain, behind his memory, his notes and (once wired) the local Kiwix ZIMs. A local read costs nothing;
  • robots.txt is fetched first and obeyed with no override; a Disallow is a refusal she says out loud. Crawl-delay is honoured;
  • same guarded fetcher as the feeds: allowlist/denylist, no private addresses, size cap, redirect cap, timeout, one request per host per second;
  • dedup state is the config fact crawl:hash:<name>.

Not yet verified / host-dependent

This stack is correct-by-construction but has not been build-tested here (no docker in the authoring env; ~1GB context; GPU). Expect a tweak on first build on the target host, most likely in one of these:

  • GPU passthroughmavsttd maps /dev/dri for Vulkan. On an NVIDIA host you'd swap to the nvidia container runtime instead of /dev/dri.
  • onnxruntime lib pathmavend's embedder needs libonnxruntime.so (on LD_LIBRARY_PATH=/opt/maven/lib). If the embedder wants an explicit path, set it in the config's embedder block.
  • cross-container voicemavweb -voice mavend:9100 only works once mavend binds its voice server on 0.0.0.0:9100 (Voice config, currently unset). Until then, voice-over-web is inert; /tools, passkey, and the dash work fine over the core socket.
  • netdatamavpoll reaches it via host.docker.internal; adjust if netdata runs elsewhere.

Updating her (mavupdate, Vikunja #249)

Off unless configured, and there is deliberately no button for it. There is no IPC method, no web route, no timer and no act that starts an update — the trigger is a human running mavupdate on the host, which needs shell access, a strictly higher bar than the step-up passkey gate that guards /tools. She cannot update herself; she can be updated. Nothing here ever fetches code: the new version is whatever you pulled into the working tree yourself.

Add an update block to mavend.json (mavend ignores it — only the CLI reads it), with paths as they exist on the host, not inside a container:

"update": {
  "source_dir": "/home/kami/apps/Maven",
  "install_dir": "/home/kami/apps/Maven",
  "snapshot_dir": "/var/lib/maven-snapshots",
  "source_rollback": "git",
  "binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
               "mavenclient", "mavpoll", "mavcaldav", "mavmaild", "mavupdate"],
  "config_files": ["deploy/mavend.json"],
  "restart_cmd": ["docker", "compose", "up", "-d", "--build", "mavend"],
  "health_socket": "/run/maven-host/mavend.sock",
  "health_timeout_sec": 120
}

snapshot_dir must be outside both install_dir and source_dir (a restore must not read from what the install writes, and a snapshot dir inside the tree lands in the docker build context). health_socket is required: an update that cannot check its own result cannot roll itself back, so the config is refused without one.

source_rollback is what makes a rollback real on this deployment. Compose builds the image from the tree — the Dockerfile copies cmd/ and internal/ and runs the build in the builder stage, and .dockerignore keeps the host binaries out — so install_dir is the tree, install is a no-op, and putting the old binaries back puts back bytes nothing reads. A rollback that only did that would rebuild the same bad image and burn a second health timeout proving it. With "source_rollback": "git" the commit is recorded before the update and checked back out before the restart, so the restore is of the thing that actually gets deployed. It requires a clean tree: apply refuses to start with uncommitted changes, because the recorded commit would not describe what is deployed and the forced checkout on the way back would delete the work. It also means a rollback moves every tracked file, deploy/mavend.json included, so on this deployment a config edit belongs in a commit.

Leaving source_rollback out is only valid when install_dir holds what actually runs. Validate refuses the combination of "same dir" and "no way to put the source back" at startup rather than at the one rollback that mattered.

The socket has to be one the account running mavupdate can open. The compose stack keeps IPC in a named volume, whose host path (/var/lib/docker/volumes/maven_sockets/_data) is under a drwx--x--- root root directory, and the socket itself is 0600 owned by the container's uid 10001. A non-root mavupdate gets EACCES on the dial, which reports as update: cannot open the health socket rather than as a daemon that will not answer. Bind-mount the socket dir to a host path he owns and run the daemon under his uid instead:

mavend:
  user: "1000:1000"
  volumes:
    - /run/maven-host:/run/maven

Do not work around it with sudo mavupdate apply. verify runs make build and make test in source_dir, and as root that leaves root-owned binaries, object files and a build cache in the working tree, so the next ordinary make fails. Verify refuses to run as root over a tree owned by someone else for exactly that reason.

Then:

mavupdate -config deploy/mavend.json verify        # make build + make test, deploys nothing
mavupdate -config deploy/mavend.json apply -yes    # snapshot, verify, install, restart, health-check
mavupdate -config deploy/mavend.json list          # what you can roll back to
mavupdate -config deploy/mavend.json rollback -yes # restore the previous artifacts and restart

apply refuses to start if she is not already answering — otherwise a failed update and a box that was already broken are indistinguishable afterwards. On any failure after the install it restores the snapshot, restarts, and checks again; if that also fails it says so loudly and names the directory to copy back by hand. The database is never snapshotted or rolled back (see the package comment in internal/update); schema compatibility is store.Migrate's job.