A Laravel single-page app that hosts WikiOasis's operational tooling as a set of apps, with one central place for accounts and access. You sign in, you get a launcher, and you open what you have been granted.
Today there is one app: Deployments — the replacement for mwdeploy, the
Python/curses CLI, as the way MediaWiki core, extensions, skins and config reach
the WikiOasis appserver fleet.
It runs on the Salt master and talks to the fleet only through the local
salt binary. There is no SSH transport in this application. mwdeploy itself is
cut down from an orchestrator into mwdeploy-shim, a set of atomic,
single-purpose subcommands the deployments app invokes one at a time, once per
server.
Browser — Vue 3 single-page app (one shell view, everything else client-routed)
│ fetch /api/* — session cookie, CSRF, no API tokens
▼
Laravel (on salt-us-east-021)
│ MySQL — deploy history, job state, repo/patch registry
│ queue worker — one RunDeployment job per deployment
│ Reverb — live deployment view over Laravel Echo, with a poll behind it
│
├─► symfony/process → /usr/bin/salt --out=json --static <minion> cmd.run_all '<mwdeploy-shim …>'
├─► git (via the shim, on staging) for branch/commit listings
└─► mwdeploy-shim tree-scan (read-only) to inventory a farm that already exists
Sign-in, the TOTP challenge and password resets stay server-rendered. Fortify owns those flows, and an ops tool whose login page depends on a JavaScript bundle having loaded is an ops tool you cannot get into on the day the bundle is what broke. Everything behind sign-in is the SPA: one shell view containing nothing but a mount point, and no page loads after it.
The console is a shell around a set of apps. An app is a submodule with a boundary around it:
- its own permissions. Every permission belongs to exactly one app — see
App\Support\Permissions::groups().deploy.coreis the deployments app's;users.manageis the console's. - its own access grant.
apps.<id>.accessopens the app and grants nothing inside it: that is what theviewerrole holds. Holding any of the app's own permissions also implies access, so grantingdeploy.coreis enough on its own. - its own API routes, in
routes/apps/<id>.php, loaded insideapp.access:<id>middleware. An account with no grant in an app cannot reach any of it — not merely the screens the launcher hides. - its own screens, under
resources/js/apps/<id>/, mounted at its own path prefix, contributing their own nav and chrome buttons through a manifest.
/ the launcher — the apps this account may open
/access accounts, roles, and which app permissions each role grants
/deployments the deployments app (overview, history, wizard, registry…)
Installing an app is one line in config/console.php. Nothing else knows the
list: the launcher, the nav, the API route table and the permission admin are all
built from the registry (App\Apps\AppRegistry) and its client-side mirror
(resources/js/apps/index.js). CONSOLE_DISABLED_APPS switches one off for an
install — it vanishes from the launcher and its API answers 404.
Access is granted centrally, on /access: accounts hold roles, roles hold
permissions, and ticking an app's access permission is what puts its tile on its
members' launcher. After adding an app, re-run php artisan db:seed --force —
that is how new permissions reach the existing roles.
| Capability | mwdeploy |
Deployments app |
|---|---|---|
| Pick a branch or commit per repo | no — always fetch && reset --hard FETCH_HEAD |
yes, per checkout |
| Deploy to one version / several / all | no notion of versions at all | yes; each version keeps its own pinned ref |
| Cut a new core version | by hand, then re-clone ~100 extensions one by one | one form: reconstruct from an existing version |
| Undeploy an extension, skin or version | no concept of it | yes, per version, behind its own permissions |
| Deploy history | last run only, in /var/log/mwdeploy-state.json |
every run, queryable |
| Rollback | no concept of it | yes — and it reverses removals as well as ref changes |
| Users and permissions | whoever is on the Salt master shell | accounts, roles, 21 permissions across the console and its apps, enforced TOTP |
| Patch consistency | --patch/--patch-target retyped each time |
registry; target path stored on the patch |
| Repo registration | by hand on disk | form, with a reachability check, then a reviewable clone |
| Adopting an existing farm | n/a — the CLI was the farm's history | tree-scan reads the tree and fills the registry in from it |
| Registering wiki config | edit paths by hand | one field: paste the git URL |
| Blocking canary prompt | curses Prompter.input() |
web modal the job polls for |
| Interface | curses, one terminal at a time | single-page app over a JSON API, live over websockets |
A repository is one logical thing — "the Echo extension". It is checked out once per core version it belongs to, and each checkout is what a deployment acts on:
repositories Echo (name, type, git_url, default_branch)
repository_versions Echo @ 1.45 → versions/1.45/extensions/Echo pinned REL1_45
Echo @ 1.46 → versions/1.46/extensions/Echo pinned REL1_46
Each checkout carries its own ref mode: pinned to a fixed branch (the
default), default_branch to follow the repository's, or floating to be chosen
every time. So "deploy Echo to all versions" sends REL1_45 to 1.45 and REL1_46 to
1.46 in one action, without the operator retyping anything — and any row can still
be overridden per deployment.
The same idea one axis over is what a core upgrade is made of: the wizard can select every extension — or every skin — in one version and put them all on one ref, so "put 1.46 on REL1_46" is three answers rather than a hundred ticked boxes. The rows it selects are ordinary line items — each appears with its own ref, individually editable, and the review step still lists every Salt call before anything runs.
MediaWiki core is modelled the same way: one mediawiki repository, whose
checkouts are the versions/<ver> trees. Config sits outside the version tree
and has one unversioned checkout.
The deployments app is not usually installed onto an empty disk. A farm that has been
running for years already has versions/1.45/extensions/… checked out, on refs
somebody chose, from remotes somebody picked — and asking an operator to retype a
hundred extensions into a form to describe code that is already there is not a
migration path.
So it reads the tree instead. mwdeploy-shim tree-scan walks the deploy
root and reports what it finds:
versions/<ver> core, plus MW_VERSION out of includes/Defines.php
versions/<ver>/extensions/<Name> extension
versions/<ver>/skins/<Name> skin
extensions/, skins/ unversioned checkouts, for a farm mid-migration
<config_dir> wiki config
For each one it reads, straight off disk and without shelling out to git per
repository: the remote from .git/config, the current branch or detached commit
from .git/HEAD (following the gitdir: pointer when .git is a file, which it is
for anything that was ever a submodule), the commit from the loose ref or
packed-refs, and the declared name, version and licence from extension.json or
skin.json — so the inventory knows that the directory called Echo contains the
extension Notifications. Several hundred checkouts cost one process, not a few
thousand.
Import then diffs that against the registry and shows you the difference, one row at a time:
| Register | on disk, nothing in the registry knows about it |
| Add checkout | the repository is registered, but not in this core version |
| Adopt | the row says undeployed, but the checkout is right there |
| Update pin | registered and present, sitting on a different ref than it pins |
| Update remote | the registered git URL is not the one the checkout came from |
| Mark undeployed | the registry says present; the tree does not have it |
| Cannot import | a directory with no git remote — nothing could describe how to deploy it |
The additive rows are ticked by default; the three that rewrite an existing decision are not. Nothing in the import path touches the tree — no clone, no checkout, no rsync, no removal. It writes registry rows describing code that is already on disk, which is why an imported checkout is recorded as already deployed, pinned to the ref it is actually on. Getting that wrong in the other direction would make the first deployment of every extension a clone over a live tree.
The same thing works before anyone has signed in:
php artisan mwdeploy:import-tree # show the plan, change nothing
php artisan mwdeploy:import-tree --apply # adopt everything additive
php artisan mwdeploy:import-tree --apply --repin # also match pins to the treeReading the tree is also the only chance to record what it looks like, so every scan — including the dry runs nobody applies — updates the observation columns on each checkout. Those are kept strictly apart from the pin, which is what lets the repository screen say "the registry pins REL1_45 but staging is on REL1_44" instead of quietly overwriting one with the other. The dashboard counts those disagreements; zero is the healthy answer.
mw-config is the repository every farm has exactly one of, always at the same
place in the tree, never versioned — so the general form's name, type, version and
pin questions all have exactly one possible answer. The config screen asks for the
git URL and works the rest out, including which situation the farm is in:
- the checkout is already on disk → adopt it, registry only, no clone over the top of a live config repository;
- it is not → register and clone it onto staging, as an ordinary reviewable, rollbackable deployment.
Where it lives is configurable (MWDEPLOY_CONFIG_DIR), because farms disagree:
config, mw-config and wikiconfig are all in the wild.
- Cut 1.46 from 1.45 — scaffolds
versions/1.46/, then registers and clones core plus every extension and skin 1.45 currently has, at a ref you pick per repository. It runs as an ordinary deployment: reviewable call-by-call, live on the dashboard, and rollbackable. - Undeploy a checkout, or a whole version. Removal is
rm -rfon staging and on every server, one call per host, guarded so the shim refuses anything outside the deploy root and refuses a bareversions/<ver>without an explicit flag. Undeploying a version is refused while wikis still point at it.
Removal is a separate grant from deployment throughout
(deploy.undeploy_extension, deploy.undeploy_skin, deploy.undeploy_version),
and every removal is reversible — see below.
app/
Apps/ the app registry: ConsoleApp, BaseApp, AppRegistry
Apps/Deployments/ the deployments app's definition (id, path, routes)
Actions/Deployments/ CreateDeployment, RollbackDeployment
Actions/Import/ ApplyImport — the only writer of "already deployed" rows
Actions/Repositories/ RegisterRepository, RegisterCheckout, RegisterConfigRepository
Actions/Versions/ CreateVersion (reconstruct), UndeployVersion
Enums/ statuses, step names, ref types, decisions
Http/Controllers/Api/ the JSON API the SPA talks to; one controller per screen
Http/Resources/ payload shapes, so a field is named once
Http/ form requests, RequireTwoFactor, EnsureAppAccess, the shell
Jobs/RunDeployment.php the queued job; unique per staging tree
Policies/ who may deploy, roll back, decide, manage
Services/Deployment/ the orchestrator — runner, rollout pool, decision gate
Services/Discovery/ tree scan → import plan; reads the farm, writes nothing
Services/Git/ branch/commit discovery (salt | local | none)
Services/Salt/ the only code that touches the fleet
Support/ DeploymentOptions, PathResolver, Permissions
resources/js/
app.js, router.js the SPA entry point; console routes + each app's
api.js, store.js, live.js fetch wrapper, session/app/flash state, Echo + polling
components/ the shared component library: panels, fields, combobox
console/ the chrome, the launcher, the access screen
apps/index.js the client-side app registry, mirroring config/console.php
apps/deployments/ that app's manifest, screens and own components
auth.js a few kB for the server-rendered sign-in pages
routes/
web.php the SPA shell and TOTP enrolment
api.php the console's API, plus one guarded group per app
apps/deployments.php the deployments app's own endpoints
shim/
mwdeploy_shim.py install as /usr/local/bin/mwdeploy-shim on every minion
tests/ python unittest suite for the shim
docs/
OPEN-QUESTIONS.md section 7 of the handoff spec, answered
OPERATIONS.md installing and running the console
SALT-INTEGRATION.md handoff for wiring this into the SaltStack repo
The orchestration is worth reading in one sitting, in this order:
app/Jobs/RunDeployment.php → app/Services/Deployment/DeploymentRunner.php →
ServerPipeline.php → RolloutPool.php.
The adoption path is a shorter read, in this one: shim/mwdeploy_shim.py's
cmd_tree_scan → app/Services/Discovery/TreeScanner.php → ImportPlanner.php →
app/Actions/Import/ApplyImport.php.
Requires PHP 8.3+, Composer, Node 20+, MySQL 8 (or SQLite for local work), and a
Salt master with the salt CLI on $PATH.
composer install --no-dev --optimize-autoloader
npm ci && npm run build
cp .env.example .env
php artisan key:generate
# Edit .env: DB_*, REVERB_*, and the MWDEPLOY_* block at the bottom.
php artisan migrate --force
php artisan db:seed --force # roles and permissions (idempotent)
php artisan mwdeploy:create-user you@wikioasis.org --role=adminnpm run build is not optional: the interface is a bundled single-page app, and the
@vite directive fails loudly rather than serving a broken page if the manifest is
missing. Nothing is fetched from a CDN at runtime — the Salt master is not
necessarily allowed to reach the internet.
If the farm already has MediaWiki on disk, fill the registry in from it rather than by hand:
php artisan mwdeploy:import-tree # what it found, and what it would do
php artisan mwdeploy:import-tree --apply --as=you@wikioasis.orgThen, still in .env, point the deployments app at the fleet:
MWDEPLOY_STAGING_TARGET=staging # Salt minion id of the staging host
MWDEPLOY_SHIM_BINARY=/usr/local/bin/mwdeploy-shim
MWDEPLOY_RSYNC_SOURCE=rsync://staging.wikioasis.org/mediawiki/Register the appservers and proxies under Targets. hostname must equal the
Salt minion id exactly — check it against salt-key -L.
Install the shim on every minion (including staging and the proxies):
install -m 0755 shim/mwdeploy_shim.py /usr/local/bin/mwdeploy-shimFull operational detail — systemd units, the rsync daemon, sudo rules, HAProxy
socket permissions — is in docs/OPERATIONS.md. To manage
all of that from Salt, including the nginx vhost and the HAProxy frontend/backend,
follow docs/SALT-INTEGRATION.md.
Three processes:
php artisan queue:work --queue=default --tries=1 --timeout=0 # deploy jobs
php artisan reverb:start # live dashboard
# plus php-fpm / nginx for the app itself--timeout=0 matters: a deploy job blocks for as long as its rsyncs, l10n
rebuilds and canary retries take, and a worker timeout mid-rollout would leave
the fleet half-updated.
php artisan test # 158 tests
python3 -m unittest discover -s shim/tests -t . # 88 tests
vendor/bin/pint --test # style
npm run build # the SPA bundleNothing in the PHP suite shells out to a real salt: FakeSaltClient records
every call and answers from a queue of canned results, so the orchestration is
tested against exact call sequences (see
tests/Feature/RolloutBehaviourTest::a_happy_path_deployment_runs_the_whole_sequence_in_order).
TreeImportTest uses the same mechanism to assert the stronger claim about
importing: that the only Salt call an import makes is the read-only scan.
The shim suite does the opposite — it builds real git repositories in a temporary
directory and runs the shim as a subprocess, because tree-scan is an assertion
about what git leaves on disk and a hand-written .git/config would only prove the
parser agrees with itself.
- For a version removal, read the farm's wiki → version map and refuse if any wiki still uses it (failing closed if the map cannot be read).
- Read every checkout's current state on staging →
repo_state_snapshots. This happens before anything mutates staging, so the undo point is real. - For a new core version, scaffold
versions/<ver>/. - Removals on staging: the staging tree and the staging host's own production
copy, or one
rm -rf versions/<ver>for a whole version. git-checkouteach ref, cloning first if the checkout is not on disk.- Apply selected patches.
rsync-localstaging → production on the staging host, restricted to the paths being deployed. Skipped entirely when everything was a removal — "no paths" means "the whole tree" to rsync.- Optional
l10n-rebuildon staging. - Canary on staging. This gates the fleet rollout.
- Per appserver, up to
--parallel Nat once: depool from every proxy → removals →rsync-remote→ optionall10n-rebuild→ canary → repool. - Aggregate the per-server results, then reconcile the registry with what is now on disk — only on success, since a half-finished deployment leaves a state the registry cannot describe.
A canary failure without --force parks that one pipeline on a blocking prompt
(Continue / Abort only / Abort and roll back) while the other pipelines keep
moving. An unanswered prompt applies MWDEPLOY_DECISION_TIMEOUT_DEFAULT rather
than leaving the fleet parked mid-rollout. Aborting still repools every server it
depooled.
A rollback is just another deployment: RollbackDeployment creates one from the
failed deployment's repo_state_snapshots, then dispatches the same
RunDeployment job. There is no second code path.
Snapshots record presence as well as ref, which is what makes one implementation reverse every direction:
| Before the deployment | Rollback does |
|---|---|
| present at ref X | check X back out |
| present, now removed | clone it back and check out X |
| absent, now present | remove it again |
So undoing an undeploy, undoing a newly added extension, undoing a whole new core version and undoing a plain ref change are the same code.
- Automatic on a canary failure without
--force, scoped to the servers the failed deployment actually reached. - Manual from any past deployment in history. The UI warns when a newer deployment has since touched the same repos.
- Never recursive: a rollback that fails its own canary is surfaced for manual intervention. One automatic hop only.
--force is deliberately dropped from a rollback's options — a rollback must not
skip its own canary.
Salt orchestrate/reactor rollouts (the app does the sequencing); rolling back schema migrations or R2 content (rollback is git refs and presence only — if a deploy included a migration, a human still has to deal with the schema); owning the wiki → version mapping (that lives in mw-config; the app only reads it to refuse an unsafe version removal); and CI-triggered deploys. This is a manual-trigger console app replacing a manual-trigger CLI.
Two more that the adoption path invites and does not do. It never writes to the
tree to reconcile it — an import that noticed drift will tell you, and moving the
tree is still a deployment you review. And it does not scan on a schedule: a scan is
a deliberate act with a five-minute cache behind it, not a background job quietly
repinning the registry while nobody is looking. mwdeploy:import-tree in cron is
yours to decide on, and --repin is why you should think about it first.