diff --git a/app/Console/Commands/Environment/AppSettingsCommand.php b/app/Console/Commands/Environment/AppSettingsCommand.php index 928c7c74e5..7016a7d865 100644 --- a/app/Console/Commands/Environment/AppSettingsCommand.php +++ b/app/Console/Commands/Environment/AppSettingsCommand.php @@ -2,16 +2,29 @@ namespace App\Console\Commands\Environment; +use App\Services\Environment\InstallationHealthService; +use App\Traits\Commands\DisplaysEnvironmentChecks; use Illuminate\Console\Command; class AppSettingsCommand extends Command { + use DisplaysEnvironmentChecks; + protected $description = 'Configure basic environment settings for the Panel.'; protected $signature = 'p:environment:setup'; - public function handle(): void + public function handle(InstallationHealthService $health): int { + $results = $health->systemRequirements(); + $this->displayEnvironmentChecks($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.environment_check.preflight_failed')); + + return self::FAILURE; + } + $path = base_path('.env'); if (!file_exists($path)) { $this->comment('Copying example .env file'); @@ -28,5 +41,7 @@ public function handle(): void $this->comment('Caching components & icons'); $this->call('filament:optimize'); + + return self::SUCCESS; } } diff --git a/app/Console/Commands/Environment/EnvironmentHealthCommand.php b/app/Console/Commands/Environment/EnvironmentHealthCommand.php new file mode 100644 index 0000000000..8b1522b044 --- /dev/null +++ b/app/Console/Commands/Environment/EnvironmentHealthCommand.php @@ -0,0 +1,38 @@ +completeInstallation( + includeQueue: !$this->option('skip-queue'), + queueTimeoutSeconds: max(0, (int) $this->option('queue-timeout')), + ); + + $this->displayEnvironmentChecks($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.environment_check.health_failed')); + + return self::FAILURE; + } + + $this->info(trans('commands.environment_check.health_passed')); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Environment/EnvironmentPreflightCommand.php b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php new file mode 100644 index 0000000000..6b41a7266c --- /dev/null +++ b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php @@ -0,0 +1,51 @@ +systemRequirements(); + + if ($this->option('with-database')) { + $driver = (string) config('database.default'); + $results[] = $health->databaseDriverExtension($driver); + $results[] = $health->database(); + } + + if ($this->option('with-queue')) { + $results[] = $health->queueWorker($this->queueTimeout()); + } + + $this->displayEnvironmentChecks($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.environment_check.preflight_failed')); + + return self::FAILURE; + } + + $this->info(trans('commands.environment_check.preflight_passed')); + + return self::SUCCESS; + } + + private function queueTimeout(): int + { + return max(0, (int) $this->option('queue-timeout')); + } +} diff --git a/app/Console/Commands/Maintenance/FinishUpdateCommand.php b/app/Console/Commands/Maintenance/FinishUpdateCommand.php new file mode 100644 index 0000000000..3274d47f06 --- /dev/null +++ b/app/Console/Commands/Maintenance/FinishUpdateCommand.php @@ -0,0 +1,50 @@ +completeInstallation( + includeQueue: !$this->option('skip-queue'), + queueTimeoutSeconds: max(0, (int) $this->option('queue-timeout')), + ); + $this->displayEnvironmentChecks($results); + + if (!$health->hasFailures($results)) { + $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; + } +} diff --git a/app/Console/Commands/Maintenance/PrepareUpdateCommand.php b/app/Console/Commands/Maintenance/PrepareUpdateCommand.php new file mode 100644 index 0000000000..191a17ba34 --- /dev/null +++ b/app/Console/Commands/Maintenance/PrepareUpdateCommand.php @@ -0,0 +1,71 @@ +option('source'); + if (!is_string($source) || $source === '') { + $this->error(trans('commands.update.source_required')); + + return self::FAILURE; + } + + $results = $health->completeInstallation( + includeQueue: !$this->option('skip-queue'), + queueTimeoutSeconds: max(0, (int) $this->option('queue-timeout')), + ); + $this->displayEnvironmentChecks($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.update.preparation_failed')); + + return self::FAILURE; + } + + $compatibilityResult = $compatibility->check($source); + $this->displayEnvironmentChecks([$compatibilityResult]); + + if ($compatibilityResult->failed()) { + $this->error(trans('commands.update.preparation_failed')); + + return self::FAILURE; + } + + try { + $snapshot = $snapshots->capture($this->option('target-version')); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + 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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 8421c1b82b..814d53e4d9 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,6 +13,7 @@ use Illuminate\Console\Scheduling\Schedule; use Illuminate\Database\Console\PruneCommand; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; +use Spatie\Health\Commands\DispatchQueueCheckJobsCommand; use Spatie\Health\Commands\RunHealthChecksCommand; use Spatie\Health\Commands\ScheduleCheckHeartbeatCommand; @@ -60,6 +61,7 @@ protected function schedule(Schedule $schedule): void } $schedule->command(ScheduleCheckHeartbeatCommand::class)->everyMinute(); + $schedule->command(DispatchQueueCheckJobsCommand::class)->everyMinute(); $schedule->command(RunHealthChecksCommand::class)->everyFiveMinutes(); } } diff --git a/app/Enums/EnvironmentCheckStatus.php b/app/Enums/EnvironmentCheckStatus.php new file mode 100644 index 0000000000..3201a7f529 --- /dev/null +++ b/app/Enums/EnvironmentCheckStatus.php @@ -0,0 +1,15 @@ +token); + + File::ensureDirectoryExists(dirname($path)); + File::put($path, now()->toIso8601String()); + } + + public static function markerPath(string $token): string + { + $safeToken = preg_replace('/[^a-zA-Z0-9-]/', '', $token); + + return storage_path("framework/cache/pelican-queue-probes/{$safeToken}"); + } +} diff --git a/app/Livewire/Installer/PanelInstaller.php b/app/Livewire/Installer/PanelInstaller.php index d79f1cd352..3246b85c01 100644 --- a/app/Livewire/Installer/PanelInstaller.php +++ b/app/Livewire/Installer/PanelInstaller.php @@ -12,11 +12,13 @@ use App\Livewire\Installer\Steps\RequirementsStep; use App\Livewire\Installer\Steps\SessionStep; use App\Models\User; +use App\Services\Environment\InstallationHealthService; use App\Services\Helpers\LanguageService; use App\Services\Users\UserCreationService; use App\Traits\CheckMigrationsTrait; use App\Traits\EnvironmentWriterTrait; use App\Traits\Filament\CanCustomizeSteps; +use App\ValueObjects\EnvironmentCheckResult; use Exception; use Filament\Actions\Action; use Filament\Facades\Filament; @@ -50,8 +52,15 @@ class PanelInstaller extends SimplePage implements HasForms /** @var array */ public array $data = []; + private InstallationHealthService $installationHealth; + protected string $view = 'filament.pages.installer'; + public function boot(InstallationHealthService $installationHealth): void + { + $this->installationHealth = $installationHealth; + } + public function getTitle(): string { return trans('installer.title'); @@ -106,9 +115,9 @@ protected function getFormSchema(): array protected function getDefaultSteps(): array { return [ - RequirementsStep::make(), + RequirementsStep::make($this->installationHealth), EnvironmentStep::make($this), - DatabaseStep::make($this), + DatabaseStep::make($this, $this->installationHealth), EggSelectionStep::make(), CacheStep::make($this), QueueStep::make($this), @@ -136,15 +145,17 @@ protected function getFormStatePath(): ?string return 'data'; } - public function submit(UserCreationService $userCreationService): void - { + public function submit( + UserCreationService $userCreationService, + InstallationHealthService $health, + ): void { try { - // Disable installer - $this->writeToEnvironment(['APP_INSTALLED' => 'true']); - // Run migrations $this->runMigrations(); + // Verify that asynchronous jobs will actually be processed before creating an account. + $this->verifyQueueWorker($health); + // Create admin user & login $user = $this->createAdminUser($userCreationService); auth()->guard()->login($user, true); @@ -155,12 +166,48 @@ public function submit(UserCreationService $userCreationService): void // Install selected eggs $this->installEggs(); + // Disable installer only after every required first-boot action completed. + $this->writeToEnvironment(['APP_INSTALLED' => 'true']); + config()->set('app.installed', true); + + $results = $health->completeInstallation(includeQueue: false); + if ($health->hasFailures($results)) { + $failedChecks = array_map( + fn (EnvironmentCheckResult $result) => $result->message, + array_filter($results, fn (EnvironmentCheckResult $result) => $result->failed()), + ); + + Notification::make() + ->title(trans('installer.health.installation_failed')) + ->body(implode("\n", $failedChecks) . "\n\n" . trans('installer.health.installation_failed_body')) + ->danger() + ->persistent() + ->send(); + } + // Redirect to admin panel $this->redirect(Filament::getPanel('admin')->getUrl()); } catch (Halt) { } } + public function verifyQueueWorker(InstallationHealthService $health): void + { + $result = $health->queueWorker(); + if (!$result->failed()) { + return; + } + + Notification::make() + ->title(trans('installer.health.queue_failed')) + ->body($result->message . "\n\n" . $result->remediation) + ->danger() + ->persistent() + ->send(); + + throw new Halt($result->message); + } + public function writeToEnv(string $key): void { try { diff --git a/app/Livewire/Installer/Steps/DatabaseStep.php b/app/Livewire/Installer/Steps/DatabaseStep.php index eaeb16f07b..c6719a43a4 100644 --- a/app/Livewire/Installer/Steps/DatabaseStep.php +++ b/app/Livewire/Installer/Steps/DatabaseStep.php @@ -4,6 +4,7 @@ use App\Enums\TablerIcon; use App\Livewire\Installer\PanelInstaller; +use App\Services\Environment\InstallationHealthService; use Exception; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\ToggleButtons; @@ -23,7 +24,7 @@ class DatabaseStep 'pgsql' => 'PostgreSQL', ]; - public static function make(PanelInstaller $installer): Step + public static function make(PanelInstaller $installer, InstallationHealthService $health): Step { return Step::make('database') ->label(trans('installer.database.title')) @@ -94,8 +95,19 @@ public static function make(PanelInstaller $installer): Step ->revealable() ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite'), ]) - ->afterValidation(function (Get $get) use ($installer) { + ->afterValidation(function (Get $get) use ($installer, $health) { $driver = $get('env_database.DB_CONNECTION'); + $extensionCheck = $health->databaseDriverExtension($driver); + + if ($extensionCheck->failed()) { + Notification::make() + ->title($extensionCheck->label) + ->body($extensionCheck->message) + ->danger() + ->send(); + + throw new Halt($extensionCheck->message); + } throw_unless(self::testConnection($driver, $get('env_database.DB_HOST'), $get('env_database.DB_PORT'), $get('env_database.DB_DATABASE'), $get('env_database.DB_USERNAME'), $get('env_database.DB_PASSWORD')), new Halt(trans('installer.database.exceptions.connection'))); diff --git a/app/Livewire/Installer/Steps/RequirementsStep.php b/app/Livewire/Installer/Steps/RequirementsStep.php index 9954db5cb2..e44b00951a 100644 --- a/app/Livewire/Installer/Steps/RequirementsStep.php +++ b/app/Livewire/Installer/Steps/RequirementsStep.php @@ -3,6 +3,8 @@ namespace App\Livewire\Installer\Steps; use App\Enums\TablerIcon; +use App\Services\Environment\InstallationHealthService; +use App\ValueObjects\EnvironmentCheckResult; use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; use Filament\Schemas\Components\Section; @@ -11,81 +13,32 @@ class RequirementsStep { - public const MIN_PHP_VERSION = '8.3'; + public const MIN_PHP_VERSION = InstallationHealthService::MIN_PHP_VERSION; - public static function make(): Step + public static function make(InstallationHealthService $health): Step { - $compare = version_compare(phpversion(), self::MIN_PHP_VERSION); - $correctPhpVersion = $compare >= 0; + $checks = $health->systemRequirements(); - $fields = [ - Section::make(trans('installer.requirements.sections.version.title')) - ->description(trans('installer.requirements.sections.version.or_newer', ['version' => self::MIN_PHP_VERSION])) - ->icon($correctPhpVersion ? TablerIcon::Check : TablerIcon::X) - ->iconColor($correctPhpVersion ? 'success' : 'danger') + $fields = array_map( + fn (EnvironmentCheckResult $check) => Section::make($check->label) + ->description($check->remediation) + ->icon($check->failed() ? TablerIcon::X : TablerIcon::Check) + ->iconColor($check->failed() ? 'danger' : 'success') ->schema([ - TextEntry::make('php_version') + TextEntry::make($check->key) ->hiddenLabel() - ->state(trans('installer.requirements.sections.version.content', ['version' => PHP_VERSION])), + ->state($check->message), ]), - ]; - - $phpExtensions = [ - 'BCMath' => extension_loaded('bcmath'), - 'cURL' => extension_loaded('curl'), - 'GD' => extension_loaded('gd'), - 'intl' => extension_loaded('intl'), - 'mbstring' => extension_loaded('mbstring'), - 'MySQL' => extension_loaded('pdo_mysql'), - 'SQLite3' => extension_loaded('pdo_sqlite'), - 'XML' => extension_loaded('xml'), - 'Zip' => extension_loaded('zip'), - ]; - $allExtensionsInstalled = !in_array(false, $phpExtensions); - - $fields[] = Section::make(trans('installer.requirements.sections.extensions.title')) - ->description(implode(', ', array_keys($phpExtensions))) - ->icon($allExtensionsInstalled ? TablerIcon::Check : TablerIcon::X) - ->iconColor($allExtensionsInstalled ? 'success' : 'danger') - ->schema([ - TextEntry::make('all_extensions_installed') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.extensions.good')) - ->visible($allExtensionsInstalled), - TextEntry::make('extensions_missing') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.extensions.bad', ['extensions' => implode(', ', array_keys($phpExtensions, false))])) - ->visible(!$allExtensionsInstalled), - ]); - - $folderPermissions = [ - 'Storage' => substr(sprintf('%o', fileperms(base_path('storage/'))), -4) >= 755, - 'Cache' => substr(sprintf('%o', fileperms(base_path('bootstrap/cache/'))), -4) >= 755, - ]; - $correctFolderPermissions = !in_array(false, $folderPermissions); - - $fields[] = Section::make(trans('installer.requirements.sections.permissions.title')) - ->description(implode(', ', array_keys($folderPermissions))) - ->icon($correctFolderPermissions ? TablerIcon::Check : TablerIcon::X) - ->iconColor($correctFolderPermissions ? 'success' : 'danger') - ->schema([ - TextEntry::make('correct_folder_permissions') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.permissions.good')) - ->visible($correctFolderPermissions), - TextEntry::make('wrong_folder_permissions') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.permissions.bad', ['folders' => implode(', ', array_keys($folderPermissions, false))])) - ->visible(!$correctFolderPermissions), - ]); + $checks, + ); return Step::make('requirements') ->label(trans('installer.requirements.title')) ->schema($fields) - ->afterValidation(function () use ($correctPhpVersion, $allExtensionsInstalled, $correctFolderPermissions) { - if (!$correctPhpVersion || !$allExtensionsInstalled || !$correctFolderPermissions) { + ->afterValidation(function () use ($checks, $health) { + if ($health->hasFailures($checks)) { Notification::make() - ->title(trans('installer.requirements.exception')) + ->title(trans('installer.health.preflight_failed')) ->danger() ->send(); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 54c52f2249..a964853766 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -43,6 +43,7 @@ use Illuminate\Support\Str; use Laravel\Passkeys\Passkeys; use Laravel\Sanctum\Sanctum; +use Spatie\Health\Checks\Checks\QueueCheck; use Spatie\Health\Facades\Health; class AppServiceProvider extends ServiceProvider @@ -110,6 +111,7 @@ public function boot( EnvironmentCheck::new(), CacheCheck::new(), DatabaseCheck::new(), + QueueCheck::new()->failWhenHealthJobTakesLongerThanMinutes(5), ScheduleCheck::new(), UsedDiskSpaceCheck::new(), PanelVersionCheck::new(), diff --git a/app/Services/Environment/InstallationHealthService.php b/app/Services/Environment/InstallationHealthService.php new file mode 100644 index 0000000000..7588d01838 --- /dev/null +++ b/app/Services/Environment/InstallationHealthService.php @@ -0,0 +1,343 @@ + 'pdo_sqlite', + 'mariadb' => 'pdo_mysql', + 'mysql' => 'pdo_mysql', + 'pgsql' => 'pdo_pgsql', + ]; + + public function __construct( + private readonly Migrator $migrator, + private readonly QueueWorkerProbeService $queueWorkerProbe, + ) {} + + /** @return EnvironmentCheckResult[] */ + public function systemRequirements(): array + { + return [ + $this->phpVersion(), + $this->phpExtensions(), + $this->writablePaths(), + ]; + } + + public function phpVersion(): EnvironmentCheckResult + { + $passed = version_compare(PHP_VERSION, self::MIN_PHP_VERSION, '>='); + + return new EnvironmentCheckResult( + 'php', + trans('installer.health.php.label'), + $passed ? EnvironmentCheckStatus::Passed : EnvironmentCheckStatus::Failed, + trans($passed ? 'installer.health.php.passed' : 'installer.health.php.failed', [ + 'current' => PHP_VERSION, + 'minimum' => self::MIN_PHP_VERSION, + ]), + $passed ? null : trans('installer.health.php.remediation', ['minimum' => self::MIN_PHP_VERSION]), + ); + } + + public function phpExtensions(): EnvironmentCheckResult + { + $missing = array_values(array_filter( + self::REQUIRED_EXTENSIONS, + fn (string $extension) => !extension_loaded($extension), + )); + + $availableDatabaseDrivers = array_values(array_filter( + self::DATABASE_EXTENSIONS, + fn (string $extension) => extension_loaded($extension), + )); + + if ($availableDatabaseDrivers === []) { + $missing[] = 'pdo_sqlite, pdo_mysql, or pdo_pgsql'; + } + + return new EnvironmentCheckResult( + 'extensions', + trans('installer.health.extensions.label'), + $missing === [] ? EnvironmentCheckStatus::Passed : EnvironmentCheckStatus::Failed, + $missing === [] + ? trans('installer.health.extensions.passed') + : trans('installer.health.extensions.failed', ['extensions' => implode(', ', $missing)]), + $missing === [] ? null : trans('installer.health.extensions.remediation'), + ); + } + + public function databaseDriverExtension(string $driver): EnvironmentCheckResult + { + $extension = self::DATABASE_EXTENSIONS[$driver] ?? null; + $passed = $extension !== null && extension_loaded($extension); + + return new EnvironmentCheckResult( + 'database_extension', + trans('installer.health.database_extension.label'), + $passed ? EnvironmentCheckStatus::Passed : EnvironmentCheckStatus::Failed, + $passed + ? trans('installer.health.database_extension.passed', ['extension' => $extension]) + : trans('installer.health.database_extension.failed', ['driver' => $driver, 'extension' => $extension ?? 'unknown']), + $passed ? null : trans('installer.health.extensions.remediation'), + ); + } + + public function writablePaths(): EnvironmentCheckResult + { + $paths = [ + storage_path(), + base_path('bootstrap/cache'), + file_exists(base_path('.env')) ? base_path('.env') : base_path(), + ]; + + $notWritable = array_values(array_filter( + $paths, + fn (string $path) => !is_writable($path), + )); + + return new EnvironmentCheckResult( + 'paths', + trans('installer.health.paths.label'), + $notWritable === [] ? EnvironmentCheckStatus::Passed : EnvironmentCheckStatus::Failed, + $notWritable === [] + ? trans('installer.health.paths.passed') + : trans('installer.health.paths.failed', ['paths' => implode(', ', $notWritable)]), + $notWritable === [] ? null : trans('installer.health.paths.remediation'), + ); + } + + public function applicationKey(): EnvironmentCheckResult + { + $key = config('app.key'); + $cipher = config('app.cipher', 'AES-256-CBC'); + + if (is_string($key) && str_starts_with($key, 'base64:')) { + $key = base64_decode(substr($key, 7), true); + } + + $passed = is_string($key) + && is_string($cipher) + && Encrypter::supported($key, $cipher); + + return $this->simpleResult( + 'app_key', + trans('installer.health.app_key.label'), + $passed, + trans($passed ? 'installer.health.app_key.passed' : 'installer.health.app_key.failed'), + trans('installer.health.app_key.remediation'), + ); + } + + public function installedFlag(): EnvironmentCheckResult + { + $passed = (bool) config('app.installed'); + + return $this->simpleResult( + 'installed', + trans('installer.health.installed.label'), + $passed, + trans($passed ? 'installer.health.installed.passed' : 'installer.health.installed.failed'), + trans('installer.health.installed.remediation'), + ); + } + + public function database(): EnvironmentCheckResult + { + $connection = (string) config('database.default'); + + try { + DB::connection($connection)->getPdo(); + + return new EnvironmentCheckResult( + 'database', + trans('installer.health.database.label'), + EnvironmentCheckStatus::Passed, + trans('installer.health.database.passed', ['connection' => $connection]), + ); + } catch (Throwable $exception) { + return new EnvironmentCheckResult( + 'database', + trans('installer.health.database.label'), + EnvironmentCheckStatus::Failed, + trans('installer.health.database.failed', ['error' => $exception->getMessage()]), + trans('installer.health.database.remediation'), + ); + } + } + + public function migrations(): EnvironmentCheckResult + { + try { + if (!$this->migrator->repositoryExists()) { + return $this->simpleResult( + 'migrations', + trans('installer.health.migrations.label'), + false, + trans('installer.health.migrations.repository_missing'), + trans('installer.health.migrations.remediation'), + ); + } + + $files = $this->migrator->getMigrationFiles(database_path('migrations')); + $pending = array_diff(array_keys($files), $this->migrator->getRepository()->getRan()); + + return $this->simpleResult( + 'migrations', + trans('installer.health.migrations.label'), + $pending === [], + $pending === [] + ? trans('installer.health.migrations.passed') + : trans('installer.health.migrations.failed', ['count' => count($pending)]), + trans('installer.health.migrations.remediation'), + ); + } catch (Throwable $exception) { + return $this->simpleResult( + 'migrations', + trans('installer.health.migrations.label'), + false, + trans('installer.health.migrations.exception', ['error' => $exception->getMessage()]), + trans('installer.health.migrations.remediation'), + ); + } + } + + public function adminUser(): EnvironmentCheckResult + { + try { + $passed = User::role(Role::ROOT_ADMIN)->exists(); + + return $this->simpleResult( + 'admin', + trans('installer.health.admin.label'), + $passed, + trans($passed ? 'installer.health.admin.passed' : 'installer.health.admin.failed'), + trans('installer.health.admin.remediation'), + ); + } catch (Throwable $exception) { + return $this->simpleResult( + 'admin', + trans('installer.health.admin.label'), + false, + trans('installer.health.admin.exception', ['error' => $exception->getMessage()]), + trans('installer.health.admin.remediation'), + ); + } + } + + public function cache(): EnvironmentCheckResult + { + $key = 'pelican:environment-check:' . Str::random(16); + $value = Str::random(24); + + try { + Cache::put($key, $value, 30); + $passed = Cache::get($key) === $value; + Cache::forget($key); + + return $this->simpleResult( + 'cache', + trans('installer.health.cache.label'), + $passed, + trans($passed ? 'installer.health.cache.passed' : 'installer.health.cache.failed'), + trans('installer.health.cache.remediation'), + ); + } catch (Throwable $exception) { + return $this->simpleResult( + 'cache', + trans('installer.health.cache.label'), + false, + trans('installer.health.cache.exception', ['error' => $exception->getMessage()]), + trans('installer.health.cache.remediation'), + ); + } + } + + public function queueWorker(int $timeoutSeconds = 10): EnvironmentCheckResult + { + return $this->queueWorkerProbe->check(timeoutSeconds: $timeoutSeconds); + } + + /** @return EnvironmentCheckResult[] */ + public function configuredEnvironment(bool $includeQueue = true, int $queueTimeoutSeconds = 10): array + { + $results = [ + ...$this->systemRequirements(), + $this->applicationKey(), + $this->database(), + $this->migrations(), + $this->cache(), + ]; + + if ($includeQueue) { + $results[] = $this->queueWorker($queueTimeoutSeconds); + } + + return $results; + } + + /** @return EnvironmentCheckResult[] */ + public function completeInstallation(bool $includeQueue = true, int $queueTimeoutSeconds = 10): array + { + return [ + ...$this->configuredEnvironment($includeQueue, $queueTimeoutSeconds), + $this->adminUser(), + $this->installedFlag(), + ]; + } + + /** @param EnvironmentCheckResult[] $results */ + public function hasFailures(array $results): bool + { + foreach ($results as $result) { + if ($result->failed()) { + return true; + } + } + + return false; + } + + private function simpleResult( + string $key, + string $label, + bool $passed, + string $message, + ?string $remediation = null, + ): EnvironmentCheckResult { + return new EnvironmentCheckResult( + $key, + $label, + $passed ? EnvironmentCheckStatus::Passed : EnvironmentCheckStatus::Failed, + $message, + $passed ? null : $remediation, + ); + } +} diff --git a/app/Services/Environment/QueueWorkerProbeService.php b/app/Services/Environment/QueueWorkerProbeService.php new file mode 100644 index 0000000000..7c8feff87b --- /dev/null +++ b/app/Services/Environment/QueueWorkerProbeService.php @@ -0,0 +1,77 @@ +failure(trans('installer.health.queue.not_configured')); + } + + $token = (string) Str::uuid(); + $marker = QueueWorkerProbeJob::markerPath($token); + + try { + File::ensureDirectoryExists(dirname($marker)); + $this->pruneStaleMarkers(dirname($marker)); + + QueueWorkerProbeJob::dispatch($token)->onConnection($connection); + + $deadline = microtime(true) + max(0, $timeoutSeconds); + do { + if (File::exists($marker)) { + File::delete($marker); + + return new EnvironmentCheckResult( + 'queue', + trans('installer.health.queue.label'), + EnvironmentCheckStatus::Passed, + trans('installer.health.queue.passed', ['connection' => $connection]), + ); + } + + usleep(100_000); + } while (microtime(true) < $deadline); + } catch (Throwable $exception) { + return $this->failure(trans('installer.health.queue.exception', ['error' => $exception->getMessage()])); + } finally { + File::delete($marker); + } + + return $this->failure(trans('installer.health.queue.timed_out', [ + 'connection' => $connection, + 'seconds' => $timeoutSeconds, + ])); + } + + private function failure(string $message): EnvironmentCheckResult + { + return new EnvironmentCheckResult( + 'queue', + trans('installer.health.queue.label'), + EnvironmentCheckStatus::Failed, + $message, + trans('installer.health.queue.remediation'), + ); + } + + private function pruneStaleMarkers(string $directory): void + { + foreach (File::files($directory) as $file) { + if ($file->getMTime() < now()->subHour()->getTimestamp()) { + File::delete($file->getPathname()); + } + } + } +} diff --git a/app/Services/Maintenance/UpdateCompatibilityService.php b/app/Services/Maintenance/UpdateCompatibilityService.php new file mode 100644 index 0000000000..2a078a14c3 --- /dev/null +++ b/app/Services/Maintenance/UpdateCompatibilityService.php @@ -0,0 +1,55 @@ +failure(trans('commands.update.compatibility_files_missing')); + } + + try { + $result = Process::path($source) + ->timeout(120) + ->run(['composer', 'check-platform-reqs', '--lock', '--no-dev']); + } catch (Throwable $exception) { + return $this->failure(trans('commands.update.compatibility_exception', ['error' => $exception->getMessage()])); + } + + if ($result->failed()) { + $details = trim($result->errorOutput() . "\n" . $result->output()); + + return $this->failure(trans('commands.update.compatibility_command_failed', [ + 'details' => $details !== '' ? $details : trans('commands.update.no_command_output'), + ])); + } + + return new EnvironmentCheckResult( + 'update_compatibility', + trans('commands.update.compatibility_label'), + EnvironmentCheckStatus::Passed, + trans('commands.update.compatibility_passed'), + ); + } + + private function failure(string $message): EnvironmentCheckResult + { + return new EnvironmentCheckResult( + 'update_compatibility', + trans('commands.update.compatibility_label'), + EnvironmentCheckStatus::Failed, + $message, + trans('commands.update.compatibility_remediation'), + ); + } +} diff --git a/app/Services/Maintenance/UpdateSnapshotService.php b/app/Services/Maintenance/UpdateSnapshotService.php new file mode 100644 index 0000000000..21f0f0f92b --- /dev/null +++ b/app/Services/Maintenance/UpdateSnapshotService.php @@ -0,0 +1,227 @@ +files->isFile($environmentPath)) { + throw new RuntimeException(trans('commands.update.environment_missing')); + } + + $snapshotPath = $snapshotRoot . DIRECTORY_SEPARATOR . now()->format('Ymd-His') . '-' . Str::lower(Str::random(6)); + + $this->files->ensureDirectoryExists($snapshotPath, 0700, true); + + try { + $environmentSnapshot = $snapshotPath . DIRECTORY_SEPARATOR . '.env'; + $this->copyRequiredArtifact($environmentPath, $environmentSnapshot); + @chmod($environmentSnapshot, 0600); + + foreach (['composer.json', 'composer.lock'] as $file) { + $this->copyRequiredArtifact(base_path($file), $snapshotPath . DIRECTORY_SEPARATOR . $file); + } + + $databaseGuidance = $this->captureDatabaseState($snapshotPath); + $rollbackGuide = $snapshotPath . DIRECTORY_SEPARATOR . 'ROLLBACK.md'; + + $this->writeRequiredArtifact($rollbackGuide, $this->rollbackGuide($snapshotPath, $databaseGuidance)); + $this->writeRequiredArtifact($snapshotPath . DIRECTORY_SEPARATOR . 'metadata.json', json_encode([ + 'created_at' => now()->toIso8601String(), + 'current_version' => $this->versionService->currentPanelVersion(), + 'target_version' => $targetVersion, + 'php_version' => PHP_VERSION, + 'database_driver' => config('database.default'), + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + + $snapshot = $this->validatedSnapshot($snapshotPath); + if ($snapshot === null) { + throw new RuntimeException(trans('commands.update.snapshot_incomplete', ['path' => $snapshotPath])); + } + + return $snapshot; + } catch (Throwable $exception) { + $this->files->deleteDirectory($snapshotPath); + + throw $exception; + } + } + + public function latest(?string $snapshotRoot = null): ?UpdateSnapshot + { + $snapshotRoot ??= storage_path('app/private/update-snapshots'); + if (!$this->files->isDirectory($snapshotRoot)) { + return null; + } + + $directories = collect($this->files->directories($snapshotRoot)) + ->sortByDesc(fn (string $directory) => $this->files->lastModified($directory)); + + foreach ($directories as $directory) { + if (($snapshot = $this->validatedSnapshot($directory)) !== null) { + return $snapshot; + } + } + + return null; + } + + public function fromPath(string $path): ?UpdateSnapshot + { + return $this->validatedSnapshot($path); + } + + private function captureDatabaseState(string $snapshotPath): string + { + $driver = (string) config('database.default'); + + if ($driver === 'sqlite') { + $database = config('database.connections.sqlite.database'); + if (!is_string($database) || !$this->files->isFile($database)) { + throw new RuntimeException(trans('commands.update.sqlite_database_missing')); + } + + $destination = $snapshotPath . DIRECTORY_SEPARATOR . 'database.sqlite'; + $this->backupSqliteDatabase($database, $destination); + + $guidance = trans('commands.update.database_backup_sqlite', ['path' => $destination]); + $this->writeRequiredArtifact($snapshotPath . DIRECTORY_SEPARATOR . 'DATABASE-BACKUP.txt', $guidance); + + return $guidance; + } + + $guidance = match ($driver) { + 'mariadb', 'mysql' => trans('commands.update.database_backup_mysql'), + 'pgsql' => trans('commands.update.database_backup_pgsql'), + default => trans('commands.update.database_backup_unknown'), + }; + + $this->writeRequiredArtifact($snapshotPath . DIRECTORY_SEPARATOR . 'DATABASE-BACKUP.txt', $guidance); + + return $guidance; + } + + private function backupSqliteDatabase(string $database, string $destination): void + { + $connection = new PDO('sqlite:' . $database, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + $connection->exec('PRAGMA busy_timeout = 5000'); + + $quotedDestination = $connection->quote($destination); + if (!is_string($quotedDestination)) { + throw new RuntimeException(trans('commands.update.snapshot_write_failed', ['artifact' => $destination])); + } + + $connection->exec("VACUUM INTO {$quotedDestination}"); + + if (!$this->files->isFile($destination)) { + throw new RuntimeException(trans('commands.update.snapshot_write_failed', ['artifact' => $destination])); + } + + @chmod($destination, 0600); + } + + private function copyRequiredArtifact(string $source, string $destination): void + { + if (!$this->files->isFile($source) || !$this->files->copy($source, $destination)) { + throw new RuntimeException(trans('commands.update.snapshot_write_failed', ['artifact' => $destination])); + } + } + + private function writeRequiredArtifact(string $path, string $contents): void + { + if ($this->files->put($path, $contents) === false) { + throw new RuntimeException(trans('commands.update.snapshot_write_failed', ['artifact' => $path])); + } + } + + private function validatedSnapshot(string $path): ?UpdateSnapshot + { + $path = rtrim($path, '/\\'); + if (!$this->files->isDirectory($path)) { + return null; + } + + foreach (self::REQUIRED_ARTIFACTS as $artifact) { + if (!$this->files->isFile($path . DIRECTORY_SEPARATOR . $artifact)) { + return null; + } + } + + try { + $metadata = $this->files->json($path . DIRECTORY_SEPARATOR . 'metadata.json'); + if (!is_array($metadata) || !is_string($metadata['database_driver'] ?? null)) { + return null; + } + + if ($metadata['database_driver'] === 'sqlite' + && !$this->files->isFile($path . DIRECTORY_SEPARATOR . 'database.sqlite')) { + return null; + } + + $databaseGuidance = trim($this->files->get($path . DIRECTORY_SEPARATOR . 'DATABASE-BACKUP.txt')); + if ($databaseGuidance === '') { + return null; + } + } catch (Throwable) { + return null; + } + + return new UpdateSnapshot( + $path, + $path . DIRECTORY_SEPARATOR . 'ROLLBACK.md', + $databaseGuidance, + ); + } + + private function rollbackGuide(string $snapshotPath, string $databaseGuidance): string + { + return <<table( + [ + trans('commands.environment_check.check'), + trans('commands.environment_check.status'), + trans('commands.environment_check.details'), + ], + array_map(fn (EnvironmentCheckResult $result) => [ + $result->label, + $this->formatEnvironmentCheckStatus($result->status), + $result->message . ($result->remediation ? "\n" . $result->remediation : ''), + ], $results), + ); + } + + private function formatEnvironmentCheckStatus(EnvironmentCheckStatus $status): string + { + return match ($status) { + EnvironmentCheckStatus::Passed => '' . trans('commands.environment_check.passed') . '', + EnvironmentCheckStatus::Warning => '' . trans('commands.environment_check.warning') . '', + EnvironmentCheckStatus::Failed => '' . trans('commands.environment_check.failed') . '', + }; + } +} diff --git a/app/ValueObjects/EnvironmentCheckResult.php b/app/ValueObjects/EnvironmentCheckResult.php new file mode 100644 index 0000000000..9ed1399c74 --- /dev/null +++ b/app/ValueObjects/EnvironmentCheckResult.php @@ -0,0 +1,21 @@ +status->isFailure(); + } +} diff --git a/app/ValueObjects/UpdateSnapshot.php b/app/ValueObjects/UpdateSnapshot.php new file mode 100644 index 0000000000..690f4629f0 --- /dev/null +++ b/app/ValueObjects/UpdateSnapshot.php @@ -0,0 +1,12 @@ + 'Your connection credentials have NOT been saved. You will need to provide valid connection information before proceeding.', 'go_back' => 'Go back and try again', ], + 'environment_check' => [ + 'status' => 'Status', + 'check' => 'Check', + 'details' => 'Details', + 'passed' => 'PASS', + 'warning' => 'WARN', + 'failed' => 'FAIL', + 'preflight_passed' => 'Environment preflight passed.', + 'preflight_failed' => 'Environment preflight failed. Resolve every failed check before continuing.', + 'health_passed' => 'Panel health validation passed.', + 'health_failed' => 'Panel health validation failed. Resolve every failed check before continuing.', + ], + 'update' => [ + 'preparation_failed' => 'The update was not prepared because one or more safety checks failed.', + 'source_required' => 'Provide --source with the extracted target release directory so its locked platform requirements can be verified.', + 'snapshot_created' => 'Pre-update snapshot created at: :path', + 'snapshot_missing' => 'No pre-update snapshot was found. Create one before changing the application.', + 'snapshot_incomplete' => 'The pre-update snapshot is incomplete and cannot be used: :path', + 'snapshot_write_failed' => 'The required snapshot artifact could not be written: :artifact', + 'compatibility_failed' => 'The target release is not compatible with this server:', + 'compatibility_passed' => 'Composer platform requirements are compatible with this server.', + 'compatibility_label' => 'Target release compatibility', + 'compatibility_files_missing' => 'The target release directory must contain composer.json and composer.lock.', + 'compatibility_exception' => 'Composer platform validation could not start: :error', + 'compatibility_command_failed' => "Composer's platform validation failed:\n:details", + 'compatibility_remediation' => 'Install the PHP version and extensions required by the target release before changing application files.', + 'no_command_output' => 'Composer returned no additional output.', + 'environment_missing' => 'The Panel .env file does not exist and cannot be captured.', + 'sqlite_database_missing' => 'The configured SQLite database does not exist and cannot be captured.', + 'database_backup_sqlite' => 'The SQLite database was copied to :path.', + 'database_backup_mysql' => 'Create a consistent backup before updating, for example: mysqldump --single-transaction --quick --lock-tables=false DATABASE_NAME > panel-before-update.sql', + 'database_backup_pgsql' => 'Create a consistent backup before updating, for example: pg_dump --format=custom --file=panel-before-update.dump DATABASE_NAME', + 'database_backup_unknown' => 'Create and verify a database backup using the tools recommended for the configured database driver.', + 'database_guidance' => 'Database backup guidance: :guidance', + 'ready' => 'Pre-update checks passed. Keep the snapshot until the post-update health check succeeds.', + 'healthy' => 'The post-update health check passed. The snapshot can be archived or removed after final verification.', + 'unhealthy' => 'The update is not healthy. Stop here and follow the rollback guide at: :path', + ], 'make_node' => [ 'name' => 'Enter a short identifier used to distinguish this node from others', 'description' => 'Enter a description to identify the node', diff --git a/lang/en/installer.php b/lang/en/installer.php index 43005bbc9d..c1628e1c1c 100644 --- a/lang/en/installer.php +++ b/lang/en/installer.php @@ -98,12 +98,89 @@ 'driver' => 'Queue Driver', 'driver_help' => 'The driver used for handling queues. We recommend "Database".', 'fields' => [ - 'done' => 'I have done both steps below.', + 'done' => 'I have completed both steps below. A real queue job will be verified before the administrator account is created.', 'done_validation' => 'You need to do both steps before continuing!', 'crontab' => 'Run the following command to set up your crontab. Note that www-data is your webserver user. On some systems this username might be different!', 'service' => 'To setup the queue worker service you simply have to run the following command.', ], ], + 'health' => [ + 'php' => [ + 'label' => 'PHP version', + 'passed' => 'PHP :current meets the minimum supported version of :minimum.', + 'failed' => 'PHP :current is installed, but Pelican requires PHP :minimum or newer.', + 'remediation' => 'Upgrade the PHP CLI and web runtime before continuing.', + ], + 'extensions' => [ + 'label' => 'PHP extensions', + 'passed' => 'All required PHP extensions and at least one supported PDO driver are available.', + 'failed' => 'Missing PHP extensions: :extensions.', + 'remediation' => 'Install the missing extensions for both the PHP CLI and web runtime, then restart PHP.', + ], + 'database_extension' => [ + 'label' => 'Database PHP extension', + 'passed' => 'The :extension extension is available.', + 'failed' => 'The :driver database driver requires the :extension PHP extension.', + ], + 'paths' => [ + 'label' => 'Writable paths', + 'passed' => 'The environment file, storage directory, and bootstrap cache are writable.', + 'failed' => 'These paths are not writable by the current PHP user: :paths.', + 'remediation' => 'Correct ownership and permissions for the web and queue-worker user before continuing.', + ], + 'app_key' => [ + 'label' => 'Application key', + 'passed' => 'The application encryption key is configured.', + 'failed' => 'The application encryption key is missing.', + 'remediation' => 'Run php artisan key:generate before continuing.', + ], + 'installed' => [ + 'label' => 'Installation state', + 'passed' => 'The Panel is marked as installed.', + 'failed' => 'The Panel is not marked as installed.', + 'remediation' => 'Complete the installer before treating this Panel as ready.', + ], + 'database' => [ + 'label' => 'Database connection', + 'passed' => 'The :connection database connection is reachable.', + 'failed' => 'The database connection failed: :error', + 'remediation' => 'Verify the database host, port, database, username, password, TLS settings, and firewall access.', + ], + 'migrations' => [ + 'label' => 'Database migrations', + 'passed' => 'All database migrations have completed.', + 'failed' => ':count database migration(s) are still pending.', + 'repository_missing' => 'The migrations table does not exist.', + 'exception' => 'Migration status could not be read: :error', + 'remediation' => 'Run php artisan migrate --force and review the error before continuing.', + ], + 'admin' => [ + 'label' => 'Administrator account', + 'passed' => 'At least one administrator account exists.', + 'failed' => 'No administrator account exists.', + 'exception' => 'Administrator status could not be read: :error', + 'remediation' => 'Complete administrator creation in the installer or use the supported user creation command.', + ], + 'cache' => [ + 'label' => 'Cache', + 'passed' => 'The configured cache can store and retrieve values.', + 'failed' => 'The configured cache did not return the test value.', + 'exception' => 'The cache check failed: :error', + 'remediation' => 'Verify the cache driver configuration, connectivity, and writable storage paths.', + ], + 'queue' => [ + 'label' => 'Queue worker', + 'passed' => 'A test job was processed through the :connection queue connection.', + 'not_configured' => 'No queue connection is configured.', + 'timed_out' => 'A test job was not processed through the :connection queue within :seconds seconds.', + 'exception' => 'The queue worker check failed: :error', + 'remediation' => 'Start or restart the Pelican queue worker, verify it uses this release and environment, then run the check again.', + ], + 'preflight_failed' => 'Environment preflight failed', + 'queue_failed' => 'Queue worker verification failed', + 'installation_failed' => 'Installation completed with failed health checks', + 'installation_failed_body' => 'Run php artisan p:environment:health for full results before putting the Panel into service.', + ], 'exceptions' => [ 'write_env' => 'Could not write to .env file', 'migration' => 'Could not run migrations', diff --git a/tests/Filament/Installer/PanelInstallerTest.php b/tests/Filament/Installer/PanelInstallerTest.php new file mode 100644 index 0000000000..2316ad337c --- /dev/null +++ b/tests/Filament/Installer/PanelInstallerTest.php @@ -0,0 +1,60 @@ +create(); + $creationService = Mockery::mock(UserCreationService::class); + $health = Mockery::mock(InstallationHealthService::class); + $installer = Mockery::mock(PanelInstaller::class)->makePartial(); + + $installer->shouldReceive('runMigrations')->once()->ordered(); + $installer->shouldReceive('verifyQueueWorker')->once()->with($health)->ordered(); + $installer->shouldReceive('createAdminUser')->once()->with($creationService)->andReturn($user)->ordered(); + $installer->shouldReceive('writeToEnv')->once()->with('env_session')->ordered(); + $installer->shouldReceive('installEggs')->once()->ordered(); + $installer->shouldReceive('writeToEnvironment')->once()->with(['APP_INSTALLED' => 'true'])->ordered(); + $installer->shouldReceive('redirect')->once()->withAnyArgs()->ordered(); + + $health->shouldReceive('completeInstallation')->once()->with(false)->andReturn([]); + $health->shouldReceive('hasFailures')->once()->with([])->andReturnFalse(); + + $installer->submit($creationService, $health); + + $this->assertTrue((bool) config('app.installed')); + $this->assertAuthenticatedAs($user); + } + + public function test_submit_stops_before_admin_creation_when_the_queue_worker_is_unavailable(): void + { + $creationService = Mockery::mock(UserCreationService::class); + $health = Mockery::mock(InstallationHealthService::class); + $installer = Mockery::mock(PanelInstaller::class)->makePartial(); + + $installer->shouldReceive('runMigrations')->once(); + $installer->shouldReceive('createAdminUser')->never(); + $health->shouldReceive('queueWorker')->once()->andReturn(new EnvironmentCheckResult( + 'queue', + 'Queue worker', + EnvironmentCheckStatus::Failed, + 'The probe timed out.', + 'Start the worker.', + )); + + $installer->submit($creationService, $health); + + $this->assertGuest(); + } +} diff --git a/tests/Integration/Console/Commands/EnvironmentCommandsTest.php b/tests/Integration/Console/Commands/EnvironmentCommandsTest.php new file mode 100644 index 0000000000..e8d0ae223a --- /dev/null +++ b/tests/Integration/Console/Commands/EnvironmentCommandsTest.php @@ -0,0 +1,52 @@ +set('queue.default', 'sync'); + + $this->artisan('p:environment:preflight', [ + '--with-database' => true, + '--with-queue' => true, + '--queue-timeout' => 1, + ])->assertExitCode(0); + } + + public function test_health_command_fails_when_no_administrator_exists(): void + { + $this->configureInstalledApplication(); + + $this->artisan('p:environment:health', ['--skip-queue' => true]) + ->assertExitCode(1); + } + + public function test_health_command_passes_for_a_complete_installation(): void + { + $this->configureInstalledApplication(); + User::factory()->create()->syncRoles(Role::getRootAdmin()); + + $this->artisan('p:environment:health', ['--skip-queue' => true]) + ->assertExitCode(0); + } + + public function test_update_preparation_requires_an_extracted_target_release(): void + { + $this->artisan('p:maintenance:prepare-update') + ->expectsOutput(trans('commands.update.source_required')) + ->assertExitCode(1); + } + + private function configureInstalledApplication(): void + { + config()->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + config()->set('app.installed', true); + config()->set('queue.default', 'sync'); + } +} diff --git a/tests/Integration/Services/Environment/InstallationHealthServiceTest.php b/tests/Integration/Services/Environment/InstallationHealthServiceTest.php new file mode 100644 index 0000000000..4c98bef8f5 --- /dev/null +++ b/tests/Integration/Services/Environment/InstallationHealthServiceTest.php @@ -0,0 +1,73 @@ +service = $this->app->make(InstallationHealthService::class); + } + + public function test_sync_queue_probe_is_processed_immediately(): void + { + config()->set('queue.default', 'sync'); + + $result = $this->service->queueWorker(1); + + $this->assertSame(EnvironmentCheckStatus::Passed, $result->status); + } + + public function test_queue_probe_fails_when_a_worker_does_not_process_the_job(): void + { + Queue::fake(); + config()->set('queue.default', 'database'); + + $result = $this->service->queueWorker(0); + + $this->assertSame(EnvironmentCheckStatus::Failed, $result->status); + $this->assertNotNull($result->remediation); + } + + public function test_complete_installation_checks_database_migrations_cache_admin_and_queue(): void + { + config()->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + config()->set('app.installed', true); + config()->set('queue.default', 'sync'); + User::factory()->create()->syncRoles(Role::getRootAdmin()); + + $results = collect($this->service->completeInstallation())->keyBy('key'); + + foreach (['database', 'migrations', 'cache', 'queue', 'admin', 'app_key', 'installed'] as $key) { + $this->assertSame(EnvironmentCheckStatus::Passed, $results->get($key)->status, "The {$key} check should pass."); + } + } + + public function test_selected_database_driver_requires_its_matching_pdo_extension(): void + { + $result = $this->service->databaseDriverExtension('sqlite'); + + $this->assertSame(EnvironmentCheckStatus::Passed, $result->status); + $this->assertStringContainsString('pdo_sqlite', $result->message); + } + + public function test_application_key_must_have_a_supported_cipher_length(): void + { + config()->set('app.key', 'base64:' . base64_encode('too-short')); + + $result = $this->service->applicationKey(); + + $this->assertSame(EnvironmentCheckStatus::Failed, $result->status); + } +} diff --git a/tests/Integration/Services/Maintenance/UpdateSafetyServiceTest.php b/tests/Integration/Services/Maintenance/UpdateSafetyServiceTest.php new file mode 100644 index 0000000000..16bbc2fa84 --- /dev/null +++ b/tests/Integration/Services/Maintenance/UpdateSafetyServiceTest.php @@ -0,0 +1,189 @@ +temporaryDirectories as $directory) { + $directory->delete(); + } + + parent::tearDown(); + } + + public function test_compatibility_check_uses_the_target_release_lock_file(): void + { + $source = $this->temporaryDirectory(); + File::put($source->path('composer.json'), '{}'); + File::put($source->path('composer.lock'), '{}'); + Process::fake(['*' => Process::result(output: 'All platform requirements satisfied.')]); + + $result = $this->app->make(UpdateCompatibilityService::class)->check($source->path()); + + $this->assertSame(EnvironmentCheckStatus::Passed, $result->status); + Process::assertRan(fn ($process) => $process->path === $source->path() + && $process->command === ['composer', 'check-platform-reqs', '--lock', '--no-dev']); + } + + public function test_compatibility_check_fails_before_composer_when_release_files_are_missing(): void + { + Process::fake(); + + $result = $this->app->make(UpdateCompatibilityService::class)->check($this->temporaryDirectory()->path()); + + $this->assertSame(EnvironmentCheckStatus::Failed, $result->status); + Process::assertNothingRan(); + } + + public function test_snapshot_captures_environment_release_metadata_and_sqlite_database(): void + { + $root = $this->temporaryDirectory(); + $environmentPath = $root->path('.env'); + $databasePath = $root->path('database.sqlite'); + File::put($environmentPath, "APP_ENV=testing\n"); + $database = new PDO('sqlite:' . $databasePath); + $database->exec('CREATE TABLE settings (name TEXT NOT NULL)'); + $database->exec("INSERT INTO settings VALUES ('captured')"); + $database = null; + config()->set([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $databasePath, + ]); + + $snapshot = $this->app->make(UpdateSnapshotService::class)->capture('v1.2.3', $root->path(), $environmentPath); + + $this->assertFileExists($snapshot->path . DIRECTORY_SEPARATOR . '.env'); + $this->assertFileExists($snapshot->path . DIRECTORY_SEPARATOR . 'composer.json'); + $this->assertFileExists($snapshot->path . DIRECTORY_SEPARATOR . 'composer.lock'); + $this->assertFileExists($snapshot->path . DIRECTORY_SEPARATOR . 'database.sqlite'); + $this->assertFileExists($snapshot->rollbackGuide); + + $metadata = File::json($snapshot->path . DIRECTORY_SEPARATOR . 'metadata.json'); + $this->assertSame('v1.2.3', $metadata['target_version']); + $this->assertSame('sqlite', $metadata['database_driver']); + $this->assertStringContainsString('php artisan p:environment:health', File::get($snapshot->rollbackGuide)); + $this->assertStringContainsString($snapshot->path . '/.env', File::get($snapshot->rollbackGuide)); + } + + public function test_snapshot_captures_committed_sqlite_wal_data(): void + { + $root = $this->temporaryDirectory(); + $environmentPath = $root->path('.env'); + $databasePath = $root->path('database.sqlite'); + File::put($environmentPath, "APP_ENV=testing\n"); + + $database = new PDO('sqlite:' . $databasePath); + $database->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $database->exec('PRAGMA journal_mode = WAL'); + $database->exec('PRAGMA wal_autocheckpoint = 0'); + $database->exec('CREATE TABLE settings (name TEXT NOT NULL)'); + $database->exec("INSERT INTO settings VALUES ('committed in WAL')"); + $this->assertFileExists($databasePath . '-wal'); + + config()->set([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $databasePath, + ]); + + $snapshot = $this->app->make(UpdateSnapshotService::class)->capture(null, $root->path(), $environmentPath); + $backup = new PDO('sqlite:' . $snapshot->path . DIRECTORY_SEPARATOR . 'database.sqlite'); + + $this->assertSame('committed in WAL', $backup->query('SELECT name FROM settings')->fetchColumn()); + } + + public function test_snapshot_capture_rejects_failed_required_copy(): void + { + $root = $this->temporaryDirectory(); + $environmentPath = $root->path('.env'); + File::put($environmentPath, "APP_ENV=testing\n"); + + $files = new class extends Filesystem + { + public function copy($path, $target) + { + return false; + } + }; + $service = new UpdateSnapshotService($this->app->make(SoftwareVersionService::class), $files); + + try { + $service->capture(null, $root->path(), $environmentPath); + $this->fail('The snapshot should fail when a required file cannot be copied.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('could not be written', $exception->getMessage()); + } + + $this->assertSame([], File::directories($root->path())); + } + + public function test_snapshot_capture_rejects_failed_required_write(): void + { + $root = $this->temporaryDirectory(); + $environmentPath = $root->path('.env'); + $databasePath = $root->path('database.sqlite'); + File::put($environmentPath, "APP_ENV=testing\n"); + $database = new PDO('sqlite:' . $databasePath); + $database->exec('CREATE TABLE settings (name TEXT NOT NULL)'); + $database = null; + config()->set([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $databasePath, + ]); + + $files = new class extends Filesystem + { + public function put($path, $contents, $lock = false) + { + return false; + } + }; + $service = new UpdateSnapshotService($this->app->make(SoftwareVersionService::class), $files); + + try { + $service->capture(null, $root->path(), $environmentPath); + $this->fail('The snapshot should fail when a required artifact cannot be written.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('could not be written', $exception->getMessage()); + } + + $this->assertSame([], File::directories($root->path())); + } + + public function test_snapshot_loaders_reject_incomplete_snapshots(): void + { + $root = $this->temporaryDirectory(); + $snapshotPath = $root->path('20260818-120000-invalid'); + File::ensureDirectoryExists($snapshotPath); + File::put($snapshotPath . DIRECTORY_SEPARATOR . 'ROLLBACK.md', '# Incomplete'); + + $service = $this->app->make(UpdateSnapshotService::class); + + $this->assertNull($service->fromPath($snapshotPath)); + $this->assertNull($service->latest($root->path())); + } + + private function temporaryDirectory(): TemporaryDirectory + { + $directory = TemporaryDirectory::make(); + $this->temporaryDirectories[] = $directory; + + return $directory; + } +}