Skip to content
This repository was archived by the owner on Oct 28, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ that is how new permissions reach the existing roles.
| 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 |
| Ship staging as it stands | the only mode there was | yes, as an explicit mode behind its own permission |
| 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 |
| Users and permissions | whoever is on the Salt master shell | accounts, roles, 22 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 |
Expand Down Expand Up @@ -207,6 +208,21 @@ Removal is a **separate grant** from deployment throughout
(`deploy.undeploy_extension`, `deploy.undeploy_skin`, `deploy.undeploy_version`),
and every removal is reversible — see below.

### Deploying staging as it stands

**Sync staging** (`/deployments/sync`) deploys the staging tree exactly as it is
on disk: no fetch, no checkout, no selection. It is the escape hatch for work that
never came from a ref — a file edited directly on staging, a patch applied by
hand, a checkout repaired in place — and the plan is just the rsync of the whole
tree, plus the same canary gate and depool/repool rollout every other deployment
gets.

Two things follow from having no line items. It is **not** scoped to a repository,
so it ships whatever anyone else has staged too and is gated on its own permission
(`deploy.sync_staging`) rather than on the per-type deploy grants. And it records
no refs, so there is no undo point: rollback is not offered for it, and the way
back is another deployment.

## Layout

```
Expand Down
4 changes: 2 additions & 2 deletions app/Actions/Deployments/CreateDeployment.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ public function __invoke(
}

// Patches are only meaningful on the way in; a removal has nothing
// left to patch.
if ($intent !== DeploymentIntent::Undeploy) {
// left to patch, and a staging sync ships whatever is already there.
if ($intent->carriesPatches()) {
foreach (Patch::query()->whereIn('id', $patchIds)->get() as $patch) {
$deployment->deploymentPatches()->create([
'patch_id' => $patch->getKey(),
Expand Down
37 changes: 36 additions & 1 deletion app/Enums/DeploymentIntent.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ enum DeploymentIntent: string
{
case Deploy = 'deploy';
case Undeploy = 'undeploy';

/**
* Ship the staging tree exactly as it stands, with no git work at all.
*
* The escape hatch for work that never came from a ref: a fix edited
* directly on staging, a hand-applied patch, a checkout someone fixed up by
* hand. There is nothing to select, so the deployment carries no line items
* — the whole tree syncs, which is also why it is gated on its own
* permission rather than on the per-repository deploy grants.
*/
case SyncStaging = 'sync_staging';
case VersionCreate = 'version_create';
case VersionUndeploy = 'version_undeploy';

Expand All @@ -23,6 +34,7 @@ public function label(): string
return match ($this) {
self::Deploy => 'Deploy',
self::Undeploy => 'Undeploy',
self::SyncStaging => 'Sync staging',
self::VersionCreate => 'Create core version',
self::VersionUndeploy => 'Undeploy core version',
};
Expand All @@ -40,12 +52,35 @@ public function label(): string
public function badgeTone(): string
{
return match ($this) {
self::Deploy, self::VersionCreate => 'neutral',
self::Deploy, self::SyncStaging, self::VersionCreate => 'neutral',
self::Undeploy => 'warning',
self::VersionUndeploy => 'danger',
};
}

/**
* Whether the operator picks checkouts and refs under this intent.
*
* False for a staging sync, which deploys whatever is already on disk: it
* carries no line items, and submitting any would imply the operator chose
* something that is in fact ignored.
*/
public function selectsCheckouts(): bool
{
return $this !== self::SyncStaging;
}

/**
* Whether patches are meaningful under this intent.
*
* A removal has nothing left to patch, and a staging sync ships the tree as
* it stands — anything to be patched was patched before the sync.
*/
public function carriesPatches(): bool
{
return $this !== self::Undeploy && $this !== self::SyncStaging;
}

/**
* Whether this intent removes things, and so needs an undeploy permission
* rather than a deploy one.
Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/Api/BootstrapController.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public function payload(?User $user): array
'can' => [
'deploy' => $user->can('create', Deployment::class),
'undeploy' => $user->hasAnyPermission(Permissions::anyUndeploy()),
'sync_staging' => $user->hasPermission(Permissions::DEPLOY_SYNC_STAGING),
'rollback' => $user->hasPermission(Permissions::DEPLOY_ROLLBACK),
'decide' => $user->hasPermission(Permissions::DEPLOY_DECIDE),
'pool' => $user->hasPermission(Permissions::DEPLOY_POOL),
Expand Down
50 changes: 49 additions & 1 deletion app/Http/Controllers/Api/DeploymentWizardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@
use App\Models\Patch;
use App\Models\Repository;
use App\Models\RepositoryVersion;
use App\Models\User;
use App\Services\Deployment\DeploymentPlanner;
use App\Services\Deployment\PlannedCall;
use App\Services\Salt\ShimCalls;
use App\Support\Permissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;

/**
* The deploy and undeploy wizards, as data.
* The deploy, undeploy and staging-sync wizards, as data.
*
* Three endpoints for one flow: what may be selected, what the selection would
* run, and go. The middle one matters — this is a destructive multi-server action,
Expand All @@ -40,6 +42,8 @@
*/
final class DeploymentWizardController extends Controller
{
public function __construct(private readonly ShimCalls $calls) {}

/**
* Repositories, checkouts, patches and targets, filtered to what this user may
* act on under the requested intent.
Expand All @@ -51,10 +55,20 @@ public function options(Request $request): JsonResponse

if ($intent === DeploymentIntent::Undeploy) {
abort_unless($user->hasAnyPermission(Permissions::anyUndeploy()), 403);
} elseif ($intent === DeploymentIntent::SyncStaging) {
$this->authorize('syncStaging', Deployment::class);
} else {
$this->authorize('create', Deployment::class);
}

// A staging sync selects nothing, so the repository and patch lists the
// picker would render are not just empty — they are not asked for. The
// rest of the payload (targets, defaults, what this account may do) is
// exactly what the options step needs.
if ($intent === DeploymentIntent::SyncStaging) {
return response()->json($this->syncStagingOptions($intent, $user));
}

$action = $intent->defaultAction();

$repositories = $user->actionableRepositories(
Expand Down Expand Up @@ -229,6 +243,40 @@ public function store(StoreDeploymentRequest $request, CreateDeployment $create)
], 201);
}

/**
* The options payload for a staging sync: no selection, just where it goes.
*
* @return array<string, mixed>
*/
private function syncStagingOptions(DeploymentIntent $intent, User $user): array
{
return [
'intent' => $intent->value,
'intent_label' => $intent->label(),
'repositories' => [],
'types' => [],
'versions' => [],
'patches' => [],
'appservers' => TargetResource::collection($this->targets(TargetRole::Appserver))->resolve(),
'proxies' => TargetResource::collection($this->targets(TargetRole::Proxy))->resolve(),
'defaults' => [
'parallel' => (int) config('mwdeploy.rollout.default_parallel', 1),
'max_parallel' => (int) config('mwdeploy.rollout.max_parallel', 8),
'staging_only' => ! $user->hasPermission(Permissions::DEPLOY_PRODUCTION_SERVERS),
],
'can' => [
'force' => $user->hasPermission(Permissions::DEPLOY_FORCE_FLAG),
'target_production' => $user->hasPermission(Permissions::DEPLOY_PRODUCTION_SERVERS),
],
// What the sync will actually move, so the screen can say it without
// knowing the deployment's own path config.
'tree' => [
'staging' => $this->calls->stagingRoot(),
'production' => $this->calls->productionRoot(),
],
];
}

/**
* @return Collection<int, DeployTarget>
*/
Expand Down
41 changes: 31 additions & 10 deletions app/Http/Requests/StoreDeploymentRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,35 @@ public function authorize(): bool
return false;
}

return $this->intent() === DeploymentIntent::Undeploy
? $user->hasAnyPermission(Permissions::anyUndeploy())
: $user->can('create', Deployment::class);
return match ($this->intent()) {
DeploymentIntent::Undeploy => $user->hasAnyPermission(Permissions::anyUndeploy()),
DeploymentIntent::SyncStaging => $user->can('syncStaging', Deployment::class),
default => $user->can('create', Deployment::class),
};
}

/**
* @return array<string, mixed>
*/
public function rules(): array
{
$isUndeploy = $this->intent() === DeploymentIntent::Undeploy;
$intent = $this->intent();
$isUndeploy = $intent === DeploymentIntent::Undeploy;
$selectsCheckouts = $intent->selectsCheckouts();

return [
'intent' => ['sometimes', Rule::in([DeploymentIntent::Deploy->value, DeploymentIntent::Undeploy->value])],

'items' => ['required', 'array', 'min:1'],
'intent' => ['sometimes', Rule::in([
DeploymentIntent::Deploy->value,
DeploymentIntent::Undeploy->value,
DeploymentIntent::SyncStaging->value,
])],

// A staging sync deploys the tree as it stands. There is nothing to
// select, and accepting a selection would imply the operator's choice
// narrowed what ships when it does not.
'items' => $selectsCheckouts
? ['required', 'array', 'min:1']
: ['prohibited'],
// Existence is checked in withValidator() against the one query that
// already loads these models, not with a per-item `exists` rule. An
// upgrade submits every extension in a version at once — a hundred-odd
Expand Down Expand Up @@ -94,6 +107,7 @@ public function messages(): array
{
return [
'items.required' => 'Select at least one checkout.',
'items.prohibited' => 'A staging sync deploys the whole tree, so nothing is selected for it.',
'items.*.ref_value.regex' => 'A git ref may only contain letters, digits, dots, slashes, dashes and underscores.',
'items.*.ref_value.prohibited' => 'An undeploy has no ref to check out.',
];
Expand Down Expand Up @@ -205,6 +219,12 @@ public function action(): RepoAction
*/
public function items(): array
{
// A staging sync has no line items at all: the tree as it stands is the
// selection.
if (! $this->intent()->selectsCheckouts()) {
return [];
}

$action = $this->action();
$items = [];

Expand Down Expand Up @@ -255,9 +275,10 @@ private function submittedCheckoutIds(): array
*/
public function patches(): Collection
{
// Patches are meaningless on a removal, so they are dropped rather than
// validated away — the wizard does not offer them either.
if ($this->intent() === DeploymentIntent::Undeploy) {
// Patches are meaningless on a removal, and a staging sync ships whatever
// is already applied on disk, so they are dropped rather than validated
// away — the wizard does not offer them under either intent.
if (! $this->intent()->carriesPatches()) {
return Patch::query()->whereRaw('1 = 0')->get();
}

Expand Down
6 changes: 6 additions & 0 deletions app/Models/Deployment.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ public function summary(): string
return 'Undeployed '.($this->mediawikiVersion?->version ?? 'a core version');
}

// No line items by design: what shipped was whatever the staging tree
// held, so naming the tree is the only honest summary.
if ($this->intent === DeploymentIntent::SyncStaging) {
return 'Synced '.rtrim((string) config('mwdeploy.paths.staging'), '/').' as it stood';
}

return $this->repoRefs->map(fn (DeploymentRepoRef $ref) => $ref->summary())->implode(', ') ?: '—';
}
}
11 changes: 11 additions & 0 deletions app/Policies/DeploymentPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ public function create(User $user): bool
]);
}

/**
* Deploying the staging tree as it stands, with nothing selected.
*
* Not implied by create: the per-repository deploy grants say which checkouts
* someone may push, and this pushes all of them at once.
*/
public function syncStaging(User $user): bool
{
return $user->hasPermission(Permissions::DEPLOY_SYNC_STAGING);
}

/**
* Deliberately broader than create: when something is broken you want
* whoever noticed to be able to revert, not to wait for an approver.
Expand Down
7 changes: 7 additions & 0 deletions app/Services/Deployment/DeploymentAuthorizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ public function violationFor(Deployment $deployment): ?string
return $this->denied($actor, Permissions::VERSIONS_MANAGE);
}

// Shipping the staging tree as it stands is not scoped to a repository,
// so the per-checkout grants below cannot stand in for it.
if ($deployment->intent === DeploymentIntent::SyncStaging
&& ! $actor->hasPermission(Permissions::DEPLOY_SYNC_STAGING)) {
return $this->denied($actor, Permissions::DEPLOY_SYNC_STAGING);
}

if (($violation = $this->checkLineItems($deployment, $actor)) !== null) {
return $violation;
}
Expand Down
2 changes: 1 addition & 1 deletion app/Services/Deployment/DeploymentPlanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public function plan(
$planned[] = new PlannedCall('Preparation', $this->calls->patchApply($patch));
}

$syncPlan = $this->calls->syncPlanFor($refs);
$syncPlan = $this->calls->syncPlanFor($refs, $intent);

if ($syncPlan->required) {
$planned[] = new PlannedCall('Preparation', $this->calls->rsyncLocal($syncPlan));
Expand Down
10 changes: 6 additions & 4 deletions app/Services/Deployment/DeploymentRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
* The orchestrator. Laravel is the brain here: it sequences, retries, aborts and
* records. Salt is a dumb remote-exec transport, one call per step per server.
*
* Every intent — deploy, undeploy, create a core version, remove a core version,
* roll any of those back — runs through this one pipeline. What differs between
* them is the line items in deployment_repo_refs, not the control flow.
* Every intent — deploy, undeploy, sync the staging tree as it stands, create a
* core version, remove a core version, roll any of those back — runs through this
* one pipeline. What differs between them is the line items in
* deployment_repo_refs, not the control flow: a staging sync simply has none, so
* the preparation steps fall away and the rsync covers the whole tree.
*/
final class DeploymentRunner
{
Expand Down Expand Up @@ -108,7 +110,7 @@ private function execute(Deployment $deployment): void
}

$removals = $this->removalPlanFor($deployment);
$syncPlan = $this->calls->syncPlanFor($refs);
$syncPlan = $this->calls->syncPlanFor($refs, $deployment->intent);

// Removals on staging, then the checkouts that are being deployed.
if (! $this->applyStagingRemovals($deployment, $removals)) {
Expand Down
11 changes: 10 additions & 1 deletion app/Services/Salt/ShimCalls.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Services\Salt;

use App\Enums\DeploymentIntent;
use App\Enums\RepoAction;
use App\Enums\RepositoryType;
use App\Enums\StepName;
Expand Down Expand Up @@ -445,10 +446,18 @@ public function haproxy(StepName $step, DeployTarget $proxy, DeployTarget $serve
* that only removes things syncs nothing at all rather than walking the whole
* tree.
*
* A staging sync is the one intent that says "everything" outright: it has no
* line items to derive paths from, because the tree as it stands *is* the
* selection.
*
* @param iterable<DeploymentRepoRef> $refs
*/
public function syncPlanFor(iterable $refs): SyncPlan
public function syncPlanFor(iterable $refs, ?DeploymentIntent $intent = null): SyncPlan
{
if ($intent === DeploymentIntent::SyncStaging) {
return SyncPlan::fullTree();
}

$paths = [];

foreach ($refs as $ref) {
Expand Down
Loading
Loading