feat: add import restore options, OAuth confirmation, and fixes

- database import: add keep_owners (restore PostgreSQL owners and
  privileges) and sqlite_database (pick the target SQLite file) to the
  API, OpenAPI spec, and import form
- oauth: require password confirmation and support team selection on
  OAuth login
- remote process: send team broadcast events to the team of the server
  that ran the process instead of the causer's user id
- servers API: handle the legacy build server setting
- compose: support parent bind paths and volume driver options
- dns: improve record hints and provider management
- notifications: fix throttling and missing backup notifications
- ui: rework the confirmation modal
- add feature and unit tests for the new behavior
This commit is contained in:
Andras Bacsai committed 2026-09-30 13:35:22 +02:00
1 parent d1bdecba6d
commit 9e7cb60b0d
57 files changed
+3032 -194

No files matched your search

+21 -6
View File
@@ -9,6 +9,7 @@ use App\Jobs\ApplicationDeploymentJob;
use App\Models\Server;
use App\Support\DatabaseImport\DatabaseImportCleanup;
use App\Support\RemoteProcessCommand;
use App\Traits\BroadcastsToTeam;
use Illuminate\Contracts\Process\ProcessResult;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -123,12 +124,7 @@ class RunRemoteProcess
$this->activity->save();
if ($this->call_event_on_finish) {
try {
$eventClass = "App\\Events\\$this->call_event_on_finish";
if (! is_null($this->call_event_data)) {
event(new $eventClass($this->call_event_data));
} else {
event(new $eventClass($this->activity->causer_id));
}
self::dispatchFinishEvent($this->activity, $this->call_event_on_finish, $this->call_event_data);
} catch (\Throwable $e) {
Log::error('Error calling event: '.$e->getMessage());
}
@@ -140,6 +136,25 @@ class RunRemoteProcess
return $processResult;
}
/**
* Dispatches the event that a remote process asked for when it finishes.
*
* Without explicit event data, a team event goes to the team of the server that ran the
* process. The causer is a user, so its id is only used for events on a user channel.
*/
public static function dispatchFinishEvent(Activity $activity, string $eventName, mixed $eventData = null): void
{
$eventClass = "App\\Events\\{$eventName}";
if (is_null($eventData)) {
$eventData = in_array(BroadcastsToTeam::class, class_uses_recursive($eventClass), true)
? $activity->getExtraProperty('team_id')
: $activity->causer_id;
}
event(new $eventClass($eventData));
}
protected function getCommand(): string
{
$server_uuid = $this->activity->getExtraProperty('server_uuid');
+23 -3
View File
@@ -5,6 +5,7 @@ namespace App\Actions\Database;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\StandaloneSqlite;
use App\Models\SwarmDocker;
use App\Support\DatabaseBackupFileValidator;
use App\Support\DatabaseImport\DatabaseImportCleanup;
@@ -45,6 +46,7 @@ class StartDatabaseImport
if (! str($resource->status)->startsWith('running')) {
throw new DatabaseImportException('The database must be running before an import can start.');
}
$sqliteDatabase = $this->sqliteRestoreTarget($resource, $source);
[$server, $container, $network] = $this->target($resource);
$destination = $resource instanceof ServiceDatabase ? $resource->service?->destination : $resource->destination;
@@ -71,14 +73,14 @@ class StartDatabaseImport
throw new DatabaseImportException(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE, 409);
}
return $this->startImport($resource, $source, $teamId, $server, $container, $network);
return $this->startImport($resource, $source, $teamId, $server, $container, $network, $sqliteDatabase);
} finally {
DatabaseOperationReservation::release($resource->uuid, $reservation);
$lock->release();
}
}
private function startImport(Model $resource, DatabaseImportSource $source, int $teamId, Server $server, string $container, string $network): Activity
private function startImport(Model $resource, DatabaseImportSource $source, int $teamId, Server $server, string $container, string $network, ?string $sqliteDatabase): Activity
{
$active = ResourceStartActivity::active($resource->uuid, ResourceStartActivity::DATABASE_IMPORT_OPERATION, $teamId);
if (ResourceStartActivity::failStale($active)->isNotEmpty()) {
@@ -150,7 +152,7 @@ class StartDatabaseImport
if ($safety = $this->commands->buildPostgresSafetyCommand($resource, $container, $containerPath)) {
$commandList[] = $safety;
}
$restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll, $source->replaceExisting));
$restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll, $source->replaceExisting, $source->keepOwners, $sqliteDatabase));
$commandList[] = 'echo '.escapeshellarg($restore).' | base64 -d > '.escapeshellarg($scriptPath);
$commandList[] = 'chmod +x '.escapeshellarg($scriptPath);
$commandList[] = 'docker cp '.escapeshellarg($scriptPath).' '.escapeshellarg("{$container}:{$scriptPath}");
@@ -178,6 +180,24 @@ class StartDatabaseImport
return [$resource->destination?->server, $resource->uuid, $resource->destination?->network ?? 'coolify'];
}
/**
* The SQLite file a backup is restored into. It must be one of the database's own files; without
* a choice it is the file named in the backup path, else the first file.
*/
private function sqliteRestoreTarget(Model $resource, DatabaseImportSource $source): ?string
{
if (! $resource instanceof StandaloneSqlite) {
return null;
}
$file = $source->sqliteDatabase ?? $resource->defaultRestoreFile($source->type === 'upload' ? null : $source->path);
if ($file === null || ! in_array($file, $resource->databaseFiles(), true)) {
throw new DatabaseImportException('The SQLite database file is not one of the files of this database.');
}
return $file;
}
private function assertServerPath(?string $path): void
{
if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
@@ -61,7 +61,7 @@ trait HandlesDatabaseImportsApi
{
$this->authorize('update', $resource);
$payload = $request->json()->all() ?: $request->request->all();
$allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing'];
$allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing', 'keep_owners', 'sqlite_database'];
$validator = Validator::make($payload, [
'source' => ['required', Rule::in(['upload', 's3', 'server'])],
'upload_id' => ['required_if:source,upload', 'prohibited_unless:source,upload', 'uuid'],
@@ -69,6 +69,8 @@ trait HandlesDatabaseImportsApi
'path' => ['required_if:source,s3,server', 'prohibited_if:source,upload', 'string', 'max:4096'],
'dump_all' => ['sometimes', 'boolean'],
'replace_existing' => ['sometimes', 'boolean'],
'keep_owners' => ['sometimes', 'boolean'],
'sqlite_database' => ['sometimes', 'string', 'max:255'],
]);
$extraFields = array_diff(array_keys($payload), $allowed);
if ($validator->fails() || ! empty($extraFields)) {
@@ -81,7 +83,7 @@ trait HandlesDatabaseImportsApi
}
try {
$source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false), (bool) ($payload['replace_existing'] ?? false));
$source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false), (bool) ($payload['replace_existing'] ?? false), (bool) ($payload['keep_owners'] ?? false), isset($payload['sqlite_database']) ? (string) $payload['sqlite_database'] : null);
$activity = app(StartDatabaseImport::class)->handle($resource, $source, $teamId);
} catch (DatabaseImportException $exception) {
return response()->json(['message' => $exception->getMessage()], $exception->status);
@@ -92,6 +94,7 @@ trait HandlesDatabaseImportsApi
'database_name' => $resource->name,
'source' => $source->type,
'replace_existing' => $source->replaceExisting,
'keep_owners' => $source->keepOwners,
'activity_id' => $activity->id,
]);
$url = route($statusRoute, [...$routeParameters, 'activity_id' => $activity->id], false);
+3 -3
View File
@@ -16,9 +16,9 @@ use OpenApi\Attributes as OA;
new OA\Schema(
schema: 'DatabaseImportRequest',
oneOf: [
new OA\Schema(required: ['source', 'upload_id'], additionalProperties: false, 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\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], additionalProperties: false, 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\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
new OA\Schema(required: ['source', 'path'], additionalProperties: false, 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), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
new OA\Schema(required: ['source', 'upload_id'], additionalProperties: false, 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\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false), new OA\Property(property: 'keep_owners', description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.', type: 'boolean', default: false), new OA\Property(property: 'sqlite_database', description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.', type: 'string', example: 'app.db')]),
new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], additionalProperties: false, 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\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false), new OA\Property(property: 'keep_owners', description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.', type: 'boolean', default: false), new OA\Property(property: 'sqlite_database', description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.', type: 'string', example: 'app.db')]),
new OA\Schema(required: ['source', 'path'], additionalProperties: false, 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), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false), new OA\Property(property: 'keep_owners', description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.', type: 'boolean', default: false), new OA\Property(property: 'sqlite_database', description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.', type: 'string', example: 'app.db')]),
],
type: 'object',
),
+50 -6
View File
@@ -40,7 +40,29 @@ class ServersController extends Controller
]);
}
return serializeApiResponse($settings);
return serializeApiResponse($settings)
->put('is_build_server', $settings->effectiveServerRole() === ServerRole::BUILD);
}
/**
* Rejects requests where the deprecated is_build_server flag disagrees with server_role.
*/
private function legacyBuildServerRoleConflict(Request $request): ?JsonResponse
{
if (! $request->filled('is_build_server') || ! $request->filled('server_role')) {
return null;
}
$wantsBuildOnly = $request->boolean('is_build_server');
$roleIsBuildOnly = $request->string('server_role')->toString() === ServerRole::BUILD->value;
if ($wantsBuildOnly === $roleIsBuildOnly) {
return null;
}
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_build_server' => ['is_build_server is deprecated and conflicts with server_role. Send only server_role.']],
], 422);
}
private function removeSensitiveData($server)
@@ -452,6 +474,7 @@ class ServersController extends Controller
'user' => ['type' => 'string', 'example' => 'root', 'description' => 'The user of the server.'],
'private_key_uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the private key.'],
'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'example' => 'both', 'description' => 'Server role.'],
'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false sets it to both. Must not conflict with server_role.'],
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Instant validate.'],
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'example' => 'traefik', 'description' => 'The proxy type.'],
],
@@ -493,7 +516,7 @@ class ServersController extends Controller
)]
public function create_server(Request $request)
{
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type'];
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -513,6 +536,7 @@ class ServersController extends Controller
'private_key_uuid' => 'string|required',
'user' => ValidationPatterns::serverUsernameRules(required: false),
'server_role' => 'string|nullable|in:deployment,build,both',
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
], [
@@ -542,9 +566,15 @@ class ServersController extends Controller
if (is_null($request->port)) {
$request->offsetSet('port', 22);
}
$serverRole = $request->filled('server_role')
? ServerRole::from($request->string('server_role')->toString())
: ServerRole::BOTH;
$legacyRoleConflict = $this->legacyBuildServerRoleConflict($request);
if ($legacyRoleConflict) {
return $legacyRoleConflict;
}
$serverRole = match (true) {
$request->filled('server_role') => ServerRole::from($request->string('server_role')->toString()),
$request->filled('is_build_server') && $request->boolean('is_build_server') => ServerRole::BUILD,
default => ServerRole::BOTH,
};
if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->exists()) {
return response()->json([
@@ -639,6 +669,7 @@ class ServersController extends Controller
'user' => ['type' => 'string', 'description' => 'The user of the server.'],
'private_key_uuid' => ['type' => 'string', 'description' => 'The UUID of the private key.'],
'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'description' => 'Server role.'],
'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false changes a build server to both and leaves other roles unchanged. Must not conflict with server_role.'],
'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'],
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'],
'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'],
@@ -684,7 +715,7 @@ class ServersController extends Controller
)]
public function update_server(Request $request)
{
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout', 'is_terminal_enabled'];
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout', 'is_terminal_enabled'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -703,6 +734,7 @@ class ServersController extends Controller
'private_key_uuid' => 'string|nullable',
'user' => ValidationPatterns::serverUsernameRules(required: false),
'server_role' => 'string|nullable|in:deployment,build,both',
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
'concurrent_builds' => 'integer|min:1',
@@ -760,9 +792,21 @@ class ServersController extends Controller
], 422);
}
$legacyRoleConflict = $this->legacyBuildServerRoleConflict($request);
if ($legacyRoleConflict) {
return $legacyRoleConflict;
}
$serverRole = null;
if ($request->filled('server_role')) {
$serverRole = ServerRole::from($request->string('server_role')->toString());
} elseif ($request->filled('is_build_server')) {
// Legacy flag: true makes the server build only, false turns a build only server back into a regular one.
if ($request->boolean('is_build_server')) {
$serverRole = ServerRole::BUILD;
} elseif ($server->isBuildServer()) {
$serverRole = ServerRole::BOTH;
}
}
if ($serverRole === ServerRole::BUILD && ! $server->isBuildServer() && ! $server->isEmpty()) {
+98 -1
View File
@@ -4,11 +4,15 @@ namespace App\Http\Controllers;
use App\Models\OauthSetting;
use App\Services\Auth\OauthLoginService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
class OauthController extends Controller
{
public const string PASSWORD_CONFIRMATION_SESSION_KEY = 'oauth.password_confirmation';
public function redirect(string $provider)
{
$oauthSetting = $this->enabledProvider($provider);
@@ -17,8 +21,40 @@ class OauthController extends Controller
return $socialiteProvider->redirect();
}
public function callback(string $provider, OauthLoginService $oauthLoginService)
/**
* Start confirming a destructive action by re-authenticating the logged-in
* user through an OAuth provider the user has a linked identity with.
*/
public function confirm(Request $request, string $provider)
{
$user = $request->user();
$oauthSetting = $this->enabledProvider($provider);
if (! $user->oauthIdentities()->where('provider', $oauthSetting->provider)->exists()) {
abort(403, 'No identity from this provider is linked to your account.');
}
$request->session()->put(self::PASSWORD_CONFIRMATION_SESSION_KEY, [
'user_id' => $user->getKey(),
'provider' => $oauthSetting->provider,
'return_to' => $this->sameOriginUrl(url()->previous()),
]);
$socialiteProvider = get_socialite_provider($oauthSetting->provider);
$parameters = socialite_reauthentication_parameters($oauthSetting);
if ($parameters !== []) {
$socialiteProvider->with($parameters);
}
return $socialiteProvider->redirect();
}
public function callback(Request $request, string $provider, OauthLoginService $oauthLoginService)
{
$pendingConfirmation = $request->session()->pull(self::PASSWORD_CONFIRMATION_SESSION_KEY);
if (is_array($pendingConfirmation) && $request->user()) {
return $this->confirmCallback($request, $provider, $pendingConfirmation, $oauthLoginService);
}
try {
$oauthSetting = $this->enabledProvider($provider);
$oauthUser = get_socialite_provider($oauthSetting->provider)->user();
@@ -38,6 +74,67 @@ class OauthController extends Controller
}
}
/**
* Complete an OAuth confirmation. The returned identity must already be
* linked to the logged-in user; the session is never switched to another
* user and no identity is linked or updated.
*
* @param array{user_id?: mixed, provider?: mixed, return_to?: mixed} $pendingConfirmation
*/
private function confirmCallback(Request $request, string $provider, array $pendingConfirmation, OauthLoginService $oauthLoginService): RedirectResponse
{
$user = $request->user();
try {
$oauthSetting = $this->enabledProvider($provider);
if (
data_get($pendingConfirmation, 'user_id') !== $user->getKey()
|| data_get($pendingConfirmation, 'provider') !== $oauthSetting->provider
) {
throw new HttpException(403, 'OAuth confirmation does not match the current session');
}
$oauthUser = get_socialite_provider($oauthSetting->provider)->user();
if (! $oauthLoginService->identityBelongsToUser($user, $oauthSetting, $oauthUser)) {
throw new HttpException(403, 'OAuth identity is not linked to the current user');
}
} catch (\Exception $e) {
auditLog('auth.oauth.password_confirmation_failed', [
'provider' => $provider,
'actor_id' => $user->getKey(),
'actor_email' => $user->email,
'exception_class' => $e::class,
], 'warning');
Log::warning('OAuth password confirmation failed.', [
'provider' => $provider,
'user_id' => $user->getKey(),
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
]);
abort(403, 'Confirmation failed. Sign in with the account linked to your Coolify user.');
}
$request->session()->passwordConfirmed();
auditLog('auth.oauth.password_confirmed', [
'provider' => $oauthSetting->provider,
'actor_id' => $user->getKey(),
'actor_email' => $user->email,
]);
return redirect()->to($this->sameOriginUrl(data_get($pendingConfirmation, 'return_to')));
}
private function sameOriginUrl(mixed $url): string
{
$home = url('/');
if (! is_string($url) || ($url !== $home && ! str_starts_with($url, $home.'/') && ! str_starts_with($url, $home.'?'))) {
return $home;
}
return $url;
}
private function logCallbackFailure(string $provider, \Throwable $exception): void
{
auditLog('auth.oauth.callback_failed', [
@@ -12,6 +12,13 @@ class DecideWhatToDoWithUser
{
public function handle(Request $request, Closure $next): Response
{
// OAuth confirmation of destructive actions only re-authenticates the
// logged-in user and returns to the page it started from, so it must not
// be redirected to onboarding, subscription, or team selection.
if ($request->routeIs('auth.confirm', 'auth.callback')) {
return $next($request);
}
if (auth()?->user()?->teams?->count() === 0) {
$currentTeam = auth()->user()?->recreate_personal_team();
refreshSession($currentTeam);
+14
View File
@@ -431,6 +431,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
$this->build_server = $this->server;
// An additional server of a multi-server application does not build: it pulls the image the main server pushed.
if ($this->is_this_additional_server && str($this->application->docker_registry_image_name)->isNotEmpty()) {
if (! $this->server->canBuildApplications()) {
// Disabled build cache forces a rebuild, which would skip the registry pull on a server that cannot build.
$this->force_rebuild = false;
}
$this->application_deployment_queue->addLogEntry('Additional server: pulls the image from the registry, no build server needed.');
return;
}
// A deployments-only server never builds. Docker image and Compose applications are exempt:
// the first builds nothing and the second does not support build servers.
$mustBuildElsewhere = ! $this->server->canBuildApplications()
@@ -1443,6 +1454,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.');
}
} else {
if ($this->is_this_additional_server && ! $this->server->canBuildApplications() && $this->application->build_pack !== 'dockercompose') {
throw new DeploymentException("Image ({$this->production_image_name}) not found in the registry; the main server must push it first. The server ({$this->server->name}) is set to deployments only and cannot build it.");
}
$this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image.");
}
if ($this->restart_only) {
+6 -5
View File
@@ -51,10 +51,11 @@ class CheckMissingDatabaseBackupsJob implements ShouldBeEncrypted, ShouldBeUniqu
}
// Send once for each period without backup activity.
if (! NotificationThrottle::claim($backup, BackupMissing::class, $lastActivityAt)) {
return;
}
$backup->team->notify(new BackupMissing($backup, $lastExecutionAt));
NotificationThrottle::sendOnce(
$backup,
BackupMissing::class,
$lastActivityAt,
fn () => $backup->team->notify(new BackupMissing($backup, $lastExecutionAt)),
);
}
}
+7
View File
@@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Events\DnsRecordConfigurationFinished;
use App\Models\DnsProviderZone;
use App\Services\Dns\CloudflareDnsProvider;
use App\Support\DnsRecordHints;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
@@ -41,6 +42,12 @@ class ConfigureDnsRecordJob implements ShouldQueue
->whereHas('integrationToken', fn ($query) => $query->where('team_id', $this->teamId))
->firstOrFail();
if (! DnsRecordHints::isPublicAddress($this->content)) {
$this->finished($zone, false, DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE);
return;
}
try {
$provider->createRecord($zone, $this->hostname, $this->content, $this->resource());
} catch (LockTimeoutException) {
+1 -6
View File
@@ -113,12 +113,7 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
// Dispatch cleanup event on failure (same as on success)
if ($this->call_event_on_finish) {
try {
$eventClass = "App\\Events\\$this->call_event_on_finish";
if (! is_null($this->call_event_data)) {
event(new $eventClass($this->call_event_data));
} else {
event(new $eventClass($this->activity->causer_id));
}
RunRemoteProcess::dispatchFinishEvent($this->activity, $this->call_event_on_finish, $this->call_event_data);
Log::info('Cleanup event dispatched after job failure', [
'event' => $this->call_event_on_finish,
]);
@@ -9,6 +9,7 @@ use App\Models\DnsProviderZone;
use App\Models\ManagedDnsRecord;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\Dns\ManagedDnsRecordCleanup;
use App\Support\DnsRecordHints;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Database\Eloquent\Model;
use Livewire\Attributes\Locked;
@@ -48,6 +49,11 @@ trait InteractsWithDnsProviders
$this->authorizeDnsProviderChange();
$hostname = strtolower($hostname);
$cloudflare = app(CloudflareDnsProvider::class);
if ($this->publicServerIpsForDnsProvider() === []) {
$this->dispatch('error', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE);
return;
}
$zone = $this->findTeamZone($zoneId, $hostname);
$content = $this->dnsRecordContent($content);
if ($zone === null || $content === null) {
@@ -102,6 +108,12 @@ trait InteractsWithDnsProviders
if (blank($this->serverIp) || filter_var($this->serverIp, FILTER_VALIDATE_IP) === false) {
return false;
}
$content = $this->dnsRecordContent();
if ($content === null) {
$this->dispatch('warning', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE);
return false;
}
$this->markDnsPending($hostnames);
$proposalsByHostname = collect($this->dnsProviderProposals)->groupBy('hostname');
@@ -134,7 +146,7 @@ trait InteractsWithDnsProviders
$resource?->getMorphClass(),
$resource?->getKey(),
$proposal['hostname'],
$this->serverIp,
$content,
);
$this->dispatch('info', "Adding DNS record for {$proposal['hostname']}.");
}
@@ -155,6 +167,11 @@ trait InteractsWithDnsProviders
$hostname = strtolower($hostname);
$key = $hostname.'|'.$zoneId;
$conflict = $this->dnsProviderConflicts[$key] ?? null;
if ($this->publicServerIpsForDnsProvider() === []) {
$this->dispatch('error', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE);
return;
}
$zone = $this->findTeamZone($zoneId, $hostname);
$content = $this->dnsRecordContent($conflict['proposed'] ?? null);
if ($conflict === null || $zone === null || $content === null) {
@@ -188,11 +205,12 @@ trait InteractsWithDnsProviders
$provider = app(CloudflareDnsProvider::class);
$hostnames ??= $this->allDomainHostnames();
$teamId = $this->dnsTeamId();
$target = $this->dnsRecordContent();
$managed = ManagedDnsRecord::query()->where('team_id', $teamId)->whereIn('name', $hostnames)->pluck('id', 'name');
$this->dnsProviderProposals = collect($hostnames)->flatMap(fn (string $hostname) => $provider->findZones($teamId, $hostname)
->map(fn (DnsProviderZone $zone) => [
'hostname' => $hostname, 'zone_id' => $zone->id, 'zone' => $zone->name,
'credential' => $zone->integrationToken->name, 'target' => (string) $this->serverIp,
'credential' => $zone->integrationToken->name, 'target' => $target,
'managed' => $managed->has($hostname),
])->all())->values()->all();
}
@@ -353,12 +371,26 @@ trait InteractsWithDnsProviders
}
/**
* Content of a DNS record for this resource: one of its server addresses (IPv4 or IPv6),
* Server addresses that a DNS provider may publish. Private, reserved, CGNAT, and link-local addresses are excluded:
* a public DNS record must not expose them, and the record would not be reachable anyway.
*
* @return array<int, string>
*/
protected function publicServerIpsForDnsProvider(): array
{
return array_values(array_filter(
$this->serverIpsForDnsHints(),
fn (?string $address): bool => is_string($address) && DnsRecordHints::isPublicAddress($address),
));
}
/**
* Content of a DNS record for this resource: one of its public server addresses (IPv4 or IPv6),
* the first one when none is requested. Any other address is rejected.
*/
protected function dnsRecordContent(?string $requested = null): ?string
{
$addresses = array_values(array_filter($this->serverIpsForDnsHints()));
$addresses = $this->publicServerIpsForDnsProvider();
return $requested === null ? ($addresses[0] ?? null) : (in_array($requested, $addresses, true) ? $requested : null);
}
+64 -7
View File
@@ -11,6 +11,7 @@ use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use App\Models\StandaloneSqlite;
use App\Rules\SafeWebhookUrl;
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
use App\Support\DatabaseImport\DatabaseImportException;
@@ -159,6 +160,16 @@ class ImportForm extends Component
public bool $replaceExisting = false;
/**
* PostgreSQL archives: restore owners and privileges instead of skipping them.
*/
public bool $keepOwners = false;
/**
* SQLite: the database file to restore into. Always one of the database's own files.
*/
public ?string $sqliteDatabase = null;
public string $restoreCommandText = '';
public string $customLocation = '';
@@ -184,6 +195,15 @@ class ImportForm extends Component
return $this->resourceType::find($this->resourceId);
}
/**
* @return list<string>
*/
#[Computed]
public function sqliteDatabaseFiles(): array
{
return $this->resource instanceof StandaloneSqlite ? $this->resource->databaseFiles() : [];
}
#[Computed]
public function server()
{
@@ -208,6 +228,12 @@ class ImportForm extends Component
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
$this->initializeRestoreOptions();
}
protected function initializeRestoreOptions(): void
{
$this->sqliteDatabase = $this->sqliteDatabaseFiles[0] ?? null;
$this->refreshRestoreCommandText();
}
@@ -221,6 +247,31 @@ class ImportForm extends Component
$this->refreshRestoreCommandText();
}
public function updatedKeepOwners(): void
{
$this->refreshRestoreCommandText();
}
public function updatedSqliteDatabase(): void
{
if (! in_array($this->sqliteDatabase, $this->sqliteDatabaseFiles, true)) {
$this->sqliteDatabase = $this->sqliteDatabaseFiles[0] ?? null;
}
$this->refreshRestoreCommandText();
}
/**
* Preselects the SQLite file whose name matches the chosen backup, else the first file.
*/
public function selectSqliteDatabaseFor(?string $backupName = null): void
{
if (! $this->resource instanceof StandaloneSqlite) {
return;
}
$this->sqliteDatabase = $this->resource->defaultRestoreFile($backupName);
$this->refreshRestoreCommandText();
}
/**
* Shows the exact script the import runs, so the confirmation matches the restore.
*/
@@ -228,9 +279,13 @@ class ImportForm extends Component
{
$commands = app(DatabaseImportCommandBuilder::class);
$this->restoreCommandText = $this->resource && $commands->supports($this->resource)
? $commands->buildRestoreCommand($this->resource, '<temp_backup_file>', $this->dumpAll, $this->replaceExisting)
: '';
try {
$this->restoreCommandText = $this->resource && $commands->supports($this->resource)
? $commands->buildRestoreCommand($this->resource, '<temp_backup_file>', $this->dumpAll, $this->replaceExisting, $this->keepOwners, $this->sqliteDatabase)
: '';
} catch (\InvalidArgumentException) {
$this->restoreCommandText = '';
}
}
public function getContainers()
@@ -361,6 +416,7 @@ class ImportForm extends Component
return;
}
$this->filename = $this->customLocation;
$this->selectSqliteDatabaseFor($this->customLocation);
$this->dispatch('success', 'The file exists.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -397,8 +453,8 @@ class ImportForm extends Component
try {
$this->importRunning = true;
$source = Storage::exists("upload/{$this->resourceUuid}/restore")
? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting)
: new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting);
? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase)
: new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$this->activityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
@@ -514,6 +570,7 @@ class ImportForm extends Component
// Get file size
$this->s3FileSize = $disk->size($cleanPath);
$this->selectSqliteDatabaseFor($cleanPath);
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
@@ -557,7 +614,7 @@ class ImportForm extends Component
try {
$this->importRunning = true;
$source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting);
$source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$this->activityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
@@ -586,6 +643,6 @@ class ImportForm extends Component
public function buildRestoreCommand(string $tmpPath): string
{
return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll, $this->replaceExisting);
return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll, $this->replaceExisting, $this->keepOwners, $this->sqliteDatabase);
}
}
+11 -2
View File
@@ -640,8 +640,17 @@ class LocalFileVolume extends BaseModel
continue;
}
$resolvedSource = replaceLocalSource(str((string) $source), str($this->composeSourceDirectory()));
if (normalizeUnixPath($resolvedSource->value()) === normalizeUnixPath($this->fs_path)) {
$sourceDirectory = str($this->composeSourceDirectory());
$fsPath = normalizeUnixPath($this->fs_path);
if (normalizeUnixPath(legacyReplaceLocalSource(str((string) $source), $sourceDirectory)->value()) === $fsPath) {
return true;
}
try {
$resolvedSource = replaceLocalSource(str((string) $source), $sourceDirectory)->value();
} catch (\Throwable) {
continue;
}
if (normalizeUnixPath($resolvedSource) === $fsPath) {
return true;
}
}
+25
View File
@@ -4,8 +4,10 @@ namespace App\Models;
use App\Contracts\ThrottledNotification;
use Carbon\CarbonInterface;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Throwable;
/**
* Records when a notification was last sent for a resource, so repeated checks
@@ -62,6 +64,29 @@ class NotificationThrottle extends Model
]) === 1;
}
/**
* Claim the throttle, then run $send. When $send throws, the claim is released so the
* next attempt is not throttled, and the exception is rethrown.
*
* Returns false when the claim failed and $send did not run.
*/
public static function sendOnce(Model $notifiable, string $notification, ?CarbonInterface $sentBefore, Closure $send): bool
{
if (! static::claim($notifiable, $notification, $sentBefore)) {
return false;
}
try {
$send();
} catch (Throwable $exception) {
static::release($notifiable, $notification);
throw $exception;
}
return true;
}
/**
* Claim the throttle a notification declares. Notifications without a throttle subject are always allowed.
*/
+17
View File
@@ -82,6 +82,23 @@ class OauthSetting extends Model
return __("auth.login.{$this->provider}");
}
/**
* Human-readable provider name, e.g. for "Confirm with Google".
*/
public function providerName(): string
{
if ($this->isOidc()) {
return filled($this->custom_label) ? $this->custom_label : 'SSO';
}
return match ($this->provider) {
'azure' => 'Microsoft',
'github' => 'GitHub',
'gitlab' => 'GitLab',
default => str($this->provider)->headline()->value(),
};
}
public function isOidc(): bool
{
return $this->provider === 'oidc';
+1 -3
View File
@@ -1815,9 +1815,7 @@ $siteAddress {
public function sendUnreachableNotification()
{
if (NotificationThrottle::claim($this, Unreachable::class)) {
$this->team->notify(new Unreachable($this));
}
NotificationThrottle::sendOnce($this, Unreachable::class, null, fn () => $this->team->notify(new Unreachable($this)));
}
public function validateConnection(bool $justCheckingNewKey = false)
+1
View File
@@ -22,6 +22,7 @@ use OpenApi\Attributes as OA;
'force_disabled' => ['type' => 'boolean'],
'force_server_cleanup' => ['type' => 'boolean'],
'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both']],
'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'readOnly' => true, 'description' => 'Deprecated: use server_role instead. true when server_role is build.'],
'is_cloudflare_tunnel' => ['type' => 'boolean'],
'is_jump_server' => ['type' => 'boolean'],
'is_logdrain_axiom_enabled' => ['type' => 'boolean'],
+46 -2
View File
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use InvalidArgumentException;
class StandaloneSqlite extends BaseModel
{
@@ -272,9 +273,52 @@ class StandaloneSqlite extends BaseModel
return 'standalone-sqlite';
}
public function databaseFilePath(): string
/**
* The database file names configured for this database, in their configured order.
*
* @return list<string>
*/
public function databaseFiles(): array
{
return self::DATA_DIRECTORY.'/'.str($this->sqlite_databases)->before(',')->trim();
return collect(explode(',', (string) $this->sqlite_databases))
->map(fn (string $file): string => trim($file))
->filter(fn (string $file): bool => $file !== '' && preg_match(self::DATABASES_PATTERN, $file) === 1)
->values()
->all();
}
/**
* The absolute path of one of this database's own files: the given file, or the first file.
*
* @throws InvalidArgumentException when the file is not one of this database's files
*/
public function databaseFilePath(?string $file = null): string
{
$files = $this->databaseFiles();
$file ??= $files[0] ?? null;
if ($file === null || ! in_array($file, $files, true)) {
throw new InvalidArgumentException('The SQLite database file is not one of the files of this database.');
}
return self::DATA_DIRECTORY.'/'.$file;
}
/**
* The file a backup is restored into by default: the file whose name appears in the
* backup file name (Coolify names backups sqlite-backup-<file>-<timestamp>.gz), else the first file.
*/
public function defaultRestoreFile(?string $backupName): ?string
{
$files = $this->databaseFiles();
$backupName = basename((string) $backupName);
$matches = array_filter($files, fn (string $file): bool => $backupName === $file
|| str_starts_with($backupName, $file.'.')
|| str_starts_with($backupName, "sqlite-backup-{$file}-"));
usort($matches, fn (string $a, string $b): int => strlen($b) <=> strlen($a));
return $matches[0] ?? $files[0] ?? null;
}
public function environment()
+11 -2
View File
@@ -285,14 +285,23 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
/**
* Send a notification, unless it is throttled and was already sent within its interval.
* A throttle claim is released when sending throws, so a failed send is retried on the next check.
*/
public function notify($instance): void
{
if ($instance instanceof ThrottledNotification && ! NotificationThrottle::claimFor($instance)) {
$subject = $instance instanceof ThrottledNotification ? $instance->throttleSubject() : null;
if ($subject === null) {
$this->sendNotification($instance);
return;
}
$this->sendNotification($instance);
NotificationThrottle::sendOnce(
$subject,
$instance::class,
now()->subMinutes($instance->throttleIntervalMinutes()),
fn () => $this->sendNotification($instance),
);
}
public function environment_variables()
+29 -1
View File
@@ -10,6 +10,7 @@ use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsRe
use App\Services\ChangelogService;
use App\Traits\DeletesUserSessions;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -578,8 +579,35 @@ class User extends Authenticatable implements SendsEmail
return ! empty($this->password);
}
/**
* Whether destructive actions must be confirmed. Users with a password
* confirm with it; users with a linked OAuth identity can also confirm by
* re-authenticating through their provider. Only users with neither have
* no way to confirm.
*/
public function requiresPasswordConfirmation(): bool
{
return $this->hasPassword() && ! $this->hasSsoIdentity();
return $this->hasPassword() || $this->hasSsoIdentity();
}
/**
* Enabled OAuth providers the user can re-authenticate with to confirm
* destructive actions.
*
* @return Collection<int, OauthSetting>
*/
public function oauthConfirmationProviders(): Collection
{
$providers = $this->oauthIdentities()->distinct()->pluck('provider');
if ($providers->isEmpty()) {
return new Collection;
}
return OauthSetting::query()
->whereIn('provider', $providers)
->where('enabled', true)
->get()
->filter(fn (OauthSetting $setting): bool => $setting->couldBeEnabled())
->values();
}
}
+58 -8
View File
@@ -28,8 +28,19 @@ class OauthLoginService
? $this->resolveOidcUser($oauthUser, $oauthSetting, $email)
: $this->resolveOauthUser($oauthUser, $oauthSetting, $email);
$team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team();
session(['currentTeam' => $team]);
// Choose the team like the password login: restore the last active team,
// or the sole team. A multi-team user without a valid stored choice gets
// no session team, so DecideWhatToDoWithUser shows the team selection.
$user->unsetRelation('teams');
$team = $user->resolveStoredTeam();
if (! $team && $user->teams->isEmpty()) {
$team = $user->recreate_personal_team();
}
if ($team) {
session(['currentTeam' => $team]);
} else {
session()->forget('currentTeam');
}
if ($this->requiresTwoFactorChallenge($user)) {
Auth::logout();
@@ -61,6 +72,50 @@ class OauthLoginService
return $user->hasEnabledTwoFactorAuthentication();
}
/**
* Whether the identity returned by the provider is already linked to the
* given user. Used to confirm destructive actions through OAuth: it never
* links, updates, or creates identities or users, and never logs anyone in.
*/
public function identityBelongsToUser(User $user, OauthSetting $oauthSetting, object $oauthUser): bool
{
if ($oauthSetting->provider === 'oidc') {
[$issuer, $subject] = $this->oidcIssuerAndSubject($oauthUser);
$providerUserId = $subject;
} else {
$issuer = $oauthSetting->provider;
$providerUserId = $oauthUser->id ?? null;
if (is_int($providerUserId)) {
$providerUserId = (string) $providerUserId;
}
}
if (! is_string($issuer) || $issuer === '' || ! is_string($providerUserId) || trim($providerUserId) === '') {
return false;
}
return $user->oauthIdentities()
->where('provider', $oauthSetting->provider)
->where('issuer', $issuer)
->where('provider_user_id', $providerUserId)
->exists();
}
/**
* @return array{0: mixed, 1: mixed}
*/
private function oidcIssuerAndSubject(object $oauthUser): array
{
$issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer)
? $oauthUser->issuer
: data_get($oauthUser->user, 'iss');
$subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject)
? $oauthUser->subject
: data_get($oauthUser->user, 'sub', $oauthUser->id);
return [$issuer, $subject];
}
private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
{
$provider = $oauthSetting->provider;
@@ -162,12 +217,7 @@ class OauthLoginService
private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
{
$issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer)
? $oauthUser->issuer
: data_get($oauthUser->user, 'iss');
$subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject)
? $oauthUser->subject
: data_get($oauthUser->user, 'sub', $oauthUser->id);
[$issuer, $subject] = $this->oidcIssuerAndSubject($oauthUser);
$emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified)
|| data_get($oauthUser->user, 'email_verified') === true;
@@ -25,14 +25,19 @@ use_single_tar_member() { [ "$(find "$work" -type f | wc -l | tr -d ' ')" = 1 ]
SH;
public function buildRestoreCommand(object $resource, string $path, bool $dumpAll, bool $replaceExisting = false): string
/**
* @param bool $keepOwners PostgreSQL archives: restore object owners and privileges. Off by default,
* because roles from another host (for example RDS) usually do not exist here.
* @param string|null $sqliteDatabase SQLite: the database file to restore into, validated by the resource.
*/
public function buildRestoreCommand(object $resource, string $path, bool $dumpAll, bool $replaceExisting = false, bool $keepOwners = false, ?string $sqliteDatabase = null): string
{
$script = match ($this->databaseType($resource)) {
'postgresql' => $dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting),
'postgresql' => $dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting, $keepOwners),
'mysql' => $this->mysql('mysql', 'MYSQL', $dumpAll),
'mariadb' => $this->mysql('mariadb', 'MARIADB', $dumpAll),
'mongodb' => $this->mongodb($replaceExisting),
'sqlite' => $this->sqlite($resource->databaseFilePath()),
'sqlite' => $this->sqlite($resource->databaseFilePath($sqliteDatabase)),
default => throw new InvalidArgumentException('Database import is not supported for this database type.'),
};
@@ -145,11 +150,14 @@ SH;
* pg_dump custom and tar archives are restored with pg_restore, SQL dumps with psql.
* pg_restore cannot read gzip files, so both clients receive the backup on stdin.
* SQL cannot replace single objects, so replacing recreates the target database.
* Archives skip owners and privileges unless they are kept, because a single missing
* role would roll back the whole single-transaction restore.
*/
private function postgresqlSingle(bool $replaceExisting): string
private function postgresqlSingle(bool $replaceExisting, bool $keepOwners): string
{
// Every restore runs in one transaction, so a failure part-way rolls back and leaves the
// current data as it was, also when --clean dropped objects first.
$owners = $keepOwners ? '' : ' --no-owner --no-acl';
$clean = $replaceExisting ? ' --clean --if-exists' : '';
$sqlRestore = $replaceExisting ? <<<'SH'
@@ -178,7 +186,7 @@ SH;
return <<<SH
db=\${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}
if [ "\$(stream | head -c 5)" = PGDMP ] || is_tar; then
stream | pg_restore --exit-on-error --single-transaction{$clean} -U \$POSTGRES_USER -d "\$db"
stream | pg_restore --exit-on-error --single-transaction{$owners}{$clean} -U \$POSTGRES_USER -d "\$db"
elif ! is_text; then
fail 'Unsupported PostgreSQL backup format. Use a pg_dump archive (custom or tar format) or an SQL file.'
elif stream | head -c 4096 | grep -q 'PostgreSQL database cluster dump'; then
@@ -276,7 +284,7 @@ SH;
}
/**
* SQLite restores a plain or gzip-compressed database file into the first database
* SQLite restores a plain or gzip-compressed database file into the selected database
* file with .restore, which replaces its contents. SQLite reads an empty file (for
* example from a failed dump) as an empty database, so only real database files are restored.
*/
@@ -13,6 +13,8 @@ readonly class DatabaseImportSource
public ?string $s3StorageUuid = null,
public bool $dumpAll = false,
public bool $replaceExisting = false,
public bool $keepOwners = false,
public ?string $sqliteDatabase = null,
) {
if (! in_array($type, ['upload', 's3', 'server'], true)) {
throw new InvalidArgumentException('Invalid database import source.');
+32
View File
@@ -7,6 +7,38 @@ namespace App\Support;
*/
class DnsRecordHints
{
public const NO_PUBLIC_ADDRESS_MESSAGE = 'The server has no public IP address; add the DNS record manually.';
/**
* Whether an address may be published as a public A/AAAA record: private, reserved, loopback, link-local,
* unique local, CGNAT (100.64.0.0/10, used by Tailscale), and multicast addresses are rejected.
*/
public static function isPublicAddress(string $address): bool
{
$binary = @inet_pton($address);
if ($binary === false) {
return false;
}
if (strlen($binary) === 16 && str_starts_with($binary, str_repeat("\0", 10)."\xff\xff")) {
$address = (string) inet_ntop(substr($binary, 12));
$binary = substr($binary, 12);
}
if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return false;
}
if (strlen($binary) === 4) {
$firstOctet = ord($binary[0]);
$isCgnat = $firstOctet === 100 && (ord($binary[1]) & 0xC0) === 64;
return ! $isCgnat && ($firstOctet & 0xF0) !== 224;
}
return ord($binary[0]) !== 0xFF;
}
/**
* Build A/AAAA entries for every hostname (deduped).
*
+5 -1
View File
@@ -310,7 +310,10 @@ function clone_application(Application $source, $destination, array $overrides =
$persistentVolumes = $source->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
$newName = '';
if (str_starts_with($volume->name, $source->uuid)) {
if ($volume->standalone_sqlite_id !== null) {
// A mounted SQLite database volume stays connected to the source only; the clone gets its own volume.
$newName = $newApplication->uuid.'-'.$volume->name;
} elseif (str_starts_with($volume->name, $source->uuid)) {
$newName = str($volume->name)->replace($source->uuid, $newApplication->uuid);
} else {
$newName = $newApplication->uuid.'-'.str($volume->name)->afterLast('-');
@@ -324,6 +327,7 @@ function clone_application(Application $source, $destination, array $overrides =
])->fill([
'name' => $newName,
'resource_id' => $newApplication->id,
'standalone_sqlite_id' => null,
]);
$newPersistentVolume->save();
+34 -24
View File
@@ -457,6 +457,24 @@ function composeExternalVolumeDockerName(string $key, array $declaration): strin
return (string) (data_get($declaration, 'name') ?? data_get($declaration, 'external.name') ?? $key);
}
/**
* The top-level declaration of a volume that a parser renamed (for example `data` to `{uuid}_data`).
* It keeps the options of the original declaration, such as `driver`, `driver_opts` and `labels`, so a
* local volume that binds a host folder still binds it. Variables in the options stay as written;
* Docker Compose resolves them from `.env`. The parser renamed the volume, so the declaration gets the
* new name, and it is not external: Docker Compose creates the renamed volume.
*
* @return array<string, mixed>
*/
function composeRenamedVolumeDeclaration(mixed $declaration, string $name): array
{
$renamed = is_array($declaration) ? $declaration : [];
unset($renamed['external'], $renamed['name']);
$renamed['name'] = $name;
return $renamed;
}
/**
* The Docker volumes that a Compose file declares as external. Returns an empty list for a
* Compose file that is empty or not valid YAML.
@@ -1383,7 +1401,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
} else {
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
}
$source = replaceLocalSource($source, $mainDirectory);
$source = resolveComposeBindSource($source, $mainDirectory, $foundConfig?->fs_path);
$isPreviewSuffixEnabled = $foundConfig
? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
: true;
@@ -1423,14 +1441,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
continue;
}
if ($topLevel->get('volumes')->has($source->value())) {
$temp = $topLevel->get('volumes')->get($source->value());
if (data_get($temp, 'driver_opts.type') === 'cifs') {
continue;
}
if (data_get($temp, 'driver_opts.type') === 'nfs') {
continue;
}
$declaration = $topLevel->get('volumes')->get($source->value());
if (in_array(data_get($declaration, 'driver_opts.type'), ['cifs', 'nfs'], true)) {
// Network volumes are used as written.
$volumesParsed->put($index, $volume);
continue;
}
$slugWithoutUuid = Str::slug($source, '-');
$name = "{$uuid}_{$slugWithoutUuid}";
@@ -1450,9 +1466,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
} elseif (is_array($volume)) {
data_set($volume, 'source', $name);
}
$topLevel->get('volumes')->put($name, [
'name' => $name,
]);
$topLevel->get('volumes')->put($name, composeRenamedVolumeDeclaration($declaration, $name));
LocalPersistentVolume::updateOrCreate(
[
'name' => $name,
@@ -2749,7 +2763,7 @@ function serviceParser(Service $resource): Collection
} else {
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
}
$source = replaceLocalSource($source, $mainDirectory);
$source = resolveComposeBindSource($source, $mainDirectory, $foundConfig?->fs_path);
LocalFileVolume::updateOrCreate(
[
'mount_path' => $target,
@@ -2779,14 +2793,12 @@ function serviceParser(Service $resource): Collection
continue;
}
if ($topLevel->get('volumes')->has($source->value())) {
$temp = $topLevel->get('volumes')->get($source->value());
if (data_get($temp, 'driver_opts.type') === 'cifs') {
continue;
}
if (data_get($temp, 'driver_opts.type') === 'nfs') {
continue;
}
$declaration = $topLevel->get('volumes')->get($source->value());
if (in_array(data_get($declaration, 'driver_opts.type'), ['cifs', 'nfs'], true)) {
// Network volumes are used as written.
$volumesParsed->put($index, $volume);
continue;
}
$slugWithoutUuid = Str::slug($source, '-');
$name = "{$uuid}_{$slugWithoutUuid}";
@@ -2803,9 +2815,7 @@ function serviceParser(Service $resource): Collection
} elseif (is_array($volume)) {
data_set($volume, 'source', $name);
}
$topLevel->get('volumes')->put($name, [
'name' => $name,
]);
$topLevel->get('volumes')->put($name, composeRenamedVolumeDeclaration($declaration, $name));
LocalPersistentVolume::updateOrCreate(
[
'name' => $name,
+108 -34
View File
@@ -1830,7 +1830,54 @@ function sourceIsLocal(Stringable $source)
return false;
}
function replaceLocalSource(Stringable $source, Stringable $replacedWith)
/**
* The host path of a local Compose bind source, relative to the resource directory $replacedWith.
*
* Sources with a `..` segment are resolved like Docker Compose resolves them, without touching the
* filesystem. Other sources keep the result of legacyReplaceLocalSource().
*
* @throws Exception If the source resolves above `/` or the resolved path is not shell-safe
*/
function replaceLocalSource(Stringable $source, Stringable $replacedWith): Stringable
{
$path = $source->value();
if (! in_array('..', explode('/', $path), true)) {
return legacyReplaceLocalSource($source, $replacedWith);
}
if (str_starts_with($path, '~')) {
$path = $replacedWith->value().substr($path, 1);
} elseif (! str_starts_with($path, '/')) {
$path = $replacedWith->value().'/'.$path;
}
$segments = [];
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
if ($segments === []) {
throw new Exception("Volume source {$source} resolves to a path above /. Remove some ../ segments.");
}
array_pop($segments);
continue;
}
$segments[] = $segment;
}
$resolved = '/'.implode('/', $segments);
validateShellSafePath($resolved, 'volume source');
return str($resolved);
}
/**
* The host path that Coolify used for a local Compose bind source before it resolved `..` segments.
* A leading `../` became `{directory}./`. Resources that still store that path keep it.
*/
function legacyReplaceLocalSource(Stringable $source, Stringable $replacedWith): Stringable
{
if ($source->startsWith('.')) {
$source = $source->replaceFirst('.', $replacedWith->value());
@@ -1848,6 +1895,22 @@ function replaceLocalSource(Stringable $source, Stringable $replacedWith)
return $source;
}
/**
* The host path of a local Compose bind source. A mount whose storage row already has the legacy path
* (also with a preview suffix) keeps it, because its data is there.
*
* @throws Exception If a new source resolves above `/` or is not shell-safe
*/
function resolveComposeBindSource(Stringable $source, Stringable $directory, ?string $existingFsPath = null): Stringable
{
$legacySource = legacyReplaceLocalSource($source, $directory);
if ($existingFsPath !== null && preg_match('/^'.preg_quote($legacySource->value(), '/').'(-pr-\d+)?$/', $existingFsPath) === 1) {
return $legacySource;
}
return replaceLocalSource($source, $directory);
}
function convertToArray($collection)
{
if ($collection instanceof Collection) {
@@ -2909,6 +2972,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
return $volume;
}
}
$declaration = $topLevelVolumes->get($source->value());
$slugWithoutUuid = Str::slug($source, '-');
$name = "{$savedService->service->uuid}_{$slugWithoutUuid}";
if (is_string($volume)) {
@@ -2919,9 +2983,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
} elseif (is_array($volume)) {
data_set($volume, 'source', $name);
}
$topLevelVolumes->put($name, [
'name' => $name,
]);
$topLevelVolumes->put($name, composeRenamedVolumeDeclaration($declaration, $name));
LocalPersistentVolume::updateOrCreate(
[
'mount_path' => $target,
@@ -3502,6 +3564,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$volume = str($volume);
if ($volume->contains(':') && ! $volume->startsWith('/')) {
$name = $volume->before(':');
$volumeKey = $name->value();
$mount = $volume->after(':');
if ($name->startsWith('.') || $name->startsWith('~')) {
$dir = base_configuration_dir().'/applications/'.$resource->uuid;
@@ -3531,9 +3594,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
}
} else {
$topLevelVolumes->put($name, [
'name' => $name,
]);
$topLevelVolumes->put($name, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name));
}
} else {
if ($topLevelVolumes->has($name->value())) {
@@ -3546,9 +3607,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
}
} else {
$topLevelVolumes->put($name->value(), [
'name' => $name->value(),
]);
$topLevelVolumes->put($name->value(), composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name->value()));
}
}
}
@@ -3564,6 +3623,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
} elseif (is_array($volume)) {
$source = data_get($volume, 'source');
$volumeKey = (string) $source;
$target = data_get($volume, 'target');
$read_only = data_get($volume, 'read_only');
if ($source && $target) {
@@ -3605,9 +3665,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
}
} else {
$topLevelVolumes->put($source, [
'name' => $source,
]);
$topLevelVolumes->put($source, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $source));
}
}
}
@@ -3628,6 +3686,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$volume = str($volume);
if ($volume->contains(':') && ! $volume->startsWith('/')) {
$name = $volume->before(':');
$volumeKey = $name->value();
$mount = $volume->after(':');
if ($name->startsWith('.') || $name->startsWith('~')) {
$dir = base_configuration_dir().'/applications/'.$resource->uuid;
@@ -3658,9 +3717,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
}
} else {
$topLevelVolumes->put($name, [
'name' => $name,
]);
$topLevelVolumes->put($name, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name));
}
} else {
$uuid = $resource->uuid;
@@ -3676,9 +3733,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
}
} else {
$topLevelVolumes->put($name->value(), [
'name' => $name->value(),
]);
$topLevelVolumes->put($name->value(), composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name->value()));
}
}
}
@@ -3694,6 +3749,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
} elseif (is_array($volume)) {
$source = data_get($volume, 'source');
$volumeKey = (string) $source;
$target = data_get($volume, 'target');
$read_only = data_get($volume, 'read_only');
if ($source && $target) {
@@ -3735,9 +3791,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
}
} else {
$topLevelVolumes->put($source, [
'name' => $source,
]);
$topLevelVolumes->put($source, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $source));
}
}
}
@@ -4868,10 +4922,15 @@ function formatContainerStatus(string $status): string
}
/**
* Check if password confirmation should be skipped.
* Check if the password step of a destructive action should be skipped.
* Returns true if:
* - Two-step confirmation is globally disabled
* - User has no usable local password confirmation (including SSO users)
* - User has neither a password nor a linked OAuth identity (no way to confirm)
* - User confirmed recently (password or OAuth re-authentication), using the
* same `auth.password_confirmed_at` session value and timeout as Laravel
*
* Users with a linked OAuth identity are never skipped silently: they confirm
* with their password or by re-authenticating through their provider.
*
* Used by modal-confirmation.blade.php to determine if password step should be shown.
*
@@ -4884,20 +4943,30 @@ function shouldSkipPasswordConfirmation(): bool
return true;
}
// OAuth users may have an unusable generated password, so the linked
// identity is the source of truth for whether confirmation is possible.
if (! Auth::user()?->requiresPasswordConfirmation()) {
return true;
}
return false;
return hasRecentPasswordConfirmation();
}
/**
* Whether the session holds a password confirmation within `auth.password_timeout`.
*/
function hasRecentPasswordConfirmation(): bool
{
$confirmedAt = session('auth.password_confirmed_at');
if (! is_numeric($confirmedAt)) {
return false;
}
return (time() - (int) $confirmedAt) < (int) config('auth.password_timeout', 10800);
}
/**
* Verify password for two-step confirmation.
* Skips verification if:
* - Two-step confirmation is globally disabled
* - User has no usable local password confirmation (including SSO users)
* Skips verification in the cases listed in shouldSkipPasswordConfirmation().
* Users without a password must confirm through their OAuth provider first.
*
* @param mixed $password The password to verify (may be array if skipped by frontend)
* @param Component|null $component Optional Livewire component to add errors to
@@ -4910,11 +4979,16 @@ function verifyPasswordConfirmation(mixed $password, ?Component $component = nul
return true;
}
$user = Auth::user();
if (! $user->hasPassword()) {
$component?->addError('password', 'Please confirm with your sign-in provider first.');
return false;
}
// Verify the password
if (! Hash::check($password, Auth::user()->password)) {
if ($component) {
$component->addError('password', 'The provided password is incorrect.');
}
if (! is_string($password) || ! Hash::check($password, $user->password)) {
$component?->addError('password', 'The provided password is incorrect.');
return false;
}
+21
View File
@@ -91,3 +91,24 @@ function get_socialite_provider(string $provider)
return $socialite;
}
/**
* Authorization parameters that ask the provider to re-authenticate the user
* instead of reusing an existing provider session, where the provider
* supports it. Used to confirm destructive actions through OAuth.
*
* Socialite's with() replaces earlier parameters, so provider defaults set in
* get_socialite_provider() (Google's hd) are repeated here.
*
* @return array<string, int|string|null>
*/
function socialite_reauthentication_parameters(OauthSetting $oauthSetting): array
{
return match ($oauthSetting->provider) {
'google' => ['hd' => $oauthSetting->tenant, 'prompt' => 'select_account', 'max_age' => 0],
'oidc', 'authentik', 'zitadel', 'clerk' => ['prompt' => 'login', 'max_age' => 0],
'azure', 'infomaniak' => ['prompt' => 'login'],
'github' => ['prompt' => 'select_account'],
default => [],
};
}
+46
View File
@@ -16821,6 +16821,11 @@
"example": "both",
"description": "Server role."
},
"is_build_server": {
"type": "boolean",
"deprecated": true,
"description": "Deprecated: use server_role instead. true sets server_role to build, false sets it to both. Must not conflict with server_role."
},
"instant_validate": {
"type": "boolean",
"example": false,
@@ -17058,6 +17063,11 @@
],
"description": "Server role."
},
"is_build_server": {
"type": "boolean",
"deprecated": true,
"description": "Deprecated: use server_role instead. true sets server_role to build, false changes a build server to both and leaves other roles unchanged. Must not conflict with server_role."
},
"instant_validate": {
"type": "boolean",
"description": "Instant validate."
@@ -22425,6 +22435,16 @@
"description": "Drop matching PostgreSQL objects before restoring a single-database archive.",
"type": "boolean",
"default": false
},
"keep_owners": {
"description": "Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.",
"type": "boolean",
"default": false
},
"sqlite_database": {
"description": "SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.",
"type": "string",
"example": "app.db"
}
},
"type": "object",
@@ -22457,6 +22477,16 @@
"description": "Drop matching PostgreSQL objects before restoring a single-database archive.",
"type": "boolean",
"default": false
},
"keep_owners": {
"description": "Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.",
"type": "boolean",
"default": false
},
"sqlite_database": {
"description": "SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.",
"type": "string",
"example": "app.db"
}
},
"type": "object",
@@ -22486,6 +22516,16 @@
"description": "Drop matching PostgreSQL objects before restoring a single-database archive.",
"type": "boolean",
"default": false
},
"keep_owners": {
"description": "Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.",
"type": "boolean",
"default": false
},
"sqlite_database": {
"description": "SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.",
"type": "string",
"example": "app.db"
}
},
"type": "object",
@@ -23690,6 +23730,12 @@
"both"
]
},
"is_build_server": {
"type": "boolean",
"deprecated": true,
"readOnly": true,
"description": "Deprecated: use server_role instead. true when server_role is build."
},
"is_cloudflare_tunnel": {
"type": "boolean"
},
+37
View File
@@ -10644,6 +10644,10 @@ paths:
enum: [deployment, build, both]
example: both
description: 'Server role.'
is_build_server:
type: boolean
deprecated: true
description: 'Deprecated: use server_role instead. true sets server_role to build, false sets it to both. Must not conflict with server_role.'
instant_validate:
type: boolean
example: false
@@ -10798,6 +10802,10 @@ paths:
type: string
enum: [deployment, build, both]
description: 'Server role.'
is_build_server:
type: boolean
deprecated: true
description: 'Deprecated: use server_role instead. true sets server_role to build, false changes a build server to both and leaves other roles unchanged. Must not conflict with server_role.'
instant_validate:
type: boolean
description: 'Instant validate.'
@@ -14225,6 +14233,14 @@ components:
description: 'Drop matching PostgreSQL objects before restoring a single-database archive.'
type: boolean
default: false
keep_owners:
description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.'
type: boolean
default: false
sqlite_database:
description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.'
type: string
example: app.db
type: object
additionalProperties: false
-
@@ -14248,6 +14264,14 @@ components:
description: 'Drop matching PostgreSQL objects before restoring a single-database archive.'
type: boolean
default: false
keep_owners:
description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.'
type: boolean
default: false
sqlite_database:
description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.'
type: string
example: app.db
type: object
additionalProperties: false
-
@@ -14269,6 +14293,14 @@ components:
description: 'Drop matching PostgreSQL objects before restoring a single-database archive.'
type: boolean
default: false
keep_owners:
description: 'Restore object owners and privileges from a single-database PostgreSQL archive. By default they are skipped, because the roles of another server usually do not exist.'
type: boolean
default: false
sqlite_database:
description: 'SQLite: the database file to restore into. Must be one of the database files. Defaults to the file named in the backup file name, else the first file.'
type: string
example: app.db
type: object
additionalProperties: false
DatabaseImportStatus:
@@ -15157,6 +15189,11 @@ components:
- deployment
- build
- both
is_build_server:
type: boolean
deprecated: true
readOnly: true
description: 'Deprecated: use server_role instead. true when server_role is build.'
is_cloudflare_tunnel:
type: boolean
is_jump_server:
@@ -34,8 +34,14 @@
use Illuminate\View\ComponentSlot;
// Global setting to disable ALL two-step confirmation (text + password)
$disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation');
// Skip ONLY password confirmation for OAuth users (they have no password)
// Skip ONLY the password step (disabled globally, recently confirmed, or no way to confirm)
$skipPasswordConfirmation = shouldSkipPasswordConfirmation();
// Users with a linked OAuth identity can confirm by re-authenticating with their provider
$confirmingUser = auth()->user();
$confirmWithPasswordInput = $confirmingUser?->hasPassword() ?? false;
$oauthConfirmationProviders = ($confirmWithPassword && ! $skipPasswordConfirmation && $confirmingUser)
? $confirmingUser->oauthConfirmationProviders()
: collect();
if ($temporaryDisableTwoStepConfirmation) {
$disableTwoStepConfirmation = false;
// Password confirmation requirement is not affected by temporary two-step disable
@@ -350,61 +356,96 @@
@if (!$skipPasswordConfirmation)
<div x-show="step === 3 && confirmWithPassword">
<x-callout type="danger" title="Final Confirmation" class="mb-4">
Please enter your password to confirm this destructive action.
@if ($confirmWithPasswordInput && $oauthConfirmationProviders->isNotEmpty())
Please enter your password or confirm with your sign-in provider to confirm this destructive action.
@elseif ($confirmWithPasswordInput)
Please enter your password to confirm this destructive action.
@elseif ($oauthConfirmationProviders->isNotEmpty())
Please confirm with your sign-in provider to confirm this destructive action.
@else
Your sign-in provider is not available, so this action cannot be confirmed. Please ask an administrator.
@endif
</x-callout>
<div class="flex flex-col gap-2 mb-4">
@php
$passwordConfirm = Str::uuid();
@endphp
<label for="password-confirm-{{ $passwordConfirm }}"
class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Your Password
</label>
<form @submit.prevent="false" @keydown.enter.prevent>
<input type="text" name="username" autocomplete="username"
value="{{ auth()->user()->email }}" style="display: none;">
<input type="password" id="password-confirm-{{ $passwordConfirm }}"
x-model="password" class="w-full input" placeholder="Enter your password"
autocomplete="current-password">
</form>
<p x-show="passwordError" x-text="passwordError" class="mt-1 text-sm text-red-500">
</p>
@error('password')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@if ($confirmWithPasswordInput)
<div class="flex flex-col gap-2 mb-4">
@php
$passwordConfirm = Str::uuid();
@endphp
<label for="password-confirm-{{ $passwordConfirm }}"
class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Your Password
</label>
<form @submit.prevent="false" @keydown.enter.prevent>
<input type="text" name="username" autocomplete="username"
value="{{ auth()->user()->email }}" style="display: none;">
<input type="password" id="password-confirm-{{ $passwordConfirm }}"
x-model="password" class="w-full input" placeholder="Enter your password"
autocomplete="current-password">
</form>
<p x-show="passwordError" x-text="passwordError" class="mt-1 text-sm text-red-500">
</p>
@error('password')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@endif
@if ($oauthConfirmationProviders->isNotEmpty())
<div class="flex flex-col gap-2 mb-4">
@if ($confirmWithPasswordInput)
<div class="text-[12px] text-neutral-500 dark:text-fg-dim">Or confirm with your sign-in provider:</div>
@endif
<div class="flex flex-wrap gap-2">
@foreach ($oauthConfirmationProviders as $oauthConfirmationProvider)
<a href="{{ route('auth.confirm', $oauthConfirmationProvider->provider) }}"
class="button">
Confirm with {{ $oauthConfirmationProvider->providerName() }}
</a>
@endforeach
</div>
<p class="text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
You will return to this page. Then start the action again; it will not ask for confirmation for a while.
</p>
@if (! $confirmWithPasswordInput)
@error('password')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
@endif
</div>
@endif
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button @click="step--">
Back
</x-forms.button>
<x-forms.button :showLoadingIndicator="false"
x-bind:disabled="!password || submitting" class="w-auto" isError
@click="
if (dispatchEvent) {
$wire.dispatch(dispatchEventType, dispatchEventMessage);
}
submitting = true;
modalOpen = false;
$nextTick(() => {
submitForm().then((result) => {
submitting = false;
if (result === true) {
resetModal();
} else {
@if ($confirmWithPasswordInput)
<x-forms.button :showLoadingIndicator="false"
x-bind:disabled="!password || submitting" class="w-auto" isError
@click="
if (dispatchEvent) {
$wire.dispatch(dispatchEventType, dispatchEventMessage);
}
submitting = true;
modalOpen = false;
$nextTick(() => {
submitForm().then((result) => {
submitting = false;
if (result === true) {
resetModal();
} else {
modalOpen = true;
passwordError = result;
password = '';
}
}).catch(() => {
submitting = false;
modalOpen = true;
passwordError = result;
password = '';
}
}).catch(() => {
submitting = false;
modalOpen = true;
});
});
});
">
<x-loading-on-button x-show="submitting" x-cloak />
<span x-text="step3ButtonText"></span>
</x-forms.button>
">
<x-loading-on-button x-show="submitting" x-cloak />
<span x-text="step3ButtonText"></span>
</x-forms.button>
@endif
</div>
</div>
@endif
@@ -36,6 +36,7 @@
});
this.on('complete', function (file) {
$wire.filename = file.name;
$wire.selectSqliteDatabaseFor(file.name);
$wire.filesize = Number(file.size / 1024 / 1024).toFixed(2) + ' MB';
$wire.isUploading = false;
});
@@ -79,6 +80,19 @@
helper="Archive backups: drops matching tables, functions, types, and other PostgreSQL objects before restoring them. SQL backups: recreates the database before the restore."
canGate="update" :canResource="$this->resource" />
</div>
<div class="max-w-sm">
<x-forms.checkbox id="keepOwners" label="Keep owners and privileges"
helper="Archive backups: restores object owners and GRANTs from the backup. Leave this off for backups from another server (for example Amazon RDS): its roles usually do not exist here, and the whole restore would be rolled back."
canGate="update" :canResource="$this->resource" />
</div>
@endif
@if ($resourceDbType === 'standalone-sqlite' && count($this->sqliteDatabaseFiles) > 0)
<div class="max-w-sm">
<x-forms.listbox id="sqliteDatabase" label="Restore into" live
helper="The database file the backup replaces. Coolify preselects the file named in the backup file name."
:options="collect($this->sqliteDatabaseFiles)->map(fn ($file) => ['value' => $file, 'label' => $file])->all()"
canGate="update" :canResource="$this->resource" />
</div>
@endif
@if ($resourceDbType === 'standalone-mongodb')
<div class="max-w-sm">
@@ -171,6 +171,11 @@
Use Recheck after changing DNS.
</x-callout>
@else
@if (collect($dnsProviderProposals)->isNotEmpty() && collect($dnsHints)->contains(fn (array $record): bool => ! \App\Support\DnsRecordHints::isPublicAddress($record['value'])))
<x-callout type="warning" title="No public IP">
{{ \App\Support\DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE }}
</x-callout>
@endif
<div class="overflow-x-auto rounded-md border border-neutral-200 dark:border-coolgray-300">
<table class="w-full min-w-[32rem] text-left text-sm">
<thead
@@ -201,7 +206,7 @@
])
</td>
<td class="px-3 py-2.5 text-right">
@php($recordProviders = collect($dnsProviderProposals)->where('hostname', $record['name'])->where('managed', false))
@php($recordProviders = \App\Support\DnsRecordHints::isPublicAddress($record['value']) ? collect($dnsProviderProposals)->where('hostname', $record['name'])->where('managed', false) : collect())
@foreach ($recordProviders as $provider)
<x-forms.button type="button"
wire:click="createManagedDnsRecord({{ \Illuminate\Support\Js::from($record['name']) }}, {{ $provider['zone_id'] }}, {{ \Illuminate\Support\Js::from($record['value']) }})"
@@ -16,11 +16,14 @@
</header>
<div class="application-settings-section-body flex flex-col gap-3">
<p class="text-sm text-neutral-600 dark:text-fg-dim">Coolify matched each hostname to a zone available through your connected DNS credentials.</p>
@if (collect($dnsProviderProposals)->contains(fn (array $proposal): bool => blank($proposal['target'])))
<x-callout type="warning" title="No public IP">{{ \App\Support\DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE }}</x-callout>
@endif
@foreach ($dnsProviderProposals as $proposal)
@php($key = $proposal['hostname'].'|'.$proposal['zone_id'])
<div wire:key="dns-proposal-{{ $key }}" class="flex flex-col gap-3 rounded-lg border border-neutral-200 p-3 dark:border-white/[0.08] sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 text-sm"><div class="truncate font-medium text-black dark:text-fg">{{ $proposal['hostname'] }}</div>
<div class="text-xs text-neutral-500 dark:text-fg-dim">{{ $proposal['credential'] }} · {{ $proposal['zone'] }} · {{ $proposal['target'] }}</div>
<div class="text-xs text-neutral-500 dark:text-fg-dim">{{ $proposal['credential'] }} · {{ $proposal['zone'] }}@if (filled($proposal['target'])) · {{ $proposal['target'] }}@endif</div>
</div>
@if ($proposal['managed'])
<x-status-badge status="Managed by Coolify" type="success" />
@@ -33,6 +36,8 @@
:confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Replace record"
canGate="update" :canResource="$dnsAuthResource" />
</div>
@elseif (blank($proposal['target']))
<span class="text-xs text-neutral-500 dark:text-fg-dim">Add manually</span>
@else
<x-forms.button type="button"
wire:click="createManagedDnsRecord({{ \Illuminate\Support\Js::from($proposal['hostname']) }}, {{ $proposal['zone_id'] }})"
+1
View File
@@ -129,6 +129,7 @@ Route::post('/auth/link', [Controller::class, 'acceptLink'])->middleware('thrott
Route::get('/auth/{provider}/redirect', [OauthController::class, 'redirect'])->name('auth.redirect');
Route::get('/auth/{provider}/callback', [OauthController::class, 'callback'])->name('auth.callback');
Route::get('/auth/{provider}/confirm', [OauthController::class, 'confirm'])->middleware('auth')->name('auth.confirm');
// Local/testing previews for HTTP error pages and the Laravel debug renderer (never in production).
if (app()->environment(['local', 'testing'])) {
@@ -0,0 +1,209 @@
<?php
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.driver', 'file');
config()->set('cache.default', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::create([
'name' => 'Legacy Key',
'private_key' => generateSSHKey('ed25519')['private'],
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->writeToken = legacyBuildServerApiToken($this->user, $this->team, ['write']);
$this->readToken = legacyBuildServerApiToken($this->user, $this->team, ['read']);
});
function legacyBuildServerApiToken(User $user, Team $team, array $abilities): string
{
$token = $user->createToken('legacy-'.implode('-', $abilities), $abilities);
$token->accessToken->forceFill(['team_id' => $team->id])->save();
return $token->plainTextToken;
}
function legacyBuildServerApi(object $test, string $bearerToken): object
{
return $test->withHeaders([
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
]);
}
function createLegacyBuildServer(object $test, array $payload): TestResponse
{
return legacyBuildServerApi($test, $test->writeToken)->postJson('/api/v1/servers', array_merge([
'name' => 'Legacy Server',
'ip' => '192.0.2.77',
'private_key_uuid' => $test->privateKey->uuid,
], $payload));
}
function updateLegacyBuildServer(object $test, Server $server, array $payload, ?string $token = null): TestResponse
{
return legacyBuildServerApi($test, $token ?? $test->writeToken)->patchJson('/api/v1/servers/'.$server->uuid, $payload);
}
describe('create', function () {
it('maps is_build_server to the server role', function (bool $isBuildServer, string $expectedRole) {
$response = createLegacyBuildServer($this, ['is_build_server' => $isBuildServer])->assertCreated();
$server = Server::whereUuid($response->json('uuid'))->firstOrFail();
expect($server->settings->server_role->value)->toBe($expectedRole)
->and($server->settings->is_build_server)->toBe($isBuildServer);
})->with([
'build server' => [true, 'build'],
'regular server' => [false, 'both'],
]);
it('accepts is_build_server together with a matching server_role', function () {
$response = createLegacyBuildServer($this, [
'is_build_server' => true,
'server_role' => 'build',
])->assertCreated();
expect(Server::whereUuid($response->json('uuid'))->firstOrFail()->settings->server_role->value)->toBe('build');
});
it('rejects is_build_server that conflicts with server_role', function (bool $isBuildServer, string $serverRole) {
$serverCount = Server::count();
createLegacyBuildServer($this, [
'is_build_server' => $isBuildServer,
'server_role' => $serverRole,
])->assertUnprocessable()
->assertJsonValidationErrors('is_build_server');
expect(Server::count())->toBe($serverCount);
})->with([
'build flag with both role' => [true, 'both'],
'build flag with deployment role' => [true, 'deployment'],
'regular flag with build role' => [false, 'build'],
]);
it('rejects a non-boolean is_build_server', function () {
createLegacyBuildServer($this, ['is_build_server' => 'yes please'])
->assertUnprocessable()
->assertJsonValidationErrors('is_build_server');
});
});
describe('update', function () {
it('makes an empty server a build server with is_build_server true', function () {
updateLegacyBuildServer($this, $this->server, ['is_build_server' => true])->assertCreated();
$settings = $this->server->settings->fresh();
expect($settings->server_role->value)->toBe('build')
->and($settings->is_build_server)->toBeTrue();
});
it('turns a build server back into a regular server with is_build_server false', function () {
$this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]);
updateLegacyBuildServer($this, $this->server, ['is_build_server' => false])->assertCreated();
$settings = $this->server->settings->fresh();
expect($settings->server_role->value)->toBe('both')
->and($settings->is_build_server)->toBeFalse();
});
it('keeps a deployment only server unchanged with is_build_server false', function () {
$this->server->settings()->update(['server_role' => 'deployment', 'is_build_server' => false]);
updateLegacyBuildServer($this, $this->server, ['is_build_server' => false])->assertCreated();
expect($this->server->settings->fresh()->server_role->value)->toBe('deployment');
});
it('accepts is_build_server together with a matching server_role', function () {
updateLegacyBuildServer($this, $this->server, [
'is_build_server' => false,
'server_role' => 'both',
])->assertCreated();
expect($this->server->settings->fresh()->server_role->value)->toBe('both');
});
it('rejects is_build_server that conflicts with server_role without changing the server', function () {
$this->server->settings()->update(['server_role' => 'both', 'is_build_server' => false]);
updateLegacyBuildServer($this, $this->server, [
'name' => 'Renamed Server',
'is_build_server' => false,
'server_role' => 'build',
])->assertUnprocessable()
->assertJsonValidationErrors('is_build_server');
expect($this->server->fresh()->name)->not->toBe('Renamed Server')
->and($this->server->settings->fresh()->server_role->value)->toBe('both');
});
it('does not let a read-only token change the build server flag', function () {
updateLegacyBuildServer($this, $this->server, ['is_build_server' => true], $this->readToken)
->assertForbidden();
expect($this->server->settings->fresh()->server_role->value)->toBe('both');
});
it('does not update a server from another team', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
updateLegacyBuildServer($this, $otherServer, ['is_build_server' => true])->assertNotFound();
expect($otherServer->settings->fresh()->server_role->value)->toBe('both');
});
});
describe('read', function () {
it('returns a derived is_build_server flag in the server settings', function (string $role, bool $expected) {
$this->server->settings()->update(['server_role' => $role, 'is_build_server' => $role === 'build']);
legacyBuildServerApi($this, $this->readToken)->getJson('/api/v1/servers/'.$this->server->uuid)
->assertOk()
->assertJsonPath('settings.server_role', $role)
->assertJsonPath('settings.is_build_server', $expected);
$listed = collect(legacyBuildServerApi($this, $this->readToken)->getJson('/api/v1/servers')->assertOk()->json())
->firstWhere('uuid', $this->server->uuid);
expect(data_get($listed, 'settings.is_build_server'))->toBe($expected);
})->with([
'build' => ['build', true],
'both' => ['both', false],
'deployment' => ['deployment', false],
]);
it('derives is_build_server from the server role, not the stale legacy column', function () {
$this->server->settings()->update(['server_role' => 'both', 'is_build_server' => true]);
legacyBuildServerApi($this, $this->readToken)->getJson('/api/v1/servers/'.$this->server->uuid)
->assertOk()
->assertJsonPath('settings.is_build_server', false);
});
});
+24
View File
@@ -3554,3 +3554,27 @@ it('restarts a preview dns check when the domain already has a completed result'
Queue::assertPushed(CheckDomainDnsJob::class);
});
it('shows a manual dns note instead of create buttons when the server has no public ip', function (string $address) {
$this->server->update(['ip' => $address]);
Http::fake();
$component = openDnsProviderModalWithZone($this->team, $this->application)
->assertSet('showDnsProviderModal', true)
->call('openManualDnsRecords');
expect($component->html())
->toContain('The server has no public IP address; add the DNS record manually.')
->not->toContain('Create DNS record')
->not->toContain('Add with Cloudflare');
Http::assertNothingSent();
})->with(['10.0.0.5', '100.100.1.1', 'fd00::5']);
it('offers dns provider record creation for a public server ip', function () {
$component = openDnsProviderModalWithZone($this->team, $this->application)->call('openManualDnsRecords');
expect($component->html())
->toContain('Create DNS record')
->toContain('Add with Cloudflare')
->not->toContain('The server has no public IP address; add the DNS record manually.');
});
@@ -299,3 +299,85 @@ test('applications on combined servers build locally unless they use a build ser
expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue()
->and(usesRemoteBuildServer($job))->toBeFalse();
});
function markAsAdditionalServer(ApplicationDeploymentJob $job, bool $forceRebuild = false): void
{
(new ReflectionProperty(ApplicationDeploymentJob::class, 'is_this_additional_server'))->setValue($job, true);
(new ReflectionProperty(ApplicationDeploymentJob::class, 'force_rebuild'))->setValue($job, $forceRebuild);
}
test('additional deployments-only servers pull the registry image without a build server', function (bool $buildServerEnabled) {
$team = Team::factory()->create(['is_build_server_fallback_enabled' => false]);
$deploymentServer = makeRoleServer($team, 'deployment');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], $buildServerEnabled);
markAsAdditionalServer($job);
$deploymentQueue->shouldNotReceive('setAttribute');
$deploymentQueue->shouldReceive('addLogEntry')->once()->with('Additional server: pulls the image from the registry, no build server needed.');
invokeBuildServerSelection($job);
expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue()
->and(usesRemoteBuildServer($job))->toBeFalse();
})->with([
'application uses a build server' => [true],
'application builds on the deployment server' => [false],
]);
test('additional servers do not use a build server when the application enables one', function () {
$team = Team::factory()->create();
$deploymentServer = makeRoleServer($team, 'both');
makeRoleServer($team, 'build');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app']);
markAsAdditionalServer($job);
$deploymentQueue->shouldNotReceive('setAttribute');
$deploymentQueue->shouldReceive('addLogEntry')->once()->with('Additional server: pulls the image from the registry, no build server needed.');
invokeBuildServerSelection($job);
expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue()
->and(usesRemoteBuildServer($job))->toBeFalse();
});
test('additional deployments-only servers pull the image even when the build cache is disabled', function () {
$team = Team::factory()->create();
$deploymentServer = makeRoleServer($team, 'deployment');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app']);
markAsAdditionalServer($job, forceRebuild: true);
$deploymentQueue->shouldReceive('addLogEntry');
invokeBuildServerSelection($job);
expect((new ReflectionProperty(ApplicationDeploymentJob::class, 'force_rebuild'))->getValue($job))->toBeFalse();
});
test('main deployments-only servers still require a build server', function () {
$team = Team::factory()->create();
$deploymentServer = makeRoleServer($team, 'deployment');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app']);
$deploymentQueue->shouldNotReceive('addLogEntry');
expect(fn () => invokeBuildServerSelection($job))
->toThrow(DeploymentException::class, "The deployment server ({$deploymentServer->name}) is set to deployments only, and no usable build server was found.");
});
test('additional deployments-only servers fail instead of building when the registry image is missing', function (bool $restartOnly) {
$team = Team::factory()->create();
$deploymentServer = makeRoleServer($team, 'deployment');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], restartOnly: $restartOnly);
markAsAdditionalServer($job);
(new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->setValue($job, collect());
(new ReflectionProperty(ApplicationDeploymentJob::class, 'pull_request_id'))->setValue($job, 0);
(new ReflectionProperty(ApplicationDeploymentJob::class, 'production_image_name'))->setValue($job, 'ghcr.io/coollabsio/app:abc123');
$deploymentQueue->shouldNotReceive('addLogEntry');
expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'should_skip_build'))->invoke($job))
->toThrow(DeploymentException::class, 'Image (ghcr.io/coollabsio/app:abc123) not found in the registry; the main server must push it first.');
})->with([
'deploy' => [false],
'restart' => [true],
]);
+284
View File
@@ -0,0 +1,284 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
config(['constants.ssh.mux_enabled' => false]);
Bus::fake();
Queue::fake();
Process::fake(['*' => Process::result(output: '')]);
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->environment = $project->environments()->first()
?? Environment::factory()->create(['project_id' => $project->id]);
});
function parentBindCompose(string $source, bool $longSyntax = false): string
{
if ($longSyntax) {
return <<<YAML
services:
web:
image: nginx:alpine
volumes:
- type: bind
source: '{$source}'
target: /data
YAML;
}
return <<<YAML
services:
web:
image: nginx:alpine
volumes:
- '{$source}:/data'
YAML;
}
function parentBindApplication(string $compose, ?string $parsingVersion = null): Application
{
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $compose,
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
]);
if ($parsingVersion !== null) {
$application->update(['compose_parsing_version' => $parsingVersion]);
}
return $application->fresh();
}
function parentBindService(string $compose): Service
{
return Service::factory()->create([
'docker_compose_raw' => $compose,
'environment_id' => test()->environment->id,
'server_id' => test()->destination->server_id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
])->fresh();
}
/**
* The host path of the first volume of the `web` service in a parsed Compose file.
*/
function parentBindParsedSource(array $compose): string
{
$volume = $compose['services']['web']['volumes'][0];
return is_array($volume) ? $volume['source'] : str($volume)->before(':/data')->value();
}
describe('applicationParser', function () {
it('resolves parent directory segments of a new bind mount', function (string $source, string $expected, bool $longSyntax) {
$application = parentBindApplication(parentBindCompose($source, $longSyntax));
$directory = base_configuration_dir().'/applications/'.$application->uuid;
$expected = str_replace('{dir}', $directory, $expected);
$compose = applicationParser($application)->toArray();
expect(parentBindParsedSource($compose))->toBe($expected)
->and($application->fileStorages()->where('mount_path', '/data')->value('fs_path'))->toBe($expected);
})->with([
'parent directory' => ['../outside', base_configuration_dir().'/applications/outside', false],
'parent directory, long syntax' => ['../outside', base_configuration_dir().'/applications/outside', true],
'dot segment in the middle' => ['./a/../b', '{dir}/b', false],
'two parent directories' => ['../../outside/', base_configuration_dir().'/outside', false],
'current directory stays unchanged' => ['./data', '{dir}/data', false],
'tilde stays unchanged' => ['~/data', '{dir}/data', false],
]);
it('rejects a bind mount that resolves above the root directory', function () {
$application = parentBindApplication(parentBindCompose('../../../../../../../outside'));
expect(fn () => applicationParser($application))
->toThrow(Exception::class, 'resolves to a path above /');
});
it('keeps the old path of an existing bind mount', function () {
$application = parentBindApplication(parentBindCompose('../outside'));
$oldPath = base_configuration_dir().'/applications/'.$application->uuid.'./outside';
LocalFileVolume::create([
'fs_path' => $oldPath,
'mount_path' => '/data',
'is_directory' => true,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$compose = applicationParser($application)->toArray();
expect(parentBindParsedSource($compose))->toBe($oldPath)
->and($application->fileStorages()->where('mount_path', '/data')->pluck('fs_path')->all())->toBe([$oldPath]);
});
it('keeps the old path of an existing bind mount that was last written by a preview deployment', function () {
$application = parentBindApplication(parentBindCompose('../outside'));
$oldPath = base_configuration_dir().'/applications/'.$application->uuid.'./outside';
LocalFileVolume::create([
'fs_path' => $oldPath.'-pr-7',
'mount_path' => '/data',
'is_directory' => true,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$compose = applicationParser($application)->toArray();
expect(parentBindParsedSource($compose))->toBe($oldPath);
});
it('uses the resolved path when the existing row has another path', function () {
$application = parentBindApplication(parentBindCompose('../outside'));
LocalFileVolume::create([
'fs_path' => '/somewhere/else',
'mount_path' => '/data',
'is_directory' => true,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$compose = applicationParser($application)->toArray();
expect(parentBindParsedSource($compose))->toBe(base_configuration_dir().'/applications/outside');
});
});
describe('serviceParser', function () {
it('resolves parent directory segments of a new bind mount', function (string $source, string $expected, bool $longSyntax) {
$service = parentBindService(parentBindCompose($source, $longSyntax));
$directory = base_configuration_dir().'/services/'.$service->uuid;
$expected = str_replace('{dir}', $directory, $expected);
$compose = serviceParser($service)->toArray();
$web = ServiceApplication::query()->where('service_id', $service->id)->where('name', 'web')->firstOrFail();
expect(parentBindParsedSource($compose))->toBe($expected)
->and($web->fileStorages()->where('mount_path', '/data')->value('fs_path'))->toBe($expected);
})->with([
'parent directory' => ['../outside', base_configuration_dir().'/services/outside', false],
'parent directory, long syntax' => ['../outside', base_configuration_dir().'/services/outside', true],
'dot segment in the middle' => ['./a/../b', '{dir}/b', false],
'current directory stays unchanged' => ['./data', '{dir}/data', false],
'tilde stays unchanged' => ['~/data', '{dir}/data', false],
]);
it('rejects a bind mount that resolves above the root directory', function () {
$service = parentBindService(parentBindCompose('../../../../../../../outside'));
expect(fn () => serviceParser($service))
->toThrow(Exception::class, 'resolves to a path above /');
});
it('keeps the old path of an existing bind mount', function () {
$service = parentBindService(parentBindCompose('../outside'));
$web = ServiceApplication::create(['name' => 'web', 'service_id' => $service->id, 'image' => 'nginx:alpine']);
$oldPath = base_configuration_dir().'/services/'.$service->uuid.'./outside';
LocalFileVolume::create([
'fs_path' => $oldPath,
'mount_path' => '/data',
'is_directory' => true,
'resource_id' => $web->id,
'resource_type' => $web->getMorphClass(),
]);
$compose = serviceParser($service)->toArray();
expect(parentBindParsedSource($compose))->toBe($oldPath)
->and($web->fileStorages()->where('mount_path', '/data')->pluck('fs_path')->all())->toBe([$oldPath]);
});
});
describe('validation', function () {
it('accepts parent directory bind sources', function (string $compose) {
validateDockerComposeForInjection($compose);
expect(parseDockerVolumeString('../outside:/data')['source']->value())->toBe('../outside');
})->with([
'short syntax' => [parentBindCompose('../outside')],
'long syntax' => [parentBindCompose('../outside', true)],
]);
it('still rejects parent directory segments in content volumes', function () {
$compose = <<<'YAML'
services:
web:
image: nginx:alpine
volumes:
- type: bind
source: ../outside/app.conf
target: /app.conf
content: hello
YAML;
expect(fn () => validateDockerComposeForInjection($compose, '/data/coolify/services/abc'))
->toThrow(Exception::class, 'must be inside the resource directory');
});
it('rejects shell metacharacters in a resolved parent directory path', function () {
expect(fn () => replaceLocalSource(str('../out;side'), str('/data/coolify/services/abc')))
->toThrow(Exception::class);
});
});
describe('legacy parser', function () {
it('keeps the old path in legacy applications, which never stored their bind mounts', function () {
$application = parentBindApplication(parentBindCompose('../outside'), '2');
$compose = parseDockerComposeFile($application)->toArray();
expect(parentBindParsedSource($compose))
->toBe(base_configuration_dir().'/applications/'.$application->uuid.'./outside');
});
});
describe('storage', function () {
it('treats both the old and the resolved path as a Compose mount of the administrator', function (string $fsPath) {
$service = parentBindService(parentBindCompose('../outside'));
$web = ServiceApplication::create(['name' => 'web', 'service_id' => $service->id, 'image' => 'nginx:alpine']);
$volume = LocalFileVolume::create([
'fs_path' => str_replace('{uuid}', $service->uuid, $fsPath),
'mount_path' => '/data',
'is_directory' => true,
'resource_id' => $web->id,
'resource_type' => $web->getMorphClass(),
]);
$method = new ReflectionMethod(LocalFileVolume::class, 'isAdminControlledComposeMount');
expect($method->invoke($volume->fresh()))->toBeTrue();
})->with([
'resolved path' => [base_configuration_dir().'/services/outside'],
'old path' => [base_configuration_dir().'/services/{uuid}./outside'],
]);
});
@@ -0,0 +1,297 @@
<?php
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
const DRIVER_OPTIONS_SHORT_COMPOSE = <<<'YAML'
services:
web:
image: nginx:alpine
volumes:
- 'opts:/data'
volumes:
opts:
driver: local
driver_opts:
type: none
o: bind
device: ${OPTS_DEVICE}
labels:
com.example.purpose: host-folder
YAML;
const DRIVER_OPTIONS_LONG_COMPOSE = <<<'YAML'
services:
web:
image: nginx:alpine
volumes:
- type: volume
source: opts
target: /data
volumes:
opts:
driver: local
driver_opts:
type: none
o: bind
device: ${OPTS_DEVICE}
labels:
com.example.purpose: host-folder
YAML;
const DRIVER_OPTIONS_NAMED_COMPOSE = <<<'YAML'
services:
web:
image: nginx:alpine
volumes:
- 'opts:/data'
volumes:
opts:
name: custom-name
driver: local
driver_opts:
type: none
o: bind
device: /srv/data
YAML;
const DRIVER_OPTIONS_NFS_MIXED_COMPOSE = <<<'YAML'
services:
web:
image: nginx:alpine
volumes:
- 'nfs-data:/nfs'
- 'app-data:/app'
volumes:
nfs-data:
driver: local
driver_opts:
type: nfs
o: addr=10.0.0.1,rw
device: ':/exports/data'
app-data: {}
YAML;
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
config(['constants.ssh.mux_enabled' => false]);
Bus::fake();
Queue::fake();
Process::fake(['*' => Process::result(output: '')]);
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
]);
$server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->environment = $project->environments()->first()
?? Environment::factory()->create(['project_id' => $project->id]);
});
function driverOptionsApplication(string $compose, ?string $parsingVersion = null): Application
{
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $compose,
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
]);
if ($parsingVersion !== null) {
$application->update(['compose_parsing_version' => $parsingVersion]);
}
return $application->fresh();
}
function driverOptionsService(string $compose, ?string $parsingVersion = null): Service
{
$service = Service::factory()->create([
'docker_compose_raw' => $compose,
'environment_id' => test()->environment->id,
'server_id' => test()->destination->server_id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
]);
if ($parsingVersion !== null) {
$service->update(['compose_parsing_version' => $parsingVersion]);
}
return $service->fresh();
}
function driverOptionsPreview(Application $application, int $pullRequestId = 42): ApplicationPreview
{
return ApplicationPreview::create([
'uuid' => 'driver-options-preview-'.$pullRequestId,
'application_id' => $application->id,
'pull_request_id' => $pullRequestId,
'pull_request_html_url' => 'https://github.com/example/repository/pull/'.$pullRequestId,
]);
}
/**
* @return array<string, mixed>
*/
function expectedDriverOptionsVolume(string $name): array
{
return [
'driver' => 'local',
'driver_opts' => [
'type' => 'none',
'o' => 'bind',
'device' => '${OPTS_DEVICE}',
],
'labels' => [
'com.example.purpose' => 'host-folder',
],
'name' => $name,
];
}
/**
* @param array<int, mixed> $volumes
* @return list<string>
*/
function driverOptionsVolumeSources(array $volumes): array
{
return collect($volumes)
->map(fn (mixed $volume): string => is_array($volume) ? (string) $volume['source'] : explode(':', $volume)[0])
->values()
->all();
}
describe('applicationParser', function () {
it('keeps driver, driver_opts and labels on the renamed volume', function (string $compose) {
$application = driverOptionsApplication($compose);
$uuid = $application->uuid;
$parsed = applicationParser($application)->toArray();
expect(driverOptionsVolumeSources($parsed['services']['web']['volumes']))->toBe(["{$uuid}_opts"])
->and($parsed['volumes']["{$uuid}_opts"])->toBe(expectedDriverOptionsVolume("{$uuid}_opts"));
})->with([
'short syntax' => [DRIVER_OPTIONS_SHORT_COMPOSE],
'long syntax' => [DRIVER_OPTIONS_LONG_COMPOSE],
]);
it('keeps driver options on the preview volume', function () {
$application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE);
$uuid = $application->uuid;
$preview = driverOptionsPreview($application);
$parsed = applicationParser($application, 42, $preview->id)->toArray();
$name = addPreviewDeploymentSuffix("{$uuid}_opts", 42);
expect(driverOptionsVolumeSources($parsed['services']['web-pr-42']['volumes']))->toBe([$name])
->and($parsed['volumes'][$name])->toBe(expectedDriverOptionsVolume($name));
});
it('replaces the name of the declaration with the renamed volume', function () {
$application = driverOptionsApplication(DRIVER_OPTIONS_NAMED_COMPOSE);
$uuid = $application->uuid;
$parsed = applicationParser($application)->toArray();
expect($parsed['volumes']["{$uuid}_opts"])->toBe([
'driver' => 'local',
'driver_opts' => ['type' => 'none', 'o' => 'bind', 'device' => '/srv/data'],
'name' => "{$uuid}_opts",
]);
});
it('keeps an nfs volume next to other volumes', function () {
$application = driverOptionsApplication(DRIVER_OPTIONS_NFS_MIXED_COMPOSE);
$uuid = $application->uuid;
$parsed = applicationParser($application)->toArray();
expect($parsed['services']['web']['volumes'])->toBe(['nfs-data:/nfs', "{$uuid}_app-data:/app"])
->and($parsed['volumes']['nfs-data']['driver_opts']['type'])->toBe('nfs');
});
});
describe('serviceParser', function () {
it('keeps driver, driver_opts and labels on the renamed volume', function (string $compose) {
$service = driverOptionsService($compose);
$uuid = $service->uuid;
$parsed = serviceParser($service)->toArray();
expect(driverOptionsVolumeSources($parsed['services']['web']['volumes']))->toBe(["{$uuid}_opts"])
->and($parsed['volumes']["{$uuid}_opts"])->toBe(expectedDriverOptionsVolume("{$uuid}_opts"));
})->with([
'short syntax' => [DRIVER_OPTIONS_SHORT_COMPOSE],
'long syntax' => [DRIVER_OPTIONS_LONG_COMPOSE],
]);
it('keeps an nfs volume next to other volumes', function () {
$service = driverOptionsService(DRIVER_OPTIONS_NFS_MIXED_COMPOSE);
$uuid = $service->uuid;
$parsed = serviceParser($service)->toArray();
expect($parsed['services']['web']['volumes'])->toBe(['nfs-data:/nfs', "{$uuid}_app-data:/app"])
->and($parsed['volumes']['nfs-data']['driver_opts']['type'])->toBe('nfs');
});
});
describe('legacy parsers', function () {
it('keeps driver options on the renamed volume of a legacy service', function (string $compose) {
$service = driverOptionsService($compose, '2');
$uuid = $service->uuid;
$parsed = parseDockerComposeFile($service)->toArray();
expect($parsed['volumes']["{$uuid}_opts"])->toBe(expectedDriverOptionsVolume("{$uuid}_opts"));
})->with([
'short syntax' => [DRIVER_OPTIONS_SHORT_COMPOSE],
'long syntax' => [DRIVER_OPTIONS_LONG_COMPOSE],
]);
it('keeps driver options on the renamed volume of a legacy application', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedName) {
$application = driverOptionsApplication($compose, $parsingVersion);
$uuid = $application->uuid;
$previewId = $pullRequestId === 0 ? null : driverOptionsPreview($application, $pullRequestId)->id;
$expectedName = str_replace('{uuid}', $uuid, $expectedName);
$parsed = parseDockerComposeFile($application, pull_request_id: $pullRequestId, preview_id: $previewId)->toArray();
expect($parsed['volumes'][$expectedName])->toBe(expectedDriverOptionsVolume($expectedName));
})->with([
'v1 short preview' => ['1', 42, DRIVER_OPTIONS_SHORT_COMPOSE, 'opts-pr-42'],
'v1 long preview' => ['1', 42, DRIVER_OPTIONS_LONG_COMPOSE, 'opts-pr-42'],
'v2 short' => ['2', 0, DRIVER_OPTIONS_SHORT_COMPOSE, '{uuid}-opts'],
'v2 short preview' => ['2', 42, DRIVER_OPTIONS_SHORT_COMPOSE, '{uuid}-opts-pr-42'],
'v2 long' => ['2', 0, DRIVER_OPTIONS_LONG_COMPOSE, '{uuid}-opts'],
'v2 long preview' => ['2', 42, DRIVER_OPTIONS_LONG_COMPOSE, '{uuid}-opts-pr-42'],
]);
it('keeps the volume of a legacy application as written', function () {
$application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE, '1');
$parsed = parseDockerComposeFile($application)->toArray();
expect($parsed['services']['web']['volumes'])->toBe(['opts:/data'])
->and($parsed['volumes']['opts']['driver_opts']['device'])->toBe('${OPTS_DEVICE}');
});
});
@@ -0,0 +1,212 @@
<?php
use App\Actions\Database\StartDatabaseImport;
use App\Livewire\Project\Database\ImportForm;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
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 App\Support\DatabaseImport\DatabaseImportException;
use App\Support\DatabaseImport\DatabaseImportSource;
use App\Support\RemoteProcessCommand;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('cache.default', 'array');
config()->set('constants.ssh.mux_enabled', false);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'disable_two_step_confirmation' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'user' => 'root']);
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'docker']
);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
$this->sqlite = create_standalone_sqlite($this->environment->id, $this->destination, ['sqlite_databases' => 'app.db,cache.db']);
$this->sqlite->update(['status' => 'running:healthy']);
$this->postgres = 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(),
]);
Queue::fake();
});
/**
* Starts an import from a server path and returns the restore script that runs in the container.
*/
function importRestoreScript(Model $database, DatabaseImportSource $source): string
{
Process::fake([
'*stat -c %s*' => Process::result('2048'),
'*od -An -tx1*' => Process::result(' 1f 8b 08 00 00 00'),
'*' => Process::result(''),
]);
$activity = app(StartDatabaseImport::class)->handle($database, $source, test()->team->id);
preg_match("/echo '([^']+)' \\| base64 -d/", (string) RemoteProcessCommand::read($activity), $matches);
return base64_decode($matches[1]);
}
class ImportFormRestoreOptionsTestComponent extends ImportForm
{
public function mount($database = null, $server = null): void
{
$this->resourceId = $database->id;
$this->resourceType = $database::class;
$this->serverId = $server->id;
$this->container = $database->uuid;
$this->resourceUuid = $database->uuid;
$this->resourceStatus = $database->status ?? '';
$this->resourceDbType = $database->type();
$this->loadAvailableS3Storages();
$this->initializeRestoreOptions();
}
public function render()
{
return view('livewire.project.database.import-form');
}
}
function restoreOptionsForm(Model $database): Testable
{
return Livewire::test(ImportFormRestoreOptionsTestComponent::class, ['database' => $database, 'server' => test()->server]);
}
test('restores a SQLite server backup into the file named in the backup', function () {
$script = importRestoreScript($this->sqlite, new DatabaseImportSource('server', path: '/backups/sqlite-backup-cache.db-1700000000.gz'));
expect($script)->toContain("sqlite3 -bail '/var/lib/sqlite/cache.db'")
->not->toContain('/var/lib/sqlite/app.db');
});
test('restores a SQLite backup into the selected database file', function () {
$script = importRestoreScript($this->sqlite, new DatabaseImportSource('server', path: '/backups/sqlite-backup-app.db-1.gz', sqliteDatabase: 'cache.db'));
expect($script)->toContain("sqlite3 -bail '/var/lib/sqlite/cache.db'");
});
test('rejects a SQLite restore target that is not one of the database files', function (string $target) {
Process::fake();
expect(fn () => app(StartDatabaseImport::class)->handle(
$this->sqlite,
new DatabaseImportSource('server', path: '/backups/backup.gz', sqliteDatabase: $target),
$this->team->id,
))->toThrow(DatabaseImportException::class, 'not one of the files of this database');
Queue::assertNothingPushed();
})->with(['unknown file' => ['other.db'], 'path traversal' => ['../app.db'], 'absolute path' => ['/etc/passwd']]);
test('passes the keep owners option to the PostgreSQL restore script', function (bool $keepOwners) {
$script = importRestoreScript($this->postgres, new DatabaseImportSource('server', path: '/backups/app.dump', keepOwners: $keepOwners));
expect(str_contains($script, '--no-owner --no-acl'))->toBe(! $keepOwners);
})->with(['default' => [false], 'keep owners' => [true]]);
test('the import form strips owners by default and keeps them when the option is checked', function () {
$activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]);
StartDatabaseImport::shouldRun()->once()->withArgs(fn ($resource, DatabaseImportSource $source) => $source->keepOwners === true)->andReturn($activity);
$form = restoreOptionsForm($this->postgres)
->assertSet('keepOwners', false)
->assertSee('Keep owners and privileges');
expect($form->get('restoreCommandText'))->toContain('--no-owner --no-acl');
$form->set('keepOwners', true);
expect($form->get('restoreCommandText'))->not->toContain('--no-owner');
$form->set('filename', 'backup.dump')->set('customLocation', '/backups/backup.dump')->call('runImport');
});
test('the import form lets the user pick the SQLite database file to restore into', function () {
$activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]);
StartDatabaseImport::shouldRun()->once()->withArgs(fn ($resource, DatabaseImportSource $source) => $source->sqliteDatabase === 'cache.db')->andReturn($activity);
$form = restoreOptionsForm($this->sqlite)
->assertSet('sqliteDatabase', 'app.db')
->assertSee('Restore into');
expect($form->get('restoreCommandText'))->toContain("'/var/lib/sqlite/app.db'");
$form->call('selectSqliteDatabaseFor', 'sqlite-backup-cache.db-1700000000.gz')
->assertSet('sqliteDatabase', 'cache.db');
expect($form->get('restoreCommandText'))->toContain("'/var/lib/sqlite/cache.db'");
$form->set('filename', 'backup.gz')->set('customLocation', '/backups/backup.gz')->call('runImport');
});
test('the import form does not accept a SQLite target from the client that is not a database file', function () {
$form = restoreOptionsForm($this->sqlite)
->set('sqliteDatabase', '../../etc/passwd')
->assertSet('sqliteDatabase', 'app.db');
expect($form->get('restoreCommandText'))->not->toContain('passwd');
});
test('the API passes keep owners and the SQLite target to the import', function () {
// API requests authenticate with the token, not with the session user from beforeEach.
app('auth')->forgetGuards();
$headers = ['Authorization' => 'Bearer '.$this->user->createToken('imports', ['*'])->plainTextToken];
$activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]);
$action = Mockery::mock(StartDatabaseImport::class);
$action->shouldReceive('handle')->once()->withArgs(fn ($resource, DatabaseImportSource $source) => $resource->is($this->postgres) && $source->keepOwners === true)->andReturn($activity);
$action->shouldReceive('handle')->once()->withArgs(fn ($resource, DatabaseImportSource $source) => $resource->is($this->sqlite) && $source->sqliteDatabase === 'cache.db')->andReturn($activity);
app()->instance(StartDatabaseImport::class, $action);
$this->withHeaders($headers)
->postJson("/api/v1/databases/{$this->postgres->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.dump', 'keep_owners' => true])
->assertAccepted();
$this->withHeaders($headers)
->postJson("/api/v1/databases/{$this->sqlite->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.gz', 'sqlite_database' => 'cache.db'])
->assertAccepted();
$this->withHeaders($headers)
->postJson("/api/v1/databases/{$this->postgres->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.dump', 'keep_owners' => 'yes'])
->assertUnprocessable()
->assertJsonValidationErrors('keep_owners');
});
test('the API rejects a SQLite target that is not a database file', function () {
Process::fake();
// API requests authenticate with the token, not with the session user from beforeEach.
app('auth')->forgetGuards();
$headers = ['Authorization' => 'Bearer '.$this->user->createToken('imports', ['*'])->plainTextToken];
$this->withHeaders($headers)
->postJson("/api/v1/databases/{$this->sqlite->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.gz', 'sqlite_database' => '../app.db'])
->assertUnprocessable();
Queue::assertNothingPushed();
});
+125
View File
@@ -143,6 +143,29 @@ test('queued dns configuration creates the record and broadcasts completion', fu
&& $event->hostname === 'app.example.com');
});
test('queued dns configuration refuses a private record address without calling the provider', function (string $address) {
Event::fake([DnsRecordConfigurationFinished::class]);
$token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
$zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
Http::fake();
$job = new ConfigureDnsRecordJob(
teamId: $token->team_id,
zoneId: $zone->id,
resourceType: null,
resourceId: null,
hostname: 'app.example.com',
content: $address,
);
$job->handle(app(CloudflareDnsProvider::class));
Http::assertNothingSent();
expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeFalse();
Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful === false
&& $event->hostname === 'app.example.com'
&& $event->message === 'The server has no public IP address; add the DNS record manually.');
})->with(['10.0.0.5', '100.100.1.1', '127.0.0.1', '169.254.1.1', 'fd00::5']);
test('missing zone throws from handle without broadcasting completion', function () {
Event::fake([DnsRecordConfigurationFinished::class]);
$token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
@@ -443,6 +466,72 @@ describe('domain DNS configuration after add', function () {
&& (string) $job->resourceId === (string) $webApp->id);
Queue::assertNotPushed(CheckDomainDnsJob::class);
});
test('private server addresses are never queued as dns records', function (string $address) {
$this->server->update(['ip' => $address]);
$application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Private DNS App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => null,
'redirect' => 'both',
'build_pack' => 'nixpacks',
]);
$application->settings()->update(['is_container_label_readonly_enabled' => true]);
Livewire::test(Domains::class, ['application' => $application->fresh()])
->set('newDomain', 'https://app.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('warning', 'The server has no public IP address; add the DNS record manually.')
->assertNotDispatched('info')
->assertSet('showDnsProviderModal', false);
expect(explode(',', (string) $application->fresh()->fqdn))->toContain('https://app.example.com');
Queue::assertNotPushed(ConfigureDnsRecordJob::class);
})->with([
'rfc1918 10/8' => '10.0.0.5',
'rfc1918 172.16/12' => '172.16.0.5',
'rfc1918 192.168/16' => '192.168.1.5',
'cgnat' => '100.64.0.5',
'tailscale' => '100.100.1.1',
'loopback' => '127.0.0.1',
'link-local' => '169.254.1.1',
'ipv6 unique local' => 'fd00::5',
'ipv6 link-local' => 'fe80::5',
'ipv6 loopback' => '::1',
]);
test('service domains never queue a dns record for a private server address', function () {
$this->server->update(['ip' => '100.100.1.1']);
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
$webApp = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'service_id' => $service->id,
'name' => 'web',
'human_name' => 'Web',
'image' => 'nginx:alpine',
'fqdn' => null,
]);
Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $webApp->id)
->set('newDomain', 'https://web.example.com')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('warning', 'The server has no public IP address; add the DNS record manually.')
->assertSet('showDnsProviderModal', false);
Queue::assertNotPushed(ConfigureDnsRecordJob::class);
});
});
test('dns provider action controls declare update authorization against the resource', function () {
@@ -659,6 +748,42 @@ test('dns records point only at an address of the resource server in a zone of t
'hostname outside the zone' => ['www.other.org', null],
]);
test('a dns record is never created for a private server address', function (string $address, ?string $content) {
['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication();
Server::query()->whereKey($application->destination->server_id)->update(['ip' => $address]);
Http::fake();
Livewire::test(Domains::class, ['application' => $application->fresh()])
->call('createManagedDnsRecord', 'app.example.com', $zone->id, $content)
->assertDispatched('error', 'The server has no public IP address; add the DNS record manually.');
Http::assertNothingSent();
expect(ManagedDnsRecord::query()->exists())->toBeFalse();
})->with([
'private ipv4' => ['10.0.0.5', null],
'private ipv4 requested explicitly' => ['10.0.0.5', '10.0.0.5'],
'tailscale cgnat' => ['100.100.1.1', '100.100.1.1'],
'ipv6 unique local' => ['fd00::5', 'fd00::5'],
]);
test('replacing a conflicting dns record is refused once the server has no public address', function () {
['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication();
Http::fake([
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([
'success' => true,
'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']],
]),
]);
$component = Livewire::test(Domains::class, ['application' => $application->fresh()])
->call('createManagedDnsRecord', 'app.example.com', $zone->id);
Server::query()->whereKey($application->destination->server_id)->update(['ip' => '192.168.1.5']);
$component->call('refreshDomains')
->call('replaceManagedDnsRecord', 'app.example.com', $zone->id)
->assertDispatched('error', 'The server has no public IP address; add the DNS record manually.');
Http::assertNotSent(fn ($request) => $request->method() === 'PATCH');
});
/**
* @return array{application: Application, zone: DnsProviderZone}
*/
+26
View File
@@ -48,3 +48,29 @@ it('formats a BIND-compatible zone snippet for copy all', function () {
"app.example.com. IN AAAA 2001:db8::1\n"
);
});
it('treats only publicly routable addresses as public dns record targets', function (string $address, bool $public) {
expect(DnsRecordHints::isPublicAddress($address))->toBe($public);
})->with([
'public ipv4' => ['8.8.8.8', true],
'public ipv6' => ['2606:4700::1111', true],
'rfc1918 10/8' => ['10.0.0.5', false],
'rfc1918 172.16/12' => ['172.20.1.5', false],
'rfc1918 192.168/16' => ['192.168.1.5', false],
'cgnat start' => ['100.64.0.1', false],
'tailscale cgnat' => ['100.100.100.100', false],
'cgnat end' => ['100.127.255.254', false],
'just outside cgnat' => ['100.128.0.1', true],
'loopback' => ['127.0.0.1', false],
'link-local' => ['169.254.10.1', false],
'unspecified' => ['0.0.0.0', false],
'multicast' => ['224.0.0.1', false],
'ipv6 unique local' => ['fd00::5', false],
'ipv6 link-local' => ['fe80::5', false],
'ipv6 loopback' => ['::1', false],
'ipv6 multicast' => ['ff02::1', false],
'ipv4-mapped private' => ['::ffff:10.0.0.5', false],
'ipv4-mapped cgnat' => ['::ffff:100.64.0.1', false],
'not an ip' => ['app.example.com', false],
'empty' => ['', false],
]);
@@ -0,0 +1,105 @@
<?php
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Enums\ProcessStatus;
use App\Events\DatabaseStatusChanged;
use App\Events\ServiceStatusChanged;
use App\Jobs\CoolifyTask;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('cache.default', 'array');
config()->set('constants.ssh.mux_enabled', false);
InstanceSettings::forceCreate(['id' => 0]);
// The user id must differ from the team id, and another team must own the user id,
// so a team event that is sent to the user id would reach the wrong team.
User::factory()->count(2)->create();
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
while (Team::query()->max('id') < $this->user->id) {
Team::factory()->create();
}
$this->otherTeam = Team::query()->findOrFail($this->user->id);
expect($this->user->id)->not->toBe($this->team->id);
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'user' => 'root',
]);
$this->activity = activity()
->causedBy($this->user)
->withProperties([
'server_uuid' => $this->server->uuid,
'command' => 'echo test',
'type' => 'inline',
'status' => ProcessStatus::QUEUED->value,
'team_id' => $this->server->team_id,
])
->event('inline')
->log('[]');
});
it('broadcasts a team event of a finished remote process to the team of the server', function () {
Event::fake([ServiceStatusChanged::class]);
Process::fake(fn () => Process::result(output: 'ok'));
(new RunRemoteProcess(Activity::query()->findOrFail($this->activity->id), call_event_on_finish: 'ServiceStatusChanged'))();
Event::assertDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->team->id
&& $event->broadcastOn()[0]->name === "private-team.{$this->team->id}");
Event::assertNotDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->otherTeam->id);
});
it('broadcasts a team event of a failed coolify task to the team of the server', function () {
Event::fake([ServiceStatusChanged::class]);
(new CoolifyTask(Activity::query()->findOrFail($this->activity->id), ignore_errors: false, call_event_on_finish: 'ServiceStatusChanged', call_event_data: null))
->failed(new RuntimeException('failed'));
Event::assertDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->team->id);
Event::assertNotDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->otherTeam->id);
});
it('still sends a user event of a finished remote process to the user who started it', function () {
Event::fake([DatabaseStatusChanged::class]);
Process::fake(fn () => Process::result(output: 'ok'));
(new RunRemoteProcess(Activity::query()->findOrFail($this->activity->id), call_event_on_finish: 'DatabaseStatusChanged'))();
Event::assertDispatched(DatabaseStatusChanged::class, fn (DatabaseStatusChanged $event) => $event->userId === $this->user->id);
});
it('keeps the explicit event data of a remote process', function () {
Event::fake([ServiceStatusChanged::class]);
Process::fake(fn () => Process::result(output: 'ok'));
(new RunRemoteProcess(Activity::query()->findOrFail($this->activity->id), call_event_on_finish: 'ServiceStatusChanged', call_event_data: $this->otherTeam->id))();
Event::assertDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->otherTeam->id);
});
it('does not broadcast a team event when the activity has no team', function () {
Event::fake([ServiceStatusChanged::class]);
Process::fake(fn () => Process::result(output: 'ok'));
$activity = Activity::query()->findOrFail($this->activity->id);
$activity->properties = $activity->properties->except('team_id');
$activity->save();
(new RunRemoteProcess($activity, call_event_on_finish: 'ServiceStatusChanged'))();
Event::assertNotDispatched(ServiceStatusChanged::class, fn (ServiceStatusChanged $event) => $event->teamId === $this->otherTeam->id);
});
@@ -6,6 +6,7 @@ use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Team;
use App\Notifications\Database\BackupMissing;
use Illuminate\Contracts\Notifications\Dispatcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification;
@@ -125,3 +126,12 @@ it('waits to mark an incident sent until a notification channel is enabled', fun
Notification::assertSentTo($team, BackupMissing::class);
});
it('releases the missing backup claim when sending the notification throws', function () {
$team = teamWithBackupFailureNotifications();
$backup = missingBackupSchedule($team);
$this->mock(Dispatcher::class, fn ($mock) => $mock->shouldReceive('send')->once()->andThrow(new RuntimeException('SMTP is down')));
expect(fn () => (new CheckMissingDatabaseBackupsJob)->handle())->toThrow(RuntimeException::class, 'SMTP is down')
->and(NotificationThrottle::wasSent($backup, BackupMissing::class))->toBeFalse();
});
@@ -8,6 +8,7 @@ use App\Models\User;
use App\Notifications\Server\HighDiskUsage;
use App\Notifications\Server\Unreachable;
use Carbon\Carbon;
use Illuminate\Contracts\Notifications\Dispatcher as NotificationDispatcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@@ -80,3 +81,59 @@ it('keeps unreachable_notification_sent in the server API response', function ()
->assertOk()
->assertJsonPath('unreachable_notification_sent', true);
});
function throttledDiskUsageNotification(Server $server): HighDiskUsage
{
return new HighDiskUsage($server, 95, 80);
}
it('releases the throttle claim when sending the notification throws', function () {
$sends = 0;
$this->mock(NotificationDispatcher::class, function ($mock) use (&$sends) {
$mock->shouldReceive('send')->twice()->andReturnUsing(function () use (&$sends) {
$sends++;
if ($sends === 1) {
throw new RuntimeException('SMTP is down');
}
});
});
expect(fn () => $this->team->notify(throttledDiskUsageNotification($this->server)))
->toThrow(RuntimeException::class, 'SMTP is down')
->and(NotificationThrottle::wasSent($this->server, HighDiskUsage::class))->toBeFalse();
$this->team->notify(throttledDiskUsageNotification($this->server));
expect($sends)->toBe(2)
->and(NotificationThrottle::wasSent($this->server, HighDiskUsage::class))->toBeTrue();
});
it('throttles the notification after it was sent successfully', function () {
$this->mock(NotificationDispatcher::class, fn ($mock) => $mock->shouldReceive('send')->once());
$this->team->notify(throttledDiskUsageNotification($this->server));
$this->team->notify(throttledDiskUsageNotification($this->server));
expect(NotificationThrottle::wasSent($this->server, HighDiskUsage::class))->toBeTrue();
});
it('does not send a notification that another worker is already sending', function () {
$notification = throttledDiskUsageNotification($this->server);
$concurrentClaim = null;
$this->mock(NotificationDispatcher::class, function ($mock) use ($notification, &$concurrentClaim) {
$mock->shouldReceive('send')->once()->andReturnUsing(function () use ($notification, &$concurrentClaim) {
$concurrentClaim = NotificationThrottle::claimFor($notification);
});
});
$this->team->notify($notification);
expect($concurrentClaim)->toBeFalse();
});
it('releases the unreachable claim when sending the unreachable notification throws', function () {
$this->mock(NotificationDispatcher::class, fn ($mock) => $mock->shouldReceive('send')->once()->andThrow(new RuntimeException('Discord is down')));
expect(fn () => $this->server->sendUnreachableNotification())->toThrow(RuntimeException::class)
->and(NotificationThrottle::wasSent($this->server, Unreachable::class))->toBeFalse();
});
@@ -0,0 +1,134 @@
<?php
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Once;
use Laravel\Socialite\Facades\Socialite;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
config()->set('app.maintenance.driver', 'file');
InstanceSettings::forceCreate([
'id' => 0,
'is_registration_enabled' => false,
]);
Once::flush();
OauthSetting::create([
'provider' => 'google',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
'tenant' => 'example.com',
'enabled' => true,
]);
});
/**
* @return array{0: User, 1: Team, 2: Team}
*/
function oauthUserWithTwoTeams(): array
{
$user = User::factory()->create(['email' => 'multi-team@example.com']);
$personal = $user->teams->first();
$personal->update(['show_boarding' => false]);
$second = Team::factory()->create(['show_boarding' => false]);
$user->teams()->attach($second, ['role' => 'owner']);
OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'google',
'issuer' => 'google',
'provider_user_id' => 'multi-team-google-id',
'email' => $user->email,
]);
return [$user->refresh(), $personal, $second];
}
function fakeGoogleLoginFor(User $user, string $providerUserId): void
{
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('with')->andReturnSelf();
$provider->shouldReceive('user')->andReturn((object) [
'email' => $user->email,
'name' => $user->name,
'id' => $providerUserId,
'user' => ['email_verified' => true, 'hd' => 'example.com'],
]);
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
}
it('restores the stored last team after an OAuth login', function () {
[$user, , $second] = oauthUserWithTwoTeams();
$user->forceFill(['current_team_id' => $second->id])->save();
fakeGoogleLoginFor($user, 'multi-team-google-id');
$this->get(route('auth.callback', 'google'))->assertRedirect('/');
$this->assertAuthenticatedAs($user);
expect(data_get(session('currentTeam'), 'id'))->toBe($second->id);
Cache::flush();
$this->get('/')->assertSuccessful();
expect($user->fresh()->current_team_id)->toBe($second->id);
});
it('sends a multi-team OAuth user without a stored team to the team selection screen', function () {
[$user] = oauthUserWithTwoTeams();
expect($user->current_team_id)->toBeNull();
fakeGoogleLoginFor($user, 'multi-team-google-id');
$this->get(route('auth.callback', 'google'))->assertRedirect('/');
$this->assertAuthenticatedAs($user);
expect(session('currentTeam'))->toBeNull();
$this->get('/')->assertRedirect(route('team.select'));
expect($user->fresh()->current_team_id)->toBeNull();
});
it('does not overwrite a stored last team with the first team after an OAuth login', function () {
[$user, $personal, $second] = oauthUserWithTwoTeams();
$user->forceFill(['current_team_id' => $second->id])->save();
fakeGoogleLoginFor($user, 'multi-team-google-id');
$this->get(route('auth.callback', 'google'));
Cache::flush();
$this->get('/');
expect($user->fresh()->current_team_id)->not->toBe($personal->id)
->and($user->fresh()->current_team_id)->toBe($second->id);
});
it('activates the only team of a single-team OAuth user', function () {
$user = User::factory()->create(['email' => 'single-team@example.com']);
$team = $user->teams->first();
$team->update(['show_boarding' => false]);
OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'google',
'issuer' => 'google',
'provider_user_id' => 'single-team-google-id',
'email' => $user->email,
]);
fakeGoogleLoginFor($user, 'single-team-google-id');
$this->get(route('auth.callback', 'google'))->assertRedirect('/');
expect(data_get(session('currentTeam'), 'id'))->toBe($team->id);
});
@@ -0,0 +1,369 @@
<?php
use App\Auth\Oidc\OidcUser;
use App\Http\Controllers\OauthController;
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Once;
use Illuminate\Support\ViewErrorBag;
use Laravel\Socialite\Facades\Socialite;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
config()->set('app.maintenance.driver', 'file');
InstanceSettings::forceCreate([
'id' => 0,
'is_registration_enabled' => false,
]);
Once::flush();
OauthSetting::create([
'provider' => 'google',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
'tenant' => 'example.com',
'enabled' => true,
]);
});
function linkGoogleIdentity(User $user, string $providerUserId): OauthIdentity
{
return OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'google',
'issuer' => 'google',
'provider_user_id' => $providerUserId,
'email' => $user->email,
]);
}
function fakeGoogleConfirmation(string $providerUserId, string $email): void
{
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('with')->andReturnSelf();
$provider->shouldReceive('user')->andReturn((object) [
'email' => $email,
'name' => 'Provider User',
'id' => $providerUserId,
'user' => ['email_verified' => true, 'hd' => 'example.com'],
]);
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
}
function pendingConfirmationFor(User $user, string $provider = 'google', string $returnTo = 'http://localhost/servers'): array
{
return [OauthController::PASSWORD_CONFIRMATION_SESSION_KEY => [
'user_id' => $user->id,
'provider' => $provider,
'return_to' => $returnTo,
]];
}
describe('confirmation requirement', function () {
it('requires password confirmation for a user with a password and a linked identity', function () {
$user = User::factory()->create(['password' => Hash::make('secret-password')]);
linkGoogleIdentity($user, 'google-id');
$this->actingAs($user);
expect($user->requiresPasswordConfirmation())->toBeTrue()
->and(shouldSkipPasswordConfirmation())->toBeFalse()
->and(verifyPasswordConfirmation('wrong-password'))->toBeFalse()
->and(verifyPasswordConfirmation('secret-password'))->toBeTrue();
});
it('requires confirmation for a user with a linked identity and no password', function () {
$user = User::factory()->create();
$user->forceFill(['password' => null])->save();
linkGoogleIdentity($user, 'google-id');
$this->actingAs($user);
expect($user->requiresPasswordConfirmation())->toBeTrue()
->and(shouldSkipPasswordConfirmation())->toBeFalse()
->and(verifyPasswordConfirmation(''))->toBeFalse();
});
it('keeps skipping confirmation for a user without a password and without an identity', function () {
$user = User::factory()->create();
$user->forceFill(['password' => null])->save();
$this->actingAs($user);
expect($user->requiresPasswordConfirmation())->toBeFalse()
->and(shouldSkipPasswordConfirmation())->toBeTrue();
});
it('accepts a recent confirmation from the session', function () {
$user = User::factory()->create();
linkGoogleIdentity($user, 'google-id');
$this->actingAs($user);
session()->put('auth.password_confirmed_at', now()->unix());
expect(shouldSkipPasswordConfirmation())->toBeTrue()
->and(verifyPasswordConfirmation(''))->toBeTrue();
});
it('rejects an expired confirmation from the session', function () {
$user = User::factory()->create();
linkGoogleIdentity($user, 'google-id');
$this->actingAs($user);
session()->put('auth.password_confirmed_at', now()->subSeconds(config('auth.password_timeout') + 1)->unix());
expect(shouldSkipPasswordConfirmation())->toBeFalse()
->and(verifyPasswordConfirmation(''))->toBeFalse();
});
});
describe('confirmation redirect', function () {
it('requires an authenticated user', function () {
$this->get(route('auth.confirm', 'google'))->assertRedirect(route('login'));
});
it('rejects a user without an identity for the provider', function () {
$user = User::factory()->create();
$this->actingAs($user)->get(route('auth.confirm', 'google'))->assertForbidden();
expect(session(OauthController::PASSWORD_CONFIRMATION_SESSION_KEY))->toBeNull();
});
it('rejects a disabled provider', function () {
$user = User::factory()->create();
linkGoogleIdentity($user, 'google-id');
OauthSetting::where('provider', 'google')->update(['enabled' => false]);
$this->actingAs($user)->get(route('auth.confirm', 'google'))->assertForbidden();
});
it('redirects to the provider with re-authentication parameters and remembers the pending confirmation', function () {
$user = User::factory()->create();
linkGoogleIdentity($user, 'google-id');
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
$provider->shouldReceive('with')->once()->with([
'hd' => 'example.com',
'prompt' => 'select_account',
'max_age' => 0,
])->andReturnSelf();
$provider->shouldReceive('redirect')->once()->andReturn(redirect('https://accounts.google.com/o/oauth2/auth'));
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
$this->actingAs($user)
->from('/servers')
->get(route('auth.confirm', 'google'))
->assertRedirect('https://accounts.google.com/o/oauth2/auth');
expect(session(OauthController::PASSWORD_CONFIRMATION_SESSION_KEY))->toBe([
'user_id' => $user->id,
'provider' => 'google',
'return_to' => url('/servers'),
]);
});
it('does not remember an external return URL', function () {
$user = User::factory()->create();
linkGoogleIdentity($user, 'google-id');
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('with')->andReturnSelf();
$provider->shouldReceive('redirect')->andReturn(redirect('https://accounts.google.com/o/oauth2/auth'));
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
$this->actingAs($user)
->withHeader('referer', 'https://evil.example.com/phish')
->get(route('auth.confirm', 'google'));
expect(session(OauthController::PASSWORD_CONFIRMATION_SESSION_KEY.'.return_to'))->toBe(url('/'));
});
});
describe('confirmation callback', function () {
it('marks the session as password confirmed when the linked identity of the same user returns', function () {
$user = User::factory()->create(['email' => 'owner@example.com']);
$identity = linkGoogleIdentity($user, 'owner-google-id');
fakeGoogleConfirmation('owner-google-id', 'owner@example.com');
$this->actingAs($user)
->withSession(pendingConfirmationFor($user))
->get(route('auth.callback', 'google'))
->assertRedirect('http://localhost/servers');
$this->assertAuthenticatedAs($user);
expect(session('auth.password_confirmed_at'))->toBeInt()
->and(session(OauthController::PASSWORD_CONFIRMATION_SESSION_KEY))->toBeNull()
->and(shouldSkipPasswordConfirmation())->toBeTrue()
->and($identity->fresh()->user_id)->toBe($user->id);
});
it('rejects the identity of another user and does not switch users', function () {
$user = User::factory()->create(['email' => 'owner@example.com']);
linkGoogleIdentity($user, 'owner-google-id');
$otherUser = User::factory()->create(['email' => 'other@example.com']);
linkGoogleIdentity($otherUser, 'other-google-id');
fakeGoogleConfirmation('other-google-id', 'other@example.com');
$this->actingAs($user)
->withSession(pendingConfirmationFor($user))
->get(route('auth.callback', 'google'))
->assertForbidden();
$this->assertAuthenticatedAs($user);
expect(session('auth.password_confirmed_at'))->toBeNull();
});
it('rejects an unknown identity without linking or creating a user', function () {
InstanceSettings::query()->update(['is_registration_enabled' => true]);
$user = User::factory()->create(['email' => 'owner@example.com']);
linkGoogleIdentity($user, 'owner-google-id');
fakeGoogleConfirmation('unknown-google-id', 'owner@example.com');
$this->actingAs($user)
->withSession(pendingConfirmationFor($user))
->get(route('auth.callback', 'google'))
->assertForbidden();
$this->assertAuthenticatedAs($user);
expect(session('auth.password_confirmed_at'))->toBeNull()
->and(OauthIdentity::where('provider_user_id', 'unknown-google-id')->exists())->toBeFalse()
->and(User::count())->toBe(1);
});
it('rejects a pending confirmation that belongs to another user', function () {
$user = User::factory()->create(['email' => 'owner@example.com']);
linkGoogleIdentity($user, 'owner-google-id');
$otherUser = User::factory()->create(['email' => 'other@example.com']);
linkGoogleIdentity($otherUser, 'other-google-id');
fakeGoogleConfirmation('other-google-id', 'other@example.com');
$this->actingAs($user)
->withSession(pendingConfirmationFor($otherUser))
->get(route('auth.callback', 'google'))
->assertForbidden();
$this->assertAuthenticatedAs($user);
expect(session('auth.password_confirmed_at'))->toBeNull();
});
it('rejects a pending confirmation for a different provider', function () {
OauthSetting::create([
'provider' => 'github',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'enabled' => true,
]);
$user = User::factory()->create(['email' => 'owner@example.com']);
linkGoogleIdentity($user, 'owner-google-id');
fakeGoogleConfirmation('owner-google-id', 'owner@example.com');
$this->actingAs($user)
->withSession(pendingConfirmationFor($user, 'github'))
->get(route('auth.callback', 'google'))
->assertForbidden();
expect(session('auth.password_confirmed_at'))->toBeNull();
});
it('confirms an OIDC identity by issuer and subject', function () {
OauthSetting::create([
'provider' => 'oidc',
'enabled' => true,
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'base_url' => 'https://idp.example.com',
'redirect_uri' => 'https://coolify.example.com/auth/oidc/callback',
]);
$user = User::factory()->create(['email' => 'oidc@example.com']);
OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'oidc-subject',
'email' => $user->email,
]);
$oidcUser = (new OidcUser)->setRaw([
'iss' => 'https://idp.example.com',
'sub' => 'oidc-subject',
'email' => 'oidc@example.com',
'email_verified' => true,
])->map(['id' => 'oidc-subject', 'email' => 'oidc@example.com', 'name' => 'OIDC User']);
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('user')->andReturn($oidcUser);
Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
$this->actingAs($user)
->withSession(pendingConfirmationFor($user, 'oidc'))
->get(route('auth.callback', 'oidc'))
->assertRedirect('http://localhost/servers');
expect(session('auth.password_confirmed_at'))->toBeInt();
});
it('keeps the normal login flow when no confirmation is pending', function () {
$user = User::factory()->create(['email' => 'owner@example.com']);
linkGoogleIdentity($user, 'owner-google-id');
fakeGoogleConfirmation('owner-google-id', 'owner@example.com');
$this->get(route('auth.callback', 'google'))->assertRedirect('/');
$this->assertAuthenticatedAs($user);
expect(session('auth.password_confirmed_at'))->toBeNull();
});
});
describe('confirmation modal', function () {
function renderConfirmationModal(): string
{
view()->share('errors', new ViewErrorBag);
return Blade::render('<x-modal-confirmation title="Delete" buttonTitle="Delete" submitAction="delete" :actions="[]" confirmationText="x" />');
}
it('offers OAuth confirmation next to the password for a user with a linked identity', function () {
$user = User::factory()->create();
linkGoogleIdentity($user, 'google-id');
$this->actingAs($user);
$html = renderConfirmationModal();
expect($html)->toContain('Confirm with Google')
->and($html)->toContain(route('auth.confirm', 'google'))
->and($html)->toContain('type="password"');
});
it('offers only OAuth confirmation for a user without a password', function () {
$user = User::factory()->create();
$user->forceFill(['password' => null])->save();
linkGoogleIdentity($user, 'google-id');
$this->actingAs($user);
$html = renderConfirmationModal();
expect($html)->toContain('Confirm with Google')
->and($html)->not->toContain('type="password"');
});
it('does not offer OAuth confirmation for a user without an identity', function () {
$user = User::factory()->create();
$this->actingAs($user);
$html = renderConfirmationModal();
expect($html)->not->toContain('Confirm with Google')
->and($html)->toContain('type="password"');
});
});
@@ -127,11 +127,12 @@ it('updates the server role through the API', function (string $role) {
'deployment and build' => ['both'],
]);
it('rejects the legacy build server API field', function () {
it('accepts the deprecated build server API field as a server role', function () {
patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [
'is_build_server' => true,
])->assertUnprocessable()
->assertJsonValidationErrors('is_build_server');
])->assertCreated();
expect($this->server->settings->fresh()->server_role->value)->toBe('build');
});
it('requires a dedicated build server before selecting deployment only', function () {
@@ -16,6 +16,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
@@ -284,3 +285,44 @@ it('blocks deleting the sqlite volume or database while an application is connec
expect($databaseVolume->fresh())->not->toBeNull()
->and(StandaloneSqlite::find($this->sqlite->id))->not->toBeNull();
});
it('does not connect a cloned application to the sqlite database', function () {
Process::fake();
$this->application->update(['redirect' => 'both']);
$this->application->refresh();
$originalVolume = connectSqliteVolume($this->application, $this->sqlite);
$clone = clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$clonedVolume = $clone->persistentStorages()->sole();
expect($clonedVolume->standalone_sqlite_id)->toBeNull()
->and($clonedVolume->name)->toBe($clone->uuid.'-sqlite-data-'.$this->sqlite->uuid)
->and($clonedVolume->mount_path)->toBe(StandaloneSqlite::DATA_DIRECTORY)
->and($clonedVolume->isSharedWithAnotherResource())->toBeFalse()
->and($this->sqlite->connectedVolumes()->pluck('id')->all())->toBe([$originalVolume->id])
->and($this->sqlite->connectedApplicationNames()->all())->toBe([$this->application->name])
->and($originalVolume->fresh()->standalone_sqlite_id)->toBe($this->sqlite->id);
});
it('allows deleting the sqlite database once the original application is gone, even if it was cloned', function () {
Process::fake();
Queue::fake();
$this->application->update(['redirect' => 'both']);
$this->application->refresh();
$originalVolume = connectSqliteVolume($this->application, $this->sqlite);
clone_application($this->application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$originalVolume->delete();
expect($this->sqlite->fresh()->hasConnectedApplications())->toBeFalse();
Livewire::test(Danger::class, ['resource' => $this->sqlite])
->call('delete', 'password')
->assertNotDispatched('error', fn (string $name, array $params) => str_contains($params[0] ?? '', 'is mounted by'));
});
+34
View File
@@ -86,6 +86,40 @@ it('restores a gzipped backup with .restore into the first database file', funct
->toEndWith("stream > \"\$backup.db\" || fail 'The backup cannot be read. Nothing was changed.'\n[ \"\$(head -c 15 \"\$backup.db\")\" = 'SQLite format 3' ] || { rm -f \"\$backup.db\"; fail 'The backup is not a SQLite database. Nothing was changed.'; }\nsqlite3 -bail '/var/lib/sqlite/app.db' '.timeout 10000' \".restore \$backup.db\"; status=\$?; rm -f \"\$backup.db\"; exit \$status");
});
it('lists its own database files and resolves only those as restore targets', function () {
expect($this->database->databaseFiles())->toBe(['app.db', 'jobs.db'])
->and($this->database->databaseFilePath('jobs.db'))->toBe('/var/lib/sqlite/jobs.db')
->and($this->database->databaseFilePath())->toBe('/var/lib/sqlite/app.db');
});
it('rejects restore targets that are not one of its database files', function (string $file) {
expect(fn () => $this->database->databaseFilePath($file))->toThrow(InvalidArgumentException::class);
})->with([
'unknown file' => ['cache.db'],
'path traversal' => ['../app.db'],
'absolute path' => ['/etc/passwd'],
'empty' => [''],
]);
it('defaults the restore target to the file named in the backup, else the first file', function (?string $backupName, string $expected) {
expect($this->database->defaultRestoreFile($backupName))->toBe($expected);
})->with([
'coolify backup of the second file' => ['sqlite-backup-jobs.db-1700000000.gz', 'jobs.db'],
'server path of the second file' => ['/backups/jobs.db.gz', 'jobs.db'],
'plain second file' => ['jobs.db', 'jobs.db'],
'coolify backup of the first file' => ['sqlite-backup-app.db-1700000000.gz', 'app.db'],
'unrelated name' => ['backup.gz', 'app.db'],
'no name' => [null, 'app.db'],
]);
it('restores into the selected database file instead of the first one', function () {
$command = app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->database, '/tmp/restore_1', false, sqliteDatabase: 'jobs.db');
expect($command)->toContain("sqlite3 -bail '/var/lib/sqlite/jobs.db'")
->not->toContain('/var/lib/sqlite/app.db')
->toContain("[ \"\$(head -c 15 \"\$backup.db\")\" = 'SQLite format 3' ]");
});
it('normalises the file list when saved from the general page', function () {
Livewire::actingAs($this->user)
->test(General::class, ['database' => $this->database])
@@ -136,6 +136,32 @@ test('replaces existing PostgreSQL objects when requested', function () {
->toContain('--exit-on-error');
});
test('restores PostgreSQL archives without owners and privileges by default', function (bool $replaceExisting) {
$command = (new DatabaseImportCommandBuilder)->buildRestoreCommand(importResource(StandalonePostgresql::class), '/tmp/backup.dump', false, $replaceExisting);
// Dumps from other hosts (for example RDS) reference roles that do not exist here, and
// --single-transaction would roll back the whole restore on the first ALTER OWNER.
expect($command)->toContain('pg_restore --exit-on-error --single-transaction --no-owner --no-acl');
})->with(['keep existing objects' => [false], 'replace existing objects' => [true]]);
test('keeps PostgreSQL owners and privileges when requested', function () {
$command = (new DatabaseImportCommandBuilder)->buildRestoreCommand(importResource(StandalonePostgresql::class), '/tmp/backup.dump', false, false, keepOwners: true);
expect($command)->toContain('pg_restore --exit-on-error --single-transaction -U')
->not->toContain('--no-owner')
->not->toContain('--no-acl');
});
test('restores SQLite backups into the selected database file', function () {
$resource = Mockery::mock(StandaloneSqlite::class);
$resource->shouldReceive('getMorphClass')->andReturn(StandaloneSqlite::class);
$resource->shouldReceive('databaseFilePath')->once()->with('cache.db')->andReturn('/var/lib/sqlite/cache.db');
$command = (new DatabaseImportCommandBuilder)->buildRestoreCommand($resource, '/tmp/backup', false, sqliteDatabase: 'cache.db');
expect($command)->toContain("sqlite3 -bail '/var/lib/sqlite/cache.db'");
});
test('rejects unsupported database types', function () {
$builder = new DatabaseImportCommandBuilder;
$redis = importResource(StandaloneRedis::class);
@@ -172,7 +172,7 @@ function restoreScriptResource(string $engine): object
/**
* @return array{exit: int, stdout: string, stderr: string, calls: list<array{name: string, args: string, header: string}>, dir: string, backup: string, leftovers: list<string>}
*/
function restoreScriptRun(string $engine, string $contents, bool $dumpAll = false, bool $replaceExisting = false): array
function restoreScriptRun(string $engine, string $contents, bool $dumpAll = false, bool $replaceExisting = false, bool $keepOwners = false): array
{
$dir = restoreScriptTempDir();
@@ -199,7 +199,7 @@ SH);
$backup = $dir.'/backup file';
file_put_contents($backup, $contents);
$script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource($engine), $backup, $dumpAll, $replaceExisting);
$script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource($engine), $backup, $dumpAll, $replaceExisting, $keepOwners);
$result = restoreScriptProcess(['sh', '-c', $script], $dir, [
'PATH' => $dir.'/bin'.PATH_SEPARATOR.(getenv('PATH') ?: '/usr/bin:/bin'),
@@ -285,12 +285,12 @@ test('restores single PostgreSQL archives with pg_restore and SQL with psql', fu
expect($run['exit'])->toBe(0, $run['stderr']);
restoreScriptExpectCalls($run['calls'], $expectedCalls);
})->with([
'custom archive' => ['pg-custom', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]],
'custom archive replacing existing objects' => ['pg-custom', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --clean --if-exists -U postgres -d app']]]],
'gzip custom archive' => ['pg-custom-gz', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]],
'gzip custom archive replacing existing objects' => ['pg-custom-gz', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --clean --if-exists -U postgres -d app']]]],
'tar archive' => ['pg-tar', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction -U postgres -d app']]]],
'gzip tar archive' => ['pg-tar-gz', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction -U postgres -d app']]]],
'custom archive' => ['pg-custom', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
'custom archive replacing existing objects' => ['pg-custom', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]],
'gzip custom archive' => ['pg-custom-gz', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
'gzip custom archive replacing existing objects' => ['pg-custom-gz', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]],
'tar archive' => ['pg-tar', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
'gzip tar archive' => ['pg-tar-gz', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
'plain SQL' => ['pg-sql', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]],
'gzip SQL' => ['pg-sql-gz', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]],
// SQL cannot replace single objects: it restores into a new database, and only a
@@ -313,6 +313,14 @@ test('restores single PostgreSQL archives with pg_restore and SQL with psql', fu
]],
]);
test('keeps PostgreSQL owners and privileges only when requested', function (string $fixture) {
$run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), keepOwners: true);
expect($run['exit'])->toBe(0, $run['stderr']);
restoreScriptExpectCalls($run['calls'], [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]);
expect($run['calls'][0]['args'])->not->toContain('--no-owner')->not->toContain('--no-acl');
})->with(['custom archive' => ['pg-custom'], 'gzip custom archive' => ['pg-custom-gz']]);
test('rejects unsupported single PostgreSQL backups before calling any client', function (string $fixture, string $message) {
$run = restoreScriptRun('postgresql', restoreScriptFixture($fixture));
+8 -4
View File
@@ -66,9 +66,9 @@ test('constrains additional properties on each database import source branch', f
expect($schema)->not->toHaveKey('additionalProperties');
$expectedProperties = [
['source', 'upload_id', 'dump_all', 'replace_existing'],
['source', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing'],
['source', 'path', 'dump_all', 'replace_existing'],
['source', 'upload_id', 'dump_all', 'replace_existing', 'keep_owners', 'sqlite_database'],
['source', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing', 'keep_owners', 'sqlite_database'],
['source', 'path', 'dump_all', 'replace_existing', 'keep_owners', 'sqlite_database'],
];
expect($schema['oneOf'])->toHaveCount(count($expectedProperties));
@@ -77,7 +77,11 @@ test('constrains additional properties on each database import source branch', f
expect($source['additionalProperties'])->toBeFalse()
->and($source['properties'])->toHaveKeys($expectedProperties[$index])
->and($source['properties']['replace_existing'])
->toMatchArray(['type' => 'boolean', 'default' => false]);
->toMatchArray(['type' => 'boolean', 'default' => false])
->and($source['properties']['keep_owners'])
->toMatchArray(['type' => 'boolean', 'default' => false])
->and($source['properties']['sqlite_database'])
->toMatchArray(['type' => 'string']);
}
});
@@ -78,9 +78,9 @@ describe('replaceLocalSource', function () {
expect((string) $result)->toBe('/app/scripts');
});
it('replaces dot-dot-slash prefix with target path', function () {
it('resolves dot-dot-slash prefix against the parent of the target path', function () {
$result = replaceLocalSource(str('../config'), str('/app'));
expect((string) $result)->toBe('/app./config');
expect((string) $result)->toBe('/config');
});
it('replaces tilde prefix with target path', function () {
+17 -1
View File
@@ -88,7 +88,7 @@ test('delete redirects before dispatching resource cleanup after the response',
Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job) => $job->resource->is($service));
});
test('delete succeeds without password for an oauth user', function () {
test('delete requires confirmation for an oauth user', function () {
OauthIdentity::create([
'user_id' => $this->user->id,
'provider' => 'oidc',
@@ -96,6 +96,22 @@ test('delete succeeds without password for an oauth user', function () {
'provider_user_id' => 'oauth-user-id',
]);
Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', '')
->assertHasErrors('password');
Queue::assertNotPushed(DeleteResourceJob::class);
});
test('delete succeeds without password for an oauth user after confirming through the provider', function () {
OauthIdentity::create([
'user_id' => $this->user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'oauth-user-id',
]);
session()->passwordConfirmed();
Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', '')
->assertHasNoErrors();