Files
Maven/deploy/README.md
T
kami be066a4b04 Deploy a new build with verification and automatic rollback (#249)
internal/update applies a new build of Maven to the box she runs on and
undoes it when the new build does not come up. cmd/mavupdate is the only
trigger: a CLI the owner runs on the host.

Apply is health-check the running daemon, snapshot the deployed artifacts,
make build, make test, install, restart, health-check — and restore the
snapshot on any failure. The order is load-bearing:

  - The preflight health check refuses to update a daemon that is already
    not answering. Without a working baseline, a failed update and a box
    that was already broken are indistinguishable, and the rollback has
    nothing to prove itself against.
  - The snapshot is taken BEFORE the build, because make build writes its
    binaries into the working tree and on the docker deployment the tree
    is the install dir — snapshotting afterwards would snapshot the new
    artifacts and leave nothing to roll back to.
  - Verification is make build plus make test, before anything is
    deployed, so a broken tree costs time and nothing else. A failed
    verify also puts the tree's artifacts back, so a later restart by
    hand cannot deploy code that failed its own tests.
  - The rollback depends on nothing that just changed: byte-for-byte
    copies out of the snapshot dir, sha256-verified on the way in, and
    the same restart command. No build, no migration, no cooperation from
    the code being replaced. It also runs on an uncancellable context —
    a rollback interrupted halfway is worse than the failure that caused
    it. When the restore itself fails it says so and names the directory
    to copy back by hand rather than reporting a tidy rollback.

Off unless configured, and the refusals are code, not documentation. The
daemon does not import this package: there is no IPC method, no web route,
no timer and no act that can start an update, so nothing Maven says or
routes reaches it. Nothing fetches code — the new version is whatever the
owner pulled into the tree. The plan's release checker, auto-update
channel and in-process crash-loop supervisor are deliberately absent; a
process cannot reliably notice that it keeps dying, and restart-on-crash
belongs to compose or systemd. The database is never snapshotted or rolled
back; schema compatibility stays store.Migrate's job.

The config is refused at load without a health socket, since an update
that cannot check its own result cannot roll back, and refused when the
snapshot dir is inside the install dir, since a restore must not read from
what the install writes.

Vikunja #249
2026-08-01 04:09:30 +04:00

7.1 KiB

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",
  "binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
               "mavenclient", "mavpoll", "mavcaldav", "mavmaild"],
  "config_files": ["deploy/mavend.json"],
  "restart_cmd": ["docker", "compose", "up", "-d", "--build"],
  "health_socket": "/var/lib/docker/volumes/maven_sockets/_data/mavend.sock",
  "health_timeout_sec": 120
}

snapshot_dir must be outside install_dir (a restore must not read from what the install writes) and health_socket is required: an update that cannot check its own result cannot roll itself back, so the config is refused without one.

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.