Index
# mmo.admin — deploying an MMOFramework project `mmo.admin` builds a project into an immutable, versioned artifact, ships it to one or more targets, and switches each target onto it with a single atomic symlink swap. It records what it did on the target itself, so rollbacks, database migrations and `status` all read from a fact rather than from a guess. ```bash php admin.php plan --targets=demo # what would happen php admin.php release --targets=demo # do it php admin.php status --all # what is actually live php admin.php rollback --targets=demo # undo it ``` ## The layout on a target ``` /var/www/demo/ releases/4.0.21/ one extracted artifact, never modified in place releases/4.0.20/ the previous one — this is what a rollback returns to current -> releases/4.0.21 what the web server serves shared/ the target's `shared` paths, symlinked into each release — only when it declares some backups/ database dumps — only when the target has a database deploy/ mmo.admin's own state deployments.json the journal (rotated into deployments.1.json, ... — never truncated) .lock held for the duration of a deployment .install the sha256 of what install's templates and postInstall last ran with ``` No tarball stays on the target: `ship` uploads it into the release's scratch folder, checks its sha256 there, extracts it and deletes it. The machine that built it keeps it. A target installed by an earlier version kept all of this in `shared/`, tarballs included: its next `install` — the first step of every release — moves the journal, the stamp and the dumps where they now belong, deletes the tarballs, and removes `shared/` once empty. Nothing a release contains is ever edited on the target, and nothing mutable is ever inside a release. That single rule is what makes a deploy atomic, a rollback instant, and `git pull` unnecessary. ## Declaring servers and targets `deploy.json` at the project root is **committed and code-reviewed** — it decides where releases go. It holds no secrets, and refuses to parse if it does. `//` and `/* */` comments are supported. Each key under `targets` becomes that target's name, usable as `{name}` in any of its values. **Every target says what it needs: nothing is inherited**, from another target or from anywhere else. `server` (or `shell`), `ref` and `folder` are required; what a target leaves out of the rest is the built-in value in the table below, never another target's. Reading a target is reading what it is. A server declares a `root`; a target's `folder` (and `webRoot`) can then be **relative to it**: `"folder": "{name}"` on a server rooted at `/var/www` is `/var/www/demo` for the target `demo`. Absolute paths work too. `php scripts/admin.php setup` writes this file by asking — see [Setting it up by answering questions](#setting-it-up-by-answering-questions) — and editing it by hand works just as well. What setup writes **declares every key** of every server and target, the built-in values included, **each with a comment** saying what it does: nothing a deployment does is left for the reader to look up. The short form below is what a hand-written file may look like; setup's next run declares and comments what it leaves out, and shows it in its diff first. ```json { "project": "my.project", "servers": { "web1": {"sshAlias": "web1", "auth": "agent", "fingerprint": "a1:b2:c3:...", "root": "/var/www"} }, "targets": { "demo": { "server": "web1", "ref": "develop", "folder": "{name}", "url": "https://demo.example.com", "database": "{name}", "shared": ["db", "www/files"], "reload": "sudo systemctl reload apache2", "tags": ["staging"] }, "clarins": { "server": "web1", "ref": "clients/{name}", "folder": "{name}", "url": "https://clarins.example.com", "database": "{name}", "shared": ["db", "www/files"], "reload": "sudo systemctl reload apache2", "tags": ["prod"] } } } ``` ### server keys | key | meaning | |---|---| | `auth` | `agent` (ssh-agent — preferred), `key`, or `password` | | `sshAlias` | a `Host` entry of the project's `deploy.ssh.config` (git-ignored, 0600), supplying host/port/user/key | | `host`, `port`, `user` | declared here instead of, or overriding, the alias | | `fingerprint` | the server's host key, MD5 or SHA1 in hex, colons optional — the ssh2 extension computes no other kind, so not the base64 `SHA256:` form `ssh-keygen -l` prints by default. `setup` reads and pins it. **Public data — commit it.** Without it, the connection trusts whatever answers at that address | | `managed` | `true` when the web server on that machine is the *host's*, not yours — a shared webspace. See [Managed webspaces](#managed-webspaces-shared-hosting) | | `root` | the absolute folder a target's relative `folder` and `webRoot` start from: `/var/www` on a server of yours, the account's home on a webspace (`setup` reads it with `pwd`) | | `shell` | an escape hatch: `local` (this machine: `install` leaves out its templates and `postInstall` there), or a configuration path to an `iShell` wired by hand | | `tags` | labels | ### target keys | key | meaning | |---|---| | `server` | **required** (or `shell`): which declared server this target lives on | | `ref` | **required**: the git ref a release is built from | | `folder` | **required**: the target's own folder, holding `releases/`, `current` and `deploy/` — and `shared/` and `backups/` when it has some — absolute, or relative to the server's `root` | | `publicPath` | the release-relative document root (default `www`; `""` when the release *root* is what is served) | | `webRoot` | the path the host serves, which `install` turns into a symlink onto `current/{publicPath}`. Absolute or relative to the server's `root`; `{folder}` is the target's folder. Default on a managed server: `{folder}/web`; elsewhere none. `"none"`: none on a managed server either | | `shared` | release-relative paths that live in `shared/` and are symlinked into every release | | `database` | the database name on the target (omit it if the target has none) | | `url` | checked after the swap: the deploy fails and rolls back if the site stops answering | | `probe` | where that check runs from: `target` (default), `local` (the deploying machine — the default on a managed server), or `none` | | `healthOrigin` | the host name the internal probe pins to 127.0.0.1 (default: the host of `url`) | | `healthPath` | the path of `url` the check requests (default `/`). Redirects are followed; only a final 2xx counts | | `healthExpect` | text the answer must contain, or the release is rolled back. `{checksum}` is the release's `.release` stamp, `{release}` its version. Without it any 2xx passes — including a static page served while the application never ran. See [Proving the right release is live](#proving-the-right-release-is-live) | | `healthTimeout` | how long the check keeps asking, in seconds (`0` disables it) | | `healthFile` | a path that must exist in a shipped release for it to be valid | | `reload` | run on the target right after the swap | | `keep` | how many releases to keep (the live one and the rollback target are never deleted) | | `tags` | labels for `--tags=` selection; `prod` is *protected* by default | | `enabled` | `false` keeps a target declared but out of every tag fan-out | | `vars` | extra `{placeholders}`. Restricted to `[A-Za-z0-9._/:@+-]`, because some of them reach `reload` and `postInstall`, which run as commands on the server | **Unknown keys are refused**, with a suggestion. A typo like `"datbase"` used to be ignored silently, leaving a target with no database — so `migrate` and `backup` quietly did nothing on it and reported success. ## Proving the right release is live After the swap, `activate` asks the site whether it is serving the release — and rolls back if not. What it asks has to be something only the application, and only *this* release of it, can answer: a status code is not. A static `index.html` left in `www/`, an `.htaccess` the vhost ignores (`AllowOverride None`) or an http→https redirect all answer 2xx or 3xx while no PHP ran. So the check requests the target's `healthPath` (redirects followed, a final 2xx required) and, with `healthExpect`, looks for a text in the answer. Make the application echo the release's stamp — `.release`, at the root of every release, holds the artifact's sha256 — from a route that goes through the front controller and touches what a release needs (its configuration, its keys, its database): ```json "healthPath": "/health", "healthExpect": "{checksum}" ``` Then a passing check proves the vhost, its rewrite rules, PHP, the application's boot and its shared state — and that it is the release just activated answering, not the one before it, nor a cache. On a server of yours the probe runs on the server, against 127.0.0.1, with the url's host name pinned there on 80 and 443 (`curl --resolve`): the redirect to https and the certificate are part of what is checked, and DNS is not. The public url is checked too, and only warns. ## Managed webspaces (shared hosting) IONOS *Hébergement Web*, OVH mutualisé, o2switch, Infomaniak: ssh works, but `sudo`, `systemctl`, a vhost and `/etc/apache2` do not exist for you. The **only** way in is to send files over ssh — which is what this tool has always done: a release travels as one `.tar.gz` and is extracted beside the running one. Nothing about that needs a privileged host. Four things do. Declare `"managed": true` on the server and they are all handled: ```json { "servers": { "ionos": {"sshAlias": "ionos", "auth": "agent", "managed": true, "fingerprint": "a1:b2:c3:…", "root": "/homepages/12/d000000000"} }, "targets": { "demo": { "server": "ionos", "ref": "master", "folder": "mmoframework", "publicPath": "", "url": "http://demo.example.com", "reload": null, "healthTimeout": 180 } } } ``` **1. There is no service to reload.** `"reload": null` is the correct value, not a gap. Nothing warns about it. **2. There is no vhost to install.** `install` writes no `installTemplates` and runs no `postInstall` on a managed server: those `init` sets up are a vhost for `/etc/apache2` and the sudo that enables it, and install — the first task of every release — used to fail there on them. Its plan says so instead, before you confirm: ``` install · skip installTemplates and postInstall: [ionos] is a managed webspace (no sudo, no vhost of ours) ``` Keep them configured when some of your servers are yours: those still get the vhost (see [Wiring it up](#wiring-it-up)). A project whose servers are all webspaces can drop them; `setup` offers to. **3. `127.0.0.1` is not your site.** On a machine of yours the post-activation probe goes to the loopback with an explicit `Host:` header, because that exercises *this* vhost rather than whatever a cache in front of it answers. On a webspace there is no vhost of yours to exercise — the loopback answers the provider's default host, or nothing — so a managed server moves the probe to **the machine you are deploying from**, and the public url becomes authoritative. It is the only statement about a shared host that is both true and checkable: *the site a visitor loads serves something*. `probe` overrides it per target (`target`, `local`, `none`); `curl` is then needed on whichever machine does the probing, and `doctor` says which. Give a managed target a longer `healthTimeout` (120–180s). There is nothing to restart, so each php-fpm worker picks the swap up as its **realpath cache** entry expires (`realpath_cache_ttl`, 120s by default). With the default 20 seconds the tool would roll back a release that was about to be perfectly fine — and a spurious rollback is its own outage. **4. The document root is fixed in a control panel.** That is what `webRoot` is for. `install` turns that one path into a symlink onto `{folder}/current/{publicPath}`: ``` /homepages/12/d000000000/ mmoframework/ ← folder: everything of this project, in one place web -> current ← webRoot, {folder}/web by default: what the panel serves (domain folder: /mmoframework/web). Linked ONCE, by `install` releases/3.0.13/ deploy/ current -> releases/3.0.13 ← the swap, exactly as on any other host ``` After that, `release` and `rollback` move `current` and the served tree follows: no control panel, no vhost, no second mechanism to keep in sync. That layout is the default on a managed server — `webRoot` is `{folder}/web` unless the target says otherwise, and `setup` reads the server's `root` with `pwd` — so each project on a webspace is one folder. Only what the link leads to is served: `releases/` and `deploy/` beside it are not. What is refused, when deploy.json is read: `webRoot` equal to `folder`, or inside `releases/`, `shared/`, `deploy/`, `backups/` or `current` (`install` would link the deployment to itself); and `folder` **inside** `webRoot`, which would serve every release and the journal, and have `install` move the whole deployment aside. One consequence of serving through the folder: Apache applies every `.htaccess` on the path it serves, so a `.htaccess` at the root of `folder` applies to the site too — a deny-all there makes it answer 403. Guard `releases/`, `deploy/` (and `shared/`, `backups/`) each with their own instead; `install`'s plan warns when `folder` has one. `install` **moves** whatever was at `webRoot` aside (`web.before-mmo.20260924T…`) rather than deleting it, and says so in the plan before you confirm. `rm -rf` on the one path a domain resolves to is not a decision a deployment tool gets to make on its own. Two caveats worth knowing before you rely on it: - Apache only follows a symlinked document root with `FollowSymLinks` (or `SymLinksIfOwnerMatch`) in effect. It is on by default on the shared hosts above; if it is not, `Options +FollowSymLinks` in the `.htaccess` at the root of your release is the one place you *can* say so. The post-activation probe is what tells you whether it worked. - If the panel lets you point the domain straight at `{folder}/current/{publicPath}`, do that and write `"webRoot": "none"`. It is one link fewer in the chain. `doctor` reports, per target, whether `tar`, a sha256 tool and `curl` are actually there — on a webspace those are the provider's choice, and finding out that `tar` is missing halfway through a release, with an artifact uploaded and the lock held, is the expensive way to learn it. `sha256sum` (GNU), `shasum -a 256` (BSD/busybox) and `openssl dgst` are all accepted, whichever the host ships. ## Credentials Nothing secret belongs in git, and the tool is built so that it reads a secret from nowhere git would commit. Two supported paths, and both work: **1. ssh-agent (preferred).** The server names a `Host` alias; host, port, user and key all come from `deploy.ssh.config` — an ordinary ssh config kept in the project, git-ignored and mode 0600 — and the agent holds the key. Nothing secret exists in any file the project commits, and two developers can reach one target through different keys without the declaration knowing. `~/.ssh/config` is never read to deploy: `setup` only offers what it says for an alias, to copy into the project. ```json {"servers": {"web1": {"sshAlias": "web1", "auth": "agent", "fingerprint": "a1:b2:c3:..."}}} ``` Adding a new server this way, from the key to `doctor`, step by step: [ssh.md](ssh.md). **2. A git-ignored file.** `deploy.credentials.json`, beside `deploy.json`, per machine. It is read only if its mode is `0600` (nobody else can read it) and git ignores it; otherwise every command refuses to start, `doctor` included, and says how to fix it. A file git already tracks is refused even when `.gitignore` matches it: ignoring never untracks anything, and whatever it holds may already be in a commit. ```bash install -m 600 credentials.example.json deploy.credentials.json # created private, never 0644 ``` ```json {"servers": {"web2": {"privateKey": "~/.ssh/id_ed25519", "passphrase": null}}} ``` `init`'s `.gitignore` covers it with `*.credentials.json`, the pre-commit hook refuses to commit it, and `AssertArtifactTask` refuses a release that contains it. A `password` is supported for a server that cannot take a key yet, from that file only. `doctor` reports which path each server resolves through, whether its host key is pinned, and which credentials file it loaded. ## Wiring it up ```php \MMO\config([ \MMO\admin\controllers\ProductionController::class => [ 'targetsFile' => '{root}/deploy.json', 'credentialsFile' => null, // deploy.credentials.json beside deploy.json 'git' => ['$' => \MMO\git\helpers\Repository::class, 'filename' => __DIR__], 'repository' => ['$' => '#repository'], // only needed for the database commands 'buildFolder' => '{root}/build', 'patchFolder' => '{root}/db/patches', 'buildCommands'=> ['php scripts/build-theme.php all --prod'], 'buildOptions' => [\MMO\client\Theme::class => ['svg' => false, 'js' => false, 'jhs' => false, 'css' => false, 'other' => false]], 'include' => ['www/bin'], // generated assets, not tracked in git 'exclude' => ['src', 'scripts', 'configs/dev.json', 'deploy.json'], 'protectedTags'=> ['prod', 'production'], 'healthTimeout'=> 20, // a target's own `healthTimeout` overrides it 'healthIsFatal'=> true, // false only when the probe cannot be about the release 'installTemplates' => ['{root}/scripts/apache2.conf' => '/tmp/{name}.conf'], 'postInstall' => 'sudo mv -f /tmp/{name}.conf /etc/apache2/sites-available/{name}.conf && sudo a2ensite {name}.conf && sudo systemctl reload apache2', ], ]); ``` On a server of yours, `installTemplates` and `postInstall` run when a target is first installed, and again at the next `install` — every release starts with one — once what they write or run has changed, placeholders resolved: `install` keeps a sha256 of it in `deploy/.install`, written only once `postInstall` has succeeded, so a failed one is tried again. `--force` runs them again anyway. The plan says which: `write` and `run` lines, or `skip ... unchanged since they last ran here`. They never run on a managed server, which has no sudo (see [Managed webspaces](#managed-webspaces-shared-hosting)), nor on a `local` one, which is your own machine. `php admin.php init` writes all of this, plus the templates below, into a fresh project. ## Keeping development files out of a release Three mechanisms, in order: 1. **`export-ignore` in `.gitattributes`** — honoured by `git archive` itself, on every branch, on a fresh clone, with no hook installed. This replaces the `.gitignore_default`/`.gitignore_prod` pair a `post-checkout` hook used to swap, which only worked for developers who had the hook, and never protected a file that was already tracked. 2. **`exclude`** on the controller, for paths git does not track. 3. **`AssertArtifactTask`**, which reads the finished tarball back and refuses to ship it if it contains `deploy.json`, a `*.key`/`*.pem`, `src/`, `.git*` or anything else on its deny-list. Configuration is what gets forgotten when a new file is added; this one trusts neither of the other two. A `pre-commit` hook (`git config core.hooksPath scripts/git`) refuses to commit a credential in the first place. It is a seatbelt, not a lock — `--no-verify` bypasses it — but the structural protections above do not depend on it. ## Setting it up by answering questions ```bash php scripts/admin.php # the dashboard: each step on a key, with what is missing beside it php scripts/admin.php setup # every step php scripts/admin.php setup --step=targets # one step, then the review php scripts/admin.php setup --dry-run # every question and every diff, nothing written ``` Until the files say setup is done (`MMO\admin\helpers\Readiness`, which reads them and connects to nothing), the dashboard offers its steps and nothing else. `setup` asks for what deploying the project needs, one step at a time, each step explained — then writes it: | step | asks | writes | |---|---|---| | `files` | nothing, unless something `init` writes is missing | the missing templates, `*.credentials.json`, `deploy.ssh.config` and `.mmo/*` in `.gitignore`, the `export-ignore` rules `.gitattributes` lacks, `git config core.hooksPath scripts/git`, `chmod 600` on the credentials file | | `project` | its name | `project` | | `servers` | how each is reached (an alias of `deploy.ssh.config`, a host, or this machine), how it lets you in, whether it is a shared webspace, its host key, its root (read with `pwd` on a webspace) | `servers`; a `Host` entry of `deploy.ssh.config` (0600, git-ignored — its values offered from `~/.ssh/config` when that has the alias); `deploy.credentials.json` for a key path or a password | | `targets` | for each, every value it needs: its server, production or not, tags, branch, url, folder (relative to the server's root), database, shared paths, reload — on a webspace, its webRoot (`{folder}/web` by default), publicPath and healthTimeout | `targets` | | `scripts` | the build commands, the generated paths, whether `install` sets up a vhost | ProductionController's values in `scripts/bootstrap.php`; `scripts/apache2.conf`, made to take each target's own `{public}`, `{folder}` and `{host}` | | `write` | whether to write | all of the above, once shown | It is made to be run again: every question starts from what the files say, so ENTER all the way through changes nothing — but what it found missing or disagreeing, and asked about first. - **deploy.json is edited, never regenerated**: only the lines that change, comments and key order kept (`JsonEditor`). A renamed server keeps its place and its comments, and whatever names it follows. A new deploy.json starts from `templates/deploy.json`, whose comments document every key. - **Every key is declared, and commented.** Each server and target gets every key it has — at its built-in value when nobody chose one — and each key without a comment gets the one `Declaration` holds for it; a comment already there is left as it is. A server or a target written on one line goes one key per line, each with its own. - **Nothing is written before the last step.** It shows every change as a diff, secrets masked, and refuses a deploy.json `Store` would not load, or a PHP file that does not parse. - **A secret goes to `deploy.credentials.json`**, created `0600`, and only once `.gitignore` ignores it. One found in deploy.json is offered a move there — and, if it was ever committed, it still has to be changed. - **Nothing on a server changes.** Reading a host key is the only connection it opens, and only when asked to: it pins the SHA1 the ssh2 extension will compare, and prints the MD5 to compare with `ssh-keygen -l -E md5 -f /etc/ssh/ssh_host_*_key.pub`, run on the server. - The questions are `\MMO\core\shell\prompt()`, `choose()` and `confirm()`: a pipe or a file answers them as well, one line per question, and `\MMO\core\shell\setInput()` is how the tests answer them. ## Commands | command | does | |---|---| | *(none)* | the dashboard: the project, its targets and the last deployments beside every action, one key each; setup alone until it is done | | `init` | write the project templates (`--into=`, `--force`) | | `setup` | write deploy.json and what deploying needs by answering questions, each step explained, every change shown first (`--step=`, `--dry-run`) | | `doctor` | check the declaration, the credentials, the host keys and every target's reachability | | `targets` | list the declared targets | | `status` | what is live, what the journal records, whether they disagree, whether a lock is held | | `history` | a target's recorded deployments, newest first | | `sync` | mirror each target's journal into the local store | | `plan` | print the full release plan and change nothing (same as `release --dry-run`) | | `release` | install → build → assert → ship → migrate → activate → prune | | `build` | build and check an artifact without shipping it | | `ship` | upload and extract a release without activating it | | `activate` | switch onto an already shipped release | | `rollback` | point back at the previous release (`--to=` for a specific one) | | `install` | create the folder layout on a new target; on a server of yours, write `installTemplates` and run `postInstall` — once, and again after they change | | `prune` | delete old releases | | `backup` | dump the target's database (`--fetch`, `--restore-locally`) | | `restore` | restore a dump over the target's database (`--file=` required) | | `migrate` | apply the database patches the target has not run yet | | `cmd` | run a command on each target, in its live release (`--exec="..."`) | Options accepted everywhere: | option | meaning | |---|---| | `--targets=a,b` | explicit selection | | `--tags=prod` | select by tag | | `--all` | every enabled target | | `--include-disabled` | also act on targets declared `"enabled": false` | | `--dry-run` / `-n` | describe everything, change nothing | | `--yes` / `-y` | skip the plan confirmation | | `--i-understand` | the non-interactive form of the protected-target confirmation | | `--no-canary` | fan out to every protected target at once | | `--force` | rebuild, re-ship, break a lock, build from a dirty tree, run install's templates and `postInstall` again | | `--continue` | keep going after a target fails | **A person is asked, a script is not.** Typed at a terminal, a command asks what it asks in the dashboard: the plan, a protected target's name, whether to go on after the canary, and which targets when none is named. Without a terminal — cron, CI, a pipe — nothing is asked and nothing is read: the plan is applied as with `--yes`, and a protected target is refused without `--i-understand`. **There is no implicit selection.** A mutating command with no `--targets`, `--tags` or `--all` asks a human, or fails — it never falls back to "every target". A selection that matches nothing is an error, and so is naming a target that is disabled. **Every command sets the process exit code**, from the run's outcome *and* from a command that was refused or threw. `php admin.php release --tags=prod --yes --i-understand && ./notify.sh` behaves. ## Protected targets A target tagged `prod` cannot be changed by accident: - the selection is printed and has to be confirmed **separately from the plan** — by typing the target's name (or, for several, how many there are), or `--i-understand` from a script. `--yes` does not satisfy it: it answers "don't ask me about the plan", which people pass out of habit. - a fan-out across more than one of them **deploys to the first alone and stops**, so a bad release reaches one machine rather than the fleet. `--no-canary` opts out. ## How a deployment runs For each target: **plan → confirm → lock → (apply + verify)\* → record → unlock**, and on any failure, **rollback in reverse → record → stop**. - The **plan** is produced by reading the target, so `--dry-run` reflects reality rather than intent. - The **lock** is a directory created with `mkdir` on the target — atomic, and visible to `status`. Breaking a stale one is atomic too (`mv` aside, then `mkdir` again): exactly one runner can win. - Every task **verifies itself against the target**. A task cannot report success; it can only fail to produce errors from an inspection of the target. - A failure **rolls back** every task that already *started*, most recent first — including one that threw half-way through its own work, which is precisely when rolling back matters. - The **journal** entry is written last, failures included. A run where nothing needed doing writes nothing. - A rollback that **cannot** complete is reported as such. The tool never records a rollback that did not happen. ## Database patches Patches live in `db/patches/` (`1.0.0.js`, `1.1.0.js`, …) and are applied in version order. A patch runs on a target **if, and only if, its filename is absent from that target's journal** — so `migrate` is safe to re-run, a patch never runs twice, and a half-finished migration resumes exactly where it stopped. The journal's rotated files are read back for this, so a patch is not forgotten once its entry ages out of the live file. Every migration takes a snapshot first, records it in the journal entry, and **restores it if the release fails** — a failed release is reversible in the database as well as on disk. Write patches expand-then-contract anyway, so a rollback never needs the database to move at all. ## The local store (and a future control panel) `deploy.json` is read into real entities — `Project`, `Server`, `Target` — and `sync` mirrors every target's journal into a `JsonRepository` at `.mmo/deploy.history.json`, in the project, as `Deployment` entities. Every deployment mirrors the targets it touched as well, which is what the dashboard's list of deployments reads. The file is this machine's: `.mmo/` is 0700, the file 0600, and `.gitignore` ignores `.mmo/*`. Nothing is kept outside the project folder. ```bash php admin.php sync --all ``` That is a read-only operation on the targets, safe to run during a deployment, and it means a fleet-wide view ("every project, every target, what is live on each, who put it there") is a query against a collection rather than 27 ssh connections. No deployment ever writes `deploy.json`: a topology is reviewed source, not application state. `setup` is the one command that edits it, and only once a person has answered its questions and read the diff. ## The test suite ```bash cd libs/admin && phpunit ``` Everything runs against a **real local shell and a real folder tree** under the system temp directory: a target whose server declares `"shell": "local"` takes exactly the same code path as one reached over ssh, which is why that escape hatch exists. So the suite exercises the lock, the checksum, the shared symlinks, the atomic swap, the journal and the rollback without a server, a database or git — and what it proves holds for a real target. | suite | covers | |---|---| | `entities` | placeholder resolution, the on-target layout, the `vars` shell-safety rules, the journal projection | | `helpers` | declaration validation, target selection, credentials, journal rotation, locking, **the runner's failure handling** | | `tasks` | install, ship, activate, the artifact deny-list, database migration and its rollback, pruning | Each test that encodes a past defect says so in its docblock, and every one of those was mutation-checked: re-introducing the bug makes the suite fail. Two conventions worth knowing before adding to it: - **`$this->serving($target)` and `$this->live($target)`, never a plain `file_get_contents()` through `current`.** PHP caches resolved realpaths, so after a swap a plain read still returns the *previous* release's file. That is the same mechanism that makes a symlink deploy need an opcache reset in its `reload` command, and a test that forgets it reads the old release and calls a working deploy broken. - **A test's folder is removed in `tearDown`, and the whole tree again at shutdown.** A repository a test created stays registered for the framework's Commit event, which fires *after* `tearDown` and rewrites its file — recreating the folder that was just deleted. ## Writing a task ```php class WarmCacheTask extends \MMO\admin\classes\aDeployTask { public function plan(Context $c): array {return ['warm the cache'];} public function apply(Context $c): void {$c->shell->exec('php bin/warm-cache.php', $c->getReleaseFolder());} public function verify(Context $c): array { return $c->shell->exists($c->getReleaseFolder() . '/var/cache/index')? [] : [new eRuntime($this, 'cache is empty')]; } public function rollback(Context $c): void {} } ``` Add it to `ProductionController::getReleaseTasks()` (or subclass the controller) and it participates in dry-run, confirmation, verification and rollback for free. `rollback()` must be safe to call on a task that only partially applied — the runner registers a task *before* running it, precisely so a half-done task is undone. ## Running commands on a target `ShellRunner` (in `mmo.core`, `MMO\core\server\ShellRunner`) is the reason the tool can tell success from failure. It wraps each command so its exit status comes back in-band: ```php $sh = new ShellRunner($target->getShell()); $sh->run('git pull')->isOk(); // a real exit code, over ssh and locally alike $sh->exec('tar -xzf x.tgz'); // throws, with the real output, on any non-zero exit $sh->write($path, $content); // atomic: written beside, then moved over $sh->symlink($link, $to); // atomic: the link is never missing $sh->quote($path); // quoted for the shell that will RUN it, not for this machine's ``` This matters because the exit code comes back as command output, so it never depends on `ssh2` reporting an `exit_status` for the channel (which it only resolves once the channel is drained and closed), and because stderr is folded into stdout: a command's own progress messages — which git, tar and mongodump all write while succeeding — cannot be mistaken for a failure. The same code then runs against a local shell or an ssh one without knowing which it has. ## Notes and limits - The local side needs a POSIX shell (`sh`), `git` and `tar`; the target needs `sh`, `tar`, one of `sha256sum` / `shasum` / `openssl`, and — only when it is probed from itself — `curl` (its absence is reported as such, not as "the site is down"). `doctor` checks all of them. - `build` refuses to run unless the working tree is clean **and** already checked out at the target's `ref`: the `.phar` dependencies are built from the working tree, so they must match what is exported. `--force` overrides it and warns. - The libraries' `.phar` are built from their checkouts (the hub's `libs/`), on whatever branch each one is, not from a commit. `build` checks each one's repository before building: uncommitted changes (its own `build/` aside) are refused from a script without `--force`, and asked about, once per run, at a terminal — ENTER is no. Each library's version, commit, branch and count of uncommitted changes go into the journal entry (`libraries`), and beside the artifact (`.libraries.json`), so a reused artifact still records them. `status` lists them; `history` marks a release built with uncommitted library code. A library whose phar is rebuilt gets its patch version raised in its `mmo.json` during the build: commit that too. - `include` paths (`www/bin`) come from the working tree, not the commit — git cannot see them, because they are ignored. Configure `buildCommands` so they are regenerated; their hash is recorded in the journal, and an overlay older than the commit it ships with is called out. - The libraries' `package` hooks run in the deployment script itself while their phars are built, with the options of `buildOptions`. mmo.client's compiles the theme into `{bin}`, the working tree, with every library that script loaded, which is not what the site loads: it overwrites what a `buildCommands` entry just compiled, and the overlay ships it. When `buildCommands` builds the theme, turn the hook off, as above. - `prune` is deliberately conservative — the live release and the rollback target are never deleted, so a target may briefly hold one more release than `keep` allows. - An artifact is reused whenever one already exists for the same (version, **commit**). Pass `--force` to rebuild. - A release folder is named after the **version**, not the commit. A new build of the version that is live is refused (`release [x] is the live one`), `--force` included: replacing the served folder in place would be neither atomic nor reversible. Raise `mmo.json`'s version for every release. A release that is not live is replaced. - With a symlink swap, PHP's opcache is keyed on the resolved realpath, so moving `current` invalidates nothing by itself: `reload` has to restart the interpreter (`systemctl reload apache2` for mod_php; reload the **pool** for php-fpm). On a managed webspace there is nothing to restart — see [Managed webspaces](#managed-webspaces-shared-hosting) for what to do instead. - `webRoot` is the one path in a declaration that points at something the tool did not create. It has to be absolute; it may sit in the target's own `folder`, beside the layout, but not be the folder, nor be inside `releases/`, `shared/`, `deploy/`, `backups/` or `current`, nor contain the folder. All of it is refused when the file is read, before a connection is opened.