The kopia adapter for Maison's backup engine seam.
Maison defines backup as an interface (internal/apps.Provider) and owns everything that
is not storage: which paths are a source, the per-app lock, the stop → snapshot → restart
sequence, retention intent, progress derivation. An engine owns exactly one thing —
getting bytes to durable storage and back.
This repository is the piece that lets kopia be that engine without kopia being compiled into Maison. It ships:
maison-engine— a small CLI implementing the adapter protocol (docs/protocol.md), which is engine-agnostic by design;- a container image built
FROMa pinnedkopia/kopia, so the adapter and the engine binary version together; docs/kopia-mapping.md— how each protocol verb is realised in kopia, and the kopia-specific hazards the adapter exists to absorb.
Maison invokes it as docker exec <engine-container> maison-engine <verb> …. There is no
HTTP service and no listening socket; see
Why argv and stdio. The resident container's own
process is either sleep infinity, where a host side provisions the repository, or
maison-engine serve, which provisions it from a connect.json and
credentials.env the owner drops into the engine directory — the shape of an engine
installed from an app store.
v0 implemented, not yet deployed. Every verb is written and the unit tests pass; nothing has run against a real repository or a real PCS yet. The protocol is authored against Maison's current kopia provider, so v0 covers 100% of what Maison does today and nothing speculative. It is expected to change as a second adapter is written — that is the point of writing the second one.
Maison's side is in internal/backup/adapter, registered from a descriptor at
${DATA_ROOT}/AppData/<engine>/engine/adapter.json (formerly
${DATA_ROOT}/AppDataShared/backup/<engine>/), written by the host side or by serve. A box
with no descriptor has no engine but Maison's local one.
Maison's side of the same design is
docs/backup.md → The engine adapter,
which is authoritative for the split of responsibility. This repository is authoritative for
the wire format.
Adding restic today means editing Maison, releasing Maison, and shipping a new Maison image to every PCS — for a change that alters no Maison behaviour. With an adapter, adding an engine means publishing an image and naming it in the host-side stack.
The secondary win is that a pin currently duplicated across two repositories collapses into
one: KOPIA_IMAGE in template-root/scripts/library/kopia.sh and kopia.DefaultImage in
Maison are held together today by a comment saying they must match. An adapter image built
from a pinned engine carries that version inside it.
cmd/maison-engine/ verb dispatch, strict unknown-flag rejection, exit codes, signal forwarding
internal/proto/ the wire types and the NDJSON emitter — engine-agnostic
internal/kopia/ the verbs, on kopia's CLI
Dockerfile FROM golang (build) → FROM kopia/kopia (ship)
docs/protocol.md the adapter contract — engine-agnostic, the thing a second adapter implements
docs/kopia-mapping.md verb → kopia command, and the kopia-specific hazards
go test ./...
docker build -t ghcr.io/yundera/maison-kopia-engine:dev .The engine version is pinned in the Dockerfile, so it moves with the adapter rather than with Maison. Bumping kopia is a change to that one line.
.github/workflows/publish.yml builds ghcr.io/yundera/maison-kopia-engine for
linux/amd64 and linux/arm64 on every push to main and on v* tags, and tags a v*
build latest. A pull request runs the tests and the build but publishes nothing.
Every publish is gated on gofmt, go vet and go test, and the pushed image is then
smoke-tested by running capabilities against it — the one verb that needs no repository,
no network and no credentials, and the only way to prove the ENTRYPOINT actually works.
Deployments pin a version, never latest. The PCS template names the image it wants;
an engine that changes under a live repository turns a format surprise into a 3am failure.