Skip to content

Latest commit

 

History

History
761 lines (607 loc) · 36.9 KB

File metadata and controls

761 lines (607 loc) · 36.9 KB

mpd Architecture

Purpose: describe how mpd is structured, what is currently in scope, and where contributors should make changes.

1) Modes and Release Scope

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 separate mpd-virt orchestrator (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 via setup/sandbox/take-over-sandbox-vm.sh. Same mpd binary; 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.env as MPD_PLATFORM=machine|sandbox and 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.

2) Core Execution Model

High-level CLI flow:

  1. CLI entry parses args and routes command type.
  2. Preflight validates environment constraints.
  3. Command dispatch selects project/runtime/service/global action.
  4. Environment orchestration executes.
  5. 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.

3) Mandatory Constraint: Host Command Boundary

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.Podman acts as the single shared command gateway for container/runtime management.

Binaries ownership rules

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.

Review/enforcement checklist

Any PR that adds command execution must answer:

  1. Is this host command in mpd/VM/Exec.swift?
  2. If not, can it be routed through Mpd.Podman or moved into Environment/?
  3. Is binary resolution sourced from Mpd.Environment.Binaries?

Sister rule: privilege model

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.

Sister rule: host-side fenced sudo (macos + linux bootstrap)

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:

  1. 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.

  2. Single fenced privileged block. All sudo calls live in one contiguous block, gated by a single sudo -v (with a one-line explanation of which operations need it printed first), and terminated by an explicit sudo -k to invalidate the cached credential immediately.

  3. No sudo outside 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.

  4. EXIT trap as backstop. trap 'sudo -k' EXIT ensures cached creds are dropped even if the script errors before reaching the explicit sudo -k.

  5. 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.

  6. Generate the CA on the host before VM creation, and only on the host. prepare_host_ca in lib/common.sh keeps the host CA at /var/lib/mpd-virt/ca/ (platform-owned; always present after the first setup.command run).

    On a wipe, the next setup.command regenerates and re-imports into the System keychain. Generation uses the bash twin of Mpd.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.sh will 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.sh configures route + DNS but skips CA import; the user has to bring a host CA across themselves.

  7. Optional dev override. Before the fenced block opens, print_sudo_recipe in lib/common.sh lists 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 trailing sudo -k so 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 .cmd shim's Start-Process -Verb RunAs is the privilege gate); the whole script body is the "fenced section" by design, and there is no per-operation sudo. CA generation and cloud-init seed ISO creation are delegated to WSL Debian bash (lib/common.sh) via wsl -d Debian -u root; no openssl or genisoimage runs in PowerShell.
  • sandbox runs entirely inside the VM, so there is no "host-side bootstrap" to fence. take-over-sandbox-vm.sh enables passwordless sudo on the VM as part of taking it over (the hostname-rename gate is the user's deliberate consent), then hands off to lib/provision.sh which 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).

4) Repository Directory Contract

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/fingerprint
    • service/ — service TLS cert/key (mpd.test)
    • temp/ — short-lived cert operation files
    • platform.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 mpd has no --uninstall verb; the VM itself is the unit of removal (drop it via mpd-virt on the host).
  • Manual cleanup of the VM's /var/lib/mpd/ is just rm -rf — state and cache are designed to be safe to remove and rebuild.

5) State Model and Mutation Points

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.

Persisted intent vs live observation

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 in RegisteredProjectRecord.requested and RuntimeStateEntry.requested (mpd/Runtime/RuntimeState.swift).
  • current — live observation, computed on each query from Mpd.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.

6) Assets and Extension Contract

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.

7) Verbs and tools

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.

Implementation: Swift by default

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.

Asset layout

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.

Lineage

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.

PATH precedence

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 PATHsudo 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.

Privilege model

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-install

Not sudo composer-install. There are two reasons:

  1. sudo's secure_path doesn't include /srv/tools/ — sudo resets PATH to a locked-down default, so sudo composer-install would fail with "command not found" even though the dev's PATH has it. Internal sudo sidesteps this entirely.
  2. 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:

  1. Wrapping a whole script in sudosudo bash <whatever>.sh. If a script needs many privileged ops, it runs as the dev user and sudos each one. Only bootstrap.sh is invoked as root, by the orchestrator, as the named bootstrap exception.
  2. 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 (Swift podman exec -u, ssh, etc.) sets that identity at exec time.
  3. 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).

Naming conventions

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.

Idempotency

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.

Composition

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".

8) Configuration model: mpd.env

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):

  1. runtime defaults (mpd-defaults.env)
  2. type defaults (mpd-defaults.env)
  3. /var/lib/mpd/env/mpd-vm.env
  4. 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 former MPD_DNS_UPSTREAM is gone — dnsmasq now bind-mounts the host's systemd-resolved upstream and follows whatever the host has configured).
  • Reserved keys: MPD_DB is owned by Swift's Mpd.Runtime.DB.parseTag (engine whitelist + version regex); other reserved keys go in the same map in ProjectOperations.sanitiseEnvValue as 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).

9) Platform identity: conf/platform.env

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.).

10) Backup persistence

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-backup under assets/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 (so git is 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. The mpd-service-fileaccess container drops interactive ssh sessions into /srv/backups/ so the human-facing path is one cd away.
  • 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.

11) Networking, DNS, and TLS (Summary)

  • Laptop ↔ VM transport is WireGuard (configured by the host-side mpd-virt orchestrator, separate repo). The tunnel's AllowedIPs on the Mac peer includes the full container subnet 10.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 sets DNS = 10.163.0.3 so the Mac resolves *.mpd.test through dnsmasq while the tunnel is up.
  • All TLS certs (per-project, per-runtime, services) are signed by the local mpd CA generated on the host and pushed into the VM.

Always-on infra services:

  • dnsmasq — DNS for *.mpd.test
  • portal — read-only status site at https://mpd.test/
  • adminer — DB management UI at https://adminer.service.mpd.test/
  • fileaccesspodman exec target for volume tool ops, plus pubkey-only ssh/scp at fileaccess.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 kind and backend declared in each project's urls.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 at https://mail.<runtime>.mpd.test/; per-project shortcut URLs https://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 with kind: 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.

Laptop-side split DNS (Windows note)

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:

12) Repository Layer Map

  • mpd/CLI/ — command handlers, routing, status rendering
  • mpd/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 records
  • mpd/Service/ — service lifecycle control (Mpd.Service.*)
  • mpd/Hooks/ — typed Event lifecycle hooks
  • mpd/TUI/ — interactive TUI
  • mpd/Util/ — Podman gateway, JSONStateStore
  • assets/ — 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

13) Contributor Change Map

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

14) Non-Goals (Current)

  • Multi-tenant isolation or production hardening
  • Binary distribution via git repository

15) Related Docs

  • README.md
  • docs/README.md
  • docs/CLI_BEHAVIOR.md
  • docs/USAGE.md
  • docs/ROADMAP.md
  • AGENTS.md — practical authoring guidance for verbs and tools (§"Authoring verbs and tools")