Index
# 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.