# mmo.admin — overview for AI code generation
`mmo.admin` (PHP namespace `MMO\admin`) is Marc Mouret's deployment tool for MMOFramework projects. It builds a
project into one immutable, versioned artifact, ships it to one or more declared **targets**, and switches each
target onto it with a single atomic symlink swap. Everything it did is recorded on the target itself (the
**journal**), so `status`, `history` and `rollback` read a fact instead of a guess.
This package has **no HTTP surface**. Its entry point is a CLI controller
(`MMO\admin\controllers\ProductionController`, extending `aShellController`) reachable only through a project's
own `scripts/admin.php` (see `templates/scripts/admin.php`) — an ordinary MMOFramework `Route`/`#[Command]` /
`#[InteractiveCommand]` shell app, not something this package's own `bootstrap.php` starts by itself.
Read [deploy.md](../deploy.md) (also exposed as an MCP resource) for the full command reference, every
`deploy.json` key, the operating manual and the test-suite conventions — this file only orients you before you
touch the source. It is deliberately shorter: what follows is what a change to this package, or a new
`aDeployTask`, needs to get right; deploy.md is the day-to-day *user's* manual (how to run `release`, read
`doctor`, set up a target).
## The shape of it
```
deploy.json → Store::FromFile() → Project / Server / Target entities (MMO\admin\entities\*, MMO\admin\helpers\Store)
│
ProductionController::selectTargets() ──────┘ (MMO\admin\helpers\Targets — no implicit "all")
│
Runner::run($targets, $tasks, $options) (MMO\admin\helpers\Runner)
for each target:
new Context($target, $dryRun, $interactive, $repository) (MMO\admin\helpers\Context)
for each aDeployTask: isNeeded() → plan() → apply() → verify() (MMO\admin\classes\aDeployTask)
on any failure: rollback() every already-applied task, most recent first
Context::toDeployment() → Journal::append() (MMO\admin\helpers\Journal, on the TARGET)
```
- `Store` (`src/helpers/Store.php`) loads and validates `deploy.json` into `Collection`s of `Project`, `Server`
and `Target` entities (`src/entities/*.php`, all `MMO\core\entities\Entity` with `#[ArrayProperty]` fields).
It refuses to parse a file that carries anything secret-shaped (`Store::SECRETS`) or an unknown key
(`Store::assertRow`), and it **never writes the file back** — the declaration is reviewed source, not
application state.
- `Target::getShell(): iShell` and `Server::getShell(): iShell` resolve to either a real `MMO\core\server\SSH`
or the local shell (`MMO\core\server\Shell::Shared()`) — every task talks to the target only through the
`ShellRunner` wrapping that shell (`Context::$shell`), never directly.
- `ProductionController` owns no deployment logic itself: each `route_*` method (`route_release`,
`route_rollback`, `route_backup`, ...) just calls `selectTargets()` then `run()`/`getRunner()->run()` with the
right list of tasks — see `getReleaseTasks()` for the canonical pipeline
(`InstallTask → BuildTask → AssertArtifactTask → ShipTask → MigrateTask → ActivateTask → PruneTask`).
## The five things that matter most
1. **A task is four questions, not one procedure.** `MMO\admin\classes\aDeployTask` (`src/classes/aDeployTask.php`)
requires `plan(Context): string[]` (describe, don't act — this is what `--dry-run` and the printed plan use)
and `apply(Context): void` (act, throw on failure), and lets you override `verify(Context): \Throwable[]`
(look at the *target* and report what's still wrong — an empty array means verified) and
`rollback(Context): void` (undo, and must be safe to call on a task that only *partially* applied, because
`Runner::runTarget()` registers a task as "applied" **before** calling `apply()`, precisely so a task that
throws half-way through still gets rolled back). A task that only inspects and never mutates
(`AssertArtifactTask`) should override `isChange(): bool` to return `false`, or a run made of nothing else
gets wrongly recorded as a deployment.
2. **`Context` is the only channel between tasks, and it is built once per (run, target) by the `Runner` —
never by a task itself.** `src/helpers/Context.php` is where `BuildTask` leaves `$version`/`$artifact`/
`$checksum`, `ShipTask` reads them and extracts the artifact into `getReleaseFolder()` (`getRelease()` is
`$release` when a task set it — only `RollbackTask` does — else `$version`), `ActivateTask` swaps onto that
release and fills `$previous`, and `Context::toDeployment()` turns the whole thing into the journal entry at
the end. A new task that needs to pass something downstream adds a public property here, not a side channel.
3. **Nothing defaults to "every target".** `ProductionController::selectTargets()` requires `--targets=a,b`,
`--tags=prod`, `--all`, or (only when a human is present) an interactive pick — a script with no selection
gets an `eRuntime`, never the fleet. `MMO\admin\helpers\Targets::select()` also refuses a selection that
matches nothing, and refuses a *named* disabled target (a *tag* match quietly skips disabled targets; a
*name* match on one is an error) — mirror this if you add another selection path.
4. **A target tagged with one of `ProductionController::$protectedTags` (`prod`, `production` by default)
cannot be changed by accident.** `confirmProtected()` demands a *separate* confirmation from the plan
itself — typing the target's name (or, for several, how many are listed) interactively, or `--i-understand`
from a script; `--yes` does not satisfy it, on purpose (`--yes` only skips the plan confirmation). A fan-out
across more than one protected target runs the canary alone first (`runFanOut()`) unless `--no-canary` is
passed. Don't bypass this funnel — every mutating `route_*` method but `route_cmd` goes through
`$this->run()`, which is where both the exit-code bookkeeping and this guard live (`route_cmd` calls
`confirmProtected()` and sets the exit code itself, and gets no canary).
5. **Secrets never enter `deploy.json`, structurally, not by convention.** `Store::assertNoSecrets()` refuses to
*parse* the file if any key (at any depth, including a `//`-prefixed comment key) looks secret-shaped
(`Store::SECRETS`: password, privatekey, passphrase, token, ...). A server's `auth` is a *method*
(`agent`/`key`/`password`), never a value; the value, if any, comes only from `deploy.credentials.json`,
beside `deploy.json`, via `MMO\admin\helpers\Credentials`, which refuses to read it unless its mode is
`0600` and git ignores it (a tracked file is refused even when `.gitignore` matches it). If you add a
field that could ever hold a secret, either route it through `Credentials` or add its key to
`Store::SECRETS` — don't let it land in the declaration.
## Writing deploy.json: `setup`
`route_setup` hands everything to `MMO\admin\helpers\SetupWizard`: a step per method (`stepFiles`,
`stepProject`, `stepServers`, `stepTargets`, `stepScripts`, `stepWrite`), answers kept in a
model (`$data`, `$secrets`, `$hosts`, `$bootstrap`...) and written only by `stepWrite`, after a diff. Two helpers
do the writing, and both edit rather than regenerate:
- `JsonEditor` changes a JSON text in place — a value's span, a key after the last one, a key with the comment
lines above it — keeping comments, key order and layout. `update($data)` changes only what differs, then
checks the text reads back as `$data`, by itself and by `\MMO\core\strings\jsonDecode()`. Write an empty
object as `new \stdClass()`: `[]` is a list.
- `PhpConfigEditor` replaces the expression of `'key' => ...` inside `Class::class => [...]` of a PHP config,
found with `PhpToken`; the wizard lints what it returns before writing it.
Aliases go to the project's `deploy.ssh.config` (`SshConfig::DefaultFilename()`), never to `~/.ssh/config`: the
user's file is only read, through the overridable `userSshConfig()`, for the values it offers. Every private file it writes
is git-ignored first (`ignoresNeeded()`), whichever step made it.
## The dashboard
`index` (no command) runs `MMO\admin\helpers\Dashboard`: it draws, reads one key, runs the command of that key
through `callInteractiveCommand()` — so it adds no behaviour — and draws again. `Readiness` decides what it offers:
while a blocking finding remains, the steps of `setup` alone (`ProductionController::runSetup($step)`). It never
connects to anything to draw; the deployments it lists come from the store's history.
A new question goes through the wizard's `ask()`/`pick()`/`yes()`/`askList()`, which turn "nobody answered"
into a cancelled run; its default must be what the file says now, so that ENTER changes nothing. Tests answer
with `\MMO\core\shell\setInput()` (`tests/helpers/TestSetupWizard.php`), and override `readHostKey()` — the one
network read — and `checkAgent()`.
## Writing a new task
```php
namespace MMO\admin\tasks;
use MMO\admin\classes\aDeployTask;
use MMO\admin\helpers\Context;
use MMO\core\exceptions\eRuntime;
class WarmCacheTask extends 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 {}
}
```
Wire it into a pipeline by adding it to `ProductionController::getReleaseTasks()` (or a project-level subclass
that overrides it) — it then participates in dry-run, confirmation, verification and rollback for free. See
`src/tasks/*.php` for real examples: `BuildTask` (git export + `.phar` build), `ShipTask` (upload + sha256
verify + extract), `ActivateTask` (the atomic symlink swap + post-swap health probe),
`AssertArtifactTask` (reads the finished tarball back and refuses to ship a forbidden path),
`MigrateTask`/`BackupTask`/`RestoreTask` (extend `MMO\admin\classes\aDatabaseTask`, which needs a repository
supporting `backup`/`restore`/`patch`/`getBackupName` — checked at runtime via `method_exists`, since
`mmo.admin` does not depend on `mmo.mongodb`).
## Wiring the controller into a project
A project configures `ProductionController` (never instantiated directly — it's an `aModule`, use
`ProductionController::Shared()`) through `\MMO\config()`, then serves it as the only route of a small CLI
entry script:
```php
\MMO\config([
\MMO\admin\controllers\ProductionController::class => [
'targetsFile' => '{root}/deploy.json',
'git' => ['$' => \MMO\git\helpers\Repository::class, 'filename' => __DIR__],
'repository' => ['$' => '#repository'], // only needed for backup/restore/migrate
'buildCommands' => ['php scripts/build-theme.php all --prod'],
'protectedTags' => ['prod', 'production'],
],
]);
$app = \MMO\app();
$app->clearRoutes()->setRoutes([\MMO\admin\controllers\ProductionController::class]);
$response = $app->runInteractive();
exit((($response?->getStatusCode()??500) >= 400) ? 1 : \MMO\admin\controllers\ProductionController::Shared()->getExitCode());
```
See `templates/scripts/bootstrap.php` and `templates/scripts/admin.php` for the full, real templates
(`init` writes both into a fresh project) — note the **double exit-code check**: a target that failed while
the run continued is `getExitCode()`; the command itself being refused (unknown target, held lock, a protected
target without `--i-understand`) is a `>= 400` response with `getExitCode()` never touched, so both have to be
checked or a broken run can still exit `0`.
## Verifying your work
There's no server and no database needed for almost anything here:
```bash
php -l src/tasks/YourTask.php
cd libs/admin && phpunit
```
The suite (`tests/`) runs every task against a **real local shell and a real folder tree** under the system
temp directory — a `Server` declaring `"shell": "local"` takes exactly the same code path as one reached over
ssh, so what the suite proves holds for a real target. If you're unsure how something actually behaves —
`Context::getRelease()`'s fallback, what `Runner::rollback()` does when a rollback itself throws, how
`Target::resolve()` handles an unknown placeholder — write a small script and run it rather than reasoning
about the source, per MMOFramework's general "write a five-line script and run it" discipline
(`require 'bootstrap.php';` then use the classes directly):
```php
getTargets()->getById('demo');
var_dump($target->getFolder(), $target->getShell()::class);
```
Two test-suite conventions worth knowing if you add to it (documented at more length in
[deploy.md](../deploy.md#the-test-suite)): use `$this->serving($target)`/`$this->live($target)` rather than a
plain `file_get_contents()` through `current` (PHP caches resolved realpaths, so a plain read after a swap can
still return the *previous* release), and a repository a test created stays registered for the framework's
`Commit` event, which fires again at **shutdown**, after `tearDown` — so cleanup that deletes a test's folder
belongs in a `register_shutdown_function`, not only in `tearDown`, or the event recreates the folder.
## What I could not verify from the source alone
- Whether anything outside this hub implements the `backup`/`restore`/`patch`/`getBackupName` trio
`aDatabaseTask` expects. Inside it, `mmo.mongodb`'s `MongoRepository` (`libs/mongodb`) is the only class that
does, as the docblock says — any other repository you configure has to provide all four itself.
- The exact wire format `ShellRunner::exec()`/`run()` uses to smuggle a remote exit code back in-band (referenced
from `MMO\core\server\ShellRunner` in `mmo.core`) — treat it as opaque and use the documented
`run()->isOk()` / `exec()` (throws) / `write()` (atomic) / `symlink()` (atomic) surface rather than assuming
how it's implemented.