diff --git a/README.md b/README.md index c8c0d15..f4a50b6 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 ``` diff --git a/app/Actions/Deployments/CreateDeployment.php b/app/Actions/Deployments/CreateDeployment.php index 19405fd..3ce0292 100644 --- a/app/Actions/Deployments/CreateDeployment.php +++ b/app/Actions/Deployments/CreateDeployment.php @@ -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(), diff --git a/app/Enums/DeploymentIntent.php b/app/Enums/DeploymentIntent.php index a2fcfa4..ecc9546 100644 --- a/app/Enums/DeploymentIntent.php +++ b/app/Enums/DeploymentIntent.php @@ -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'; @@ -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', }; @@ -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. diff --git a/app/Http/Controllers/Api/BootstrapController.php b/app/Http/Controllers/Api/BootstrapController.php index 18d67da..b1ff621 100644 --- a/app/Http/Controllers/Api/BootstrapController.php +++ b/app/Http/Controllers/Api/BootstrapController.php @@ -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), diff --git a/app/Http/Controllers/Api/DeploymentWizardController.php b/app/Http/Controllers/Api/DeploymentWizardController.php index 0ee0728..11b2b43 100644 --- a/app/Http/Controllers/Api/DeploymentWizardController.php +++ b/app/Http/Controllers/Api/DeploymentWizardController.php @@ -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, @@ -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. @@ -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( @@ -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 + */ + 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 */ diff --git a/app/Http/Requests/StoreDeploymentRequest.php b/app/Http/Requests/StoreDeploymentRequest.php index 73e0354..78e53fb 100644 --- a/app/Http/Requests/StoreDeploymentRequest.php +++ b/app/Http/Requests/StoreDeploymentRequest.php @@ -39,9 +39,11 @@ 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), + }; } /** @@ -49,12 +51,23 @@ public function authorize(): bool */ 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 @@ -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.', ]; @@ -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 = []; @@ -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(); } diff --git a/app/Models/Deployment.php b/app/Models/Deployment.php index 36085f5..26114d7 100644 --- a/app/Models/Deployment.php +++ b/app/Models/Deployment.php @@ -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(', ') ?: '—'; } } diff --git a/app/Policies/DeploymentPolicy.php b/app/Policies/DeploymentPolicy.php index fb08bd0..86fa485 100644 --- a/app/Policies/DeploymentPolicy.php +++ b/app/Policies/DeploymentPolicy.php @@ -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. diff --git a/app/Services/Deployment/DeploymentAuthorizer.php b/app/Services/Deployment/DeploymentAuthorizer.php index db839ed..8f814f4 100644 --- a/app/Services/Deployment/DeploymentAuthorizer.php +++ b/app/Services/Deployment/DeploymentAuthorizer.php @@ -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; } diff --git a/app/Services/Deployment/DeploymentPlanner.php b/app/Services/Deployment/DeploymentPlanner.php index 23f2f81..e2a14b8 100644 --- a/app/Services/Deployment/DeploymentPlanner.php +++ b/app/Services/Deployment/DeploymentPlanner.php @@ -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)); diff --git a/app/Services/Deployment/DeploymentRunner.php b/app/Services/Deployment/DeploymentRunner.php index ccd66cf..1b605a3 100644 --- a/app/Services/Deployment/DeploymentRunner.php +++ b/app/Services/Deployment/DeploymentRunner.php @@ -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 { @@ -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)) { diff --git a/app/Services/Salt/ShimCalls.php b/app/Services/Salt/ShimCalls.php index 64eb193..51590a5 100644 --- a/app/Services/Salt/ShimCalls.php +++ b/app/Services/Salt/ShimCalls.php @@ -4,6 +4,7 @@ namespace App\Services\Salt; +use App\Enums\DeploymentIntent; use App\Enums\RepoAction; use App\Enums\RepositoryType; use App\Enums\StepName; @@ -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 $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) { diff --git a/app/Support/Permissions.php b/app/Support/Permissions.php index eccd4d0..c83cb71 100644 --- a/app/Support/Permissions.php +++ b/app/Support/Permissions.php @@ -58,6 +58,15 @@ final class Permissions public const DEPLOY_PRODUCTION_SERVERS = 'deploy.production_servers'; + /** + * Deploy the staging tree as it stands, without selecting checkouts. + * + * Its own grant because it is not scoped to a repository: it ships whatever + * happens to be on staging — including someone else's half-finished work — + * so being trusted with one extension is not enough to authorise it. + */ + public const DEPLOY_SYNC_STAGING = 'deploy.sync_staging'; + public const DEPLOY_FORCE_FLAG = 'deploy.force_flag'; public const DEPLOY_ROLLBACK = 'deploy.rollback'; @@ -118,6 +127,7 @@ public static function groups(): array self::DEPLOY_SKIN, self::DEPLOY_CONFIG, self::DEPLOY_PRODUCTION_SERVERS, + self::DEPLOY_SYNC_STAGING, self::DEPLOY_FORCE_FLAG, self::DEPLOY_ROLLBACK, self::DEPLOY_DECIDE, @@ -166,6 +176,7 @@ public static function all(): array self::DEPLOY_SKIN => 'Deploy skins', self::DEPLOY_CONFIG => 'Deploy wiki config', self::DEPLOY_PRODUCTION_SERVERS => 'Target production appservers (rather than staging-only dry runs)', + self::DEPLOY_SYNC_STAGING => 'Deploy the staging tree as it stands, without selecting checkouts', self::DEPLOY_FORCE_FLAG => 'Set --force, skipping the canary gate', self::DEPLOY_ROLLBACK => 'Roll back a past deployment', self::DEPLOY_DECIDE => 'Answer a blocking canary-failure prompt on a running deployment', diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index 8ed69f8..68275b8 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -32,8 +32,8 @@ final class RolesAndPermissionsSeeder extends Seeder 'permissions' => [ P::DEPLOYMENTS_ACCESS, P::DEPLOY_CORE, P::DEPLOY_EXTENSION, P::DEPLOY_SKIN, P::DEPLOY_CONFIG, - P::DEPLOY_PRODUCTION_SERVERS, P::DEPLOY_FORCE_FLAG, P::DEPLOY_ROLLBACK, - P::DEPLOY_DECIDE, P::DEPLOY_POOL, P::DEPLOY_FORCE_FAIL, + P::DEPLOY_PRODUCTION_SERVERS, P::DEPLOY_SYNC_STAGING, P::DEPLOY_FORCE_FLAG, + P::DEPLOY_ROLLBACK, P::DEPLOY_DECIDE, P::DEPLOY_POOL, P::DEPLOY_FORCE_FAIL, P::UNDEPLOY_EXTENSION, P::UNDEPLOY_SKIN, P::UNDEPLOY_CONFIG, P::UNDEPLOY_VERSION, P::VERSIONS_MANAGE, P::REPOSITORIES_MANAGE, P::PATCHES_MANAGE, P::TARGETS_MANAGE, P::USERS_MANAGE, P::ROLES_MANAGE, @@ -44,7 +44,8 @@ final class RolesAndPermissionsSeeder extends Seeder 'permissions' => [ P::DEPLOYMENTS_ACCESS, P::DEPLOY_CORE, P::DEPLOY_EXTENSION, P::DEPLOY_SKIN, P::DEPLOY_CONFIG, - P::DEPLOY_PRODUCTION_SERVERS, P::DEPLOY_ROLLBACK, P::DEPLOY_DECIDE, P::DEPLOY_POOL, + P::DEPLOY_PRODUCTION_SERVERS, P::DEPLOY_SYNC_STAGING, P::DEPLOY_ROLLBACK, + P::DEPLOY_DECIDE, P::DEPLOY_POOL, P::UNDEPLOY_EXTENSION, P::UNDEPLOY_SKIN, P::VERSIONS_MANAGE, P::REPOSITORIES_MANAGE, P::DEPLOY_FORCE_FLAG, ], diff --git a/resources/js/apps/deployments/components/DeployOptions.vue b/resources/js/apps/deployments/components/DeployOptions.vue index c2a5a76..6bf135c 100644 --- a/resources/js/apps/deployments/components/DeployOptions.vue +++ b/resources/js/apps/deployments/components/DeployOptions.vue @@ -2,9 +2,9 @@ import { computed } from 'vue'; /** - * Target and rollout options, shared by the deploy and undeploy wizards. These are - * the CLI's --servers / --parallel / --rollout / --l10n / --force, with the same - * meanings. + * Target and rollout options, shared by the deploy, undeploy and staging-sync + * wizards. These are the CLI's --servers / --parallel / --rollout / --l10n / + * --force, with the same meanings. */ const props = defineProps({ /** { stagingOnly, allServers, servers[], rollout, l10n, force, parallel } */ @@ -19,6 +19,7 @@ const props = defineProps({ const emit = defineEmits(['update:modelValue']); const isUndeploy = computed(() => props.intent === 'undeploy'); +const isSyncStaging = computed(() => props.intent === 'sync_staging'); const set = (changes) => emit('update:modelValue', { ...props.modelValue, ...changes }); @@ -47,6 +48,10 @@ const toggleServer = (hostname) => { + diff --git a/resources/js/apps/deployments/manifest.js b/resources/js/apps/deployments/manifest.js index 805cac9..174ff25 100644 --- a/resources/js/apps/deployments/manifest.js +++ b/resources/js/apps/deployments/manifest.js @@ -33,9 +33,10 @@ export const deploymentsApp = { { to: at('/scoping'), label: 'Repository access', requires: 'manage_users' }, ], - /** The two things you come here to do, so they stay one click away. */ + /** The things you come here to do, so they stay one click away. */ actions: [ { to: at('/undeploy'), label: 'Undeploy', requires: 'undeploy', variant: 'secondary' }, + { to: at('/sync'), label: 'Sync staging', requires: 'sync_staging', variant: 'secondary' }, { to: at('/new'), label: 'New deployment', requires: 'deploy', variant: 'primary' }, ], @@ -68,6 +69,16 @@ export const deploymentsApp = { props: { intent: 'undeploy' }, meta: { requires: 'undeploy' }, }, + // Its own route for the same reason undeploy has one: shipping whatever + // staging currently holds is not a variant of picking refs, and should not + // be one mis-click away from it. + { + path: at('/sync'), + name: 'deployments.sync-staging', + component: () => import('./pages/DeployWizardPage.vue'), + props: { intent: 'sync_staging' }, + meta: { requires: 'sync_staging' }, + }, { path: at('/versions'), name: 'deployments.versions', component: () => import('./pages/VersionsPage.vue') }, { diff --git a/resources/js/apps/deployments/pages/DeployWizardPage.vue b/resources/js/apps/deployments/pages/DeployWizardPage.vue index 5439b3e..0335dc9 100644 --- a/resources/js/apps/deployments/pages/DeployWizardPage.vue +++ b/resources/js/apps/deployments/pages/DeployWizardPage.vue @@ -14,12 +14,15 @@ import PlanReview from '../components/PlanReview.vue'; import { flash, flashError, session } from '../../../store'; /** - * The deploy and undeploy wizards. One component, two intents — but deliberately - * two routes: removing checkouts off the whole fleet should not be one mis-click - * away from updating them, so there is no mode toggle here. + * The deploy, undeploy and staging-sync wizards. One component, three intents — + * but deliberately three routes: removing checkouts off the whole fleet, or + * shipping whatever staging currently holds, should not be one mis-click away + * from updating a ref, so there is no mode toggle here. * - * Three steps: pick, set options, review. The review step is not decoration — it - * shows the exact `salt` calls that will run, built by the server's planner. + * Three steps: pick, set options, review. A staging sync has nothing to pick, so + * it drops that step rather than showing an empty picker. The review step is not + * decoration — it shows the exact `salt` calls that will run, built by the + * server's planner. */ const props = defineProps({ intent: { type: String, default: 'deploy' }, @@ -49,6 +52,7 @@ const settings = ref({ }); const isUndeploy = computed(() => props.intent === 'undeploy'); +const isSyncStaging = computed(() => props.intent === 'sync_staging'); /** * The three steps and where you are in them. @@ -58,10 +62,13 @@ const isUndeploy = computed(() => props.intent === 'undeploy'); * the actual consequence. A generic "Continue" on one step and a specific "Review * the plan" on the next makes them look like different kinds of control. */ -const STEP_ORDER = ['pick', 'options', 'review']; +const STEP_ORDER = computed(() => (isSyncStaging.value ? ['options', 'review'] : ['pick', 'options', 'review'])); const STEP_LABELS = { pick: 'Select', options: 'Where it goes', review: 'Review' }; +/** Where a staging sync starts, since there is nothing to select first. */ +const firstStep = computed(() => STEP_ORDER.value[0]); + /** What the final button says, which is the consequence and not "Confirm". */ const confirmLabel = computed(() => { if (plan.value?.removes_anything) { @@ -69,18 +76,20 @@ const confirmLabel = computed(() => { } if (settings.value?.stagingOnly) { - return 'Deploy to staging'; + return isSyncStaging.value ? 'Sync the production tree on staging' : 'Deploy to staging'; } - return `Deploy to ${ - settings.value?.allServers ? 'all appservers' : pluralise(settings.value?.servers.length ?? 0, 'appserver') - }`; + const where = settings.value?.allServers + ? 'all appservers' + : pluralise(settings.value?.servers.length ?? 0, 'appserver'); + + return isSyncStaging.value ? `Sync staging to ${where}` : `Deploy to ${where}`; }); const steps = computed(() => { - const at = STEP_ORDER.indexOf(step.value); + const at = STEP_ORDER.value.indexOf(step.value); - return STEP_ORDER.map((key, index) => ({ + return STEP_ORDER.value.map((key, index) => ({ key, label: STEP_LABELS[key], state: index < at ? 'done' : index === at ? 'current' : 'upcoming', @@ -91,6 +100,7 @@ const selectedIds = computed(() => Object.keys(selection.value).map(Number)); const load = async () => { loading.value = true; + step.value = firstStep.value; try { options.value = await api.get(endpoint('deployments/wizard'), { @@ -123,7 +133,6 @@ watch( selection.value = {}; patchIds.value = []; plan.value = null; - step.value = 'pick'; load(); }, ); @@ -133,7 +142,7 @@ watch( * so nobody retypes a patch target; pre-selecting them is the other half of that. */ const relevantPatches = computed(() => { - if (isUndeploy.value) { + if (isUndeploy.value || isSyncStaging.value) { return []; } @@ -148,13 +157,17 @@ watch(relevantPatches, (patches) => { const payload = () => ({ intent: props.intent, - items: Object.entries(selection.value).map(([id, choice]) => ({ - repository_version_id: Number(id), - ...(isUndeploy.value - ? {} - : { ref_type: choice.refType, ref_value: (choice.refValue ?? '').trim() }), - })), - patches: isUndeploy.value ? [] : patchIds.value, + // A staging sync selects nothing: the tree as it stands is what ships, and the + // server refuses items under this intent rather than quietly ignoring them. + items: isSyncStaging.value + ? [] + : Object.entries(selection.value).map(([id, choice]) => ({ + repository_version_id: Number(id), + ...(isUndeploy.value + ? {} + : { ref_type: choice.refType, ref_value: (choice.refValue ?? '').trim() }), + })), + patches: isUndeploy.value || isSyncStaging.value ? [] : patchIds.value, servers: settings.value.stagingOnly || settings.value.allServers ? [] : settings.value.servers, parallel: settings.value.stagingOnly ? 1 : settings.value.parallel, force: settings.value.force, @@ -194,7 +207,7 @@ const confirm = async () => { } catch (thrown) { if (thrown instanceof ApiError && thrown.isValidation) { validation.value = thrown.all(); - step.value = 'pick'; + step.value = firstStep.value; } else { flashError(thrown); } @@ -209,7 +222,13 @@ const confirm = async () => {

- {{ isUndeploy ? 'Undeploy from the fleet' : 'New deployment' }} + {{ + isUndeploy + ? 'Undeploy from the fleet' + : isSyncStaging + ? 'Sync staging as it stands' + : 'New deployment' + }}

+ @@ -266,7 +291,7 @@ const confirm = async () => {

-