Purpose: describe how mpd is structured, what is currently in scope, and where contributors should make changes.
mpd is a single Linux binary that runs inside a Debian Trixie VM
(under rootful Podman). Two user-facing modes share this binary:
mpd VM: VM driven from the host by the separatempd-virtorchestrator (own repo); headless by default, GNOME toggleable on demand. Primary target.sandbox: same Debian Trixie VM but with a GNOME desktop, set up in-VM viasetup/sandbox/take-over-sandbox-vm.sh. Samempdbinary; the host stays untouched.
Current scope:
- The Swift control plane is the same for both modes — setup,
lifecycle (
--setup/--start/--stop/--restart,--status,mpd list), runtime/project orchestration, per-runtime sidecar reconciliation. - The mode distinction is recorded in
/var/lib/mpd/conf/platform.envasMPD_PLATFORM=machine|sandboxand used by paths/messages that need to differ slightly. - Outstanding work is project-type coverage under
assets/runtimes/<runtime>/project_types/— not control-plane functionality.
High-level CLI flow:
- CLI entry parses args and routes command type.
- Preflight validates environment constraints.
- Command dispatch selects project/runtime/service/global action.
- Environment orchestration executes.
- Persisted state is updated through state-owner APIs.
Control-plane lifecycle commands:
--setup--start--stop--restart
These should remain stable even as implementation details evolve.
This is a required architecture rule.
- Direct host OS command execution is allowed only in
mpd/VM/Exec.swift. - All other layers (
CLI,Runtime,Service,Core) must not execute host commands directly. - Cross-layer container/runtime operations must go through
Mpd.Podman.*only.
Allowed exception:
Mpd.Podmanacts as the single shared command gateway for container/runtime management.
Mpd.Environment.Binaries defines host binaries used inside the VM.
- Non-environment layers must not introduce new host-binary calls; they request environment or Podman actions.
Any PR that adds command execution must answer:
- Is this host command in
mpd/VM/Exec.swift? - If not, can it be routed through
Mpd.Podmanor moved intoEnvironment/? - Is binary resolution sourced from
Mpd.Environment.Binaries?
A second mandatory rule governs how shell code runs inside runtime
containers, the mpd VM, and fileaccess: scripts always run as
the dev user; sudo is for individual privileged commands; whole
scripts are never wrapped in sudo; identity-switching to a non-root
user (sudo -u <user>, runuser, su - <user>) is forbidden. Full
text in AGENTS.md §"Mandatory privilege rule", with
the in-depth tool-level explanation in §7 below. Enforced by make check-privilege-boundary.
Bootstrap-stage shell scripts under
setup/{macos,linux}/lib/ run on the dev host
(not in a container or VM) and need sudo for a fixed set of operations
— route to the container subnet, DNS resolver pointing *.mpd.test at
the in-VM dnsmasq, system-trust import of the mpd CA, plus
platform-specific extras (Firefox enterprise policy + cert under
/etc/firefox/policies/ on Ubuntu, mpd CA into the System keychain on
macOS). The pattern these scripts follow:
-
Detect first, no
sudo. Read current state with unprivileged tools (route get,cat /etc/resolver/...,security find-certificate -a -Z). Decide which operations are actually needed. -
Single fenced privileged block. All
sudocalls live in one contiguous block, gated by a singlesudo -v(with a one-line explanation of which operations need it printed first), and terminated by an explicitsudo -kto invalidate the cached credential immediately. -
No
sudooutside the fence. Discovery, reporting, and state writes (/var/lib/mpd-virt/,~/.ssh/config,~/Desktop/) all run as the user with no cached creds — a later bug cannot accidentally piggy-back on the elevated session. -
EXIT trap as backstop.
trap 'sudo -k' EXITensures cached creds are dropped even if the script errors before reaching the explicitsudo -k. -
Skip the fence entirely when nothing needs to change. On a re-run where route, resolver, and CA are already correct, the user sees no password prompt at all.
-
Generate the CA on the host before VM creation, and only on the host.
prepare_host_cainlib/common.shkeeps the host CA at/var/lib/mpd-virt/ca/(platform-owned; always present after the firstsetup.commandrun).On a wipe, the next
setup.commandregenerates and re-imports into the System keychain. Generation uses the bash twin ofMpd.VM.Certificate.generateCA(mpd/VM/Certificate.swift); the two generators must stay in sync. The CA is then uploaded into the VM, where mpd's reuse check (mpd/Action/ActionSetup.swift) picks it up. Route, resolver, and CA-trust collapse into a single upfront fenced block, after which the long unattended VM-creation phase runs holding no sudo creds.Boundary rule: CAs flow host → VM only. The macOS keychain only ever trusts certificates the host generated itself.
configure-client.shwill not pull a CA off a VM and import it into the keychain — that would invert the trust direction by accepting a cert of unknown provenance from inside an SSH session. On a Mac with/var/lib/mpd-virt/ca/empty (e.g. an imported VM created elsewhere),configure-client.shconfigures route + DNS but skips CA import; the user has to bring a host CA across themselves. -
Optional dev override. Before the fenced block opens,
print_sudo_recipeinlib/common.shlists the exact runnable commands and lets the dev choose to run them in another terminal instead of providing a password to the script. The recipe ends with a trailingsudo -kso the dev's terminal also drops cached creds. Idempotent predicates let the script re-check after the prompt and skip whatever the dev already did — the fenced block then runs only what's actually still missing, possibly nothing.
Reference implementations: lib/setup.sh (new-VM path: upfront fence,
host-first CA), lib/configure-client.sh (existing-VM and start.sh
warm path), and lib/uninstall.sh (teardown path).
This rule applies to the two automated mpd VM platforms whose
bootstrap runs as the dev user on the host: macos and
linux. The other platforms differ:
- windows runs each entry script wholesale via UAC
elevation (the
.cmdshim'sStart-Process -Verb RunAsis the privilege gate); the whole script body is the "fenced section" by design, and there is no per-operationsudo. CA generation and cloud-init seed ISO creation are delegated to WSL Debian bash (lib/common.sh) viawsl -d Debian -u root; noopensslorgenisoimageruns in PowerShell. - sandbox runs entirely inside the VM, so there is no "host-side
bootstrap" to fence.
take-over-sandbox-vm.shenables passwordless sudo on the VM as part of taking it over (the hostname-rename gate is the user's deliberate consent), then hands off tolib/provision.shwhich sudo's individual privileged commands per the in-VM rule. - Inside the VM and runtime containers, the previous sister rule
applies (per-command
sudo, no whole-script elevation).
Fixed source checkout path: /opt/mpd
Directory ownership split:
bin/— local built binaries (bin/mpd); executable path checks depend on this./var/lib/mpd/conf/— persistent local trust/network material:caroot/— root CA keypair/fingerprintservice/— service TLS cert/key (mpd.test)temp/— short-lived cert operation filesplatform.env— platform identity (see §9)
/var/lib/mpd/(other subdirs) — state/cache (machine metadata, runtime/project state, transient runtime files)
Project backups live inside the data volume at /srv/backups/, not on the
host filesystem (see §10).
Cleanup contract:
- The in-VM
mpdhas no--uninstallverb; the VM itself is the unit of removal (drop it viampd-virton the host). - Manual cleanup of the VM's
/var/lib/mpd/is justrm -rf— state and cache are designed to be safe to remove and rebuild.
Primary persisted state domains:
- Core/global state (
mpd/mpd/Core/*) - Runtime/project state (
mpd/mpd/Runtime/*)
State mutation convention:
- Persisted state writes should happen only in dedicated state-owner APIs.
- Command/orchestration code should request state changes via those APIs, not write JSON/files directly.
- Naming for mutators should be explicit (
upsert*,delete*,set*,mark*).
Goal: keep state transitions auditable and prevent inconsistent partial writes.
mpd splits resource state into two distinct concepts:
requested— persisted intent, written to disk. Mutated only by explicit user verbs (mpd <p> create/start/stop/delete,mpd --runtime-create/start/stop/delete). Survives reboots. Lives inRegisteredProjectRecord.requestedandRuntimeStateEntry.requested(mpd/Runtime/RuntimeState.swift).current— live observation, computed on each query fromMpd.Podman(no persistence). Domain:running,stopped,missing(no container exists). Accessors:Mpd.Runtime.current(_:),Mpd.Project.current(_:),Mpd.Runtime.DB.current(engine:version:)—mpd/Runtime/CurrentState.swift.
Reconciliation closes the gap: mpd --start walks requested and
brings current into agreement; mpd --gc (planned) does the
opposite trim. This is the same desired-vs-observed model used by
Kubernetes, systemd, and Terraform.
DBs and services have no requested field — they're emergent.
DB lifecycle is derived from runtime + project records (see
docs/HOOKS.md §"Resource lifecycle model"); services are always-on.
Display layers show both columns side-by-side (mpd list runtimes,
mpd list, mpd <project> show). Divergence — e.g.
requested=running, current=stopped after a reboot but before
mpd --start — is legible from the listing alone.
Out-of-process consumers (the portal container, in-runtime tools)
don't have podman access, so they can't compute current themselves.
mpd writes a snapshot to
/var/lib/mpd/state/current-state.json (CurrentStateSnapshot —
runtimes/projects/databases name → status map plus a refreshedAt
timestamp). The snapshot is refreshed automatically by mpd list,
mpd --status, mpd --start / --stop / --restart, mpd --setup,
and at every state-mutator save (saveProjects,
saveRuntimeStateEntry, deleteProject, deleteRuntimeStateEntry).
The portal bind-mounts the file at /mpd-state/current-state.json and
prefers it over the persisted intent files for live status display.
assets/ is the extension surface for runtime/type behavior.
- Runtime definitions and provisioning:
assets/runtimes/<runtime>/... - Project-type behavior:
assets/runtimes/<runtime>/project_types/<type>/... - Runtime / project-type tools: single executable per file under
corresponding
tools/(see §7). Verbs are Swift, not assets. - Service config/templates:
assets/services/...,assets/templates/...
Contributor rule:
- Prefer additive asset changes for runtime/project-type customization.
- Reserve Swift changes for control-plane, state, networking, and orchestration behavior.
mpd exposes two kinds of executables, with deliberately different
homes and audiences.
Verbs are run from outside the runtime by the mpd binary on the
host (or inside the VM, on mpd VM). They handle work that the
runtime container can't do for itself — provisioning DB containers,
attaching sidecars, writing project metadata, podman lifecycle. Surface:
mpd <verb> <project>. Lifetime: one invocation per CLI call.
Tools are run from inside the runtime container — by a developer
in an SSH session, by an AI agent in the same SSH session, by a verb
that podman execs into the runtime, or by another tool. They handle
work the runtime container can do for itself: running phpunit, doing a
Moodle install, fetching a binary into runtime FS. Surface: any
executable on PATH. Lifetime: ad-hoc.
The rule that decides which a thing should be:
A capability is a verb if and only if it does work that the runtime container can't do for itself. Otherwise it's a tool.
Don't duplicate. If a tool covers a capability, the verb is redundant.
Convenience verbs that just podman exec into the runtime to run a
tool with no host-side coordination should not exist.
Verbs are Swift, in mpd/Runtime/, mpd/CLI/, etc. The verb set
is fixed and small — create, configure, start, stop, delete,
show — all Swift control-plane methods with direct access to
Mpd.Podman.*, state APIs, and sidecar reconciliation. There is no
asset-shipped-verb mechanism: project-type-specific operations live
inside the runtime as tools, not as host-side verbs.
Previous asset-shipped verbs (Moodle: cache-purge, cron, upgrade,
install; Astro: rebuild, upgrade) all migrated to tools — they
were essentially podman exec <tool> wrappers, redundant with the
tool itself. The lesson generalised: if a host-side verb's body would
be podman exec <container> <tool>, write only the tool.
Tools are shell under assets/.../tools/. They run inside the
runtime as the dev user, so anything you'd naturally write in bash
(with optional sudo — see "Privilege model" below) is the right
shape.
Tools ship as scripts under assets/, in three tiers chosen by scope:
assets/runtime-base/tools/ # cross-runtime: works in any Trixie runtime
assets/runtimes/<runtime>/tools/ # runtime-wide: only in this runtime
assets/runtimes/<runtime>/project_types/<type>/tools/
# type-only: only when a project of this type is in the runtime
runtime-base/tools/ is symlinked into /srv/tools/_base/ by
bootstrap.sh. The other two tiers are symlinked into /srv/tools/<runtime>/
and /srv/tools/<type>/ by the runtime's build.sh. PATH precedence
across the three tiers is documented below.
The tool concept is inherited from MDC
(github.com/skodak/mdc),
which had a single bin/ directory mixing what mpd now calls verbs
(mdc-start, mdc-stop, mdc-backup, …) and tools (phpunit,
behat, grunt, site-install, …). mpd splits them by which side
of the runtime they execute on. Tools that match MDC's bare names and
upstream package names are kept verbatim (phpunit, behat, etc.);
operations whose bare name would be too generic or collide with
system commands take an mdl- prefix.
Inside the runtime, PATH is set so type tools win over runtime tools win over base tools win over system binaries:
/srv/tools/<every-project-type-active-in-the-runtime>/ ← type tools first
/srv/tools/<runtime>/ ← runtime tools second
/srv/tools/_base/ ← runtime-base tools third
[normal system PATH] ← system fallback
PATH is set by the dev user's ~/.bashrc (shipped via skel —
assets/runtime-base/skel/.bashrc), which globs every directory under
/srv/tools/ and prepends each to PATH in alphabetical order. The glob
is self-extending: a new project type or runtime that drops a new
/srv/tools/<name>/ is picked up automatically, no .bashrc edit
required.
Order within the glob: alphabetical iteration with each entry prepending
to PATH means later entries rank higher. _base sorts first (underscore
is < lowercase), then runtimes (php, node, util), then project
types whose names happen to sort after their runtime (moodle > php).
The "by design" caveat: if a project type name collides earlier than its
runtime, the ranking flips. Name your types accordingly.
The dev user is the only login identity inside a runtime. Root has
none of the mpd tool dirs on PATH — sudo composer install returns
"command not found" by design (see AGENTS.md "Mandatory privilege rule").
Tools that need root sudo individual operations; whole tools never run
under sudo.
This means a tool named php overrides the system php when invoked
from anywhere inside the runtime as the dev user. The same holds for
composer, node, etc. A wrapper tool execs the upstream binary by
absolute path (e.g. exec /usr/bin/php8.4 "$@") to avoid recursing into
itself.
Tools run as the dev user — the only non-root user inside the runtime,
created at provisioning time with the same UID as the developer's host
account so files written through the runtime land with the right owner
on the data volume. The dev user has passwordless sudo inside the
runtime by design (see SECURITY.md).
Tools sudo internally; the dev/AI invokes them bare. The right
shape is:
# In the tool: most work as the calling user, sudo only what needs root.
sudo install -m 755 "$TMPDIR/binary" /usr/local/bin/binary
sudo systemctl restart php8.4-fpm# In an SSH session: just type the tool name.
$ composer-install
$ node-installNot sudo composer-install. There are two reasons:
sudo'ssecure_pathdoesn't include/srv/tools/— sudo resets PATH to a locked-down default, sosudo composer-installwould fail with "command not found" even though the dev's PATH has it. Internal sudo sidesteps this entirely.- Least privilege — the script runs as the dev user, only the operations that genuinely need root run elevated. Easier to audit, easier to reason about.
Verbs run from outside the runtime. When a verb needs root inside the
container (e.g. for -install setup at provision time), it uses
podman exec --user 0:0. From inside the runtime, the equivalent is
just plain sudo inside the tool.
Forbidden shapes (mandatory rule, see
AGENTS.md §"Mandatory privilege rule"). Three shapes
are banned anywhere in mpd shell code, with no exceptions:
- Wrapping a whole script in
sudo—sudo bash <whatever>.sh. If a script needs many privileged ops, it runs as the dev user andsudos each one. Onlybootstrap.shis invoked as root, by the orchestrator, as the named bootstrap exception. - Identity-switching to a non-root user —
sudo -u <user>,runuser -u <user>,runuser <user>,su - <user>,su <user> -c …. If you need a script to run as the dev user, the orchestrator (Swiftpodman exec -u, ssh, etc.) sets that identity at exec time. - Re-execing the script as another identity from inside itself (any flavor of the above). Same root cause: identity belongs to the orchestrator, not the script.
A single bootstrap exception applies: the dev user must exist before
rule (1) can hold, so exactly one root-context script
(assets/runtime-base/bootstrap.sh) runs as root to create the user
plus the rest of the runtime base. The orchestrator is the only
caller. After it returns, phase 2 (assets/runtimes/<rt>/build.sh)
runs as the dev user via podman exec -u. make check-privilege-boundary enforces shapes (1) and (2).
Bare names are used for tools whose name matches a well-known
upstream package or whose meaning is clear from the bare word:
composer, php, node, phpunit, behat, grunt, mpci,
site-install.
mdl- prefix for Moodle-project-type tools whose bare name would
be too generic or collide with system commands: mdl-install,
mdl-cache-purge, mdl-cron, mdl-upgrade, mdl-backup,
mdl-restore. The prefix is also a usability cue — when an AI agent
or a human sees mdl-cron on PATH, it's unambiguously the
mpd-installed Moodle cron, not the system cron daemon.
Suffixes:
-install— fetches/installs a tool into runtime FS (typically/usr/local/bin/, never under/srv/). Runtime-wide, one-shot. Examples:composer-install,node-install,mpci-install.-init— readies a tool's state for the current project (cwd-walks to find the project root, does whatever's needed). Project-scoped. May include some installation as a side effect (npm packages, composer deps, test DB), but the operation is "ready this project for the tool," not "fetch the tool." Examples:phpunit-init,behat-init,node-init.
The bare name (no suffix) is the wrapper that does the work on demand:
phpunit, composer, php, node. Suffixed tools sort adjacent to
their bare-name companion in directory listings and share a
tab-completion stem.
Both -install and -init tools must be idempotent — safe to
call when their target is already present / already initialized,
exiting 0 with no changes. This lets orchestrator tools (e.g.
mdl-install calling composer-install and mpci-install to satisfy
prerequisites) invoke dependencies blindly without guard-checking
first.
Tools may freely invoke other tools through PATH. The typical pattern:
project-type tools (mdl-install, mdl-upgrade) orchestrate by
calling runtime-level setup tools (composer-install, mpci-install)
as prerequisites, then doing project-specific work. Runtime-level
tools are typically the leaves of the call graph; they don't depend on
project-level state.
For practical authoring guidance (file templates, $PWD walking,
testing checklist), see AGENTS.md §"Authoring verbs
and tools".
mpd.env files carry runtime configuration that the user is meant to edit:
DB tag, PHP version, Moodle install defaults, headless-Behat toggle, etc.
Five named files with distinct lifecycles. Four are sourced at every
configure/start invocation (the layered hierarchy); the fifth is a
seed used once at project create time.
| File | Path | Owner | Purpose |
|---|---|---|---|
| Runtime defaults | assets/runtimes/<rt>/mpd-defaults.env |
runtime, in repo (read-only) | "the default value" for MPD_<RT>_* keys; sourced 1st |
| Type defaults | assets/runtimes/<rt>/project_types/<type>/mpd-defaults.env |
project type, in repo (read-only) | type-specific overrides of the runtime default; sourced 2nd |
| VM-wide | /var/lib/mpd/env/mpd-vm.env (host; bind-mounted RO into runtime containers at the same path) |
developer (manual edit) | cross-project preferences and secrets; sourced 3rd |
| Per-project | /srv/projects/<n>/mpd.env |
seeded by project-create.sh, mutated by mpd configure <project> KEY=VALUE and manual edit |
project-scoped truth; sourced 4th, wins |
| Per-type seed | assets/runtimes/<rt>/project_types/<type>/mpd-template.env |
project type, in repo (read-only) | NOT sourced — copied to /srv/projects/<n>/mpd.env at create time |
Seeding (one-shot, at create time): at mpd create <project>, the
project type's project-create.sh copies mpd-template.env to the project
as mpd.env only if absent — pre-existing mpd.env (e.g. from a clone or
hand-staged) is sacred. The seed file is not the source of defaults; it's a
starting point for the user's own per-project overrides, with commented
hints for discoverability.
Sourcing order at runtime (in
assets/runtime-base/lib/source-mpd-env.sh):
- runtime defaults (
mpd-defaults.env) - type defaults (
mpd-defaults.env) /var/lib/mpd/env/mpd-vm.env- project
mpd.env
Runtime + type are read from /srv/meta/<n>/project.json (written by
Swift's writeProjectMeta) using jq. Bash "last assignment wins" gives
the right semantics — each layer overrides earlier ones, and explicit
KEY="" blocks fall-through:
| layer-1 (runtime) | layer-2 (type) | layer-3 (user) | layer-4 (project) | result |
|---|---|---|---|---|
MPD_PHP_VERSION=8.3 |
(absent) | (absent) | (absent) | 8.3 |
MPD_PHP_VERSION=8.3 |
MPD_PHP_VERSION=8.2 |
(absent) | (absent) | 8.2 (type override) |
MPD_PHP_VERSION=8.3 |
MPD_PHP_VERSION=8.2 |
MPD_PHP_VERSION=8.4 |
(absent) | 8.4 (user override) |
MPD_PHP_VERSION=8.3 |
(absent) | (absent) | MPD_PHP_VERSION=8.5 |
8.5 (project override) |
MPD_DB is the same: a project type that doesn't use a DB ships MPD_DB=""
in its mpd-template.env (astro, bare) so the seeded project file blocks
any MPD_DB=... the developer set in mpd-vm.env; types that do ship a
sensible default (MPD_DB=postgres:latest for moodle).
How mpd-vm.env reaches the runtime: the host file
/var/lib/mpd/env/mpd-vm.env is bind-mounted RO into every runtime
container at the same absolute path (Mpd.envMountRO in
mpd/VM/VM.swift). Directory mount, so vim/nano
atomic-rename writes on the host propagate inside the container
immediately. No sync, no restart needed.
Naming convention:
MPD_<RUNTIME>_<TYPE>_<KEY>— project-type-specific knobs (MPD_PHP_MOODLE_BEHAT,MPD_NODE_ASTRO_PORT).MPD_<RUNTIME>_<KEY>— runtime-wide, applies to every type on that runtime (MPD_PHP_VERSION,MPD_PHP_XDEBUG_MODE).MPD_<KEY>— global mpd infra (none currently in active use; the formerMPD_DNS_UPSTREAMis gone — dnsmasq now bind-mounts the host's systemd-resolved upstream and follows whatever the host has configured).- Reserved keys:
MPD_DBis owned by Swift'sMpd.Runtime.DB.parseTag(engine whitelist + version regex); other reserved keys go in the same map inProjectOperations.sanitiseEnvValueas they're added.
CLI surface for editing: mpd configure <project> KEY=VALUE [...]
parses positional pairs matching ^MPD_[A-Z0-9_]+=.*$, sanitises in Swift
(reserved-key map for strict validators, otherwise a generic safe-charset
check that blocks shell metacharacters), and rewrites the corresponding line
in /srv/projects/<n>/mpd.env via
assets/runtime-base/tools/set-mpd-env. Empty value deletes the line.
After mutations are applied, the project type's configure.sh sources the
layered env, generates config files, and emits resolved values into
/srv/meta/<n>/effective.json (where Swift reads dbTag to provision the
DB container).
mpd records the mode context — machine vs sandbox — in
/var/lib/mpd/conf/platform.env, sibling to caroot/ and service/. Lives
under /var/lib/mpd/conf/ so it's part of the persistent identity that
survives runtime-state wipes.
MPD_PLATFORM=machine | sandbox
MPD_VM_IP=<ip> # empty for sandbox
MPD_INSTANCE_SUFFIX=<-suffix> # e.g. "-161"; empty for the unsuffixed instance
MPD_INSTANCE_SUFFIX is the disambiguator for concurrent VMs. Auto-derived
at mpd --setup from the VM hostname (mpd-<X>), with the leading
dash included (or empty when there's no suffix). Used as the hostname suffix
on runtime pods (mpd-runtime-<rt>-<X>), so SSH'ing into
<rt>.runtime.mpd.test gives a bash prompt that makes the instance
unambiguous. DNS names are unaffected — they still resolve by IP via
dnsmasq. Hand-edit to override; the next mpd --setup will overwrite back
to the auto-derived value.
Platform.write preserves any other MPD_* keys it doesn't manage
(e.g. MPD_NETWORK_* written by a bootstrap script) so bootstrap scripts
and Platform can share the same file without clobbering each other.
Writers:
| Path | Writer | Values | Behavior |
|---|---|---|---|
mpd VM via mpd-virt |
mpd-virt orchestrator (host, separate repo, over SSH) |
machine, ${IP} |
written before mpd --setup runs in the VM |
mpd VM via sandbox |
setup/sandbox/lib/provision.sh |
sandbox, "" |
written before mpd --setup runs inside the Debian VM |
Reader: Mpd.VM.Platform.load() (Swift). Throws with a fix-it message
when missing, pointing at the matching bootstrap script. The machine path
records the VM's IP so the in-VM mpd can verify network identity; sandbox
has no host side, so its MPD_VM_IP stays empty.
Why under /var/lib/mpd/conf/: the platform identity is part of the
persistent setup — the same answers should apply across rebuilds.
/var/lib/mpd/ (excluding conf/) is state cache; /var/lib/mpd/conf/ is
durable config (CA, certs, etc.).
Goal: Moodle-style project backups (dataroot tar + DB dump produced by a shell script) have one well-defined path off the data volume, identical across modes.
/srv/backups is a subdirectory of the mpd-data-volume data volume.
Every container that mounts the volume — runtime pods, the fileaccess
service, etc. — sees the same content there. There is no host bind-mount
on either mode; the directory lives entirely inside the volume.
Read/write contract:
- Runtime backup tools write here. Project-type-specific tools
(e.g. the planned
mdl-backupunderassets/runtimes/php/project_types/moodle/tools/) tar dataroot + DB dumps into/srv/backups/from inside the runtime. Backup is currently a Moodle-only concern; other project types keep state in the source tree (sogitis their backup mechanism). - Fileaccess is the exit/entry point. From the dev's laptop:
scp fileaccess.service.mpd.test:/srv/backups/<file> .pulls a backup off; reverse direction stages a restore. Thempd-service-fileaccesscontainer drops interactive ssh sessions into/srv/backups/so the human-facing path is onecdaway. - Authentication: pubkey-only. fileaccess reuses the user's existing
~/.ssh/authorized_keys, so the laptop's SSH key already works.
Wipe contract:
podman volume rm mpd-data-volume(or anything that wipes the Podman whole VM) deletes/srv/backups/along with everything else on the volume.- Before wiping, pull anything you want to keep via fileaccess.
- This is intentional: fileaccess is the single transit point, so there's exactly one place to remember.
- Laptop ↔ VM transport is WireGuard (configured by the host-side
mpd-virtorchestrator, separate repo). The tunnel'sAllowedIPson the Mac peer includes the full container subnet10.163.0.0/24, so containers are directly reachable while the tunnel is up. - dnsmasq inside the VM serves
*.mpd.test; the WireGuard tunnel config setsDNS = 10.163.0.3so the Mac resolves*.mpd.testthrough dnsmasq while the tunnel is up. - All TLS certs (per-project, per-runtime, services) are signed by
the local
mpdCA generated on the host and pushed into the VM.
Always-on infra services:
dnsmasq— DNS for*.mpd.testportal— read-only status site athttps://mpd.test/adminer— DB management UI athttps://adminer.service.mpd.test/fileaccess—podman exectarget for volume tool ops, plus pubkey-only ssh/scp atfileaccess.service.mpd.test, the single transit point for project backups (/srv/backups/is a data-volume subdirectory)
Per-runtime sidecars (attached to the runtime pod, not global):
- Caddy frontdoor — terminates TLS for project URLs and routes per-URL by
kindandbackenddeclared in each project'surls.json. Always-on for any runtime. mailpit— declared by PHP runtime defaults. One instance per runtime, shared by all projects on it (the SMTP black hole is per-pod). Canonical UI athttps://mail.<runtime>.mpd.test/; per-project shortcut URLshttps://mail.<project>.mpd.test/302-redirect to the canonical with?q=<project>.mpd.test, so the user lands on a filtered view of the shared inbox. Runtime-level URL meta lives at/srv/meta/_runtime-<rt>/(a pseudo-project that flows through the same Caddy/cert/dnsmasq plumbing as real projects).selenium— pulled in when any project on the runtime has a URL withkind: behat.valkey— wired but not currently triggered.
Project URLs are project-type-driven (via configure.sh writing
/srv/meta/<project>/urls.json) and surfaced via the frontdoor sidecar — the
control-plane Swift code never hard-codes per-runtime URL shapes.
macOS (/etc/resolver/mpd.test) and Linux (systemd-resolved drop-in) handle
split DNS natively, so mpd --setup prints a one-line recipe and is done.
Windows is the awkward case. The built-in NRPT mechanism
(Add-DnsClientNrptRule) works for most queries but is bypassed by some
clients (notably Chromium's async resolver) and interacts poorly with
corporate VPN clients. The recommended fallback is to install a small
local DNS forwarder on the laptop, point the system resolver at 127.0.0.1,
and let the forwarder split queries by domain (*.mpd.test → 10.163.0.3,
everything else → system upstream).
Recommended tool: Acrylic DNS Proxy — Windows-only, MSI installer with
tray icon, one-file config. This is the established precedent on Windows:
Laravel Valet for Windows (the cretueusebiu/valet-windows port and its
descendants) ships Acrylic and configures it to resolve *.test → 127.0.0.1,
mirroring what macOS Valet does with /etc/resolver/. So the split-DNS story
is symmetric: macOS uses the OS-native /etc/resolver/<tld> mechanism (which
Apple's own container tool also uses),
and Windows uses Acrylic because the OS has no native equivalent — both are
the convention rather than a mpd-specific choice. Alternatives considered:
dnscrypt-proxy (cross-platform, but its "encrypted DNS" framing confuses
non-privacy users) and Deadwood from the MaraDNS suite (works, but obscure on
Windows). We recommend Acrylic for the intended audience; the others are fine
for users who already know them.
See detailed docs:
mpd/CLI/— command handlers, routing, status renderingmpd/Action/— top-level verb implementations (Setup,Start,Stop,Restart,Status)mpd/VM/— VM-host operations (exec, paths, DNS, Certificate, ShutdownUnit, Identity, Platform, DataVolume, Config)mpd/Runtime/— runtime/project orchestration and recordsmpd/Service/— service lifecycle control (Mpd.Service.*)mpd/Hooks/— typedEventlifecycle hooksmpd/TUI/— interactive TUImpd/Util/— Podman gateway, JSONStateStoreassets/— runtime/type/service scripts/config/templates +runtime-base/skel/bootstrap/— VM bring-up steps 10–60 (passwordless sudo, repo clone, networking, apt, build, WireGuard)setup/— per-platform host-side orchestration (sandbox, macos, linux, windows)docs/— behavioral and architecture contracts
If you change:
- CLI behavior: update
docs/CLI_BEHAVIOR.md - Runtime/project behavior: update
assets/runtimes/...and matching docs - Networking/TLS/DNS behavior: update networking/security docs for affected mode
- State shape or mutation behavior: update this file and relevant state docs/comments
- Multi-tenant isolation or production hardening
- Binary distribution via git repository
README.mddocs/README.mddocs/CLI_BEHAVIOR.mddocs/USAGE.mddocs/ROADMAP.mdAGENTS.md— practical authoring guidance for verbs and tools (§"Authoring verbs and tools")