diff --git a/CHANGELOG.md b/CHANGELOG.md index db44a93..ca3b434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added * On GitHub Actions, every build also reads and writes the [GitHub Actions cache](tasks.md#on-github-actions), and `castor docker:push` pushes to it. +* `{service}:restore` completes the path of the dump, and asks for confirmation before replacing the database with a file (`--force` to skip it). ### Changed diff --git a/doc/services/databases.md b/doc/services/databases.md index f38486c..1018fd3 100644 --- a/doc/services/databases.md +++ b/doc/services/databases.md @@ -129,6 +129,10 @@ custom format and the tar one, and hands them to `pg_restore` rather than to The content of the database is replaced, not merged: the dump lands in a database that starts empty. +Restoring a file asks for confirmation first, which `--force` skips; a dump +piped into the task is restored straight away, the standard input carrying the +dump rather than an answer. + Postgres restores into a scratch database first, and swaps it with the current one once the dump is fully loaded. A dump that fails half-way — truncated by a download, or written for another schema — leaves the database as it was, and diff --git a/src/database.php b/src/database.php index a78c896..ef51acf 100644 --- a/src/database.php +++ b/src/database.php @@ -5,8 +5,10 @@ namespace Castor\Docker; use Castor\Attribute\AsArgument; +use Castor\Attribute\AsOption; use Castor\Attribute\AsTask; use Castor\Docker\Service\DumpableServiceInterface; +use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Process\Process; @@ -47,9 +49,11 @@ function get_dump_tasks(DumpableServiceInterface $service): iterable yield [ 'task' => new AsTask('restore', $name, \sprintf('Replace the content of the %s database with a dump, read from a file or from the standard input', $name)), 'function' => static fn( - #[AsArgument(description: 'The dump to restore, compressed or not ("-" or nothing for the standard input)')] + #[AsArgument(description: 'The dump to restore, compressed or not ("-" or nothing for the standard input)', autocomplete: 'Castor\Docker\autocomplete_dump_file')] ?string $file = null, - ): int => restore_database($service, $file) ? 0 : 1, + #[AsOption(description: 'Restore without confirmation', shortcut: 'f')] + bool $force = false, + ): int => restore_database($service, $file, $force) ? 0 : 1, ]; } @@ -77,6 +81,58 @@ function describe_dump_files(array $formats): array return $files; } +/** + * The service is guessed from the command namespace, else every database's + * formats are accepted. + * + * @return list + */ +function autocomplete_dump_file(CompletionInput $input): array +{ + $command = $input->getFirstArgument(); + $application = null === $command ? false : strstr($command, ':', true); + $formats = []; + + foreach (collect_services() as $service) { + if ($service instanceof DumpableServiceInterface) { + if ($service->getName() === $application) { + $formats = $service->getDumpFormats(); + + break; + } + + array_push($formats, ...$service->getDumpFormats()); + } + } + + $extensions = array_map(static fn(string $file): string => '.' . $file, describe_dump_files(array_values(array_unique($formats)))); + + $typed = $input->getCompletionValue(); + $prefix = false === ($slash = strrpos($typed, '/')) ? '' : substr($typed, 0, $slash + 1); + $directory = '' === $prefix ? '.' : $prefix; + $entries = is_dir($directory) ? scandir($directory) : false; + + if (false === $entries) { + return []; + } + + $files = []; + + foreach ($entries as $entry) { + if (str_starts_with($entry, '.')) { + continue; + } + + if (is_dir($prefix . $entry)) { + $files[] = $prefix . $entry . '/'; + } elseif (array_filter($extensions, static fn(string $extension): bool => str_ends_with(strtolower($entry), $extension))) { + $files[] = $prefix . $entry; + } + } + + return $files; +} + /** * "prod.sql.zst" is a zstd-compressed "sql" dump, "prod.dump" an uncompressed * "dump". @@ -276,13 +332,14 @@ function dump_database(DumpableServiceInterface $service, ?string $file = null): } /** - * From the standard input when there is no file (or "-"). + * From the standard input when there is no file (or "-"). Only a file is + * confirmed: the standard input carries the dump, not an answer. * * The containers depending on the database are stopped for the duration: an * application would otherwise hold connections on a database being dropped, or * read one half restored, and a worker losing its connection exits for good. */ -function restore_database(DumpableServiceInterface $service, ?string $file = null): bool +function restore_database(DumpableServiceInterface $service, ?string $file = null, bool $force = false): bool { $fromStdin = null === $file || '-' === $file; $environment = []; @@ -304,6 +361,12 @@ function restore_database(DumpableServiceInterface $service, ?string $file = nul return false; } + if (!$force && !io()->confirm(\sprintf('This replaces the content of the %s database with %s. Continue?', $service->getName(), basename($file)), false)) { + io()->comment('Aborted.'); + + return false; + } + $volumes[] = realpath($file) . ':/castor/input:ro'; $environment['CASTOR_INPUT'] = '/castor/input'; } @@ -511,7 +574,7 @@ function copy_databases_to_worktree(array $services, string $path): void dump_database($service, $file); - $restore = run([castor_binary(), $name . ':restore', $file], context: in_worktree($path)->withAllowFailure()); + $restore = run([castor_binary(), $name . ':restore', '--force', $file], context: in_worktree($path)->withAllowFailure()); if (!$restore->isSuccessful()) { io()->warning(\sprintf('The "%s" database could not be restored in the worktree, it starts empty.', $name));