diff --git a/.ai/lessons.md b/.ai/lessons.md index 130a87bc6b..d6db4e49b2 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -21,6 +21,7 @@ - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. `click()` also hung on a button far down the long `/analytics` page; click it with `$page->script("document.querySelector('...').click()")` there. - A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. +- A running dev instance mounts the checkout, and its scheduled `CheckForUpdatesJob` writes the CDN data to `base_path('versions.json')`. Check `git diff versions.json` before you commit a version change. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:-dind` container); newer clients can reject data from older daemons. - Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. @@ -41,6 +42,8 @@ - Horizon workers in a dev instance keep the code they loaded. Run `./scripts/dev exec php artisan horizon:terminate` after a code change and before a live test of queued jobs. - To prove that a server side effect is blocked, check the server itself after the request. A model hook or job can be one of several write paths (for example `ServerStorageSaveJob` and `ServerFilesFromServerJob` both write file storages). +- When you add a gate that lets a user through only after a step (2FA, password reset, onboarding), test it with users who have no known password (OAuth, magic-link invites) and together with the other redirecting gates. Two gates that each allow only their own page make a redirect loop, and a step that needs password confirmation blocks users who cannot confirm one. + ## Preserve product scope - Do not replace required SPA navigation with a full-page redirect to hide a lifecycle or ordering defect. - Do not add billing restrictions, live reconciliation, or fallback behavior unless the request includes them. diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 4f525ebcc8..f2c21ca121 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -113,7 +113,8 @@ class RunRemoteProcess $status = ProcessStatus::IN_PROGRESS; $timeout = config('constants.ssh.command_timeout'); try { - $process = Process::timeout($timeout)->start($this->getCommand(), $this->handleOutput(...)); + [$command, $input] = $this->getCommand(); + $process = Process::timeout($timeout)->input($input)->start($command, $this->handleOutput(...)); // Start from the stored properties and save now, so later output writes only change the log and // never write back an old copy over a status that Coolify set meanwhile (for example a stopped import). $stored = Activity::query()->whereKey($this->activity->getKey())->first()?->properties ?? $this->activity->properties; @@ -214,13 +215,19 @@ class RunRemoteProcess event(new $eventClass($eventData)); } - protected function getCommand(): string + /** + * The SSH command and its input. The command batch goes to ssh on stdin, so its secrets are + * not in a command line on the Coolify host. + * + * @return array{0: string, 1: string} + */ + protected function getCommand(): array { $server_uuid = $this->activity->getExtraProperty('server_uuid'); $command = RemoteProcessCommand::read($this->activity) ?? throw new \RuntimeException('The command of this task was already removed.'); $server = Server::whereUuid($server_uuid)->firstOrFail(); - return SshMultiplexingHelper::generateSshCommand($server, $command); + return [SshMultiplexingHelper::generateSshBatchCommand($server), SshMultiplexingHelper::sshBatchInput($command)]; } protected function handleOutput(string $type, string $output) diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index daa74c98bd..9d8add6b8f 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -2,6 +2,8 @@ namespace App\Actions\Database; +use App\Helpers\SshMultiplexingHelper; +use App\Helpers\SshRetryHandler; use App\Models\S3Storage; use App\Models\Server; use App\Models\ServiceDatabase; @@ -157,10 +159,9 @@ class StartDatabaseImport throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.'); } $helper = "s3-restore-{$operation}"; - $this->startS3HelperWithEnv($storage, $server, $helper, $network); + $this->startS3Helper($storage, $server, $helper, $network); $sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}"); $commandList = [ - 'docker exec '.escapeshellarg($helper).' sh -c '.escapeshellarg('mc alias set s3temp "$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY"'), 'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore', ...$this->streamFromHelper($helper, $container, $containerPath), 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true', @@ -289,7 +290,12 @@ class StartDatabaseImport ]; } - private function startS3HelperWithEnv(S3Storage $storage, Server $server, string $helper, string $network): void + /** + * Starts the S3 restore helper and sets its `s3temp` mc alias. The S3 keys go to mc through the + * stdin of a separate SSH process, so they are not in the arguments of any process on Coolify or + * on the server. The helper is removed when either step fails. + */ + private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): void { $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion()); @@ -298,16 +304,33 @@ class StartDatabaseImport 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true', 'docker run -d --network '.escapeshellarg($network) .' --name '.escapeshellarg($helper) - .' -e S3_ENDPOINT='.escapeshellarg((string) $storage->endpoint) - .' -e S3_ACCESS_KEY='.escapeshellarg((string) $storage->key) - .' -e S3_SECRET_KEY='.escapeshellarg((string) $storage->secret) .' '.$storage->minioClientRegionOption() .' '.$image.' sleep 86400', ], $server); + $this->setMinioClientAlias($storage, $server, $helper); } catch (Throwable) { instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false); throw new DatabaseImportException('Unable to start the S3 restore helper.'); } } + + private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper): void + { + $remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($helper) + .' mc alias set s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null'; + $timeout = (int) config('constants.ssh.command_timeout'); + + SshRetryHandler::retry( + function () use ($server, $remoteCommand, $storage, $timeout) { + $sshCommand = SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand, disableMultiplexing: true, commandTimeout: $timeout); + $process = runSshProcess($sshCommand, $timeout, $storage->key."\n".$storage->secret."\n"); + + if ($process->exitCode() !== 0) { + excludeCertainErrors($process->errorOutput(), $process->exitCode()); + } + }, + ['server' => $server->ip, 'function' => 'setMinioClientAlias'], + ); + } } diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index a0419feb31..2b5a502977 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -114,7 +114,7 @@ class CheckProxy $results = Process::concurrently(function ($pool) use ($server, $ports, $proxyContainerName) { foreach ($ports as $port) { $commands = $this->buildPortCheckCommands($server, $port, $proxyContainerName); - $pool->command($commands['ssh_command'])->timeout(10); + $pool->command($commands['ssh_command'])->input($commands['input'])->timeout(10); } }); @@ -152,10 +152,10 @@ class CheckProxy private function buildPortCheckCommands(Server $server, int $port, string $proxyContainerName): array { $portCheckScript = $this->buildPortCheckScript($port, $proxyContainerName); - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $portCheckScript); return [ - 'ssh_command' => $sshCommand, + 'ssh_command' => SshMultiplexingHelper::generateSshBatchCommand($server), + 'input' => SshMultiplexingHelper::sshBatchInput($portCheckScript), 'script' => $portCheckScript, ]; } diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index f132ebd17d..17b796a6df 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -43,6 +43,11 @@ class CleanupDocker implements ShouldBeUnique */ public const STOP_CLEANUP_COOLDOWN = 3600; + /** + * Cleanup log command of the entry that records a skipped build cache prune. + */ + public const BUILD_CACHE_KEPT_LOG_COMMAND = 'docker builder prune, docker buildx prune'; + public int $jobTries = 1; /** @@ -87,8 +92,9 @@ class CleanupDocker implements ShouldBeUnique /** * The deployment that is queued or running on the server (as target or build server), if any. - * The cleanup prunes the build caches, which fails a build that uses them. A deployment that - * wrote no log for longer than the deployment timeout is stuck and does not count. + * Pruning the build caches fails a build that uses them, so the cleanup keeps them while such a + * deployment exists. A deployment that wrote no log for longer than the deployment timeout is + * stuck and does not count. */ public static function activeDeployment(Server $server): ?ApplicationDeploymentQueue { @@ -99,10 +105,17 @@ class CleanupDocker implements ShouldBeUnique ->first(['id', 'deployment_uuid']); } + /** + * The reason the build cache was kept, read from a cleanup log, if it was kept. + */ + public static function buildCacheKeptReason(array $cleanupLog): ?string + { + return collect($cleanupLog)->firstWhere('command', self::BUILD_CACHE_KEPT_LOG_COMMAND)['output'] ?? null; + } + /** * Cleanup after a resource stop: skipped when a cleanup ran recently, and the queued run - * only cleans when the disk usage is at or above the server cleanup threshold and no - * deployment is active on the server. + * only cleans when the disk usage is at or above the server cleanup threshold. */ public static function dispatchAfterStop(Server $server): void { @@ -137,14 +150,15 @@ class CleanupDocker implements ShouldBeUnique ]; } - public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false, bool $onlyAboveThreshold = false) + /** + * @param bool $pruneBuildCacheDuringDeployment Also prune the build caches while a deployment is active + * (a manual cleanup). Otherwise they stay until the next run. + */ + public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false, bool $onlyAboveThreshold = false, bool $pruneBuildCacheDuringDeployment = false) { $this->deadline = now()->getTimestamp() + self::REMOTE_COMMANDS_DEADLINE; if ($onlyAboveThreshold) { - if (self::activeDeployment($server)) { - return []; - } $diskUsage = $server->getDiskUsage(); if (is_numeric($diskUsage) && (int) $diskUsage < $server->settings->docker_cleanup_threshold) { return []; @@ -177,12 +191,22 @@ class CleanupDocker implements ShouldBeUnique $commands = [ $this->buildContainerPruneCommand(), $imagePruneCmd, - 'docker builder prune -af', - // The prune fails on a stopped builder, so start it first. - railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'), - $this->buildCoolifyImageCleanupCommand($helperImageVersion), ]; + $activeDeployment = $pruneBuildCacheDuringDeployment ? null : self::activeDeployment($server); + if (! $activeDeployment) { + $commands[] = 'docker builder prune -af'; + // The prune fails on a stopped builder, so start it first. + $commands[] = railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'); + } else { + $cleanupLog[] = [ + 'command' => self::BUILD_CACHE_KEPT_LOG_COMMAND, + 'output' => 'Skipped: the build cache was not pruned because deployment '.$activeDeployment->deployment_uuid.' is queued or running on this server.', + ]; + } + + $commands[] = $this->buildCoolifyImageCleanupCommand($helperImageVersion); + if ($deleteUnusedVolumes) { $commands[] = 'docker volume prune -af'; } diff --git a/app/Actions/Shared/CloneVolumeDataWithSourceStopped.php b/app/Actions/Shared/CloneVolumeDataWithSourceStopped.php new file mode 100644 index 0000000000..b0ee519fd2 --- /dev/null +++ b/app/Actions/Shared/CloneVolumeDataWithSourceStopped.php @@ -0,0 +1,333 @@ + $volumeJobs + */ + public function handle( + Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $source, + array $volumeJobs, + ): void { + if ($volumeJobs === []) { + return; + } + + $wasRunning = filled($source->status) && ! $source->isExited(); + + // One closure per line: a serialized closure is found by its line, so two closures on one line run the same code. + $chain = $wasRunning ? [ + static fn () => self::stopSource($source), + ...$volumeJobs, + static fn () => self::startSource($source), + ] : $volumeJobs; + + // The jobs load the new volume rows, so they must not run before the transaction of the caller commits. + DB::afterCommit(fn () => Bus::chain($chain) + ->onQueue('high') + ->catch(static fn (Throwable $e) => self::recoverSource($source, $wasRunning, $e)) + ->dispatch()); + } + + /** + * The policy ability that allows a user to stop and start the source. + */ + public static function sourceAbility( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ): string { + return match (true) { + $source instanceof Application => 'deploy', + $source instanceof Service => 'stop', + default => 'manage', + }; + } + + /** + * Stops the source and fails the chain, so that no copy runs, when the stop reports an error + * or a container of the source still runs. The stop actions return their errors instead of throwing. + */ + public static function stopSource( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ): void { + // A partly stopped application is redeployed, so remember which of its containers ran. + $runningBefore = $source instanceof Application ? self::runningContainersOrNull($source) : null; + $result = match (true) { + $source instanceof Application => StopApplication::run($source, false, false), + $source instanceof Service => StopService::run($source, dockerCleanup: false), + default => StopDatabase::run($source, dockerCleanup: false), + }; + if (filled($result) && $result !== 'Database stopped successfully') { + throw new RuntimeException($result, self::failedStopCode($source, $runningBefore)); + } + + try { + $runningContainers = self::runningContainers($source); + } catch (Throwable $e) { + throw new RuntimeException('Could not check that the containers stopped: '.$e->getMessage(), self::STOP_FAILED, $e); + } + if ($runningContainers !== []) { + throw new RuntimeException('These containers still run: '.implode(', ', $runningContainers), self::failedStopCode($source, $runningBefore, $runningContainers)); + } + } + + /** + * An application whose stop took down some of its containers, while others still run, is + * partly stopped. When the containers before the stop are unknown, it may be partly stopped. + * + * @param list|null $runningBefore + * @param list|null $runningAfter + */ + private static function failedStopCode( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ?array $runningBefore, + ?array $runningAfter = null, + ): int { + if (! $source instanceof Application) { + return self::STOP_FAILED; + } + $runningAfter ??= self::runningContainersOrNull($source); + if (blank($runningAfter)) { + return self::STOP_FAILED; + } + + return $runningBefore === null || array_diff($runningBefore, $runningAfter) !== [] + ? self::APPLICATION_PARTLY_STOPPED + : self::STOP_FAILED; + } + + /** + * @return list|null + */ + private static function runningContainersOrNull(Application $application): ?array + { + try { + return self::runningContainers($application); + } catch (Throwable) { + return null; + } + } + + /** + * The names of the running containers of the source on its main server. A swarm stack is + * removed asynchronously, so it is not checked. + * + * @return list + */ + private static function runningContainers( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ): array { + $server = $source->destination->server; + if ($server->isSwarm()) { + return []; + } + [$type, $filters] = match (true) { + $source instanceof Application => ['application', ['label=coolify.pullRequestId=0']], + $source instanceof Service => ['service', []], + default => ['database', []], + }; + $output = instant_remote_process(dockerPsByOwnerCommands($type, $source->uuid, $filters, '{{.Names}}', all: false), $server); + + return collect(explode("\n", (string) $output))->map(fn (string $name) => trim($name))->filter()->unique()->values()->all(); + } + + public static function startSource( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ): void { + match (true) { + $source instanceof Application => queue_application_deployment( + application: $source, + deployment_uuid: new_public_id(), + server: $source->destination->server, + destination: $source->destination, + no_questions_asked: true, + ), + $source instanceof Service => StartService::run($source), + default => StartDatabase::run($source), + }; + } + + public static function recoverSource( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + bool $wasRunning, + Throwable $e, + ): void { + if (in_array($e->getCode(), [self::STOP_FAILED, self::APPLICATION_PARTLY_STOPPED], true)) { + self::recoverFromFailedStop($source, $e); + + return; + } + + $message = "Copying the volume data of '{$source->name}' to its clone failed. The clone may have no or partial data."; + + if ($wasRunning) { + try { + self::startSource($source); + $message .= " Coolify started '{$source->name}' again."; + } catch (Throwable $startError) { + Log::error("Failed to start {$source->name} again after a failed volume clone: ".$startError->getMessage()); + $message .= " Coolify could not start '{$source->name}' again. Start it manually."; + } + } + + $source->team()?->notify(new GeneralNotification( + $message.' Error: '.remove_iip($e->getMessage()), + success: false, + )); + } + + /** + * No data was copied. A source that the stop took down fully (or whose state is unknown) is + * started again. When containers of a service still run, only its stopped containers are + * started again; an application or a database that still runs is left alone. + */ + private static function recoverFromFailedStop( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + Throwable $e, + ): void { + $message = "Coolify did not copy the volume data of '{$source->name}' to its clone, because stopping '{$source->name}' failed. The clone has no copied data."; + + try { + $stillRunning = self::runningContainers($source) !== []; + } catch (Throwable) { + $stillRunning = false; + } + + if ($stillRunning && $source instanceof Service) { + try { + self::startStoppedServiceContainers($source); + $message .= " Some containers of '{$source->name}' were still running, so Coolify started its stopped containers again."; + } catch (Throwable $startError) { + Log::error("Failed to start the stopped containers of {$source->name} after a failed stop for a volume clone: ".$startError->getMessage()); + $message .= " Some containers of '{$source->name}' are still running. Coolify could not start its stopped containers again. Start '{$source->name}' manually."; + } + } elseif ($stillRunning && $source instanceof Application && $e->getCode() === self::APPLICATION_PARTLY_STOPPED) { + $message .= ' '.self::redeployPartlyStoppedApplication($source); + } elseif ($stillRunning) { + $message .= " '{$source->name}' is still running."; + } else { + try { + self::startSource($source); + $message .= " Coolify started '{$source->name}' again."; + } catch (Throwable $startError) { + Log::error("Failed to start {$source->name} again after a failed stop for a volume clone: ".$startError->getMessage()); + $message .= " Coolify could not start '{$source->name}' again. Start it manually."; + } + } + + $source->team()?->notify(new GeneralNotification( + $message.' Error: '.remove_iip($e->getMessage()), + success: false, + )); + } + + /** + * Queues a normal deployment (a rolling update when the application supports it) of the commit + * of the last successful deployment, so the running containers are replaced only by the same + * version. Without such a commit, the configured commit is deployed. + * + * @return string The sentence for the notification. + */ + private static function redeployPartlyStoppedApplication(Application $application): string + { + $commit = $application->deployment_queue() + ->where('status', ApplicationDeploymentStatus::FINISHED->value) + ->where('pull_request_id', 0) + ->whereNotNull('commit') + ->where('commit', '!=', 'HEAD') + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->value('commit'); + + try { + $result = queue_application_deployment( + application: $application, + deployment_uuid: new_public_id(), + commit: $commit, + no_questions_asked: true, + ); + if (($result['status'] ?? null) !== 'queued') { + throw new RuntimeException($result['message'] ?? 'The deployment was not queued.'); + } + } catch (Throwable $deployError) { + Log::error("Failed to queue a redeploy of {$application->name} after a failed stop for a volume clone: ".$deployError->getMessage()); + + return "Some containers of '{$application->name}' stopped and others are still running. Coolify could not queue a redeploy of '{$application->name}' to bring back its stopped containers. Redeploy it manually."; + } + + $sentence = "Some containers of '{$application->name}' stopped and others were still running, so Coolify started a redeploy of '{$application->name}' to bring back its stopped containers"; + + return $commit === null + ? $sentence.'. Coolify could not find the commit that ran before, so the redeploy uses the configured commit or image tag.' + : $sentence." with the commit that ran before ({$commit})."; + } + + /** + * Creates and starts the containers that the stop removed, with the compose file of the last + * start. Running containers are not recreated, so `up --force-recreate` (StartService) is not + * used. `docker compose start` is not enough: StopService also removes the stopped containers. + */ + private static function startStoppedServiceContainers(Service $service): void + { + $server = $service->destination->server; + $workdir = $service->workdir(); + $compose = serverDockerConfigPrefix($server)."docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid}"; + + if (! data_get($service, 'connect_to_docker_network')) { + instant_remote_process(["{$compose} up -d --no-recreate < /dev/null"], $server); + + return; + } + + // Like StartService: connect new containers to the predefined network before they start. + $commands = ["{$compose} up -d --no-recreate --no-start < /dev/null"]; + $composeServices = data_get(parseDockerComposeYaml(data_get($service, 'docker_compose', '')), 'services', []); + foreach ($composeServices as $serviceName => $serviceConfig) { + if ($command = StartService::predefinedNetworkConnectCommand($service, $serviceName, $serviceConfig)) { + $commands[] = $command; + } + } + $commands[] = "{$compose} start < /dev/null"; + instant_remote_process($commands, $server); + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 86034b8cbd..38e832187f 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -253,6 +253,25 @@ class SshMultiplexingHelper .$delimiter; } + /** + * Build an SSH command that runs the command batch given to the process as input (see sshBatchInput()). + * The batch is not part of any command line on the Coolify host, where `ps` would show its secrets + * (database passwords, registry passwords, tokens), and it is not limited to 128 KiB. + * Like the quoted heredoc of generateSshCommand(), the batch gets no local shell expansion. + */ + public static function generateSshBatchCommand(Server $server, bool $disableMultiplexing = false, ?int $commandTimeout = null): string + { + return self::generateSshStdinCommand($server, self::remoteShellCommand(), $disableMultiplexing, $commandTimeout); + } + + /** + * The process input for generateSshBatchCommand(). A heredoc ends its content with a newline, so does this. + */ + public static function sshBatchInput(string $command): string + { + return $command.PHP_EOL; + } + /** * Build an SSH command that runs one short remote command and leaves stdin free for data. * Large content must go through stdin: Linux limits one exec argument to 128 KiB, and diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 608e88a4c2..50f71f46dc 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4070,7 +4070,9 @@ class ApplicationsController extends Controller ], 404); } - $env->value = $request->value; + if ($request->has('value')) { + $env->value = $request->value; + } $env->fill($request->only(['is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'])); $env->save(); @@ -4226,7 +4228,9 @@ class ApplicationsController extends Controller if ($is_preview) { $env = $application->environment_variables_preview()->where('key', $key)->first(); if ($env) { - $env->value = $item->get('value'); + if ($item->has('value')) { + $env->value = $item->get('value'); + } $env->fill($item->only(['is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'])->all()); $env->save(); } else { @@ -4247,7 +4251,9 @@ class ApplicationsController extends Controller } else { $env = $application->environment_variables()->where('key', $key)->first(); if ($env) { - $env->value = $item->get('value'); + if ($item->has('value')) { + $env->value = $item->get('value'); + } $env->fill($item->only(['is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'])->all()); $env->save(); } else { @@ -5177,7 +5183,7 @@ class ApplicationsController extends Controller #[OA\Patch( summary: 'Update Storage', - description: 'Update a persistent storage or file storage by application UUID.', + description: 'Update a persistent storage or file storage by application UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.', path: '/applications/{uuid}/storages', operationId: 'update-storage-by-application-uuid', security: [ diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 41ee9ffb7d..efa2a4a5d1 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -7,6 +7,7 @@ use App\Actions\Database\StartDatabase; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabaseProxy; +use App\Actions\Shared\CloneVolumeDataWithSourceStopped; use App\Enums\NewDatabaseTypes; use App\Http\Controllers\Controller; use App\Jobs\DatabaseBackupJob; @@ -36,7 +37,6 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -4023,7 +4023,9 @@ class DatabasesController extends Controller return response()->json(['message' => 'Environment variable not found.'], 404); } - $env->value = $request->value; + if ($request->has('value')) { + $env->value = $request->value; + } if ($request->has('is_literal')) { $env->is_literal = $request->is_literal; } @@ -4759,7 +4761,7 @@ class DatabasesController extends Controller #[OA\Patch( summary: 'Update Storage', - description: 'Update a persistent storage or file storage by database UUID.', + description: 'Update a persistent storage or file storage by database UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.', path: '/databases/{uuid}/storages', operationId: 'update-storage-by-database-uuid', security: [ @@ -5352,33 +5354,11 @@ class DatabasesController extends Controller } } - // Stop once, clone all volumes, then start once — avoids per-volume stop/start races. - if ($pendingVolumeClones !== [] && $sourceServer && $targetServer) { - try { - $chain = [ - function () use ($database) { - StopDatabase::run($database); - }, - ]; - - foreach ($pendingVolumeClones as $clone) { - $chain[] = new VolumeCloneJob( - $clone['source'], - $clone['target'], - $sourceServer, - $targetServer, - $clone['model'], - ); - } - - $chain[] = function () use ($database) { - StartDatabase::run($database); - }; - - Bus::chain($chain)->onQueue('high')->dispatch(); - } catch (\Exception $e) { - \Log::error('Failed to queue database volume clone for '.$database->uuid.': '.$e->getMessage()); - } + if ($sourceServer && $targetServer) { + CloneVolumeDataWithSourceStopped::run($database, array_map( + fn (array $clone) => new VolumeCloneJob($clone['source'], $clone['target'], $sourceServer, $targetServer, $clone['model']), + $pendingVolumeClones, + )); } foreach ($database->fileStorages()->get() as $storage) { diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index febd83a9ac..899a1ea2f9 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -776,8 +776,8 @@ class ServersController extends Controller 'connection_timeout' => 'integer|min:1|max:300', 'is_terminal_enabled' => 'boolean|nullable', 'wildcard_domain' => 'nullable|url', - 'server_timezone' => ['string', function (string $attribute, mixed $value, \Closure $fail): void { - if (! validate_timezone($value)) { + 'server_timezone' => ['bail', 'filled', 'string', function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_string($value) || ! validate_timezone($value)) { $fail('The server_timezone must be a valid timezone.'); } }], diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 1e5231400a..7eecdd30b6 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1573,7 +1573,9 @@ class ServicesController extends Controller return response()->json(['message' => 'Environment variable not found.'], 404); } - $env->value = $request->value; + if ($request->has('value')) { + $env->value = $request->value; + } if ($request->has('is_literal')) { $env->is_literal = $request->is_literal; } @@ -2760,7 +2762,7 @@ class ServicesController extends Controller #[OA\Patch( summary: 'Update Storage', - description: 'Update a persistent storage or file storage by service UUID.', + description: 'Update a persistent storage or file storage by service UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.', path: '/services/{uuid}/storages', operationId: 'update-storage-by-service-uuid', security: [ diff --git a/app/Http/Middleware/RequireTeamTwoFactor.php b/app/Http/Middleware/RequireTeamTwoFactor.php index bb5421458e..0670d7e5c6 100644 --- a/app/Http/Middleware/RequireTeamTwoFactor.php +++ b/app/Http/Middleware/RequireTeamTwoFactor.php @@ -17,7 +17,9 @@ class RequireTeamTwoFactor public function handle(Request $request, Closure $next): Response { $user = $request->user(); - if (! $user || filled($user->two_factor_confirmed_at)) { + // Users who must set a password first (magic link invitees) stay on the password page + // (CheckForcePasswordReset); they need a known password to confirm the 2FA setup. + if (! $user || filled($user->two_factor_confirmed_at) || $user->force_password_reset) { return $next($request); } diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 26337ebe70..a05d713165 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -3,6 +3,8 @@ namespace App\Jobs; use App\Events\BackupCreated; +use App\Helpers\SshMultiplexingHelper; +use App\Helpers\SshRetryHandler; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; @@ -921,8 +923,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue protected function upload_to_s3(S3Storage $s3): void { try { - $key = $s3->key; - $secret = $s3->secret; $bucket = $s3->bucket; $endpoint = $s3->endpoint; $s3->testConnection(shouldSave: true); @@ -940,10 +940,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $mount = escapeshellarg($this->backupMountSource().':'.$this->backup_location.':ro'); $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm {$s3->minioClientRegionOption()} -v {$mount} {$fullImageName}"; - // Escape S3 credentials to prevent command injection - $escapedEndpoint = escapeshellarg($endpoint); - $escapedKey = escapeshellarg($key); - $escapedSecret = escapeshellarg($secret); $escapedBackupLocation = escapeshellarg($this->backup_location); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $s3->trustedInternalHosts())) @@ -951,9 +947,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue ->implode(' '); $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp{$resolveOptions} {$escapedBackupLocation} {$escapedS3Destination}"; instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); + $this->setMinioClientAlias($this->server, "backup-of-{$this->backup_log_uuid}", $s3, $resolveOptions); + instant_remote_process( + ["docker exec backup-of-{$this->backup_log_uuid} mc cp{$resolveOptions} {$escapedBackupLocation} {$escapedS3Destination}"], + $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true, + ); } catch (Throwable $e) { $this->add_to_error_output($e->getMessage()); @@ -964,6 +963,32 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * Sets the `temporary` mc alias in a helper container. The S3 keys go to mc through the stdin of the + * SSH process, so they are not in the arguments of any process on Coolify or on the server. + */ + private function setMinioClientAlias(Server $server, string $container, S3Storage $s3, string $resolveOptions): void + { + $remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($container) + .' mc alias set'.$resolveOptions.' temporary '.escapeshellarg($s3->endpoint).' >/dev/null'; + $timeout = (int) config('constants.ssh.command_timeout'); + + SshRetryHandler::retry( + function () use ($server, $remoteCommand, $s3, $timeout) { + $sshCommand = SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand, disableMultiplexing: true, commandTimeout: $timeout); + $process = runSshProcess($sshCommand, $timeout, $s3->key."\n".$s3->secret."\n"); + + if ($process->exitCode() !== 0) { + excludeCertainErrors($process->errorOutput(), $process->exitCode()); + } + }, + [ + 'server' => $server->ip, + 'function' => 'setMinioClientAlias', + ], + ); + } + private function getFullImageName(): string { $helperImage = coolifyHelperImage(); diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 9daee0c82c..af1778eacf 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -110,28 +110,19 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue return; } - $activeDeployment = $this->manualCleanup ? null : CleanupDocker::activeDeployment($this->server); - if ($activeDeployment) { - $this->execution_log->update([ - 'status' => 'success', - 'message' => 'Docker cleanup skipped: deployment '.$activeDeployment->deployment_uuid.' is queued or running on this server. The next scheduled cleanup runs as usual.', - ]); - event(new DockerCleanupDone($this->execution_log)); - - return; - } - $this->usageBefore = $this->server->getDiskUsage(); if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { $cleanup_log = CleanupDocker::run( server: $this->server, deleteUnusedVolumes: $this->deleteUnusedVolumes, - deleteUnusedNetworks: $this->deleteUnusedNetworks + deleteUnusedNetworks: $this->deleteUnusedNetworks, + pruneBuildCacheDuringDeployment: $this->manualCleanup ); $usageAfter = $this->server->getDiskUsage(); $message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.'; + $message = $this->withBuildCacheKeptReason($message, $cleanup_log); $this->execution_log->update([ 'status' => 'success', 'message' => $message, @@ -148,10 +139,12 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue $cleanup_log = CleanupDocker::run( server: $this->server, deleteUnusedVolumes: $this->deleteUnusedVolumes, - deleteUnusedNetworks: $this->deleteUnusedNetworks + deleteUnusedNetworks: $this->deleteUnusedNetworks, + pruneBuildCacheDuringDeployment: $this->manualCleanup ); $message = 'Docker cleanup job executed successfully, but no disk usage could be determined.'; + $message = $this->withBuildCacheKeptReason($message, $cleanup_log); $this->execution_log->update([ 'status' => 'success', 'message' => $message, @@ -168,7 +161,8 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue $cleanup_log = CleanupDocker::run( server: $this->server, deleteUnusedVolumes: $this->deleteUnusedVolumes, - deleteUnusedNetworks: $this->deleteUnusedNetworks + deleteUnusedNetworks: $this->deleteUnusedNetworks, + pruneBuildCacheDuringDeployment: $this->manualCleanup ); $usageAfter = $this->server->getDiskUsage(); $diskSaved = $this->usageBefore - $usageAfter; @@ -179,6 +173,7 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue $message = 'Docker cleanup job executed successfully, but no disk space was saved. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.'; } + $message = $this->withBuildCacheKeptReason($message, $cleanup_log); $this->execution_log->update([ 'status' => 'success', 'message' => $message, @@ -227,6 +222,13 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue } } + private function withBuildCacheKeptReason(string $message, array $cleanupLog): string + { + $reason = CleanupDocker::buildCacheKeptReason($cleanupLog); + + return $reason ? $message.' '.$reason : $message; + } + /** * failed() runs on a fresh job copy built from the queue payload (for example after a worker timeout), * so the execution this run created is kept under the queue job uuid instead of on the job. diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 841e915b78..a641dfdc11 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -7,6 +7,7 @@ use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Services\ServerTransfer\ServerTransferClaimer; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Process\ProcessResult; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -225,8 +226,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue private function attemptConnection(): bool { try { - $process = Process::timeout(self::SSH_TIMEOUT_SECONDS) - ->run($this->sshCommand(self::probeCommand(), disableMultiplexing: true)); + $process = $this->runSsh(self::probeCommand(), self::SSH_TIMEOUT_SECONDS, disableMultiplexing: true); if ($process->exitCode() !== 0) { $this->connectionError = 'ssh exit '.$process->exitCode().': '.Str::limit(trim($process->errorOutput()), 300); } else { @@ -308,7 +308,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue private function runDockerCommand(string $command): ?string { try { - $process = Process::timeout(self::DOCKER_TIMEOUT_SECONDS)->run($this->sshCommand($command, disableMultiplexing: $this->disableMux)); + $process = $this->runSsh($command, self::DOCKER_TIMEOUT_SECONDS, disableMultiplexing: $this->disableMux); } catch (\Throwable $e) { Log::debug('ServerConnectionCheck: Docker command failed', [ 'server_id' => $this->server->id, @@ -322,13 +322,15 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue return $process->successful() ? sanitize_utf8_text(trim($process->output())) : null; } - private function sshCommand(string $command, bool $disableMultiplexing = false): string + private function runSsh(string $command, int $timeout, bool $disableMultiplexing = false): ProcessResult { $commands = [$command]; if ($this->server->isNonRoot()) { $commands = parseCommandsByLineForSudo(collect($commands), $this->server); } - return SshMultiplexingHelper::generateSshCommand($this->server, implode("\n", $commands), $disableMultiplexing); + return Process::timeout($timeout) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($this->server, $disableMultiplexing)); } } diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index d3079fe096..344cefdccc 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -3,6 +3,8 @@ namespace App\Jobs; use App\Events\BackupCreated; +use App\Helpers\SshMultiplexingHelper; +use App\Helpers\SshRetryHandler; use App\Models\LocalPersistentVolume; use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; @@ -102,7 +104,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $archiveScript = self::archiveScript($compressionCpuPercentage); if ($streamToS3) { $this->execution->update(['local_storage_deleted' => true]); - $archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $source, $containerName, $image); + $archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $containerName); $this->execution->update(['s3_cleanup_pending' => true]); } else { $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) @@ -124,11 +126,20 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - $archiveOutput = instant_remote_process(array_filter([ - $verifySourceCommand, - $streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory), - $archiveCommand, - ]), $server, timeout: $this->timeout, disableMultiplexing: true); + try { + if ($streamToS3) { + $this->startStreamHelper($destinations->first(), $verifySourceCommand, $source, $containerName, $image, $server); + } + $archiveOutput = instant_remote_process(array_filter([ + $verifySourceCommand, + $streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory), + $archiveCommand, + ]), $server, timeout: $this->timeout, disableMultiplexing: true); + } finally { + if ($streamToS3) { + instant_remote_process(['docker rm -f '.escapeshellarg($containerName).' >/dev/null 2>&1 || true'], $server, throwError: false, disableMultiplexing: true); + } + } $this->execution->update([ 'stop_container_ids' => null, 'stop_recovery_pending' => false, @@ -429,18 +440,16 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $s3->testConnection(shouldSave: true); $containerName = 'volume-upload-'.$this->execution->uuid; $image = coolifyHelperImage().':'.getHelperVersion(); - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) - ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) - ->implode(' '); - $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + $resolveOptions = $this->minioClientResolveOptions($s3); try { instant_remote_process([ 'docker rm -f '.escapeshellarg($containerName).' >/dev/null 2>&1 || true', 'docker run -d --name '.escapeshellarg($containerName).' --rm '.$s3->minioClientRegionOption().' -v ' .escapeshellarg($backupLocation.':'.$backupLocation.':ro').' '.escapeshellarg($image), - 'docker exec '.escapeshellarg($containerName).' mc alias set'.$resolveOptions.' temporary ' - .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret), + ], $server, timeout: $this->timeout, disableMultiplexing: true); + $this->setMinioClientAlias($server, $containerName, $s3, $resolveOptions); + instant_remote_process([ 'docker exec '.escapeshellarg($containerName).' mc cp'.$resolveOptions.' '.escapeshellarg($backupLocation).' ' .escapeshellarg('temporary/'.$s3->bucket.$backupDirectory.'/'), ], $server, timeout: $this->timeout, disableMultiplexing: true); @@ -454,32 +463,75 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string + /** + * Starts the helper container that streams the volume to S3 and sets its mc alias. The archive then + * runs in it with `docker exec`, so the S3 keys are never part of a command line. + */ + private function startStreamHelper(S3Storage $s3, string $verifySourceCommand, string $source, string $containerName, string $image, Server $server): void + { + instant_remote_process([ + $verifySourceCommand, + 'docker rm -f '.escapeshellarg($containerName).' >/dev/null 2>&1 || true', + 'docker run -d --rm --name '.escapeshellarg($containerName) + .' '.$s3->minioClientRegionOption() + .' -v '.escapeshellarg($source.':/volume:ro') + .' '.escapeshellarg($image).' >/dev/null', + ], $server, timeout: $this->timeout, disableMultiplexing: true); + $this->setMinioClientAlias($server, $containerName, $s3, $this->minioClientResolveOptions($s3)); + } + + /** + * Sets the `temporary` mc alias in a helper container. The S3 keys go to mc through the stdin of the + * SSH process, so they are not in the arguments of any process on Coolify or on the server. + */ + private function setMinioClientAlias(Server $server, string $container, S3Storage $s3, string $resolveOptions): void + { + $remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($container) + .' mc alias set'.$resolveOptions.' temporary '.escapeshellarg($s3->endpoint).' >/dev/null'; + $timeout = (int) config('constants.ssh.command_timeout'); + + SshRetryHandler::retry( + function () use ($server, $remoteCommand, $s3, $timeout) { + $sshCommand = SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand, disableMultiplexing: true, commandTimeout: $timeout); + $process = runSshProcess($sshCommand, $timeout, $s3->key."\n".$s3->secret."\n"); + + if ($process->exitCode() !== 0) { + excludeCertainErrors($process->errorOutput(), $process->exitCode()); + } + }, + [ + 'server' => $server->ip, + 'function' => 'setMinioClientAlias', + ], + ); + } + + private function minioClientResolveOptions(S3Storage $s3): string { - $s3->testConnection(shouldSave: true); $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) ->implode(' '); - $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + + return $resolveOptions === '' ? '' : ' '.$resolveOptions; + } + + private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $containerName): string + { + $s3->testConnection(shouldSave: true); + $resolveOptions = $this->minioClientResolveOptions($s3); $destination = 'temporary/'.$s3->bucket.$backupLocation; // `mc pipe --quiet` panics in recent mc releases, so keep its progress bar out of the error output instead. // Each progress frame starts with "\r", so printing the last field per line keeps only the final frame and real errors. $mcPipeLog = '/tmp/mc-pipe.log'; // Object Lock buckets need Content-MD5 on each upload. `mc cp` adds it when the bucket lock is enabled, `mc pipe` only with `--checksum=MD5`. - $streamScript = 'set -o pipefail; mc alias set'.$resolveOptions.' temporary ' - .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret) - .' >/dev/null && lock=$(mc retention info --default --json'.$resolveOptions.' '.escapeshellarg('temporary/'.$s3->bucket).' 2>/dev/null || true)' + $streamScript = 'set -o pipefail; lock=$(mc retention info --default --json'.$resolveOptions.' '.escapeshellarg('temporary/'.$s3->bucket).' 2>/dev/null || true)' .' && case "$lock" in *\'"enabled":"Enabled"\'*) checksum=--checksum=MD5 ;; *) checksum= ;; esac' .' && ('.$archiveScript.' | mc pipe'.$resolveOptions.' $checksum '.escapeshellarg($destination).' >/dev/null 2>'.$mcPipeLog .' || { status=$?; awk -F '.escapeshellarg('\r').' '.escapeshellarg('{ print $NF }').' '.$mcPipeLog.' >&2; exit $status; })' .' && mc stat --json'.$resolveOptions.' '.escapeshellarg($destination) .' | sed -n '.escapeshellarg('s/.*"size":\([0-9][0-9]*\).*/\1/p'); - return 'docker run --rm --name '.escapeshellarg($containerName) - .' '.$s3->minioClientRegionOption() - .' -v '.escapeshellarg($source.':/volume:ro') - .' '.escapeshellarg($image) - .' sh -c '.escapeshellarg($streamScript); + return 'docker exec '.escapeshellarg($containerName).' sh -c '.escapeshellarg($streamScript); } private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index 20ad2ff8a8..953afc4eb1 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -30,6 +30,22 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue $this->onQueue('high'); } + /** + * Explicit tags, so Horizon does not compare the models of the job. In a chain, the job is restored + * with two copies of one server for a clone on the same server, and comparing them fails on PHP 8.4 + * ("Nesting level too deep"), which stops the chain before the copy. + * + * @return array + */ + public function tags(): array + { + return array_values(array_unique([ + Server::class.':'.$this->sourceServer->id, + ...($this->targetServer ? [Server::class.':'.$this->targetServer->id] : []), + LocalPersistentVolume::class.':'.$this->persistentVolume->id, + ])); + } + public function handle(): void { try { diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index a8e11ee0fe..0b71d44cfa 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -12,6 +12,7 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Concurrency; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Lazy; use Livewire\Attributes\Url; use Livewire\Component; @@ -194,16 +195,45 @@ class Metrics extends Component public function setRange(string $range): void { $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + // Every refresh fetches the full range from each server over SSH, so a new range starts paused. + $this->live = false; $this->loadData(); } public function updatedLive(): void { if ($this->live) { - $this->loadData(); + $this->pollData(); } } + /** + * The poll target. The server decides the refresh rate, so a client cannot fetch faster + * than once a minute or while live refresh is off. + */ + public function pollData(): void + { + $seconds = $this->pollSeconds(); + if ($seconds === null) { + return; + } + + // One second of slack, so a poll that fires slightly early is not dropped. + if (RateLimiter::attempt('metrics-poll:'.$this->getId(), 1, fn () => true, $seconds - 1) === false) { + return; + } + + $this->loadData(); + } + + /** + * Seconds between live refreshes, or null when live refresh is off. + */ + public function pollSeconds(): ?int + { + return $this->live ? 60 : null; + } + /** * @return Collection */ diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 99cacd1a93..fce8bffd0b 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -2,8 +2,7 @@ namespace App\Livewire\Project; -use App\Actions\Database\StartDatabase; -use App\Actions\Database\StopDatabase; +use App\Actions\Shared\CloneVolumeDataWithSourceStopped; use App\Jobs\VolumeCloneJob; use App\Models\Environment; use App\Models\Project; @@ -103,6 +102,11 @@ class CloneMe extends Component if (! $this->environment || (int) $this->environment->project_id !== (int) $this->project->id) { throw new \Exception('Environment not found.'); } + if ($this->cloneVolumeData) { + foreach ([...$this->environment->applications, ...$this->environment->databases(), ...$this->environment->services] as $source) { + $this->authorize(CloneVolumeDataWithSourceStopped::sourceAbility($source), $source); + } + } $selectedDestination = $this->findProjectTeamDestination($this->selectedDestination); if (! $selectedDestination) { throw new \Exception('Destination not found.'); @@ -192,6 +196,7 @@ class CloneMe extends Component } $newDatabase->persistentStorages()->delete(); + $pendingVolumeClones = []; $persistentVolumes = $database->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { $originalName = $volume->name; @@ -235,22 +240,18 @@ class CloneMe extends Component $newPersistentVolume->save(); if ($this->cloneVolumeData) { - try { - StopDatabase::dispatch($database); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $database->destination->server; - $targetServer = $newDatabase->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartDatabase::dispatch($database); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } + $pendingVolumeClones[] = new VolumeCloneJob( + $volume->name, + $newPersistentVolume->name, + $database->destination->server, + $newDatabase->destination->server, + $newPersistentVolume, + ); } } + CloneVolumeDataWithSourceStopped::run($database, $pendingVolumeClones); + $fileStorages = $database->fileStorages()->get(); foreach ($fileStorages as $storage) { $newStorage = $storage->replicate([ diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index cabf0bc469..57bfbd16f3 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -496,10 +496,11 @@ class GetLogs extends Component */ private function executeLogCommand(Server $server, string $command, int $maxBytes): array { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $this->boundedLogCommand($command, $maxBytes)); - // boundedLogCommand() caps the output at $maxBytes + 1, so one extra byte means truncation. - $output = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand)->output(); + $output = Process::timeout(config('constants.ssh.command_timeout')) + ->input(SshMultiplexingHelper::sshBatchInput($this->boundedLogCommand($command, $maxBytes))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($server)) + ->output(); $truncated = strlen($output) > $maxBytes; $logs = rtrim(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output), "\n"); diff --git a/app/Livewire/Project/Shared/Metrics.php b/app/Livewire/Project/Shared/Metrics.php index 8b027d7b49..a5217a7be6 100644 --- a/app/Livewire/Project/Shared/Metrics.php +++ b/app/Livewire/Project/Shared/Metrics.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Component; class Metrics extends Component @@ -40,18 +41,51 @@ class Metrics extends Component $this->loadData(); } + /** + * Ranges up to one hour refresh live by default. Longer ranges load once, because each + * refresh fetches the full range from Sentinel over SSH; the user can still turn live on. + */ + public function updatedInterval(): void + { + $this->live = $this->interval <= 60; + } + public function updatedLive(): void { if ($this->live) { - $this->loadData(); + $this->pollData(); } } /** - * Short ranges refresh every 5 seconds; longer ranges change slowly, so they refresh every minute. + * The poll target. The server decides the refresh rate, so a client cannot fetch faster + * than the range allows or while live refresh is off. */ - public function pollSeconds(): int + public function pollData(): void { + $seconds = $this->pollSeconds(); + if ($seconds === null) { + return; + } + + // One second of slack, so a poll that fires slightly early is not dropped. + if (RateLimiter::attempt('metrics-poll:'.$this->getId(), 1, fn () => true, $seconds - 1) === false) { + return; + } + + $this->loadData(); + } + + /** + * Seconds between live refreshes, or null when live refresh is off. Short ranges refresh + * every 5 seconds; longer ranges change slowly, so they refresh every minute. + */ + public function pollSeconds(): ?int + { + if (! $this->live) { + return null; + } + return $this->interval <= 60 ? 5 : 60; } diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 414af27128..198972406e 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -2,8 +2,7 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Database\StartDatabase; -use App\Actions\Database\StopDatabase; +use App\Actions\Shared\CloneVolumeDataWithSourceStopped; use App\Actions\Shared\MigrateResourceToDestination; use App\Jobs\VolumeCloneJob; use App\Models\Application; @@ -72,6 +71,9 @@ class ResourceOperations extends Component { try { $this->authorize('update', $this->resource); + if ($this->cloneVolumeData) { + $this->authorize(CloneVolumeDataWithSourceStopped::sourceAbility($this->resource), $this->resource); + } $new_environment = $this->resource->environment; if ($environment_id !== null) { @@ -145,6 +147,7 @@ class ResourceOperations extends Component } $new_resource->persistentStorages()->delete(); + $pendingVolumeClones = []; $persistentVolumes = $this->resource->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { $originalName = $volume->name; @@ -188,22 +191,18 @@ class ResourceOperations extends Component $newPersistentVolume->save(); if ($this->cloneVolumeData) { - try { - StopDatabase::dispatch($this->resource); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $this->resource->destination->server; - $targetServer = $new_resource->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartDatabase::dispatch($this->resource); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } + $pendingVolumeClones[] = new VolumeCloneJob( + $volume->name, + $newPersistentVolume->name, + $this->resource->destination->server, + $new_resource->destination->server, + $newPersistentVolume, + ); } } + CloneVolumeDataWithSourceStopped::run($this->resource, $pendingVolumeClones); + $fileStorages = $this->resource->fileStorages()->get(); foreach ($fileStorages as $storage) { $newStorage = $storage->replicate([ diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 65ddde8a7d..77a56a0073 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server; use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Validate; use Livewire\Component; @@ -150,18 +151,51 @@ class Charts extends Component $this->loadData(); } + /** + * Ranges up to one hour refresh live by default. Longer ranges load once, because each + * refresh fetches the full range from Sentinel over SSH; the user can still turn live on. + */ + public function updatedInterval(): void + { + $this->live = $this->interval <= 60; + } + public function updatedLive(): void { if ($this->live) { - $this->loadData(); + $this->pollData(); } } /** - * Short ranges refresh every 5 seconds; longer ranges change slowly, so they refresh every minute. + * The poll target. The server decides the refresh rate, so a client cannot fetch faster + * than the range allows or while live refresh is off. */ - public function pollSeconds(): int + public function pollData(): void { + $seconds = $this->pollSeconds(); + if ($seconds === null) { + return; + } + + // One second of slack, so a poll that fires slightly early is not dropped. + if (RateLimiter::attempt('metrics-poll:'.$this->getId(), 1, fn () => true, $seconds - 1) === false) { + return; + } + + $this->loadData(); + } + + /** + * Seconds between live refreshes, or null when live refresh is off. Short ranges refresh + * every 5 seconds; longer ranges change slowly, so they refresh every minute. + */ + public function pollSeconds(): ?int + { + if (! $this->live) { + return null; + } + return $this->interval <= 60 ? 5 : 60; } } diff --git a/app/Livewire/TwoFactorRequired.php b/app/Livewire/TwoFactorRequired.php index 72d4091b10..7b4a06ea2a 100644 --- a/app/Livewire/TwoFactorRequired.php +++ b/app/Livewire/TwoFactorRequired.php @@ -17,6 +17,7 @@ class TwoFactorRequired extends Component { return view('livewire.two-factor-required', [ 'teamName' => data_get(session('currentTeam'), 'name'), + 'teamsWithoutTwoFactor' => auth()->user()->teams()->where('is_two_factor_required', false)->get(), ])->layout('layouts.simple'); } } diff --git a/app/Mcp/Tools/ManageApplication.php b/app/Mcp/Tools/ManageApplication.php index bf857b4fda..71758d80e0 100644 --- a/app/Mcp/Tools/ManageApplication.php +++ b/app/Mcp/Tools/ManageApplication.php @@ -16,7 +16,7 @@ class ManageApplication extends RestActionTool protected function actions(): array { return [ - 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command; instant_deploy and manual_webhook_secret_* need deploy ability)'], + 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command, is_container_label_readonly_enabled, include_coolify_default_variables; instant_deploy and manual_webhook_secret_* need deploy ability)'], 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], 'clone' => ['method' => 'POST', 'path' => 'applications/{uuid}/clone', 'ability' => 'write', 'description' => 'clone the application to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'applications/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the application to another environment of the team (data: environment_uuid)'], diff --git a/app/Mcp/Tools/ManageDatabase.php b/app/Mcp/Tools/ManageDatabase.php index 5e4c11308d..e879c7f091 100644 --- a/app/Mcp/Tools/ManageDatabase.php +++ b/app/Mcp/Tools/ManageDatabase.php @@ -16,7 +16,7 @@ class ManageDatabase extends RestActionTool protected function actions(): array { return [ - 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}', 'ability' => 'write', 'description' => 'update database settings (data: name, description, image, is_public, public_port, public_port_timeout, limits_*, type credentials and *_conf fields, redact_all_env_values_in_logs, instant_deploy; instant_deploy and changing is_public need deploy ability)'], + 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}', 'ability' => 'write', 'description' => 'update database settings (data: name, description, image, is_public, public_port, public_port_timeout, limits_*, type credentials and *_conf fields, redact_all_env_values_in_logs, is_log_drain_enabled, include_coolify_default_variables, instant_deploy; instant_deploy and changing is_public need deploy ability)'], 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'], 'clone' => ['method' => 'POST', 'path' => 'databases/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the database to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source database while volumes are copied and needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'databases/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the database to another environment of the team (data: environment_uuid)'], diff --git a/app/Mcp/Tools/ManageServer.php b/app/Mcp/Tools/ManageServer.php index 7680d1c41b..31f8acd5d1 100644 --- a/app/Mcp/Tools/ManageServer.php +++ b/app/Mcp/Tools/ManageServer.php @@ -17,7 +17,7 @@ class ManageServer extends RestActionTool { return [ 'create' => ['method' => 'POST', 'path' => 'servers', 'ability' => 'write', 'description' => 'add a server (data: ip, private_key_uuid, name, description, port, user, server_role, proxy_type, instant_validate)'], - 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}', 'ability' => 'write', 'description' => 'update a server (data: name, description, ip, port, user, private_key_uuid, server_role, proxy_type, concurrent_builds, dynamic_timeout, deployment_queue_limit, connection_timeout, is_terminal_enabled, server_disk_usage_*; changing proxy_type needs deploy ability)'], + 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}', 'ability' => 'write', 'description' => 'update a server (data: name, description, ip, port, user, private_key_uuid, server_role, proxy_type, concurrent_builds, dynamic_timeout, deployment_queue_limit, connection_timeout, is_terminal_enabled, server_disk_usage_*, wildcard_domain, server_timezone, backup_compression_cpu_percentage; changing proxy_type needs deploy ability)'], 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'], 'validate' => ['method' => 'POST', 'path' => 'servers/{uuid}/validate', 'ability' => 'write', 'description' => 'start a server validation (data: install=true also installs Docker and needs deploy ability)'], ]; diff --git a/app/Mcp/Tools/ManageService.php b/app/Mcp/Tools/ManageService.php index cfd5b38c75..f7868dea4f 100644 --- a/app/Mcp/Tools/ManageService.php +++ b/app/Mcp/Tools/ManageService.php @@ -16,11 +16,11 @@ class ManageService extends RestActionTool protected function actions(): array { return [ - 'update' => ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, redact_all_env_values_in_logs, instant_deploy; instant_deploy needs deploy ability)'], + 'update' => ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, include_coolify_default_variables, redact_all_env_values_in_logs, instant_deploy; instant_deploy needs deploy ability)'], 'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], 'clone' => ['method' => 'POST', 'path' => 'services/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the service to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source service while volumes are copied and needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'services/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the service to another environment of the team (data: environment_uuid)'], - 'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'], + 'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, redirect, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'], 'update_database' => ['method' => 'PATCH', 'path' => 'services/{uuid}/databases/{database_uuid}', 'ability' => 'write', 'description' => 'update a service database (data: human_name, description, image, exclude_from_status, is_log_drain_enabled, is_public, public_port, public_port_timeout; changing is_public needs deploy ability)'], 'import_database' => ['method' => 'POST', 'path' => 'services/{uuid}/databases/{database_uuid}/imports', 'ability' => 'deploy', 'confirm' => true, 'description' => 'import a backup into a service database, can overwrite data (data: source s3 | server, s3_storage_uuid, path, dump_all, replace_existing, keep_owners, restore_mysql_users); check progress with get_database_import'], ]; diff --git a/app/Mcp/Tools/ManageStorage.php b/app/Mcp/Tools/ManageStorage.php index 5fdf754964..1211833052 100644 --- a/app/Mcp/Tools/ManageStorage.php +++ b/app/Mcp/Tools/ManageStorage.php @@ -17,7 +17,7 @@ class ManageStorage extends RestActionTool { return [ 'create' => ['method' => 'POST', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'create a storage (data: type persistent|file, name, mount_path, content, is_directory, is_host_file, fs_path; services also resource_uuid; file content and an fs_path outside the resource directory need deploy ability)'], - 'update' => ['method' => 'PATCH', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'update a storage (data: uuid of the storage, type, name, mount_path, content, is_preview_suffix_enabled; content of a file outside the resource directory needs deploy ability)'], + 'update' => ['method' => 'PATCH', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'update a storage (data: uuid of the storage, type, name, mount_path, content, is_preview_suffix_enabled; a content change needs deploy ability; the content is saved right away and written to the server in the background, so a failed write does not change the response)'], 'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete a storage'], 'set_backup' => ['method' => 'PUT', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'description' => 'create or replace the volume backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), stop_during_backup, retention fields ...; stop_during_backup needs deploy ability)'], 'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'], diff --git a/app/Models/Application.php b/app/Models/Application.php index f743be45f1..1f474d4aa3 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1885,9 +1885,7 @@ class Application extends BaseModel $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; - if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { @@ -1912,9 +1910,7 @@ class Application extends BaseModel $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } - if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { @@ -1960,11 +1956,7 @@ class Application extends BaseModel $escapedRepoUrl = escapeshellarg($repoUrl); $fullRepoUrl = $repoUrl; $git_clone_command_base = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; - if ($only_checkout) { - $git_clone_command = $git_clone_command_base; - } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); if ($pull_request_id !== 0) { $branch = "{$pullRequestRef}:{$pr_branch_name}"; @@ -2002,11 +1994,7 @@ class Application extends BaseModel $gitlabSshCommand = gitSshCommand($gitlabPort, $customSshKeyLocation); $gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\""; $git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - if ($only_checkout) { - $git_clone_command = $git_clone_command_base; - } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($pull_request_id !== 0) { @@ -2063,11 +2051,7 @@ class Application extends BaseModel $deployKeySshCommand = gitSshCommand($customPort, $customSshKeyLocation); $deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\""; $git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - if ($only_checkout) { - $git_clone_command = $git_clone_command_base; - } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($pull_request_id !== 0) { if ($git_type === 'gitlab') { @@ -2270,8 +2254,9 @@ class Application extends BaseModel { ['commands' => $cloneCommand, 'git' => $git] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir); + // Callers read data from stdout after the checkout, so progress lines of the checkout go to stderr. return [ - 'command' => str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)), + 'command' => 'exec >&2 && '.str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)), 'git' => $git, ]; } @@ -2290,9 +2275,9 @@ class Application extends BaseModel $checkoutDir = "/tmp/{$uuid}/checkout"; ['command' => $cloneCommand, 'git' => $git] = $this->serverCheckout($uuid, $checkoutDir); $composeFile = ltrim(rtrim($this->base_directory, '/').$this->docker_compose_location, '/'); - $submoduleCommands = $this->composeFileSubmoduleCommands($git, $composeFile); + $submoduleCommand = $this->composeFileSubmoduleCommand($git, $composeFile, $checkoutDir); // Go into the submodule that contains the Compose file, and read the file from the objects of that submodule. - $enterSubmodule = $submoduleCommands === [] ? '' : implode(' ', [ + $enterSubmodule = $submoduleCommand === null ? '' : implode(' ', [ 'while sub=$(git ls-tree -r HEAD | while read -r mode type object path; do', 'if [ "$mode" = 160000 ]; then case "$file" in "$path"/*) echo "$path"; break ;; esac; fi; done) && [ -n "$sub" ];', 'do cd "$sub" || exit 1; file="${file#"$sub"/}"; done;', @@ -2317,9 +2302,9 @@ class Application extends BaseModel "mkdir -p /tmp/{$uuid}", "cd /tmp/{$uuid}", // One sh -c line: the non-root sudo parser would turn `&& if ...; then` into invalid `&& sudo if`. - 'sh -c '.escapeshellarg($cloneCommand), + // The submodule step runs in the same script, because a deploy key is removed when the script exits. + 'sh -c '.escapeshellarg($submoduleCommand === null ? $cloneCommand : "{$cloneCommand} && {$submoduleCommand}"), "cd {$checkoutDir}", - ...$submoduleCommands, 'sh -c '.escapeshellarg($readComposeFile), ]); } @@ -2328,23 +2313,21 @@ class Application extends BaseModel * The clone has no checkout and no submodules. If the Compose file is inside a submodule, this fills the * index (without a working tree) and clones only that submodule, with the credentials of the clone. * - * @return list + * The command is appended to the clone script, so it must not be a separate sh -c line. */ - private function composeFileSubmoduleCommands(string $git, string $composeFile): array + private function composeFileSubmoduleCommand(string $git, string $composeFile, string $checkoutDir): ?string { if (! $this->settings?->is_git_submodules_enabled) { - return []; + return null; } $depthFlag = $this->settings->is_git_shallow_clone_enabled ? ' --depth=1' : ''; $escapedComposeFile = escapeshellarg($composeFile); - // One sh -c line: the non-root sudo parser cannot change commands inside a loop. - return ['git read-tree HEAD', 'sh -c '.escapeshellarg( - 'git ls-files -s | while read -r mode object stage path; do ' + return 'cd '.escapeshellarg($checkoutDir).' && git read-tree HEAD && ' + .'git ls-files -s | while read -r mode object stage path; do ' ."if [ \"\$mode\" = 160000 ]; then case {$escapedComposeFile} in \"\$path\"/*) " - ."{$git} submodule update --init --recursive{$depthFlag} -- \"\$path\" >/dev/null || exit 1 ;; esac; fi; done" - )]; + ."{$git} submodule update --init --recursive{$depthFlag} -- \"\$path\" >/dev/null || exit 1 ;; esac; fi; done"; } /** diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index c36934e539..4ef77a4f16 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -55,6 +55,17 @@ class EnvironmentVariable extends BaseModel 'RAILPACK_SKIP_MIGRATIONS', ]; + /** + * Magic variable commands (SERVICE__) whose value generateEnvValue() generates. + */ + public const GENERATED_MAGIC_VARIABLE_COMMANDS = [ + 'PASSWORD', 'PASSWORD_64', 'PASSWORDWITHSYMBOLS', 'PASSWORDWITHSYMBOLS_64', + 'BASE64', 'BASE64_32', 'BASE64_64', 'BASE64_128', + 'REALBASE64', 'REALBASE64_32', 'REALBASE64_64', 'REALBASE64_128', + 'HEX_32', 'HEX_64', 'HEX_128', + 'USER', 'LOWERCASEUSER', 'SUPABASEANON', 'SUPABASESERVICE', + ]; + protected $attributes = [ 'is_runtime' => true, 'is_buildtime' => true, @@ -125,7 +136,8 @@ class EnvironmentVariable extends BaseModel if (! $found) { $application = Application::find($environment_variable->resourceable_id); - if ($application) { + // A compose preview generates its own magic secrets on the first pull request parse. + if ($application && ! ($application->build_pack === 'dockercompose' && $environment_variable->isGeneratedMagicVariable())) { ModelsEnvironmentVariable::create([ 'key' => $environment_variable->key, 'value' => $environment_variable->value, @@ -156,6 +168,12 @@ class EnvironmentVariable extends BaseModel }); } + public function isGeneratedMagicVariable(): bool + { + return str_starts_with($this->key, 'SERVICE_') + && in_array(parseCommandFromMagicEnvVariable($this->key)->value(), self::GENERATED_MAGIC_VARIABLE_COMMANDS, true); + } + public function preservesLegacyComposeNull(): bool { return $this->preserve_compose_null && $this->value === null; diff --git a/app/Services/ContainerFilesystemService.php b/app/Services/ContainerFilesystemService.php index 5392f7d483..7fe8221f03 100644 --- a/app/Services/ContainerFilesystemService.php +++ b/app/Services/ContainerFilesystemService.php @@ -402,12 +402,15 @@ class ContainerFilesystemService $command = parseCommandsByLineForSudo(collect([$command]), $this->server)[0]; } - $process = proc_open($this->sshCommand($command), [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); + $process = proc_open($this->sshCommand(), [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); if (! is_resource($process)) { throw new \RuntimeException('Could not start the download.'); } try { + // The command goes to ssh on stdin, so it is not in a command line on the Coolify host. + fwrite($pipes[0], SshMultiplexingHelper::sshBatchInput($command)); + fclose($pipes[0]); while (! feof($pipes[1])) { $chunk = fread($pipes[1], 64 * 1024); if ($chunk === false || connection_aborted()) { @@ -450,9 +453,9 @@ class ContainerFilesystemService instant_remote_process([$this->buildS3UploadCommand($path, $isDir, $url, $headers)], $this->server, timeout: 3600); } - protected function sshCommand(string $command): string + protected function sshCommand(): string { - return SshMultiplexingHelper::generateSshCommand($this->server, $command); + return SshMultiplexingHelper::generateSshBatchCommand($this->server); } protected function escapePath(string $path, string $label): string diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php index 4fc38a9539..7f8090fc61 100644 --- a/app/Services/DatabaseStartCommandExecutor.php +++ b/app/Services/DatabaseStartCommandExecutor.php @@ -23,7 +23,8 @@ class DatabaseStartCommandExecutor $secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction') ? $database->resolvedSecretManagerValuesForRedaction() : []; - $remoteCommand = SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)); + // The commands go to ssh on stdin, so the database passwords in them are not in a command line on the Coolify host. + $remoteCommand = SshMultiplexingHelper::generateSshBatchCommand($server); $activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]); $activity->save(); @@ -34,6 +35,7 @@ class DatabaseStartCommandExecutor try { $process = Process::timeout($timeout) ->idleTimeout(3600) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets, &$pending): void { $pending[$type] = ($pending[$type] ?? '').$output; $end = max(strrpos($pending[$type], "\n"), strrpos($pending[$type], "\r")); diff --git a/app/Services/DockerRegistryLogins.php b/app/Services/DockerRegistryLogins.php index 8406fa9ec4..8b3d62b4fc 100644 --- a/app/Services/DockerRegistryLogins.php +++ b/app/Services/DockerRegistryLogins.php @@ -216,10 +216,9 @@ class DockerRegistryLogins /** * Run docker login on the server, writing to the same config file that deployments mount. * - * The SSH wrapper sends this script to the server's shell on stdin, and the shell builtin echo passes the - * secret to docker on stdin, so it is not a process argument on the server. On the Coolify host it is still - * in the command line of the local shell that runs ssh, because SshMultiplexingHelper::generateSshCommand() - * embeds the script in a heredoc. It is kept out of SSH error logs and exception messages. + * The script goes to the server's shell on ssh stdin, and the shell builtin echo passes the secret to docker + * on stdin, so it is not a process argument on the Coolify host or on the server. It is kept out of SSH error + * logs and exception messages. */ public static function login(Server $server, string $registry, string $username, string $password): void { @@ -277,7 +276,9 @@ class DockerRegistryLogins { $commands = $server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $server) : [$command]; try { - $process = Process::timeout(60)->run(SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands))); + $process = Process::timeout(60) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($server)); } catch (ProcessTimedOutException|SymfonyProcessTimedOutException) { // The timeout message contains the full SSH command, including the registry password. throw new RuntimeException('The command on the server timed out after 60 seconds.', 124); diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index 1c5c28e2d8..5398456a33 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -727,9 +727,9 @@ class SentinelTrafficClient $commands = $this->server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $this->server) : [$command]; try { - $process = Process::timeout(self::REMOTE_TIMEOUT)->run( - SshMultiplexingHelper::generateSshCommand($this->server, implode("\n", $commands), commandTimeout: self::REMOTE_TIMEOUT) - ); + $process = Process::timeout(self::REMOTE_TIMEOUT) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($this->server, commandTimeout: self::REMOTE_TIMEOUT)); if ($process->exitCode() !== 0) { excludeCertainErrors($process->errorOutput(), $process->exitCode()); diff --git a/app/Services/ServerTransfer/ServerTransferBundle.php b/app/Services/ServerTransfer/ServerTransferBundle.php index dd75f61b2e..09cee9d951 100644 --- a/app/Services/ServerTransfer/ServerTransferBundle.php +++ b/app/Services/ServerTransfer/ServerTransferBundle.php @@ -213,6 +213,7 @@ class ServerTransferBundle } foreach (self::items($environment['databases'] ?? null) as $d => $database) { + self::checkDatabasePorts($errors, "{$at}.databases.{$d}", (array) ($database['attributes'] ?? [])); self::checkStorages($errors, "{$at}.databases.{$d}", $database); self::checkScheduledBackups($errors, "{$at}.databases.{$d}", $database); } @@ -395,4 +396,38 @@ class ServerTransferBundle self::checkPath($errors, "{$at}.scheduled_backups.{$i}.databases_to_backup", $backup['databases_to_backup'] ?? null); } } + + /** + * Checks the port values of a database. Older databases were saved before the UI checked them + * (March 2026), so this accepts any public port in 1-65535, spaces around mappings, empty entries, + * and container-only ports. It refuses values that are not ports, because they reach the compose file. + * + * @param list $errors + * @param array $attributes + */ + private static function checkDatabasePorts(array &$errors, string $at, array $attributes): void + { + if (array_key_exists('public_port', $attributes)) { + self::check($errors, "{$at}.public_port", $attributes['public_port'], ['nullable', 'integer', 'between:1,65535']); + } + + if (! array_key_exists('ports_mappings', $attributes) || $attributes['ports_mappings'] === null) { + return; + } + $mappings = $attributes['ports_mappings']; + if (! is_string($mappings)) { + $errors[] = "{$at}.ports_mappings must be a string."; + + return; + } + foreach (explode(',', $mappings) as $mapping) { + $mapping = trim($mapping, " \t"); + if ($mapping === '' || preg_match('/^\d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))?$/', $mapping) === 1 || preg_match(ValidationPatterns::PORT_MAPPINGS_PATTERN, $mapping) === 1) { + continue; + } + $errors[] = "{$at}.ports_mappings must be a comma-separated list of port mappings (e.g. 5432:5432,127.0.0.1:8080:80/udp)."; + + return; + } + } } diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 6e885123f3..eadf066bc6 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -100,6 +100,8 @@ class DatabaseBackupFileValidator return false; } + $sql = self::withoutCopyData($sql); + // Token separator PostgreSQL treats as whitespace: real whitespace, a block comment // (the lazy match can extend to the outer end of a nested comment), or a line // comment. Comment forms also match inside string literals, which can only block @@ -120,6 +122,29 @@ class DatabaseBackupFileValidator return preg_match('~(?:from|to)'.$sep.'+program|lo_(?:import|export)'.$sep.'*\(|alter'.$sep.'+system'.$sep.'+(?:set|reset)~is', $sql) !== 0; } + /** + * Removes the table data of COPY ... FROM stdin blocks: rows are data, not SQL, and ordinary + * text such as "how to program" matched the patterns. Same rule as the restore scan in + * DatabaseImportCommandBuilder: a block starts at the line "COPY ... FROM stdin;" as pg_dump + * writes it and ends at the line "\.". + */ + private static function withoutCopyData(string $sql): string + { + $kept = ''; + $offset = 0; + + while (preg_match('/^COPY .* FROM stdin;$/m', $sql, $match, PREG_OFFSET_CAPTURE, $offset) === 1) { + $dataStart = $match[0][1] + strlen($match[0][0]); + $kept .= substr($sql, $offset, $dataStart - $offset); + $end = preg_match('/^\\\\\\.$/m', $sql, $terminator, PREG_OFFSET_CAPTURE, $dataStart) === 1 + ? $terminator[0][1] + 2 + : strlen($sql); + $offset = $end; + } + + return $kept.substr($sql, $offset); + } + private static function extensionFor(string $name): ?string { $lower = strtolower($name); @@ -152,14 +177,14 @@ class DatabaseBackupFileValidator $sample = (string) file_get_contents($path, false, null, 0, 4096); return match ($extension) { - 'sql' => self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample), + 'sql' => self::looksLikeText($sample), 'sql.gz', 'gz', 'tar.gz', 'tgz', 'bson.gz', 'archive.gz', 'db.gz', 'sqlite.gz', 'sqlite3.gz' => str_starts_with($sample, "\x1f\x8b"), 'zip' => str_starts_with($sample, "PK\x03\x04") || str_starts_with($sample, "PK\x05\x06") || str_starts_with($sample, "PK\x07\x08"), 'tar' => substr($sample, 257, 5) === 'ustar', 'bz2' => str_starts_with($sample, 'BZh'), 'xz' => str_starts_with($sample, "\xfd7zXZ\x00"), 'dump', 'bak', 'archive', 'dmp' => str_starts_with($sample, 'PGDMP') - || (self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample)), + || self::looksLikeText($sample), 'bson' => self::looksLikeBson($path, $sample), 'db', 'sqlite', 'sqlite3' => str_starts_with($sample, "SQLite format 3\0"), default => false, diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php index 4535420e6f..851c47fa37 100644 --- a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php +++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php @@ -54,6 +54,11 @@ tolower($0) ~ /^[ \t]*create[ \t]+(database|schema)[ \t]/ { if (is_system(databa !skip { print } AWK; + /** + * Prints a PostgreSQL SQL dump without the table data of its COPY ... FROM stdin blocks. + */ + private const POSTGRES_COPY_DATA_FILTER = 'data { if ($0 == "\\\\.") data = 0; next } { print } /^COPY .* FROM stdin;$/ { data = 1 }'; + /** * @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. @@ -135,13 +140,19 @@ SH; // Comments are no longer stripped: deleting the rest of a line on "--" also cut // inside string literals, which let "SELECT '--'; COPY ... PROGRAM" slip through. // A commented-out PROGRAM statement is now blocked too, which is the safer default. - $scan = static fn (string $source): string => "{$source} | LC_ALL=C grep -Eiq {$escapedPsqlPattern} || {$source} | tr -d '~' | tr '\\n\\r' '~~' | LC_ALL=C grep -Eiq {$escapedSqlPattern}"; + // Table data of COPY ... FROM stdin blocks is not scanned: it is data, not SQL, and ordinary + // text such as "how to program" matched the patterns. pg_dump writes each block as the line + // "COPY ... FROM stdin;", the rows, and the line "\.". When awk is missing or fails, the + // scan receives a line that matches, so the restore is blocked. + $withoutCopyData = static fn (string $source): string => "{ {$source} | LC_ALL=C awk ".escapeshellarg(self::POSTGRES_COPY_DATA_FILTER)." || echo '\\! from program'; }"; + $scan = static fn (string $source): string => $withoutCopyData($source)." | LC_ALL=C grep -Eiq {$escapedPsqlPattern} || ".$withoutCopyData($source)." | tr -d '~' | tr '\\n\\r' '~~' | LC_ALL=C grep -Eiq {$escapedSqlPattern}"; $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null'); $sqlScan = $scan($contents); $blockedProgram = 'echo \'Blocked PostgreSQL restore: the backup contains a COPY ... PROGRAM, psql shell command, lo_import/lo_export, or ALTER SYSTEM pattern. If you trust this dump, restore it from the database container terminal.\'; exit 1'; $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1'; return <</dev/null 2>&1 || { echo 'Blocked PostgreSQL restore: awk is required to scan the backup.'; exit 1; } header=\$({$contents} | head -c 5) tar_magic=\$({$contents} | head -c 262 | tail -c 5) if [ "\$header" = 'PGDMP' ] || [ "\$tar_magic" = 'ustar' ]; then @@ -270,6 +281,12 @@ SH; /** * Checks the backup format before any database is dropped, then recreates the * target database and restores archives with pg_restore and SQL with psql. + * The restore must not stop on the first error: a pg_dumpall dump always creates the + * superuser role (and creates the target database) that already exist here, and an + * archive with owners references roles this server may not have. Stopping there would + * leave the server without the databases that were just dropped. + * pg_dumpall does not create the postgres database (it expects it to exist), so it is + * recreated too when POSTGRES_DB names another database. */ private function postgresqlDumpAll(): string { @@ -287,10 +304,11 @@ check_superuser psql -U "$pg_user" -d template1 -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" || exit 1 psql -U "$pg_user" -d template1 -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U "$pg_user" --maintenance-db=template1 --if-exists {} || exit 1 createdb -U "$pg_user" "$db" || exit 1 +[ "$db" = postgres ] || createdb -U "$pg_user" postgres || exit 1 if [ "$kind" = archive ]; then - stream | pg_restore --exit-on-error -U "$pg_user" -d "$db" + stream | pg_restore -U "$pg_user" -d "$db" else - stream | psql -v ON_ERROR_STOP=1 -U "$pg_user" -d "$db" + stream | psql -U "$pg_user" -d "$db" fi SH; } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 21912fb82f..4dffd37b5f 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -159,9 +159,9 @@ trait ExecuteRemoteCommand } /** - * Execute the actual command with process handling. With $input, the content goes to the command over - * SSH stdin, because Linux limits one exec argument to 128 KiB and generateSshCommand() puts the whole - * script into one argument. + * Execute the actual command with process handling. Without $input, the command goes to the remote shell + * over SSH stdin, so its secrets are not in a command line on the Coolify host. With $input, the command + * is the SSH argument and the content goes to it over SSH stdin. */ private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false, ?string $input = null) { @@ -169,9 +169,12 @@ trait ExecuteRemoteCommand $this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true); } - $remote_command = $input === null - ? SshMultiplexingHelper::generateSshCommand($this->server, $command) - : SshMultiplexingHelper::generateSshStdinCommand($this->server, $command); + if ($input === null) { + $remote_command = SshMultiplexingHelper::generateSshBatchCommand($this->server); + $input = SshMultiplexingHelper::sshBatchInput((string) $command); + } else { + $remote_command = SshMultiplexingHelper::generateSshStdinCommand($this->server, $command); + } $addLogEntry = function (string $type, string $output) use ($command, $hidden, $customType, $command_hidden, $skip_command_log): void { // Sanitize output to ensure valid UTF-8 encoding before JSON encoding $log_output = str(sanitize_utf8_text($output))->trim(); diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index c6d7a20a0c..d8f2d27296 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -1,6 +1,6 @@ persistentStorages()->get(); foreach ($persistentVolumes as $volume) { $newName = ''; @@ -472,28 +473,18 @@ function clone_application(Application $source, $destination, array $overrides = $newPersistentVolume->save(); if ($cloneVolumeData) { - try { - StopApplication::dispatch($source, false, false); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $source->destination->server; - $targetServer = $newApplication->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - queue_application_deployment( - deployment_uuid: new_public_id(), - application: $source, - server: $sourceServer, - destination: $source->destination, - no_questions_asked: true - ); - } catch (Exception $e) { - Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } + $pendingVolumeClones[] = new VolumeCloneJob( + $volume->name, + $newPersistentVolume->name, + $source->destination->server, + $newApplication->destination->server, + $newPersistentVolume, + ); } } + CloneVolumeDataWithSourceStopped::run($source, $pendingVolumeClones); + // Clone file storages $fileStorages = $source->fileStorages()->get(); foreach ($fileStorages as $storage) { diff --git a/bootstrap/helpers/auth.php b/bootstrap/helpers/auth.php index 564ae31062..4d7b1943ef 100644 --- a/bootstrap/helpers/auth.php +++ b/bootstrap/helpers/auth.php @@ -3,19 +3,49 @@ use Illuminate\Http\Request; /** - * The client IP for rate limits, allow-lists, and audit logs. Behind Cloudflare, $request->ip() - * is the Cloudflare edge, so a valid CF-Connecting-IP is used: on cloud, and on self-hosted - * instances when the request comes from a Cloudflare IP. A client that connects directly - * cannot set the client IP with the header. + * The client IP for rate limits, allow-lists, and audit logs. + * + * TrustProxies trusts every peer, so a client that connects directly to the dashboard port could + * set $request->ip() with X-Forwarded-For. The TCP peer (REMOTE_ADDR) decides what to trust: + * - A public peer is the client. Forwarded headers are ignored, except CF-Connecting-IP when the + * peer is a Cloudflare edge. + * - A private or loopback peer is a local reverse proxy (coolify-proxy, cloudflared, a Docker + * network), so the address that proxy forwarded is used. CF-Connecting-IP is used only when + * that address is a Cloudflare edge. + * On cloud, which runs behind Cloudflare, a valid CF-Connecting-IP is always used. */ function client_ip(Request $request): string { - $ip = (string) $request->ip(); $cloudflareIp = $request->header('CF-Connecting-IP'); + $cloudflareIp = is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false ? $cloudflareIp : null; - if (is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false && (isCloud() || isCloudflareIp($ip))) { + $peer = (string) $request->server('REMOTE_ADDR'); + $hop = isLocalProxyIp($peer) ? (string) $request->ip() : $peer; + + if ($cloudflareIp !== null && (isCloud() || isCloudflareIp($hop))) { return $cloudflareIp; } - return $ip; + return $hop; +} + +/** + * Whether an address is loopback, private, or link-local (IPv4, IPv6, or IPv4-mapped IPv6): a + * reverse proxy on the same host or Docker network rather than a client on the internet. + */ +function isLocalProxyIp(string $ip): bool +{ + if (filter_var($ip, FILTER_VALIDATE_IP) === false) { + return false; + } + + $packed = inet_pton($ip); + if (strlen($packed) === 16 && str_starts_with($packed, str_repeat("\0", 10)."\xff\xff")) { + $ip = inet_ntop(substr($packed, 12)); + } + + return ipMatch($ip, [ + '127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '169.254.0.0/16', + '::1/128', 'fc00::/7', 'fe80::/10', + ]); } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 5e34a4dc4c..8ae596a1e1 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1151,6 +1151,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $pullRequestId = $pull_request_id; $isPullRequest = $pullRequestId == 0 ? false : true; + // A public preview can run the compose file of a fork pull request. + $isPublicPullRequest = $isPullRequest && (bool) data_get($resource, 'settings.is_pr_deployments_public_enabled'); $server = data_get($resource, 'destination.server'); try { $yaml = parseDockerComposeYaml($compose); @@ -1216,12 +1218,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // convert environment variables to one format $environment = convertToKeyValueCollection($environment); - // Add Coolify defined environments - $allEnvironments = $resource->environment_variables()->get(['key', 'value']); - - $allEnvironments = $allEnvironments->mapWithKeys(function ($item) { - return [$item['key'] => $item['value']]; - }); // filter and add magic environments foreach ($environment as $key => $value) { // Get all SERVICE_ variables from keys and values @@ -1269,7 +1265,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $resource->save(); } - if (! $parsed['has_port']) { + // The stored variables belong to the main application; a preview deployment gets its own domains. + if (! $parsed['has_port'] && ! $isPullRequest) { $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), @@ -1279,7 +1276,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); } - if ($parsed['has_port']) { + if ($parsed['has_port'] && ! $isPullRequest) { $newKey = str($key)->beforeLast('_'); $resource->environment_variables()->updateOrCreate([ @@ -1375,45 +1372,48 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port"; } - // ALWAYS create base SERVICE_FQDN variable (host only, no scheme) - $resource->environment_variables()->firstOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceNamePreserved}", - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdnValueForEnv, - 'is_preview' => false, - ]); - - // ALWAYS create base SERVICE_URL variable (with scheme) - $resource->environment_variables()->firstOrCreate([ - 'key' => "SERVICE_URL_{$serviceNamePreserved}", - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $url, - 'is_preview' => false, - ]); - - // If port-specific, ALSO create port-specific pairs - if ($parsed['has_port'] && $port) { + // The stored variables belong to the main application; a preview deployment gets its own domains. + if (! $isPullRequest) { + // ALWAYS create base SERVICE_FQDN variable (host only, no scheme) $resource->environment_variables()->firstOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceNamePreserved}_{$port}", + 'key' => "SERVICE_FQDN_{$serviceNamePreserved}", 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $fqdnValueForEnvWithPort, + 'value' => $fqdnValueForEnv, 'is_preview' => false, ]); + // ALWAYS create base SERVICE_URL variable (with scheme) $resource->environment_variables()->firstOrCreate([ - 'key' => "SERVICE_URL_{$serviceNamePreserved}_{$port}", + 'key' => "SERVICE_URL_{$serviceNamePreserved}", 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $urlWithPort, + 'value' => $url, 'is_preview' => false, ]); + + // If port-specific, ALSO create port-specific pairs + if ($parsed['has_port'] && $port) { + $resource->environment_variables()->firstOrCreate([ + 'key' => "SERVICE_FQDN_{$serviceNamePreserved}_{$port}", + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $fqdnValueForEnvWithPort, + 'is_preview' => false, + ]); + + $resource->environment_variables()->firstOrCreate([ + 'key' => "SERVICE_URL_{$serviceNamePreserved}_{$port}", + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $urlWithPort, + 'is_preview' => false, + ]); + } } if ($resource->build_pack === 'dockercompose') { @@ -1437,6 +1437,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } } + } elseif ($isPullRequest) { + // A preview deployment reads its preview variables, so a pull request parse generates the value there. + $previewVariable = $resource->environment_variables_preview()->firstOrCreate([ + 'key' => $key->value(), + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => generateEnvValue($command, $resource), + 'is_preview' => true, + ]); + // A public preview does not get the production secret; it gets its own value. + if ($isPublicPullRequest && ! $previewVariable->wasRecentlyCreated + && $previewVariable->value === $resource->environment_variables()->where('key', $key->value())->first()?->value) { + $previewVariable->update(['value' => generateEnvValue($command, $resource)]); + } } else { $value = generateEnvValue($command, $resource); $resource->environment_variables()->firstOrCreate([ @@ -1452,6 +1467,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } + // A pull request parse keeps its magic variables in the preview variables (see above), not in the production variables. + $isPullRequestMagicVariable = fn (string $key): bool => $isPullRequest && $allMagicEnvironments->has($key); + // generate SERVICE_NAME variables for docker compose services $serviceNameEnvironments = collect([]); if ($resource->build_pack === 'dockercompose') { @@ -1863,15 +1881,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $isRequired = str_contains($operator, '?'); // Create the primary variable with its default (only if it doesn't exist) - $envVar = $resource->environment_variables()->firstOrCreate([ - 'key' => $varName, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $isRequired ? null : $defaultValue, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); + if (! $isPullRequestMagicVariable($varName)) { + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $isRequired ? null : $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + } // Recursively process nested variables in default value if (str_contains($defaultValue, '${')) { @@ -1887,7 +1907,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); - if (! $isMagicVariable) { + if (! $isMagicVariable && ! $isPullRequestMagicVariable($nestedVarName)) { if ($nestedSplit !== null) { $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ 'key' => $nestedSplit['variable'], @@ -1918,14 +1938,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } else { // Simple variable reference without default $parsedKeyValue = replaceVariables($value); - $envVar = $resource->environment_variables()->firstOrCreate([ - 'key' => $content, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'is_preview' => false, - 'is_required' => $isRequired, - ]); + if (! $isPullRequestMagicVariable($content)) { + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + } } } else { // Fallback to old behavior for malformed input (backward compatibility) @@ -2108,7 +2130,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($environment->count() > 0) { $environment = $environment->filter(function ($value, $key) { return ! str($key)->startsWith('SERVICE_FQDN_'); - })->map(function ($value, $key) use ($resource, $isPullRequest) { + })->map(function ($value, $key) use ($resource, $isPullRequest, $isPublicPullRequest) { + $isValueFromDatabase = false; // Preserve empty strings and null values with correct Docker Compose semantics: // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy") // - Null: Variable is unset/removed from container environment (may inherit from host) @@ -2119,10 +2142,18 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } elseif ($value === '' && ! ($isPullRequest && str($key)->startsWith('SERVICE_URL_'))) { // Empty string - allow database override for backward compatibility. // The stored service URLs belong to the main application, so a preview does not use them. - $dbEnv = $resource->environment_variables()->where('key', $key)->first(); + // A preview uses its preview variables, like ApplicationDeploymentJob does. + $dbEnv = $isPullRequest + ? $resource->environment_variables_preview()->where('key', $key)->first() + : $resource->environment_variables()->where('key', $key)->first(); + // A public preview does not get preview variables that still have the production value. + if ($dbEnv && $isPublicPullRequest && $resource->environment_variables()->where('key', $key)->first()?->value === $dbEnv->value) { + $dbEnv = null; + } // Only use database override if it exists AND has a non-empty value if ($dbEnv && str($dbEnv->value)->isNotEmpty()) { $value = $dbEnv->value; + $isValueFromDatabase = true; } // Otherwise keep empty string as-is } @@ -2130,7 +2161,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}} // Without this, literal {{...}} strings end up in the compose environment: section, // which takes precedence over the resolved values in the .env file (env_file:) - if (is_string($value) && str_contains($value, '{{')) { + // A public preview resolves them only in its own variables, not in the compose file of the pull request. + if (is_string($value) && str_contains($value, '{{') && ($isValueFromDatabase || ! $isPublicPullRequest)) { $value = resolveSharedEnvironmentVariables($value, $resource); } diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index e5dd73de46..0c8bb86ebd 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -192,8 +192,8 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $ return SshRetryHandler::retry( function () use ($server, $command_string) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); - $process = runSshProcess($sshCommand, 30); + $sshCommand = SshMultiplexingHelper::generateSshBatchCommand($server); + $process = runSshProcess($sshCommand, 30, SshMultiplexingHelper::sshBatchInput($command_string)); $output = trim($process->output()); $exitCode = $process->exitCode(); @@ -228,8 +228,8 @@ function instant_remote_process(Collection|array $command, Server $server, bool return SshRetryHandler::retry( function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing, (int) $effectiveTimeout); - $process = runSshProcess($sshCommand, (int) $effectiveTimeout); + $sshCommand = SshMultiplexingHelper::generateSshBatchCommand($server, $disableMultiplexing, (int) $effectiveTimeout); + $process = runSshProcess($sshCommand, (int) $effectiveTimeout, SshMultiplexingHelper::sshBatchInput($command_string)); $output = trim($process->output()); $exitCode = $process->exitCode(); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 7c664649cb..bfb5f22759 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -25,8 +25,7 @@ function service_logo_urls(mixed $logo): array ]; } -use App\Actions\Service\StartService; -use App\Actions\Service\StopService; +use App\Actions\Shared\CloneVolumeDataWithSourceStopped; use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\GithubApp; @@ -38,8 +37,6 @@ use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Bus; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Stringable; use Spatie\Url\Url; @@ -778,22 +775,11 @@ function clone_service(Service $source, StandaloneDocker|SwarmDocker $destinatio $sourceServer = $source->destination?->server; $targetServer = $newService->destination?->server; - if ($pendingVolumeClones !== [] && $sourceServer && $targetServer) { - $chain = [ - function () use ($source) { - StopService::run($source); - }, - ...array_map( - fn (array $clone) => new VolumeCloneJob($clone[0], $clone[1]->name, $sourceServer, $targetServer, $clone[1]), - $pendingVolumeClones, - ), - function () use ($source) { - StartService::run($source); - }, - ]; - - // The jobs load the new volume rows, so they must not run before the transaction of the caller commits. - DB::afterCommit(fn () => Bus::chain($chain)->onQueue('high')->dispatch()); + if ($sourceServer && $targetServer) { + CloneVolumeDataWithSourceStopped::run($source, array_map( + fn (array $clone) => new VolumeCloneJob($clone[0], $clone[1]->name, $sourceServer, $targetServer, $clone[1]), + $pendingVolumeClones, + )); } return $newService; diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index f91c165e48..951fdeab1b 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -122,10 +122,13 @@ function allowedPathsForTwoFactorSetup(): array { return [ 'two-factor-required', + 'two-factor-required/switch-team', 'two-factor-challenge', 'logout', 'user/confirm-password', 'user/confirmed-password-status', + 'passkeys/confirm', + 'passkeys/confirm/options', 'user/two-factor-authentication', 'user/confirmed-two-factor-authentication', 'user/two-factor-qr-code', diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3a63b279d7..8e214b385e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,8 +3,9 @@ services: # Realtime runs inside the coolify container now. Without this entry, such an override is an # incomplete service and every upgrade fails. Zero replicas means it never starts, and # "docker compose up" removes the old coolify-realtime container (an inactive profile keeps it). + # It uses the coolify image, so install and upgrade do not pull another image. soketi: - image: busybox:1.37 + image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" deploy: replicas: 0 coolify: diff --git a/openapi.json b/openapi.json index 4ed696c239..3d24978d9c 100644 --- a/openapi.json +++ b/openapi.json @@ -5395,7 +5395,7 @@ "Applications" ], "summary": "Update Storage", - "description": "Update a persistent storage or file storage by application UUID.", + "description": "Update a persistent storage or file storage by application UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.", "operationId": "update-storage-by-application-uuid", "parameters": [ { @@ -10286,7 +10286,7 @@ "Databases" ], "summary": "Update Storage", - "description": "Update a persistent storage or file storage by database UUID.", + "description": "Update a persistent storage or file storage by database UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.", "operationId": "update-storage-by-database-uuid", "parameters": [ { @@ -17932,7 +17932,7 @@ "Servers" ], "summary": "Migrate server to another Coolify instance", - "description": "Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET \/servers\/{uuid}\/transfer. The target URL must use https. Requires read:sensitive and write.", + "description": "Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. All secrets of the server, also locked environment variables, are sent to the target. Follow the progress with GET \/servers\/{uuid}\/transfer. The target URL must use https. Requires read:sensitive and write, and the owner role of the server team.", "operationId": "migrate-server-between-instances", "parameters": [ { @@ -17973,7 +17973,7 @@ "description": "Transfer started" }, "403": { - "description": "Missing sensitive permission" + "description": "Missing sensitive permission or not an owner of the server team" }, "404": { "$ref": "#\/components\/responses\/404" @@ -21735,7 +21735,7 @@ "Services" ], "summary": "Update Storage", - "description": "Update a persistent storage or file storage by service UUID.", + "description": "Update a persistent storage or file storage by service UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.", "operationId": "update-storage-by-service-uuid", "parameters": [ { diff --git a/openapi.yaml b/openapi.yaml index f1f4987405..4f02ba373d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3545,7 +3545,7 @@ paths: tags: - Applications summary: 'Update Storage' - description: 'Update a persistent storage or file storage by application UUID.' + description: 'Update a persistent storage or file storage by application UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.' operationId: update-storage-by-application-uuid parameters: - @@ -6738,7 +6738,7 @@ paths: tags: - Databases summary: 'Update Storage' - description: 'Update a persistent storage or file storage by database UUID.' + description: 'Update a persistent storage or file storage by database UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.' operationId: update-storage-by-database-uuid parameters: - @@ -11335,7 +11335,7 @@ paths: tags: - Servers summary: 'Migrate server to another Coolify instance' - description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write.' + description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. All secrets of the server, also locked environment variables, are sent to the target. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write, and the owner role of the server team.' operationId: migrate-server-between-instances parameters: - @@ -11364,7 +11364,7 @@ paths: '202': description: 'Transfer started' '403': - description: 'Missing sensitive permission' + description: 'Missing sensitive permission or not an owner of the server team' '404': $ref: '#/components/responses/404' '422': @@ -13712,7 +13712,7 @@ paths: tags: - Services summary: 'Update Storage' - description: 'Update a persistent storage or file storage by service UUID.' + description: 'Update a persistent storage or file storage by service UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.' operationId: update-storage-by-service-uuid parameters: - diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 3a63b279d7..8e214b385e 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -3,8 +3,9 @@ services: # Realtime runs inside the coolify container now. Without this entry, such an override is an # incomplete service and every upgrade fails. Zero replicas means it never starts, and # "docker compose up" removes the old coolify-realtime container (an inactive profile keeps it). + # It uses the coolify image, so install and upgrade do not pull another image. soketi: - image: busybox:1.37 + image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" deploy: replicas: 0 coolify: diff --git a/other/nightly/install.sh b/other/nightly/install.sh index b00f35da86..7a1783082a 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -1028,8 +1028,8 @@ if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal - # Remove only the key from a previous install (generated with the comment "coolify"), not other keys that contain "coolify" - sed -i -E "/[[:space:]]coolify[[:space:]]*$/d" ~/.ssh/authorized_keys + # Remove only the key from a previous install (comment "coolify", or "root@coolify" before September 2024), not other keys that contain "coolify" + sed -i -E "/[[:space:]](root@)?coolify[[:space:]]*$/d" ~/.ssh/authorized_keys cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >>~/.ssh/authorized_keys rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub fi diff --git a/resources/views/components/live-toggle.blade.php b/resources/views/components/live-toggle.blade.php index 2716d7c826..56bbca6fd3 100644 --- a/resources/views/components/live-toggle.blade.php +++ b/resources/views/components/live-toggle.blade.php @@ -1,11 +1,14 @@ {{-- Live refresh toggle. Entangles the `live` bool of the host Livewire component, so it works with any time range. The button shows "Live" with a pulsing dot when refresh is on and - "Paused" with a play icon when it is off. `storageKey` keeps the choice per browser. + "Paused" with a play icon when it is off. `storageKey` keeps the last choice the user clicked per + browser; changes made by the component (for example a range that starts paused) are not stored. + A stored "off" always applies on page load; a stored "on" applies only when `restoreLive` is true. --}} @props([ 'active' => false, 'storageKey' => null, + 'restoreLive' => true, 'title' => 'Turn live refresh on or off', ]) @@ -13,10 +16,9 @@ @if ($storageKey) x-init=" const stored = localStorage.getItem(@js($storageKey)); if (stored === '0') { live = false; } - if (stored === '1') { live = true; } - $watch('live', value => localStorage.setItem(@js($storageKey), value ? '1' : '0')); + if (stored === '1' && @js((bool) $restoreLive)) { live = true; } " @endif> -