mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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:
1 parent
d1bdecba6d
commit
9e7cb60b0d
57 files changed
+3032
-194
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 => [],
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'] }})"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
]);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user