Skip to content
Open
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
50 changes: 50 additions & 0 deletions app/Console/Commands/Maintenance/FinishUpdateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace App\Console\Commands\Maintenance;

use App\Services\Maintenance\UpdateSnapshotService;
use Illuminate\Console\Command;
use Spatie\Health\Commands\RunHealthChecksCommand;

/**
* Runs the existing Panel health checks after an update and points to rollback guidance.
*/
class FinishUpdateCommand extends Command
{
protected $description = 'Validate the Panel after an update and show rollback guidance when validation fails.';

protected $signature = 'p:maintenance:finish-update
{--snapshot= : Pre-update snapshot directory. The newest snapshot is used by default.}';

/**
* Keep the application in maintenance mode when any registered health check fails.
*/
public function handle(UpdateSnapshotService $snapshots): int
{
$result = $this->call(RunHealthChecksCommand::class, [
'--no-notification' => true,
'--fail-command-on-failing-check' => true,
]);

if ($result === self::SUCCESS) {
$this->info(trans('commands.update.healthy'));

return self::SUCCESS;
}

$snapshotOption = $this->option('snapshot');
$snapshot = is_string($snapshotOption) && $snapshotOption !== ''
? $snapshots->fromPath($snapshotOption)
: $snapshots->latest();

if ($snapshot === null) {
$this->error(trans('commands.update.snapshot_missing'));

return self::FAILURE;
}

$this->error(trans('commands.update.unhealthy', ['path' => $snapshot->rollbackGuide]));

return self::FAILURE;
}
}
70 changes: 70 additions & 0 deletions app/Console/Commands/Maintenance/PrepareUpdateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace App\Console\Commands\Maintenance;

use App\Services\Maintenance\UpdateCompatibilityService;
use App\Services\Maintenance\UpdateSnapshotService;
use Illuminate\Console\Command;
use Throwable;

/**
* Prepares a guarded update by validating the target release and capturing current state.
*/
class PrepareUpdateCommand extends Command
{
protected $description = 'Capture the current Panel state and verify the target release before an update.';

protected $signature = 'p:maintenance:prepare-update
{--source= : Extracted target release directory containing composer.json and composer.lock.}
{--target-version= : Target Panel version recorded in the snapshot metadata.}
{--composer= : Absolute Composer executable or composer.phar path.}
{--retain= : Number of protected update snapshots to retain.}';

/**
* Validate compatibility before creating any rollback artifacts.
*/
public function handle(
UpdateCompatibilityService $compatibility,
UpdateSnapshotService $snapshots,
): int {
$source = $this->option('source');
if (!is_string($source) || $source === '') {
$this->error(trans('commands.update.source_required'));

return self::FAILURE;
}

$composer = $this->option('composer');
$retained = $this->option('retain');
$targetVersion = $this->option('target-version');
if ($retained !== null && (!is_numeric($retained) || (int) $retained < 1)) {
$this->error(trans('commands.update.retention_invalid'));

return self::FAILURE;
}

try {
$compatibility->assertCompatible(
$source,
is_string($composer) && $composer !== '' ? $composer : null,
);
$this->info(trans('commands.update.compatibility_passed'));

$snapshot = $snapshots->capture(
targetVersion: is_string($targetVersion) ? $targetVersion : null,
retainedSnapshots: $retained !== null ? (int) $retained : null,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
$this->error(trans('commands.update.preparation_failed'));

return self::FAILURE;
}

$this->info(trans('commands.update.snapshot_created', ['path' => $snapshot->path]));
$this->line(trans('commands.update.database_guidance', ['guidance' => $snapshot->databaseGuidance]));
$this->info(trans('commands.update.ready'));

return self::SUCCESS;
}
}
20 changes: 20 additions & 0 deletions app/Data/UpdateSnapshotData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Data;

use Spatie\LaravelData\Data;

/**
* Describes a validated pre-update snapshot and the operator guidance stored with it.
*/
final class UpdateSnapshotData extends Data
{
/**
* Create a validated snapshot result for update and rollback commands.
*/
public function __construct(
public string $path,
public string $rollbackGuide,
public string $databaseGuidance,
) {}
}
89 changes: 89 additions & 0 deletions app/Services/Maintenance/UpdateCompatibilityService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace App\Services\Maintenance;

use Illuminate\Support\Facades\Process;
use RuntimeException;
use Symfony\Component\Process\ExecutableFinder;
use Throwable;

/**
* Verifies the target release's locked Composer platform requirements before files change.
*/
class UpdateCompatibilityService
{
/**
* @throws RuntimeException when Composer cannot run or the target is incompatible
*/
public function assertCompatible(string $source, ?string $composerBinary = null): void
{
$composerJson = $source . DIRECTORY_SEPARATOR . 'composer.json';
$composerLock = $source . DIRECTORY_SEPARATOR . 'composer.lock';

if (!is_file($composerJson) || !is_file($composerLock)) {
throw new RuntimeException(trans('commands.update.compatibility_files_missing'));
}

$command = [
...$this->resolveComposerCommand($composerBinary),
'check-platform-reqs',
'--lock',
'--no-dev',
'--no-interaction',
];

try {
$result = Process::path($source)->timeout(120)->run($command);
} catch (Throwable $exception) {
throw new RuntimeException(
trans('commands.update.compatibility_exception', ['error' => $exception->getMessage()]),
previous: $exception,
);
}

if ($result->failed()) {
$details = trim($result->errorOutput() . "\n" . $result->output());

throw new RuntimeException(trans('commands.update.compatibility_command_failed', [
'details' => $details !== '' ? $details : trans('commands.update.no_command_output'),
]));
}
}

/**
* Resolve an explicit executable or PHAR first, then a local composer.phar, and finally PATH.
*
* @return list<string>
*/
private function resolveComposerCommand(?string $composerBinary): array
{
$finder = new ExecutableFinder();

if (is_string($composerBinary) && trim($composerBinary) !== '') {
$candidate = trim($composerBinary);
$resolved = is_file($candidate) ? $candidate : $finder->find($candidate);

if (!is_string($resolved)) {
throw new RuntimeException(trans('commands.update.composer_binary_missing', [
'binary' => $candidate,
]));
}

return str_ends_with(strtolower($resolved), '.phar')
? [PHP_BINARY, $resolved]
: [$resolved];
}

$localPhar = base_path('composer.phar');
if (is_file($localPhar)) {
return [PHP_BINARY, $localPhar];
}

$resolved = $finder->find('composer');
if (!is_string($resolved)) {
throw new RuntimeException(trans('commands.update.composer_binary_required'));
}

return [$resolved];
}
}
Loading
Loading