diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d208218 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# keep the build context small — the repo carries a full Go toolchain, the +# whisper.cpp source tree, and ~1GB of models that must NOT ship in the image. +.git +models +*.db +certs + +# built binaries (rebuilt inside the image) +/mavend +/mavsttd +/mavttsd +/mavweb +/mavpoll +/mavcaldav +/mavenclient + +# heavy deps we don't need in context. We keep only the prebuilt runtime libs +# (deps/lib, deps/piper) and the headers the CGO build needs. +deps/go +deps/onnxruntime-linux-x64-* +deps/whisper.cpp/** +!deps/whisper.cpp +!deps/whisper.cpp/ggml +!deps/whisper.cpp/ggml/include +!deps/whisper.cpp/ggml/include/** + +# local secrets — never bake into an image layer +deploy/db_key.env diff --git a/.gitignore b/.gitignore index e4c764c..a9fce89 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ models/ # Runtime data *.db +# Deploy secret (the at-rest db key) — never commit +deploy/db_key.env + # Temp files /tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43f8bb8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +# syntax=docker/dockerfile:1 +# +# Single image, all six daemons. docker-compose runs one container per daemon +# off this image with a different command — the native-lib + toolchain surface +# is shared, so separate images would duplicate ~all of it. Isolation still +# holds: each daemon is its own container/namespace, only mavend mounts the key +# and the db volume. +# +# Native deps are the prebuilt artifacts the repo already carries under deps/ +# (libwhisper+ggml-vulkan, onnxruntime, piper/espeak). We do NOT build +# whisper.cpp from source here — COPY the prebuilt .so and headers. +# ponytail: prebuilt-lib copy, not a from-source build. Add a whisper.cpp build +# stage if you ever need reproducibility / a different arch than the host libs. + +FROM golang:1.23-bookworm AS build +WORKDIR /src + +# native build inputs (prebuilt libs + headers), then module cache, then source +COPY deps/lib/ /src/deps/lib/ +COPY deps/piper/ /src/deps/piper/ +COPY deps/include/ /src/deps/include/ +COPY deps/whisper.cpp/ggml/include/ /src/deps/whisper.cpp/ggml/include/ +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ + +# CGO wiring mirrors the Makefile; rpath points at the RUNTIME lib location so +# the binaries find their .so at /opt/maven/lib regardless of LD_LIBRARY_PATH. +ENV CGO_ENABLED=1 \ + CGO_CFLAGS="-I/src/deps/include -I/src/deps/whisper.cpp/ggml/include" \ + CGO_LDFLAGS="-L/src/deps/lib -L/src/deps/piper -Wl,-rpath,/opt/maven/lib" +RUN go build -o /out/mavend ./cmd/mavend && \ + go build -o /out/mavsttd ./cmd/mavsttd && \ + go build -o /out/mavttsd ./cmd/mavttsd && \ + go build -o /out/mavweb ./cmd/mavweb && \ + go build -o /out/mavpoll ./cmd/mavpoll && \ + go build -o /out/mavcaldav ./cmd/mavcaldav + +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 && \ + rm -rf /var/lib/apt/lists/* + +# runtime native libs: whisper/ggml (incl. vulkan), onnxruntime, piper/espeak. +COPY deps/lib/ /opt/maven/lib/ +COPY deps/piper/ /opt/maven/piper/ +# piper ships its own .so (onnxruntime, espeak, phonemize) — put them on the path too. +RUN cp -a /opt/maven/piper/*.so* /opt/maven/lib/ 2>/dev/null || true +COPY --from=build /out/ /opt/maven/bin/ + +ENV LD_LIBRARY_PATH=/opt/maven/lib PATH=/opt/maven/bin:$PATH + +# unprivileged; core owns the key + db, modules own nothing. +RUN useradd -r -u 10001 -m maven \ + && mkdir -p /run/maven /var/lib/maven \ + && chown maven:maven /run/maven /var/lib/maven +USER maven +WORKDIR /opt/maven diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..afdeba2 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,55 @@ +# 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 + +```sh +# 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) | + +## 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 passthrough** — `mavsttd` maps `/dev/dri` for Vulkan. On an NVIDIA host + you'd swap to the nvidia container runtime instead of `/dev/dri`. +- **onnxruntime lib path** — `mavend`'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 voice** — `mavweb -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. +- **netdata** — `mavpoll` reaches it via `host.docker.internal`; adjust if + netdata runs elsewhere. diff --git a/deploy/db_key.env.example b/deploy/db_key.env.example new file mode 100644 index 0000000..85456b7 --- /dev/null +++ b/deploy/db_key.env.example @@ -0,0 +1,4 @@ +# Copy to deploy/db_key.env (gitignored) and fill with a real key: +# openssl rand 32 | base64 -w0 +# This is the AES-256 key that encrypts the at-rest db. Losing it = losing the db. +MAVEN_DB_KEY= diff --git a/deploy/mavend.json b/deploy/mavend.json new file mode 100644 index 0000000..4acbfae --- /dev/null +++ b/deploy/mavend.json @@ -0,0 +1,7 @@ +{ + "db_path": "/var/lib/maven/maven.db.enc", + "db_tmpfs": "/dev/shm/maven-plain.db", + "db_key_env": "MAVEN_DB_KEY", + "socket_path": "/run/maven/mavend.sock", + "state_dir": "/var/lib/maven" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..61374ac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +name: maven + +# One image (built once), one container per daemon. Only mavend holds the key +# and the db volume; the modules mount just the shared socket dir + models. +# IPC stays unix-domain over the shared `sockets` volume — no code change from +# the bare-metal setup, only paths move to /run/maven. + +x-image: &image + image: maven:latest + restart: unless-stopped + +services: + mavend: + <<: *image + build: . + command: ["mavend", "-config", "/opt/maven/config/mavend.json"] + # the key lives ONLY here. deploy/db_key.env holds MAVEN_DB_KEY=. + env_file: [./deploy/db_key.env] + volumes: + - dbdata:/var/lib/maven # encrypted db at rest + - sockets:/run/maven # IPC socket dir + - ./deploy/mavend.json:/opt/maven/config/mavend.json:ro + - ./models:/opt/maven/models:ro + # the decrypted working copy lives in RAM (see db_tmpfs in mavend.json). + tmpfs: + - /dev/shm + + mavsttd: + <<: *image + command: ["mavsttd", "-socket", "/run/maven/stt.sock", "-model", "/opt/maven/models/stt/ggml-small.bin"] + depends_on: [mavend] + # whisper uses libggml-vulkan → needs the GPU render node. + devices: + - "/dev/dri:/dev/dri" + volumes: + - sockets:/run/maven + - ./models:/opt/maven/models:ro + + mavttsd: + <<: *image + command: ["mavttsd", "-socket", "/run/maven/tts.sock", + "-piper", "/opt/maven/piper/piper", + "-model", "/opt/maven/models/tts/ru_RU-irina-medium.onnx", + "-espeak_data", "/opt/maven/piper/espeak-ng-data"] + depends_on: [mavend] + volumes: + - sockets:/run/maven + - ./models:/opt/maven/models:ro + + mavweb: + <<: *image + # NOTE: -voice must reach mavend's voice TCP server cross-container. That + # requires mavend to BIND its voice server on 0.0.0.0:9100 (Voice config, + # currently unset). Until that's configured, voice-over-web is inert — the + # rest of mavweb (/tools, passkey, dash) works over the core socket. + command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock"] + depends_on: [mavend] + ports: ["9201:9201"] + volumes: + - sockets:/run/maven + + mavpoll: + <<: *image + command: ["mavpoll", "-socket", "/run/maven/mavend.sock", "-netdata", "http://host.docker.internal:19999"] + depends_on: [mavend] + extra_hosts: + - "host.docker.internal:host-gateway" # reach netdata on the host + volumes: + - sockets:/run/maven + +volumes: + dbdata: + sockets: