From d4a5f362158c4c93642374cd601d6412c934ced2 Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Tue, 25 Aug 2026 16:27:23 +0200 Subject: [PATCH] Add guarded update snapshots and rollback guidance --- .../Maintenance/FinishUpdateCommand.php | 50 +++ .../Maintenance/PrepareUpdateCommand.php | 70 ++++ app/Data/UpdateSnapshotData.php | 20 + .../UpdateCompatibilityService.php | 89 +++++ .../Maintenance/UpdateSnapshotService.php | 358 ++++++++++++++++++ config/panel.php | 5 + lang/en/commands.php | 29 ++ .../Maintenance/UpdateSafetyTest.php | 185 +++++++++ 8 files changed, 806 insertions(+) create mode 100644 app/Console/Commands/Maintenance/FinishUpdateCommand.php create mode 100644 app/Console/Commands/Maintenance/PrepareUpdateCommand.php create mode 100644 app/Data/UpdateSnapshotData.php create mode 100644 app/Services/Maintenance/UpdateCompatibilityService.php create mode 100644 app/Services/Maintenance/UpdateSnapshotService.php create mode 100644 tests/Integration/Maintenance/UpdateSafetyTest.php diff --git a/app/Console/Commands/Maintenance/FinishUpdateCommand.php b/app/Console/Commands/Maintenance/FinishUpdateCommand.php new file mode 100644 index 0000000000..21724ffa46 --- /dev/null +++ b/app/Console/Commands/Maintenance/FinishUpdateCommand.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/app/Console/Commands/Maintenance/PrepareUpdateCommand.php b/app/Console/Commands/Maintenance/PrepareUpdateCommand.php new file mode 100644 index 0000000000..c384cc292b --- /dev/null +++ b/app/Console/Commands/Maintenance/PrepareUpdateCommand.php @@ -0,0 +1,70 @@ +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; + } +} diff --git a/app/Data/UpdateSnapshotData.php b/app/Data/UpdateSnapshotData.php new file mode 100644 index 0000000000..74172e18b5 --- /dev/null +++ b/app/Data/UpdateSnapshotData.php @@ -0,0 +1,20 @@ +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 + */ + 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]; + } +} diff --git a/app/Services/Maintenance/UpdateSnapshotService.php b/app/Services/Maintenance/UpdateSnapshotService.php new file mode 100644 index 0000000000..13a3bc6931 --- /dev/null +++ b/app/Services/Maintenance/UpdateSnapshotService.php @@ -0,0 +1,358 @@ +files->isFile($environmentPath)) { + throw new RuntimeException(trans('commands.update.environment_missing')); + } + + $this->files->ensureDirectoryExists($snapshotRoot, 0700, true); + $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); + + foreach (['composer.json', 'composer.lock'] as $file) { + $this->copyRequiredArtifact(base_path($file), $snapshotPath . DIRECTORY_SEPARATOR . $file); + } + + [$databaseGuidance, $databaseDriver] = $this->captureDatabaseState($snapshotPath); + $rollbackGuide = $snapshotPath . DIRECTORY_SEPARATOR . 'ROLLBACK.md'; + $environmentMetadata = $this->environmentMetadata($environmentPath); + + $this->writeRequiredArtifact( + $rollbackGuide, + $this->rollbackGuide($snapshotPath, $environmentPath, $environmentMetadata, $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' => $databaseDriver, + 'environment' => $environmentMetadata, + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + + $snapshot = $this->validatedSnapshot($snapshotPath); + if ($snapshot === null) { + throw new RuntimeException(trans('commands.update.snapshot_incomplete', ['path' => $snapshotPath])); + } + + $this->pruneSnapshots($snapshotRoot, $snapshotPath, max(1, $retainedSnapshots)); + + return $snapshot; + } catch (Throwable $exception) { + $this->files->deleteDirectory($snapshotPath); + + throw $exception; + } + } + + /** + * Return the newest complete snapshot, ignoring incomplete or unrelated directories. + */ + public function latest(?string $snapshotRoot = null): ?UpdateSnapshotData + { + $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; + } + + /** + * Load a snapshot only when all required rollback artifacts are present and valid. + */ + public function fromPath(string $path): ?UpdateSnapshotData + { + return $this->validatedSnapshot($path); + } + + /** + * Capture a consistent database state or write driver-specific backup guidance. + * + * @return array{string, string} guidance and resolved database driver + */ + private function captureDatabaseState(string $snapshotPath): array + { + $connectionName = (string) config('database.default'); + + try { + $driver = DB::connection($connectionName)->getDriverName(); + } catch (Throwable $exception) { + throw new RuntimeException( + trans('commands.update.database_connection_failed', ['error' => $exception->getMessage()]), + previous: $exception, + ); + } + + if ($driver === 'sqlite') { + $database = config("database.connections.{$connectionName}.database"); + if (!is_string($database) || $database === ':memory:' || !$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, $driver]; + } + + $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', ['driver' => $driver]), + }; + + $this->writeRequiredArtifact($snapshotPath . DIRECTORY_SEPARATOR . 'DATABASE-BACKUP.txt', $guidance); + + return [$guidance, $driver]; + } + + /** + * Use SQLite's VACUUM INTO support so committed WAL data is included consistently. + */ + 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])); + } + + $this->protectArtifact($destination); + } + + /** + * Copy a required file and restrict the resulting snapshot artifact to its owner. + */ + 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])); + } + + $this->protectArtifact($destination); + } + + /** + * Write a required artifact and reject partial snapshot creation on failure. + */ + 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])); + } + + $this->protectArtifact($path); + } + + /** + * Apply restrictive permissions to files that may contain secrets or recovery data. + */ + private function protectArtifact(string $path): void + { + if ($this->files->chmod($path, 0600) === false) { + throw new RuntimeException(trans('commands.update.snapshot_permissions_failed', ['artifact' => $path])); + } + } + + /** + * Validate every artifact before exposing a snapshot to update or rollback commands. + */ + private function validatedSnapshot(string $path): ?UpdateSnapshotData + { + $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')); + $rollbackGuide = trim($this->files->get($path . DIRECTORY_SEPARATOR . 'ROLLBACK.md')); + if ($databaseGuidance === '' || $rollbackGuide === '') { + return null; + } + } catch (Throwable) { + return null; + } + + return new UpdateSnapshotData( + $path, + $path . DIRECTORY_SEPARATOR . 'ROLLBACK.md', + $databaseGuidance, + ); + } + + /** + * Keep the current snapshot plus the newest retained snapshots and securely limit growth. + */ + private function pruneSnapshots(string $snapshotRoot, string $currentSnapshot, int $retain): void + { + $managedDirectories = collect($this->files->directories($snapshotRoot)) + ->filter(fn (string $directory) => preg_match('/^\d{8}-\d{6}-[a-z0-9]{6}$/', basename($directory)) === 1) + ->sortByDesc(fn (string $directory) => $directory === $currentSnapshot + ? PHP_INT_MAX + : $this->files->lastModified($directory)); + + foreach ($managedDirectories->slice($retain) as $directory) { + if (!$this->files->deleteDirectory($directory)) { + throw new RuntimeException(trans('commands.update.snapshot_prune_failed', ['path' => $directory])); + } + } + } + + /** + * Record the original `.env` owner, group, and permissions for exact restoration guidance. + * + * @return array{path: string, owner: int|null, group: int|null, mode: string} + */ + private function environmentMetadata(string $environmentPath): array + { + $owner = @fileowner($environmentPath); + $group = @filegroup($environmentPath); + $permissions = @fileperms($environmentPath); + + return [ + 'path' => $environmentPath, + 'owner' => is_int($owner) ? $owner : null, + 'group' => is_int($group) ? $group : null, + 'mode' => is_int($permissions) ? sprintf('%04o', $permissions & 0777) : '0600', + ]; + } + + /** + * Produce concrete, snapshot-specific rollback steps without changing the live installation. + * + * @param array{path: string, owner: int|null, group: int|null, mode: string} $environment + */ + private function rollbackGuide( + string $snapshotPath, + string $environmentPath, + array $environment, + string $databaseGuidance, + ): string { + $source = $this->shellArgument($snapshotPath . DIRECTORY_SEPARATOR . '.env'); + $destination = $this->shellArgument($environmentPath); + $ownership = is_int($environment['owner']) && is_int($environment['group']) + ? "chown {$environment['owner']}:{$environment['group']} {$destination}" + : '# Restore the original owner and group for the environment file.'; + + return <<shellArgument($snapshotPath)}` and only run `php artisan up` after every health check passes. + +Database backup guidance recorded before the update: + +{$databaseGuidance} +MARKDOWN; + } + + /** + * Quote a filesystem path for the POSIX shell commands shown in rollback guidance. + */ + private function shellArgument(string $value): string + { + return "'" . str_replace("'", "'\\''", $value) . "'"; + } +} diff --git a/config/panel.php b/config/panel.php index 584dadfb06..81fb9d1d70 100644 --- a/config/panel.php +++ b/config/panel.php @@ -87,4 +87,9 @@ 'dev_mode' => env('PANEL_PLUGIN_DEV_MODE', false), 'max_import_size' => env('PANEL_PLUGIN_MAX_IMPORT_SIZE', 1024 * 1024 * 100), ], + + 'updates' => [ + // Snapshots contain .env and possibly SQLite data, so retain only a small recovery window. + 'retained_snapshots' => env('PANEL_UPDATE_RETAINED_SNAPSHOTS', 3), + ], ]; diff --git a/lang/en/commands.php b/lang/en/commands.php index a24222b3a8..70aff5a2ee 100644 --- a/lang/en/commands.php +++ b/lang/en/commands.php @@ -20,6 +20,35 @@ 'DB_error_2' => 'Your connection credentials have NOT been saved. You will need to provide valid connection information before proceeding.', 'go_back' => 'Go back and try again', ], + 'update' => [ + 'preparation_failed' => 'The update was not prepared. No application files were changed.', + 'source_required' => 'Provide --source with the extracted target release directory so its locked platform requirements can be verified.', + 'retention_invalid' => 'The --retain value must be at least 1.', + 'snapshot_created' => 'Pre-update snapshot created at: :path', + 'snapshot_missing' => 'No complete pre-update snapshot was found. Keep the Panel in maintenance mode and restore from your external backup.', + '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', + 'snapshot_permissions_failed' => 'Restrictive permissions could not be applied to snapshot artifact: :artifact', + 'snapshot_prune_failed' => 'An expired protected snapshot could not be removed: :path', + 'compatibility_passed' => 'Composer platform requirements for the target release are compatible with this server.', + '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 platform validation failed:\n:details", + 'composer_binary_missing' => 'The requested Composer executable could not be found: :binary', + 'composer_binary_required' => 'Composer could not be located. Provide its executable or PHAR path with --composer.', + 'no_command_output' => 'Composer returned no additional output.', + 'environment_missing' => 'The Panel .env file does not exist and cannot be captured.', + 'database_connection_failed' => 'The configured database connection could not be inspected: :error', + 'sqlite_database_missing' => 'The configured SQLite database does not exist and cannot be captured.', + 'database_backup_sqlite' => 'A consistent SQLite backup was captured at :path.', + 'database_backup_mysql' => 'Create and verify a consistent database backup before updating, for example with mysqldump --single-transaction --quick --lock-tables=false.', + 'database_backup_pgsql' => 'Create and verify a consistent database backup before updating, for example with pg_dump --format=custom.', + 'database_backup_unknown' => 'Create and verify a database backup using the tools recommended for the configured :driver driver.', + 'database_guidance' => 'Database backup guidance: :guidance', + 'ready' => 'Pre-update validation passed. Keep the snapshot until the post-update health check succeeds.', + 'healthy' => 'The post-update health check passed. The snapshot may be archived or removed after final verification.', + 'unhealthy' => 'The update is not healthy. Keep the Panel in maintenance mode 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/tests/Integration/Maintenance/UpdateSafetyTest.php b/tests/Integration/Maintenance/UpdateSafetyTest.php new file mode 100644 index 0000000000..d7674a672d --- /dev/null +++ b/tests/Integration/Maintenance/UpdateSafetyTest.php @@ -0,0 +1,185 @@ + */ + public static array $directories = []; +} + +beforeEach(function () { + UpdateSafetyTestDirectories::$directories = []; + $this->originalDatabaseDefault = config('database.default'); +}); + +afterEach(function () { + DB::purge('snapshot_test'); + config()->set('database.default', $this->originalDatabaseDefault); + + foreach (UpdateSafetyTestDirectories::$directories as $directory) { + $directory->delete(); + } +}); + +/** + * Create and register a temporary directory for automatic cleanup after each test. + */ +function updateSafetyDirectory(): TemporaryDirectory +{ + $directory = TemporaryDirectory::make(); + UpdateSafetyTestDirectories::$directories[] = $directory; + + return $directory; +} + +/** + * Configure a named SQLite connection used by the snapshot service tests. + */ +function configureSnapshotSqlite(string $databasePath): void +{ + config()->set([ + 'database.default' => 'snapshot_test', + 'database.connections.snapshot_test' => [ + 'driver' => 'sqlite', + 'database' => $databasePath, + 'prefix' => '', + 'foreign_key_constraints' => true, + 'busy_timeout' => 5000, + ], + ]); + DB::purge('snapshot_test'); +} + +it('checks the target lock file with an explicitly resolved Composer PHAR', function () { + $source = updateSafetyDirectory(); + $composer = $source->path('composer.phar'); + File::put($source->path('composer.json'), '{}'); + File::put($source->path('composer.lock'), '{}'); + File::put($composer, 'composer fixture'); + Process::fake(['*' => Process::result(output: 'All platform requirements satisfied.')]); + + app(UpdateCompatibilityService::class)->assertCompatible($source->path(), $composer); + + Process::assertRan(fn ($process) => $process->path === $source->path() + && $process->command === [ + PHP_BINARY, + $composer, + 'check-platform-reqs', + '--lock', + '--no-dev', + '--no-interaction', + ]); +}); + +it('requires a valid explicit Composer executable', function () { + $source = updateSafetyDirectory(); + File::put($source->path('composer.json'), '{}'); + File::put($source->path('composer.lock'), '{}'); + + expect(fn () => app(UpdateCompatibilityService::class) + ->assertCompatible($source->path(), $source->path('missing-composer'))) + ->toThrow(RuntimeException::class, 'could not be found'); +}); + +it('captures named SQLite connections including committed WAL data', function () { + $root = updateSafetyDirectory(); + $environmentPath = $root->path('.env'); + $databasePath = $root->path('database.sqlite'); + File::put($environmentPath, "APP_ENV=testing\n"); + + $database = new PDO('sqlite:' . $databasePath, null, null, [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')"); + expect($databasePath . '-wal')->toBeFile(); + + configureSnapshotSqlite($databasePath); + + $snapshot = app(UpdateSnapshotService::class)->capture( + targetVersion: 'v1.2.3', + snapshotRoot: $root->path('snapshots'), + environmentPath: $environmentPath, + ); + $backup = new PDO('sqlite:' . $snapshot->path . DIRECTORY_SEPARATOR . 'database.sqlite'); + $metadata = File::json($snapshot->path . DIRECTORY_SEPARATOR . 'metadata.json'); + $rollback = File::get($snapshot->rollbackGuide); + + expect($snapshot)->toBeInstanceOf(UpdateSnapshotData::class) + ->and($backup->query('SELECT name FROM settings')->fetchColumn())->toBe('committed in WAL') + ->and($metadata['database_driver'])->toBe('sqlite') + ->and($metadata['target_version'])->toBe('v1.2.3') + ->and($rollback)->toContain($snapshot->path . DIRECTORY_SEPARATOR . '.env') + ->and($rollback)->toContain($environmentPath) + ->and($rollback)->toContain('p:maintenance:finish-update'); +}); + +it('retains only the configured number of protected snapshots', function () { + $root = updateSafetyDirectory(); + $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; + configureSnapshotSqlite($databasePath); + + $service = app(UpdateSnapshotService::class); + $service->capture(snapshotRoot: $root->path('snapshots'), environmentPath: $environmentPath, retainedSnapshots: 2); + $service->capture(snapshotRoot: $root->path('snapshots'), environmentPath: $environmentPath, retainedSnapshots: 2); + $latest = $service->capture(snapshotRoot: $root->path('snapshots'), environmentPath: $environmentPath, retainedSnapshots: 2); + + expect(File::directories($root->path('snapshots')))->toHaveCount(2) + ->and($latest->path)->toBeDirectory() + ->and($service->latest($root->path('snapshots')))->not->toBeNull(); +}); + +it('rejects failed required writes and removes the partial snapshot', function () { + $root = updateSafetyDirectory(); + $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(app(SoftwareVersionService::class), $files); + + expect(fn () => $service->capture( + snapshotRoot: $root->path('snapshots'), + environmentPath: $environmentPath, + ))->toThrow(RuntimeException::class, 'could not be written'); + + expect(File::directories($root->path('snapshots')))->toBeEmpty(); +}); + +it('does not expose incomplete snapshot paths for rollback', function () { + $root = updateSafetyDirectory(); + $snapshotPath = $root->path('20260825-120000-invalid'); + File::ensureDirectoryExists($snapshotPath); + File::put($snapshotPath . DIRECTORY_SEPARATOR . 'ROLLBACK.md', '# Incomplete'); + + $service = app(UpdateSnapshotService::class); + + expect($service->fromPath($snapshotPath))->toBeNull() + ->and($service->latest($root->path()))->toBeNull(); +});