mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-24 07:25:13 -05:00
feat(api): add database backup import endpoints
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
|
||||
use App\Support\DatabaseImport\DatabaseImportException;
|
||||
use App\Support\DatabaseImport\DatabaseImportSource;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class StartDatabaseImport
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public const MAX_BYTES = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
public function __construct(private readonly DatabaseImportCommandBuilder $commands) {}
|
||||
|
||||
public function handle(Model $resource, DatabaseImportSource $source, int $teamId): Activity
|
||||
{
|
||||
if (! $this->commands->supports($resource)) {
|
||||
throw new DatabaseImportException('Database imports are not supported for this database type.');
|
||||
}
|
||||
if (! str($resource->status)->startsWith('running')) {
|
||||
throw new DatabaseImportException('The database must be running before an import can start.');
|
||||
}
|
||||
|
||||
[$server, $container, $network] = $this->target($resource);
|
||||
$destination = $resource instanceof ServiceDatabase ? $resource->service?->destination : $resource->destination;
|
||||
if ($destination instanceof SwarmDocker) {
|
||||
throw new DatabaseImportException('Database imports are not supported for Swarm servers yet.', 501);
|
||||
}
|
||||
if (! $server || ! ValidationPatterns::isValidContainerName($container)) {
|
||||
throw new DatabaseImportException('The database server or container is invalid.', 400);
|
||||
}
|
||||
|
||||
$active = Activity::query()->where('properties->team_id', $teamId)
|
||||
->where('properties->type_uuid', $resource->uuid)
|
||||
->where('properties->operation', 'database_import')
|
||||
->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value])
|
||||
->exists();
|
||||
if ($active) {
|
||||
throw new DatabaseImportException('A database import is already running.', 409);
|
||||
}
|
||||
|
||||
$operation = (string) Str::uuid();
|
||||
$containerPath = "/tmp/restore_{$operation}";
|
||||
$scriptPath = "/tmp/restore_{$operation}.sh";
|
||||
$commandList = [];
|
||||
$cleanup = ['container' => $container, 'containerTmpPath' => $containerPath, 'scriptPath' => $scriptPath, 'serverId' => $server->id];
|
||||
|
||||
if ($source->type === 'upload') {
|
||||
$staged = $source->uploadId
|
||||
? "upload/imports/{$teamId}/{$resource->uuid}/{$source->uploadId}/restore"
|
||||
: "upload/{$resource->uuid}/restore";
|
||||
if (! Storage::exists($staged)) {
|
||||
throw new DatabaseImportException('The completed upload was not found.');
|
||||
}
|
||||
$local = Storage::path($staged);
|
||||
if ($this->commands->databaseType($resource) === 'postgresql' && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($local)) {
|
||||
Storage::delete($staged);
|
||||
throw new DatabaseImportException('The uploaded backup contains disallowed PostgreSQL restore directives.');
|
||||
}
|
||||
$serverPath = "/tmp/database-import-{$operation}";
|
||||
instant_scp($local, $serverPath, $server);
|
||||
$source->uploadId ? Storage::deleteDirectory(dirname($staged)) : Storage::delete($staged);
|
||||
$commandList[] = 'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}");
|
||||
$commandList[] = 'rm -f '.escapeshellarg($serverPath);
|
||||
$cleanup['serverTmpPath'] = $serverPath;
|
||||
} elseif ($source->type === 'server') {
|
||||
$this->assertServerPath($source->path);
|
||||
$size = (int) trim((string) instant_remote_process(['stat -c %s -- '.escapeshellarg($source->path)], $server));
|
||||
if ($size < 1 || $size > self::MAX_BYTES) {
|
||||
throw new DatabaseImportException('The backup file is empty or exceeds the 10 GiB limit.');
|
||||
}
|
||||
$commandList[] = 'docker cp '.escapeshellarg($source->path).' '.escapeshellarg("{$container}:{$containerPath}");
|
||||
} else {
|
||||
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)
|
||||
->where(fn ($query) => $query->whereUuid($source->s3StorageUuid)->orWhere('id', ctype_digit((string) $source->s3StorageUuid) ? (int) $source->s3StorageUuid : -1))
|
||||
->where('is_usable', true)->first();
|
||||
if (! $storage || ! ValidationPatterns::isValidS3BucketName($storage->bucket)) {
|
||||
throw new DatabaseImportException('S3 storage was not found or has an invalid bucket.');
|
||||
}
|
||||
$key = ltrim((string) $source->path, '/');
|
||||
$this->assertS3Path($key);
|
||||
$disk = Storage::build(['driver' => 's3', 'region' => $storage->region, 'key' => $storage->key, 'secret' => $storage->secret, 'bucket' => $storage->bucket, 'endpoint' => $storage->endpoint, 'use_path_style_endpoint' => true, 'http' => SafeWebhookUrl::httpClientOptions($storage->endpoint)]);
|
||||
if (! $disk->exists($key) || $disk->size($key) > self::MAX_BYTES) {
|
||||
throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.');
|
||||
}
|
||||
$helper = "s3-restore-{$operation}";
|
||||
$serverPath = "/tmp/s3-restore-{$operation}";
|
||||
$sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}");
|
||||
$commandList = [
|
||||
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
|
||||
'docker run -d --network '.escapeshellarg($network).' --name '.escapeshellarg($helper).' '.escapeshellarg(coolifyHelperImage().':'.getHelperVersion()).' sleep 3600',
|
||||
'docker exec '.escapeshellarg($helper).' mc alias set s3temp '.escapeshellarg($storage->endpoint).' '.escapeshellarg($storage->key).' '.escapeshellarg($storage->secret),
|
||||
'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore',
|
||||
'docker cp '.escapeshellarg("{$helper}:/tmp/restore").' '.escapeshellarg($serverPath),
|
||||
'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}"),
|
||||
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
|
||||
'rm -f '.escapeshellarg($serverPath),
|
||||
];
|
||||
$cleanup += ['containerName' => $helper, 'serverTmpPath' => $serverPath];
|
||||
}
|
||||
|
||||
if ($safety = $this->commands->buildPostgresSafetyCommand($resource, $container, $containerPath)) {
|
||||
$commandList[] = $safety;
|
||||
}
|
||||
$restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll));
|
||||
$commandList[] = 'echo '.escapeshellarg($restore).' | base64 -d > '.escapeshellarg($scriptPath);
|
||||
$commandList[] = 'chmod +x '.escapeshellarg($scriptPath);
|
||||
$commandList[] = 'docker cp '.escapeshellarg($scriptPath).' '.escapeshellarg("{$container}:{$scriptPath}");
|
||||
$commandList[] = 'rm -f '.escapeshellarg($scriptPath);
|
||||
$commandList[] = 'docker exec '.escapeshellarg($container).' sh -c '.escapeshellarg($scriptPath);
|
||||
|
||||
$activity = remote_process($commandList, $server, type_uuid: $resource->uuid, model: $resource, callEventOnFinish: 'DatabaseImportFinished', callEventData: $cleanup);
|
||||
$activity->properties = $activity->properties->merge(['operation' => 'database_import', 'resource_kind' => $resource instanceof ServiceDatabase ? 'service_database' : 'standalone_database', 'operation_uuid' => $operation]);
|
||||
$activity->save();
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
private function target(Model $resource): array
|
||||
{
|
||||
if ($resource instanceof ServiceDatabase) {
|
||||
return [$resource->service?->server, $resource->name.'-'.$resource->service?->uuid, $resource->service?->destination?->network ?? 'coolify'];
|
||||
}
|
||||
|
||||
return [$resource->destination?->server, $resource->uuid, $resource->destination?->network ?? 'coolify'];
|
||||
}
|
||||
|
||||
private function assertServerPath(?string $path): void
|
||||
{
|
||||
if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
|
||||
throw new DatabaseImportException('The server path is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertS3Path(string $path): void
|
||||
{
|
||||
if ($path === '' || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
|
||||
throw new DatabaseImportException('The S3 path is invalid.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DatabaseImportFinished
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$commands = [];
|
||||
if (filled($data['containerName'] ?? null)) {
|
||||
$commands[] = 'docker rm -f '.escapeshellarg($data['containerName']).' 2>/dev/null || true';
|
||||
}
|
||||
if (isSafeTmpPath($data['serverTmpPath'] ?? null)) {
|
||||
$commands[] = 'rm -f '.escapeshellarg($data['serverTmpPath']).' 2>/dev/null || true';
|
||||
}
|
||||
if (filled($data['container'] ?? null)) {
|
||||
foreach (['containerTmpPath', 'scriptPath'] as $key) {
|
||||
if (isSafeTmpPath($data[$key] ?? null)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($data['container']).' rm -f '.escapeshellarg($data[$key]).' 2>/dev/null || true';
|
||||
}
|
||||
}
|
||||
}
|
||||
$server = Server::find($data['serverId'] ?? null);
|
||||
if ($server && $commands !== []) {
|
||||
instant_remote_process($commands, $server, throwError: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Actions\CoolifyTask\RunRemoteProcess;
|
||||
use App\Actions\Database\StartDatabaseImport;
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use App\Support\DatabaseImport\DatabaseImportException;
|
||||
use App\Support\DatabaseImport\DatabaseImportSource;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Pion\Laravel\ChunkUpload\Handler\HandlerFactory;
|
||||
use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
trait HandlesDatabaseImportsApi
|
||||
{
|
||||
protected function uploadDatabaseImport(Request $request, Model $resource, int $teamId): JsonResponse
|
||||
{
|
||||
$this->authorize('uploadBackup', $resource);
|
||||
$validator = Validator::make($request->all(), ['upload_id' => ['required', 'uuid'], 'file' => ['required', 'file']]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
$originalName = $request->file('file')?->getClientOriginalName();
|
||||
if (! $originalName || ! DatabaseBackupFileValidator::hasAllowedExtension($originalName)) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Unsupported backup file extension.']]], 422);
|
||||
}
|
||||
if ((int) $request->input('dzTotalFilesize', 0) > StartDatabaseImport::MAX_BYTES) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['The backup exceeds the 10 GiB limit.']]], 422);
|
||||
}
|
||||
|
||||
$request->merge(['dzuuid' => $request->input('dzuuid', $request->string('upload_id')->value())]);
|
||||
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
|
||||
$save = $receiver->receive();
|
||||
if (! $save->isFinished()) {
|
||||
return response()->json(['upload_id' => $request->string('upload_id')->value(), 'done' => $save->handler()->getPercentageDone(), 'status' => true]);
|
||||
}
|
||||
|
||||
$file = $save->getFile();
|
||||
if (! $file instanceof UploadedFile || ! DatabaseBackupFileValidator::isUploadAllowed($file, StartDatabaseImport::MAX_BYTES)) {
|
||||
@unlink($file->getPathname());
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Uploaded file failed validation.']]], 422);
|
||||
}
|
||||
$mimeType = $file->getMimeType();
|
||||
$size = $file->getSize();
|
||||
$directory = "upload/imports/{$teamId}/{$resource->uuid}/{$request->string('upload_id')->value()}";
|
||||
Storage::makeDirectory($directory);
|
||||
$file->move(Storage::path($directory), 'restore');
|
||||
|
||||
return response()->json(['upload_id' => $request->string('upload_id')->value(), 'filename' => $originalName, 'mime_type' => $mimeType, 'size' => $size], 201);
|
||||
}
|
||||
|
||||
protected function startDatabaseImport(Request $request, Model $resource, int $teamId, string $statusRoute, array $routeParameters): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
$payload = $request->json()->all() ?: $request->request->all();
|
||||
$allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all'];
|
||||
$validator = Validator::make($payload, [
|
||||
'source' => ['required', Rule::in(['upload', 's3', 'server'])],
|
||||
'upload_id' => ['required_if:source,upload', 'prohibited_unless:source,upload', 'uuid'],
|
||||
's3_storage_uuid' => ['required_if:source,s3', 'prohibited_unless:source,s3', 'string'],
|
||||
'path' => ['required_if:source,s3,server', 'prohibited_if:source,upload', 'string', 'max:4096'],
|
||||
'dump_all' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
foreach (array_diff(array_keys($payload), $allowed) as $field) {
|
||||
$validator->errors()->add($field, 'This field is not allowed.');
|
||||
}
|
||||
if ($validator->fails() || $validator->errors()->isNotEmpty()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false));
|
||||
$activity = app(StartDatabaseImport::class)->handle($resource, $source, $teamId);
|
||||
} catch (DatabaseImportException $exception) {
|
||||
return response()->json(['message' => $exception->getMessage()], $exception->status);
|
||||
}
|
||||
$url = route($statusRoute, [...$routeParameters, 'activity_id' => $activity->id], false);
|
||||
|
||||
return response()->json(['id' => $activity->id, 'status' => data_get($activity, 'properties.status'), 'message' => 'Database import queued.', 'status_url' => $url], 202)->header('Location', $url);
|
||||
}
|
||||
|
||||
protected function showDatabaseImport(Model $resource, int $teamId, int $activityId): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
$activity = Activity::query()->whereKey($activityId)
|
||||
->where('properties->team_id', $teamId)
|
||||
->where('properties->type_uuid', $resource->uuid)
|
||||
->where('properties->operation', 'database_import')->first();
|
||||
if (! $activity) {
|
||||
return response()->json(['message' => 'Database import not found.'], 404);
|
||||
}
|
||||
$status = data_get($activity, 'properties.status');
|
||||
$terminal = in_array($status, ['finished', 'error', 'killed', 'cancelled', 'closed'], true);
|
||||
|
||||
return response()->json([
|
||||
'id' => $activity->id,
|
||||
'status' => $status,
|
||||
'exit_code' => data_get($activity, 'properties.exitCode'),
|
||||
'output' => remove_iip(RunRemoteProcess::decodeOutput($activity)),
|
||||
'created_at' => $activity->created_at,
|
||||
'updated_at' => $activity->updated_at,
|
||||
'finished_at' => $terminal ? $activity->updated_at : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,36 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class DatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesDatabaseImportsApi;
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
#[OA\Post(path: '/databases/{uuid}/imports/uploads', operationId: 'upload-database-import', summary: 'Upload database import', security: [['bearerAuth' => []]], tags: ['Databases'], responses: [new OA\Response(response: 201, description: 'Upload completed'), new OA\Response(response: 422, ref: '#/components/responses/422')])]
|
||||
public function upload_import(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
$database = $teamId === null ? null : queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
|
||||
return $database ? $this->uploadDatabaseImport($request, $database, $teamId) : response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
#[OA\Post(path: '/databases/{uuid}/imports', operationId: 'create-database-import', summary: 'Import database backup', requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')), security: [['bearerAuth' => []]], tags: ['Databases'], responses: [new OA\Response(response: 202, description: 'Import queued'), new OA\Response(response: 409, description: 'Import already active'), new OA\Response(response: 422, ref: '#/components/responses/422')])]
|
||||
public function create_import(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
$database = $teamId === null ? null : queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
|
||||
return $database ? $this->startDatabaseImport($request, $database, $teamId, 'api.databases.imports.show', ['uuid' => $uuid]) : response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
#[OA\Get(path: '/databases/{uuid}/imports/{activity_id}', operationId: 'get-database-import', summary: 'Get database import status', security: [['bearerAuth' => []]], tags: ['Databases'], responses: [new OA\Response(response: 200, description: 'Import status'), new OA\Response(response: 404, ref: '#/components/responses/404')])]
|
||||
public function show_import(Request $request, string $uuid, int $activity_id): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
$database = $teamId === null ? null : queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
|
||||
return $database ? $this->showDatabaseImport($database, $teamId, $activity_id) : response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
|
||||
@@ -12,6 +12,31 @@ use OpenApi\Attributes as OA;
|
||||
securityScheme: 'bearerAuth',
|
||||
description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.')]
|
||||
#[OA\Components(
|
||||
schemas: [
|
||||
new OA\Schema(
|
||||
schema: 'DatabaseImportRequest',
|
||||
oneOf: [
|
||||
new OA\Schema(required: ['source', 'upload_id'], properties: [new OA\Property(property: 'source', type: 'string', enum: ['upload']), new OA\Property(property: 'upload_id', type: 'string', format: 'uuid'), new OA\Property(property: 'dump_all', type: 'boolean', default: false)]),
|
||||
new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], properties: [new OA\Property(property: 'source', type: 'string', enum: ['s3']), new OA\Property(property: 's3_storage_uuid', type: 'string'), new OA\Property(property: 'path', type: 'string'), new OA\Property(property: 'dump_all', type: 'boolean', default: false)]),
|
||||
new OA\Schema(required: ['source', 'path'], properties: [new OA\Property(property: 'source', type: 'string', enum: ['server']), new OA\Property(property: 'path', type: 'string', example: '/var/backups/database.sql.gz'), new OA\Property(property: 'dump_all', type: 'boolean', default: false)]),
|
||||
],
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
),
|
||||
new OA\Schema(
|
||||
schema: 'DatabaseImportStatus',
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'id', type: 'integer'),
|
||||
new OA\Property(property: 'status', type: 'string', enum: ['queued', 'in_progress', 'finished', 'error', 'killed', 'cancelled', 'closed']),
|
||||
new OA\Property(property: 'exit_code', type: 'integer', nullable: true),
|
||||
new OA\Property(property: 'output', type: 'string'),
|
||||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||||
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
|
||||
new OA\Property(property: 'finished_at', type: 'string', format: 'date-time', nullable: true),
|
||||
],
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
|
||||
@@ -18,6 +18,35 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class ServiceDatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesDatabaseImportsApi;
|
||||
|
||||
#[OA\Post(path: '/services/{uuid}/databases/{database_uuid}/imports/uploads', operationId: 'upload-service-database-import', summary: 'Upload service database import', security: [['bearerAuth' => []]], tags: ['Service databases'], responses: [new OA\Response(response: 201, description: 'Upload completed'), new OA\Response(response: 422, ref: '#/components/responses/422')])]
|
||||
public function upload_import(Request $request): JsonResponse
|
||||
{
|
||||
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->uploadDatabaseImport($request, $database, $teamId));
|
||||
}
|
||||
|
||||
#[OA\Post(path: '/services/{uuid}/databases/{database_uuid}/imports', operationId: 'create-service-database-import', summary: 'Import service database backup', requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')), security: [['bearerAuth' => []]], tags: ['Service databases'], responses: [new OA\Response(response: 202, description: 'Import queued'), new OA\Response(response: 409, description: 'Import already active'), new OA\Response(response: 422, ref: '#/components/responses/422')])]
|
||||
public function create_import(Request $request): JsonResponse
|
||||
{
|
||||
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->startDatabaseImport($request, $database, $teamId, 'api.service-databases.imports.show', ['uuid' => $request->route('uuid'), 'database_uuid' => $database->uuid]));
|
||||
}
|
||||
|
||||
#[OA\Get(path: '/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', operationId: 'get-service-database-import', summary: 'Get service database import status', security: [['bearerAuth' => []]], tags: ['Service databases'], responses: [new OA\Response(response: 200, description: 'Import status'), new OA\Response(response: 404, ref: '#/components/responses/404')])]
|
||||
public function show_import(Request $request): JsonResponse
|
||||
{
|
||||
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->showDatabaseImport($database, $teamId, (int) $request->route('activity_id')));
|
||||
}
|
||||
|
||||
private function withImportDatabase(Request $request, callable $callback): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
$service = $teamId === null ? null : $this->resolveService($request, $teamId);
|
||||
$database = $service ? $this->resolveServiceDatabase($request, $service) : null;
|
||||
|
||||
return $database ? $callback($database, $teamId) : response()->json(['message' => 'Service database not found.'], 404);
|
||||
}
|
||||
|
||||
private function removeSensitiveData(ServiceDatabase $serviceDatabase): array
|
||||
{
|
||||
$serviceDatabase->makeHidden([
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Actions\Database\StartDatabaseImport;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
@@ -10,12 +11,13 @@ use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
|
||||
use App\Support\DatabaseImport\DatabaseImportException;
|
||||
use App\Support\DatabaseImport\DatabaseImportSource;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -446,77 +448,21 @@ EOD;
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
$this->importCommands = [];
|
||||
$backupFileName = "upload/{$this->resourceUuid}/restore";
|
||||
|
||||
// Check if an uploaded file exists first (takes priority over custom location)
|
||||
if (Storage::exists($backupFileName)) {
|
||||
$path = Storage::path($backupFileName);
|
||||
|
||||
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
|
||||
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
|
||||
Storage::delete($backupFileName);
|
||||
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
|
||||
instant_scp($path, $tmpPath, $this->server);
|
||||
Storage::delete($backupFileName);
|
||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
|
||||
} elseif (filled($this->customLocation)) {
|
||||
// Validate the custom location to prevent command injection
|
||||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||||
|
||||
return true;
|
||||
}
|
||||
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
|
||||
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||||
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
|
||||
} else {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copy the restore command to a script file
|
||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||
|
||||
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
$this->importCommands[] = "chmod +x {$scriptPath}";
|
||||
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
if (! empty($this->importCommands)) {
|
||||
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
|
||||
'scriptPath' => $scriptPath,
|
||||
'tmpPath' => $tmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
auditLog('ui.database.import_started', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'database_uuid' => $this->resource->uuid,
|
||||
'database_name' => $this->resource->name,
|
||||
'source' => 'file',
|
||||
]);
|
||||
}
|
||||
$source = Storage::exists("upload/{$this->resourceUuid}/restore")
|
||||
? new DatabaseImportSource('upload', dumpAll: $this->dumpAll)
|
||||
: new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll);
|
||||
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
|
||||
$this->activityId = $activity->id;
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
auditLog('ui.database.import_started', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'database_uuid' => $this->resource->uuid,
|
||||
'database_name' => $this->resource->name,
|
||||
'source' => 'file',
|
||||
]);
|
||||
} catch (DatabaseImportException $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
@@ -660,118 +606,9 @@ EOD;
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
$key = $s3Storage->key;
|
||||
$secret = $s3Storage->secret;
|
||||
$bucket = $s3Storage->bucket;
|
||||
$endpoint = $s3Storage->endpoint;
|
||||
|
||||
// Validate bucket name to prevent command injection
|
||||
if (! $this->validateBucketName($bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clean the S3 path
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Validate the S3 path to prevent command injection
|
||||
if (! $this->validateS3Path($cleanPath)) {
|
||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get helper image
|
||||
$helperImage = coolifyHelperImage();
|
||||
$latestVersion = getHelperVersion();
|
||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||
|
||||
// Get the database destination network
|
||||
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
|
||||
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
|
||||
} else {
|
||||
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||
}
|
||||
|
||||
// Generate unique names for this operation
|
||||
$containerName = "s3-restore-{$this->resourceUuid}";
|
||||
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
|
||||
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
|
||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||
|
||||
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
|
||||
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
|
||||
$escapedScriptPath = escapeshellarg($scriptPath);
|
||||
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
|
||||
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
|
||||
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
|
||||
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
|
||||
|
||||
// Prepare all commands in sequence
|
||||
$commands = [];
|
||||
|
||||
// 1. Clean up any existing helper container and temp files from previous runs
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||||
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
|
||||
|
||||
// 2. Start helper container on the database network
|
||||
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
||||
|
||||
// 3. Configure S3 access in helper container
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
|
||||
// 4. Check file exists in S3 (bucket and path already validated above)
|
||||
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
||||
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
||||
|
||||
// 5. Download from S3 to helper container (progress shown by default)
|
||||
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
||||
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
||||
|
||||
// 6. Copy from helper to server, then immediately to database container
|
||||
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
|
||||
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
|
||||
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
|
||||
|
||||
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||||
|
||||
// 8. Build and execute restore command inside database container
|
||||
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
|
||||
$commands[] = "chmod +x {$escapedScriptPath}";
|
||||
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
|
||||
|
||||
// 9. Execute restore and cleanup temp files immediately after completion
|
||||
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
|
||||
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
// Execute all commands with cleanup event (as safety net for edge cases)
|
||||
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||||
'containerName' => $containerName,
|
||||
'serverTmpPath' => $serverTmpPath,
|
||||
'scriptPath' => $scriptPath,
|
||||
'containerTmpPath' => $containerTmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll);
|
||||
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
auditLog('ui.database.restore_started', [
|
||||
@@ -782,6 +619,8 @@ EOD;
|
||||
'storage_id' => $this->s3StorageId,
|
||||
]);
|
||||
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||||
} catch (DatabaseImportException $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
$this->importRunning = false;
|
||||
handleError($e, $this);
|
||||
@@ -794,13 +633,7 @@ EOD;
|
||||
|
||||
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
|
||||
{
|
||||
$script = $this->buildPostgresRestoreScanScript($tmpPath);
|
||||
|
||||
if ($script === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
|
||||
return app(DatabaseImportCommandBuilder::class)->buildPostgresSafetyCommand($this->resource, $this->container, $tmpPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -856,59 +689,6 @@ EOD;
|
||||
|
||||
public function buildRestoreCommand(string $tmpPath): string
|
||||
{
|
||||
$escapedTmpPath = escapeshellarg($tmpPath);
|
||||
$morphClass = $this->resource->getMorphClass();
|
||||
|
||||
// Handle ServiceDatabase by checking the database type
|
||||
if ($morphClass === ServiceDatabase::class) {
|
||||
$dbType = $this->resource->databaseType();
|
||||
if (str_contains($dbType, 'mysql')) {
|
||||
$morphClass = 'mysql';
|
||||
} elseif (str_contains($dbType, 'mariadb')) {
|
||||
$morphClass = 'mariadb';
|
||||
} elseif (str_contains($dbType, 'postgres')) {
|
||||
$morphClass = 'postgresql';
|
||||
} elseif (str_contains($dbType, 'mongo')) {
|
||||
$morphClass = 'mongodb';
|
||||
}
|
||||
}
|
||||
|
||||
switch ($morphClass) {
|
||||
case StandaloneMariadb::class:
|
||||
case 'mariadb':
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
|
||||
} else {
|
||||
$restoreCommand .= " < {$escapedTmpPath}";
|
||||
}
|
||||
break;
|
||||
case StandaloneMysql::class:
|
||||
case 'mysql':
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
|
||||
} else {
|
||||
$restoreCommand .= " < {$escapedTmpPath}";
|
||||
}
|
||||
break;
|
||||
case StandalonePostgresql::class:
|
||||
case 'postgresql':
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
|
||||
} else {
|
||||
$restoreCommand .= " {$escapedTmpPath}";
|
||||
}
|
||||
break;
|
||||
case StandaloneMongodb::class:
|
||||
case 'mongodb':
|
||||
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
|
||||
break;
|
||||
default:
|
||||
$restoreCommand = '';
|
||||
}
|
||||
|
||||
return $restoreCommand;
|
||||
return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\DatabaseImport;
|
||||
|
||||
use App\Models\ServiceDatabase;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class DatabaseImportCommandBuilder
|
||||
{
|
||||
public function buildRestoreCommand(object $resource, string $path, bool $dumpAll): string
|
||||
{
|
||||
$path = escapeshellarg($path);
|
||||
|
||||
return match ($this->databaseType($resource)) {
|
||||
'postgresql' => $dumpAll
|
||||
? 'psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} && (gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'
|
||||
: 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} '.$path,
|
||||
'mysql' => $dumpAll
|
||||
? $this->mysqlDumpAll('mysql', 'MYSQL', $path)
|
||||
: 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < '.$path,
|
||||
'mariadb' => $dumpAll
|
||||
? $this->mysqlDumpAll('mariadb', 'MARIADB', $path)
|
||||
: 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE < '.$path,
|
||||
'mongodb' => 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='.$path,
|
||||
default => throw new InvalidArgumentException('Database import is not supported for this database type.'),
|
||||
};
|
||||
}
|
||||
|
||||
public function buildPostgresSafetyCommand(object $resource, string $container, string $path): ?string
|
||||
{
|
||||
if ($this->databaseType($resource) !== 'postgresql') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = escapeshellarg($path);
|
||||
$separator = '([[:space:]]|/\\*[^*]*\\*/)';
|
||||
$sqlPattern = escapeshellarg("(^|;){$separator}*copy{$separator}+[^;]*(from|to){$separator}+program");
|
||||
$psqlPattern = escapeshellarg("^{$separator}*\\\\(!|copy{$separator}+[^[:space:]]+.*{$separator}+program|(o|g){$separator}*\\|)");
|
||||
$contents = "{ gunzip -cf {$path} 2>/dev/null || cat {$path}; }";
|
||||
$script = "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq {$psqlPattern} || {$contents} | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$sqlPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
|
||||
|
||||
return 'docker exec '.$container.' sh -c '.escapeshellarg($script);
|
||||
}
|
||||
|
||||
public function supports(object $resource): bool
|
||||
{
|
||||
return in_array($this->databaseType($resource), ['postgresql', 'mysql', 'mariadb', 'mongodb'], true);
|
||||
}
|
||||
|
||||
public function databaseType(object $resource): string
|
||||
{
|
||||
$class = $resource->getMorphClass();
|
||||
$type = ($resource instanceof ServiceDatabase || str_contains(strtolower($class), 'service'))
|
||||
? strtolower($resource->databaseType())
|
||||
: strtolower($class);
|
||||
|
||||
return match (true) {
|
||||
str_contains($type, 'postgres') => 'postgresql',
|
||||
str_contains($type, 'mariadb') => 'mariadb',
|
||||
str_contains($type, 'mysql') => 'mysql',
|
||||
str_contains($type, 'mongo') => 'mongodb',
|
||||
default => 'unsupported',
|
||||
};
|
||||
}
|
||||
|
||||
private function mysqlDumpAll(string $binary, string $prefix, string $path): string
|
||||
{
|
||||
return "for pid in \$({$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} -N -e \"SELECT id FROM information_schema.processlist WHERE user != 'root';\"); do {$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} -e \"KILL \$pid\" 2>/dev/null || true; done && {$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} -N -e \"SELECT CONCAT('DROP DATABASE IF EXISTS \\`',schema_name,'\\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');\" | {$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} && {$binary} -u root -p\${{$prefix}_ROOT_PASSWORD} -e \"CREATE DATABASE IF NOT EXISTS \\`\${{{$prefix}_DATABASE:-default}}\\`;\" && (gunzip -cf {$path} 2>/dev/null || cat {$path}) | {$binary} -u root -p\${{{$prefix}_ROOT_PASSWORD}} \${{{$prefix}_DATABASE:-default}}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\DatabaseImport;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class DatabaseImportException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly int $status = 422)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\DatabaseImport;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
readonly class DatabaseImportSource
|
||||
{
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public ?string $uploadId = null,
|
||||
public ?string $path = null,
|
||||
public ?string $s3StorageUuid = null,
|
||||
public bool $dumpAll = false,
|
||||
) {
|
||||
if (! in_array($type, ['upload', 's3', 'server'], true)) {
|
||||
throw new InvalidArgumentException('Invalid database import source.');
|
||||
}
|
||||
}
|
||||
}
|
||||
+496
-3
@@ -11,6 +11,66 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"\/applications\/{uuid}\/secret-manager": {
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Secret Managers"
|
||||
],
|
||||
"summary": "Configure Application Secret Manager",
|
||||
"description": "Configure the secret manager source used by an application.",
|
||||
"operationId": "configure-application-secret-manager",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"integration_token_uuid"
|
||||
],
|
||||
"properties": {
|
||||
"integration_token_uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Secret manager configured."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/applications": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -5778,6 +5838,85 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/imports\/uploads": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Upload database import",
|
||||
"operationId": "upload-database-import",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Upload completed"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/imports": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Import database backup",
|
||||
"operationId": "create-database-import",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"$ref": "#\/components\/schemas\/DatabaseImportRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Import queued"
|
||||
},
|
||||
"409": {
|
||||
"description": "Import already active"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/imports\/{activity_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Get database import status",
|
||||
"operationId": "get-database-import",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Import status"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -11689,13 +11828,129 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/settings\/email": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get instance email settings",
|
||||
"description": "Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.",
|
||||
"operationId": "get-instance-email-settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Instance email settings."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Update instance email settings",
|
||||
"description": "Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.",
|
||||
"operationId": "update-instance-email-settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated instance email settings."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden."
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/security\/integration-tokens": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Secret Managers"
|
||||
],
|
||||
"summary": "Create Secret Manager Token",
|
||||
"description": "Create and validate a Doppler, Infisical, or Vault integration token.",
|
||||
"operationId": "create-secret-manager-integration-token",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"provider",
|
||||
"name",
|
||||
"token"
|
||||
],
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"doppler",
|
||||
"infisical",
|
||||
"vault"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Integration token created."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/notifications\/email": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notifications"
|
||||
],
|
||||
"summary": "Get email notification settings",
|
||||
"description": "Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.",
|
||||
"description": "Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.",
|
||||
"operationId": "get-current-team-email-notifications",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -11719,7 +11974,7 @@
|
||||
"Notifications"
|
||||
],
|
||||
"summary": "Update email notification settings",
|
||||
"description": "Update the current team email notification settings.",
|
||||
"description": "Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.",
|
||||
"operationId": "update-current-team-email-notifications",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -16816,6 +17071,12 @@
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"is_force_https_enabled": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -17203,6 +17464,85 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/services\/{uuid}\/databases\/{database_uuid}\/imports\/uploads": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Service databases"
|
||||
],
|
||||
"summary": "Upload service database import",
|
||||
"operationId": "upload-service-database-import",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Upload completed"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/services\/{uuid}\/databases\/{database_uuid}\/imports": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Service databases"
|
||||
],
|
||||
"summary": "Import service database backup",
|
||||
"operationId": "create-service-database-import",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"$ref": "#\/components\/schemas\/DatabaseImportRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Import queued"
|
||||
},
|
||||
"409": {
|
||||
"description": "Import already active"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/services\/{uuid}\/databases\/{database_uuid}\/imports\/{activity_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Service databases"
|
||||
],
|
||||
"summary": "Get service database import status",
|
||||
"operationId": "get-service-database-import",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Import status"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/services\/{uuid}\/databases": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -21402,6 +21742,128 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"DatabaseImportRequest": {
|
||||
"type": "object",
|
||||
"oneOf": [
|
||||
{
|
||||
"required": [
|
||||
"source",
|
||||
"upload_id"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"upload"
|
||||
]
|
||||
},
|
||||
"upload_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"dump_all": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"source",
|
||||
"s3_storage_uuid",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"s3"
|
||||
]
|
||||
},
|
||||
"s3_storage_uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"dump_all": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"source",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"server"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"example": "\/var\/backups\/database.sql.gz"
|
||||
},
|
||||
"dump_all": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DatabaseImportStatus": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"queued",
|
||||
"in_progress",
|
||||
"finished",
|
||||
"error",
|
||||
"killed",
|
||||
"cancelled",
|
||||
"closed"
|
||||
]
|
||||
},
|
||||
"exit_code": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"finished_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"VolumeBackupScheduleRequest": {
|
||||
"required": [
|
||||
"frequency"
|
||||
@@ -21474,7 +21936,7 @@
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"default": 3600,
|
||||
"default": 36000,
|
||||
"maximum": 36000,
|
||||
"minimum": 60
|
||||
}
|
||||
@@ -22520,6 +22982,9 @@
|
||||
"deployment_queue_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"backup_compression_cpu_percentage": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dynamic_timeout": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -22630,6 +23095,26 @@
|
||||
"connection_timeout": {
|
||||
"type": "integer",
|
||||
"description": "SSH connection timeout in seconds."
|
||||
},
|
||||
"docker_version": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Detected Docker Engine version on the server."
|
||||
},
|
||||
"docker_version_checked_at": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "When Docker Engine version was last detected."
|
||||
},
|
||||
"compose_version": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Detected Docker Compose plugin version on the server."
|
||||
},
|
||||
"compose_version_checked_at": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "When Docker Compose version was last detected."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -22969,6 +23454,10 @@
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Secret Managers",
|
||||
"description": "Secret Managers"
|
||||
},
|
||||
{
|
||||
"name": "Applications",
|
||||
"description": "Applications"
|
||||
@@ -23009,6 +23498,10 @@
|
||||
"name": "Hetzner",
|
||||
"description": "Hetzner"
|
||||
},
|
||||
{
|
||||
"name": "Settings",
|
||||
"description": "Settings"
|
||||
},
|
||||
{
|
||||
"name": "Notifications",
|
||||
"description": "Notifications"
|
||||
|
||||
+328
-3
@@ -7,6 +7,45 @@ servers:
|
||||
url: 'https://app.coolify.io/api/v1'
|
||||
description: 'Coolify Cloud API. Change the host to your own instance if you are self-hosting.'
|
||||
paths:
|
||||
'/applications/{uuid}/secret-manager':
|
||||
patch:
|
||||
tags:
|
||||
- 'Secret Managers'
|
||||
summary: 'Configure Application Secret Manager'
|
||||
description: 'Configure the secret manager source used by an application.'
|
||||
operationId: configure-application-secret-manager
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
required:
|
||||
- integration_token_uuid
|
||||
properties:
|
||||
integration_token_uuid:
|
||||
type: string
|
||||
settings:
|
||||
type: object
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'Secret manager configured.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/applications:
|
||||
get:
|
||||
tags:
|
||||
@@ -3720,6 +3759,56 @@ paths:
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/imports/uploads':
|
||||
post:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Upload database import'
|
||||
operationId: upload-database-import
|
||||
responses:
|
||||
'201':
|
||||
description: 'Upload completed'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/imports':
|
||||
post:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Import database backup'
|
||||
operationId: create-database-import
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DatabaseImportRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: 'Import queued'
|
||||
'409':
|
||||
description: 'Import already active'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/imports/{activity_id}':
|
||||
get:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Get database import status'
|
||||
operationId: get-database-import
|
||||
responses:
|
||||
'200':
|
||||
description: 'Import status'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/databases:
|
||||
get:
|
||||
tags:
|
||||
@@ -7490,12 +7579,86 @@ paths:
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/settings/email:
|
||||
get:
|
||||
tags:
|
||||
- Settings
|
||||
summary: 'Get instance email settings'
|
||||
description: 'Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.'
|
||||
operationId: get-instance-email-settings
|
||||
responses:
|
||||
'200':
|
||||
description: 'Instance email settings.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: Forbidden.
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
patch:
|
||||
tags:
|
||||
- Settings
|
||||
summary: 'Update instance email settings'
|
||||
description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.'
|
||||
operationId: update-instance-email-settings
|
||||
responses:
|
||||
'200':
|
||||
description: 'Updated instance email settings.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: Forbidden.
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/security/integration-tokens:
|
||||
post:
|
||||
tags:
|
||||
- 'Secret Managers'
|
||||
summary: 'Create Secret Manager Token'
|
||||
description: 'Create and validate a Doppler, Infisical, or Vault integration token.'
|
||||
operationId: create-secret-manager-integration-token
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
required:
|
||||
- provider
|
||||
- name
|
||||
- token
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
enum: [doppler, infisical, vault]
|
||||
name:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
description: 'Integration token created.'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/notifications/email:
|
||||
get:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: 'Get email notification settings'
|
||||
description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.'
|
||||
description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.'
|
||||
operationId: get-current-team-email-notifications
|
||||
responses:
|
||||
'200':
|
||||
@@ -7511,7 +7674,7 @@ paths:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: 'Update email notification settings'
|
||||
description: 'Update the current team email notification settings.'
|
||||
description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.'
|
||||
operationId: update-current-team-email-notifications
|
||||
responses:
|
||||
'200':
|
||||
@@ -10658,6 +10821,8 @@ paths:
|
||||
type: [boolean, 'null']
|
||||
is_stripprefix_enabled:
|
||||
type: [boolean, 'null']
|
||||
is_force_https_enabled:
|
||||
type: [boolean, 'null']
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
@@ -10909,6 +11074,56 @@ paths:
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/services/{uuid}/databases/{database_uuid}/imports/uploads':
|
||||
post:
|
||||
tags:
|
||||
- 'Service databases'
|
||||
summary: 'Upload service database import'
|
||||
operationId: upload-service-database-import
|
||||
responses:
|
||||
'201':
|
||||
description: 'Upload completed'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/services/{uuid}/databases/{database_uuid}/imports':
|
||||
post:
|
||||
tags:
|
||||
- 'Service databases'
|
||||
summary: 'Import service database backup'
|
||||
operationId: create-service-database-import
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DatabaseImportRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: 'Import queued'
|
||||
'409':
|
||||
description: 'Import already active'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/services/{uuid}/databases/{database_uuid}/imports/{activity_id}':
|
||||
get:
|
||||
tags:
|
||||
- 'Service databases'
|
||||
summary: 'Get service database import status'
|
||||
operationId: get-service-database-import
|
||||
responses:
|
||||
'200':
|
||||
description: 'Import status'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/services/{uuid}/databases':
|
||||
get:
|
||||
tags:
|
||||
@@ -13601,6 +13816,92 @@ paths:
|
||||
bearerAuth: []
|
||||
components:
|
||||
schemas:
|
||||
DatabaseImportRequest:
|
||||
type: object
|
||||
oneOf:
|
||||
-
|
||||
required:
|
||||
- source
|
||||
- upload_id
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- upload
|
||||
upload_id:
|
||||
type: string
|
||||
format: uuid
|
||||
dump_all:
|
||||
type: boolean
|
||||
default: false
|
||||
type: object
|
||||
-
|
||||
required:
|
||||
- source
|
||||
- s3_storage_uuid
|
||||
- path
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- s3
|
||||
s3_storage_uuid:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
dump_all:
|
||||
type: boolean
|
||||
default: false
|
||||
type: object
|
||||
-
|
||||
required:
|
||||
- source
|
||||
- path
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- server
|
||||
path:
|
||||
type: string
|
||||
example: /var/backups/database.sql.gz
|
||||
dump_all:
|
||||
type: boolean
|
||||
default: false
|
||||
type: object
|
||||
additionalProperties: false
|
||||
DatabaseImportStatus:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- queued
|
||||
- in_progress
|
||||
- finished
|
||||
- error
|
||||
- killed
|
||||
- cancelled
|
||||
- closed
|
||||
exit_code:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
output:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
finished_at:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
type: object
|
||||
VolumeBackupScheduleRequest:
|
||||
required:
|
||||
- frequency
|
||||
@@ -13659,7 +13960,7 @@ components:
|
||||
minimum: 0
|
||||
timeout:
|
||||
type: integer
|
||||
default: 3600
|
||||
default: 36000
|
||||
maximum: 36000
|
||||
minimum: 60
|
||||
type: object
|
||||
@@ -14429,6 +14730,8 @@ components:
|
||||
type: integer
|
||||
deployment_queue_limit:
|
||||
type: integer
|
||||
backup_compression_cpu_percentage:
|
||||
type: integer
|
||||
dynamic_timeout:
|
||||
type: integer
|
||||
force_disabled:
|
||||
@@ -14504,6 +14807,22 @@ components:
|
||||
connection_timeout:
|
||||
type: integer
|
||||
description: 'SSH connection timeout in seconds.'
|
||||
docker_version:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Detected Docker Engine version on the server.'
|
||||
docker_version_checked_at:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'When Docker Engine version was last detected.'
|
||||
compose_version:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Detected Docker Compose plugin version on the server.'
|
||||
compose_version_checked_at:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'When Docker Compose version was last detected.'
|
||||
type: object
|
||||
Service:
|
||||
description: 'Service model'
|
||||
@@ -14733,6 +15052,9 @@ components:
|
||||
description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.'
|
||||
scheme: bearer
|
||||
tags:
|
||||
-
|
||||
name: 'Secret Managers'
|
||||
description: 'Secret Managers'
|
||||
-
|
||||
name: Applications
|
||||
description: Applications
|
||||
@@ -14763,6 +15085,9 @@ tags:
|
||||
-
|
||||
name: Hetzner
|
||||
description: Hetzner
|
||||
-
|
||||
name: Settings
|
||||
description: Settings
|
||||
-
|
||||
name: Notifications
|
||||
description: Notifications
|
||||
|
||||
@@ -308,6 +308,9 @@ Route::group([
|
||||
Route::post('/databases/keydb', [DatabasesController::class, 'create_database_keydb'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']);
|
||||
Route::post('/databases/{uuid}/imports/uploads', [DatabasesController::class, 'upload_import'])->middleware(['api.ability:deploy'])->name('api.databases.imports.upload');
|
||||
Route::post('/databases/{uuid}/imports', [DatabasesController::class, 'create_import'])->middleware(['api.ability:deploy'])->name('api.databases.imports.store');
|
||||
Route::get('/databases/{uuid}/imports/{activity_id}', [DatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->name('api.databases.imports.show');
|
||||
Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']);
|
||||
Route::get('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', [DatabasesController::class, 'list_backup_executions'])->middleware(['api.ability:read']);
|
||||
Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
|
||||
@@ -407,6 +410,9 @@ Route::group([
|
||||
|
||||
Route::get('/services/{uuid}/databases', [ServiceDatabasesController::class, 'index'])->middleware(['api.ability:read']);
|
||||
Route::get('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'show'])->middleware(['api.ability:read']);
|
||||
Route::post('/services/{uuid}/databases/{database_uuid}/imports/uploads', [ServiceDatabasesController::class, 'upload_import'])->middleware(['api.ability:deploy'])->name('api.service-databases.imports.upload');
|
||||
Route::post('/services/{uuid}/databases/{database_uuid}/imports', [ServiceDatabasesController::class, 'create_import'])->middleware(['api.ability:deploy'])->name('api.service-databases.imports.store');
|
||||
Route::get('/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', [ServiceDatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->name('api.service-databases.imports.show');
|
||||
Route::patch('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'update'])->middleware(['api.ability:write']);
|
||||
Route::get('/services/{uuid}/databases/{database_uuid}/logs', [ServiceDatabasesController::class, 'logs'])->middleware(['api.ability:read']);
|
||||
Route::post('/services/{uuid}/databases/{database_uuid}/start', [ServiceDatabasesController::class, 'start'])->middleware(['api.ability:deploy']);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['deploy', 'read'], 'team_id' => $this->team->id]);
|
||||
$this->headers = ['Authorization' => 'Bearer '.$this->token->id.'|secret'];
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::firstOrCreate(['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
test('validates standalone import source and hides foreign databases', function () {
|
||||
$database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
|
||||
|
||||
$this->withHeaders($this->headers)->postJson("/api/v1/databases/{$database->uuid}/imports", ['source' => 'upload', 'path' => '../bad'])
|
||||
->assertUnprocessable()->assertJsonValidationErrors(['upload_id', 'path']);
|
||||
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
$foreign = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'foreign', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $otherEnvironment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
|
||||
|
||||
$this->withHeaders($this->headers)->postJson("/api/v1/databases/{$foreign->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertNotFound();
|
||||
});
|
||||
|
||||
test('requires deploy ability to start standalone import', function () {
|
||||
$database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
|
||||
$read = $this->user->createToken('read', ['read']);
|
||||
|
||||
$this->withToken($read->plainTextToken)->postJson("/api/v1/databases/{$database->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('returns only a team and resource scoped import activity', function () {
|
||||
$database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
|
||||
$activity = Activity::create(['log_name' => 'default', 'description' => json_encode([['order' => 1, 'output' => 'restored', 'type' => 'stdout']]), 'properties' => ['team_id' => $this->team->id, 'type_uuid' => $database->uuid, 'operation' => 'database_import', 'status' => 'finished', 'exitCode' => 0]]);
|
||||
|
||||
$this->withHeaders($this->headers)->getJson("/api/v1/databases/{$database->uuid}/imports/{$activity->id}")
|
||||
->assertOk()->assertJson(['id' => $activity->id, 'status' => 'finished', 'exit_code' => 0, 'output' => 'restored'])
|
||||
->assertJsonMissingPath('command');
|
||||
|
||||
$activity->properties = $activity->properties->merge(['team_id' => $this->team->id + 1]);
|
||||
$activity->save();
|
||||
$this->withHeaders($this->headers)->getJson("/api/v1/databases/{$database->uuid}/imports/{$activity->id}")->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
test('registers standalone and service database import routes with abilities', function () {
|
||||
$routes = collect(Route::getRoutes()->getRoutes())->keyBy(fn ($route) => $route->getName());
|
||||
|
||||
foreach (['api.databases.imports.upload', 'api.databases.imports.store', 'api.databases.imports.show', 'api.service-databases.imports.upload', 'api.service-databases.imports.store', 'api.service-databases.imports.show'] as $name) {
|
||||
expect($routes)->toHaveKey($name);
|
||||
}
|
||||
|
||||
expect($routes['api.databases.imports.store']->gatherMiddleware())->toContain('api.ability:deploy')
|
||||
->and($routes['api.databases.imports.show']->gatherMiddleware())->toContain('api.ability:read');
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['deploy', 'read'], 'team_id' => $this->team->id]);
|
||||
$this->headers = ['Authorization' => 'Bearer '.$this->token->id.'|secret'];
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::firstOrCreate(['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
|
||||
test('validates service database imports and binds database to service', function () {
|
||||
$service = Service::factory()->create(['environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services:\n postgres:\n image: postgres:17\n"]);
|
||||
$database = ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'name' => 'postgres', 'service_id' => $service->id, 'image' => 'postgres:17']);
|
||||
|
||||
$url = "/api/v1/services/{$service->uuid}/databases/{$database->uuid}/imports";
|
||||
$this->withHeaders($this->headers)->postJson($url, ['source' => 's3', 'upload_id' => (string) Str::uuid()])
|
||||
->assertUnprocessable()->assertJsonValidationErrors(['upload_id', 's3_storage_uuid', 'path']);
|
||||
|
||||
$otherService = Service::factory()->create(['environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services: {}\n"]);
|
||||
$this->withHeaders($this->headers)->postJson("/api/v1/services/{$otherService->uuid}/databases/{$database->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
|
||||
use AppModels\ServiceDatabase;
|
||||
use AppModels\StandaloneMariadb;
|
||||
use AppModels\StandaloneMongodb;
|
||||
use AppModels\StandaloneMysql;
|
||||
use AppModels\StandalonePostgresql;
|
||||
|
||||
function importResource(string $class, ?string $databaseType = null): object
|
||||
{
|
||||
$resource = Mockery::mock($class);
|
||||
$resource->shouldReceive('getMorphClass')->andReturn($class);
|
||||
if ($class === ServiceDatabase::class) {
|
||||
$resource->shouldReceive('databaseType')->andReturn($databaseType);
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
test('builds database-specific restore commands', function (string $class, ?string $type, string $needle) {
|
||||
$builder = new DatabaseImportCommandBuilder;
|
||||
|
||||
$command = $builder->buildRestoreCommand(importResource($class, $type), '/tmp/restore file', false);
|
||||
|
||||
expect($command)->toContain($needle)->toContain("'/tmp/restore file'");
|
||||
})->with([
|
||||
'postgresql' => [StandalonePostgresql::class, null, 'pg_restore'],
|
||||
'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER'],
|
||||
'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER'],
|
||||
'mongodb' => [StandaloneMongodb::class, null, 'mongorestore'],
|
||||
'service postgres' => [ServiceDatabase::class, 'postgresql', 'pg_restore'],
|
||||
'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER'],
|
||||
'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER'],
|
||||
'service mongo' => [ServiceDatabase::class, 'mongodb', 'mongorestore'],
|
||||
]);
|
||||
|
||||
test('builds dump-all commands and postgres safety scan', function () {
|
||||
$builder = new DatabaseImportCommandBuilder;
|
||||
$postgres = importResource(StandalonePostgresql::class);
|
||||
|
||||
expect($builder->buildRestoreCommand($postgres, '/tmp/dump.sql.gz', true))
|
||||
->toContain('pg_terminate_backend')
|
||||
->toContain("gunzip -cf '/tmp/dump.sql.gz'")
|
||||
->and($builder->buildPostgresSafetyCommand($postgres, 'postgres-safe', '/tmp/dump.sql.gz'))
|
||||
->toContain('COPY ... PROGRAM')
|
||||
->toContain('docker exec postgres-safe')
|
||||
->toContain('| tr')
|
||||
->toContain('/\\*[^*]*\\*/');
|
||||
});
|
||||
|
||||
test('rejects unsupported database types', function () {
|
||||
$builder = new DatabaseImportCommandBuilder;
|
||||
$redis = importResource(StandaloneRedis::class);
|
||||
|
||||
expect(fn () => $builder->buildRestoreCommand($redis, '/tmp/backup', false))
|
||||
->toThrow(InvalidArgumentException::class, 'not supported');
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
test('documents standalone and service database import endpoints', function () {
|
||||
$document = json_decode(file_get_contents(__DIR__.'/../../openapi.json'), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($document['paths'])
|
||||
->toHaveKey('/databases/{uuid}/imports/uploads')
|
||||
->toHaveKey('/databases/{uuid}/imports')
|
||||
->toHaveKey('/databases/{uuid}/imports/{activity_id}')
|
||||
->toHaveKey('/services/{uuid}/databases/{database_uuid}/imports/uploads')
|
||||
->toHaveKey('/services/{uuid}/databases/{database_uuid}/imports')
|
||||
->toHaveKey('/services/{uuid}/databases/{database_uuid}/imports/{activity_id}');
|
||||
});
|
||||
Reference in New Issue
Block a user