From 554bd354bf0064634b495cc24d1cdb5d0bca821f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:08:52 +0200 Subject: [PATCH 1/3] fix: support host storage paths and clean up cancelled deployments Allow validated host file and directory mounts outside resource directories while protecting external paths from deletion. Clean up cancelled deployment containers and clear tracked process IDs. Preserve legacy KeyDB and Dragonfly password parsing, accept server backup paths without extension restrictions, and allow Compose variable proxy ports. --- .../CleanupCancelledDeployment.php | 70 ++++ app/Actions/Database/StartDatabaseImport.php | 5 +- app/Actions/Database/StartDragonfly.php | 6 +- app/Actions/Database/StartKeydb.php | 6 +- app/Actions/Proxy/CheckProxy.php | 5 +- .../Api/ApplicationsController.php | 21 +- .../Controllers/Api/DatabasesController.php | 21 +- app/Http/Controllers/Api/DeployController.php | 31 +- .../Api/ScheduledTasksController.php | 12 +- .../Api/ServerSentinelController.php | 6 +- .../Controllers/Api/ServicesController.php | 21 +- app/Http/Controllers/Webhook/Bitbucket.php | 4 - .../ThrottlesManualWebhookFailures.php | 4 +- app/Http/Controllers/Webhook/Gitea.php | 4 - app/Http/Controllers/Webhook/Github.php | 15 +- app/Http/Controllers/Webhook/Gitlab.php | 4 - app/Jobs/ApplicationDeploymentJob.php | 51 ++- app/Jobs/DatabaseBackupJob.php | 95 +++-- app/Jobs/DeregisterGithubRunnerJob.php | 43 +++ app/Jobs/ProvisionGithubRunnerJob.php | 10 + app/Jobs/ReconcileGithubRunnersJob.php | 13 + app/Jobs/ScheduledTaskJob.php | 57 ++- app/Livewire/Analytics.php | 3 + .../Concerns/InteractsWithDnsProviders.php | 14 + app/Livewire/Dashboard/TrafficAnalytics.php | 2 + app/Livewire/Project/Application/Advanced.php | 27 ++ .../Project/Application/Backup/Create.php | 2 +- .../Project/Application/DeploymentNavbar.php | 57 +-- .../Database/CreateScheduledBackup.php | 2 +- .../Database/Sqlite/ConnectApplication.php | 6 +- app/Livewire/Project/Service/FileStorage.php | 42 +- app/Livewire/Project/Service/Storage.php | 30 +- .../Project/Service/VolumeBackup/Create.php | 2 +- .../Shared/ResourceTrafficAnalytics.php | 11 + .../Project/Shared/Storages/VolumeBackups.php | 2 +- app/Livewire/Server/Proxy.php | 9 + .../Server/TrafficAnalyticsSettings.php | 6 +- app/Livewire/SettingsBackup.php | 5 +- app/Mcp/Tools/CancelDeployment.php | 30 +- app/Models/ApplicationDeploymentQueue.php | 17 + app/Models/EnvironmentVariable.php | 46 ++- app/Models/GithubRunnerExecution.php | 20 + app/Models/LocalFileVolume.php | 155 +++++++- app/Models/Server.php | 3 + app/Providers/EventServiceProvider.php | 14 +- app/Services/ProxyPortParser.php | 42 +- app/Services/SentinelTrafficClient.php | 70 +++- app/Support/DatabaseBackupFileValidator.php | 9 +- app/Traits/ExecuteRemoteCommand.php | 8 +- app/Traits/HasSecretManager.php | 19 +- app/Traits/HasSecretManagerAutocomplete.php | 3 +- ...uest_to_github_runner_executions_table.php | 22 ++ ...gacy_enqueued_scheduled_job_deliveries.php | 31 ++ .../project/service/file-storage.blade.php | 10 +- .../project/service/storage.blade.php | 12 +- .../livewire/traffic/_device-chart.blade.php | 3 +- templates/compose/9router.yaml | 6 +- templates/service-templates-latest.json | 2 +- templates/service-templates.json | 2 +- tests/Feature/Api/ScheduledTaskApiTest.php | 57 +++ ...ationDeploymentControlVarFilteringTest.php | 12 + tests/Feature/Auth/LoginFailedAuditTest.php | 42 ++ tests/Feature/BackupRunSafetyTest.php | 46 +++ .../Feature/BuildServerFallbackPolicyTest.php | 51 +++ .../CreateScheduledBackupValidationTest.php | 49 +++ .../DatabaseBackupUploadValidationTest.php | 23 ++ .../DeploymentCancellationCleanupTest.php | 156 ++++++++ .../DeploymentSensitiveCommandFailureTest.php | 89 +++++ tests/Feature/DnsProviderManagementTest.php | 87 +++++ tests/Feature/FileStorageMountPathTest.php | 7 +- tests/Feature/GithubRunnerFlowTest.php | 155 +++++++- ...eydbDragonflyLegacyPasswordQuotingTest.php | 40 ++ .../AdvancedContainerNamingTest.php | 66 ++++ .../Feature/NineRouterServiceTemplateTest.php | 51 +++ .../Feature/Proxy/ProxyPortVariablesTest.php | 26 ++ .../ScheduledJobManagerDispatchTest.php | 87 ++++- .../RemoteSecretResourceLifecycleTest.php | 23 ++ .../SecretManagerLinksComponentTest.php | 30 ++ ...verProxyConfigurationAuthorizationTest.php | 58 +++ .../Feature/SettingsBackupEmptyStateTest.php | 63 +++ .../Feature/SqliteConnectApplicationTest.php | 23 ++ ...artDatabaseImportBackupPreparationTest.php | 51 +++ tests/Feature/StorageAndServiceAuditTest.php | 2 +- tests/Feature/StorageApiTest.php | 14 +- tests/Feature/StorageHostPathTest.php | 365 ++++++++++++++++++ tests/Feature/TraefikVersionStateTest.php | 3 + .../SentinelTrafficAvailabilityTest.php | 94 +++++ .../SentinelTrafficCacheTest.php | 2 + .../SentinelTrafficTokenStdinTest.php | 2 + .../TrafficLockedPropertiesTest.php | 66 ++++ .../TrafficSettingsLimitsTest.php | 74 ++++ tests/Feature/VolumeBackupTest.php | 55 +++ tests/Feature/Webhook/WebhookHmacTest.php | 42 +- tests/Unit/ProxyPortValidationTest.php | 20 +- 94 files changed, 2922 insertions(+), 357 deletions(-) create mode 100644 app/Actions/Application/CleanupCancelledDeployment.php create mode 100644 app/Jobs/DeregisterGithubRunnerJob.php create mode 100644 database/migrations/2026_10_05_064824_add_is_pull_request_to_github_runner_executions_table.php create mode 100644 database/migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php create mode 100644 tests/Feature/Auth/LoginFailedAuditTest.php create mode 100644 tests/Feature/DeploymentCancellationCleanupTest.php create mode 100644 tests/Feature/NineRouterServiceTemplateTest.php create mode 100644 tests/Feature/ServerProxyConfigurationAuthorizationTest.php create mode 100644 tests/Feature/StorageHostPathTest.php create mode 100644 tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php create mode 100644 tests/Feature/TrafficAnalytics/TrafficLockedPropertiesTest.php create mode 100644 tests/Feature/TrafficAnalytics/TrafficSettingsLimitsTest.php diff --git a/app/Actions/Application/CleanupCancelledDeployment.php b/app/Actions/Application/CleanupCancelledDeployment.php new file mode 100644 index 0000000000..171e660e66 --- /dev/null +++ b/app/Actions/Application/CleanupCancelledDeployment.php @@ -0,0 +1,70 @@ +deployment_uuid; + $containerStopped = false; + $firstError = null; + + try { + foreach ($this->helperContainerServers($deployment, $teamId) as $server) { + try { + $containerExists = instant_remote_process(["docker ps -a --filter name={$deploymentUuid} --format '{{.Names}}'"], $server); + + if (str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process(["docker rm -f {$deploymentUuid}"], $server); + $containerStopped = true; + } + } catch (\Throwable $e) { + $firstError ??= $e; + } + } + + if ($containerStopped) { + $deployment->addLogEntry('Deployment container stopped.'); + } elseif (! $firstError) { + $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); + } + } finally { + $deployment->update(['current_process_id' => null]); + } + + if ($firstError) { + throw $firstError; + } + } + + /** + * A restart creates the helper on the deployment server even when a build server was selected. + * + * @return Collection + */ + private function helperContainerServers(ApplicationDeploymentQueue $deployment, int $teamId): Collection + { + $serverIds = collect([$deployment->build_server_id, $deployment->server_id]) + ->reject(fn ($id) => is_null($id)) + ->map(fn ($id) => (int) $id) + ->unique() + ->values(); + + return Server::whereTeamId($teamId) + ->whereKey($serverIds) + ->get() + ->sortBy(fn (Server $server) => $serverIds->search($server->id)) + ->values(); + } +} diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index 235399e652..5689882c2b 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -219,9 +219,12 @@ class StartDatabaseImport return $file; } + /** + * Server backups can have any file name; the restore script checks the format. + */ private function assertServerPath(?string $path): void { - if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) { + if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path)) { throw new DatabaseImportException('The server path is invalid.'); } } diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index eff973c36d..20f69bb715 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -272,14 +272,14 @@ class StartDragonfly /** * The password argument of the start command. Docker Compose splits the command like a shell, so the * password is quoted. Databases created before this release keep their unquoted v4.3.23 argument when - * quoting would change the password the server receives (backslashes and quotes), so their server - * password stays the same. + * quoting would change it (quotes, backslashes, whitespace, ; & | < >): Compose splits or cuts the old + * command there, so quoting now would change the password or options and can lose data. */ private function requirePassArgument(): string { $keepsUnquotedPassword = $this->database->legacy_password_quoting && ! $this->redisPasswordFromSecretManager - && strpbrk($this->resolvedRedisPassword, '\\\'"') !== false; + && strpbrk($this->resolvedRedisPassword, "\\'\";&|<> \t\r\n") !== false; return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword); } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 4940f38f58..f6c2ff0bc7 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -318,14 +318,14 @@ class StartKeydb /** * The password argument of the start command. Docker Compose splits the command like a shell, so the * password is quoted. Databases created before this release keep their unquoted v4.3.23 argument when - * quoting would change the password the server receives (backslashes and quotes), so their server - * password stays the same. + * quoting would change it (quotes, backslashes, whitespace, ; & | < >): Compose splits or cuts the old + * command there, so quoting now would change the password or options and can lose data. */ private function requirePassArgument(): string { $keepsUnquotedPassword = $this->database->legacy_password_quoting && ! $this->redisPasswordFromSecretManager - && strpbrk($this->resolvedRedisPassword, '\\\'"') !== false; + && strpbrk($this->resolvedRedisPassword, "\\'\";&|<> \t\r\n") !== false; return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword); } diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 6ac4487082..a0419feb31 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -65,11 +65,13 @@ class CheckProxy } $portsToCheck = []; + $publishesOnlyUncheckedPorts = false; try { if ($server->proxyType() !== ProxyTypes::NONE->value) { $proxyCompose = GetProxyConfiguration::run($server); $portsToCheck = ProxyPortParser::fromConfiguration($proxyCompose); + $publishesOnlyUncheckedPorts = $portsToCheck === [] && ProxyPortParser::publishesOnlyUncheckedPorts($proxyCompose); } } catch (\Throwable $e) { Log::error('Error checking proxy: '.$e->getMessage()); @@ -77,7 +79,8 @@ class CheckProxy return false; } if (count($portsToCheck) === 0) { - return false; + // Variable ports such as `${HTTP_PORT}:80` are only resolved by Compose on start. + return $publishesOnlyUncheckedPorts; } $portsToCheck = array_values(array_unique($portsToCheck)); // Check port conflicts in parallel diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ba762aff74..b2d84113f2 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -5243,7 +5243,7 @@ class ApplicationsController extends Controller 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], - 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'], ], additionalProperties: false, ), @@ -5367,9 +5367,9 @@ class ApplicationsController extends Controller } try { - $fsPath = confinePathToBase(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path'); + $fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path'); $mountPath = validateFileMountPath($request->mount_path, 'storage destination path'); - LocalFileVolume::assertRemotePathIsConfined($application->workdir(), $fsPath, $application->destination->server); + LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: true); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -5421,7 +5421,6 @@ class ApplicationsController extends Controller } else { try { $mountPath = validateFileMountPath($request->mount_path, 'file storage path'); - $fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path'); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -5429,6 +5428,20 @@ class ApplicationsController extends Controller ], 422); } + try { + if ($request->filled('fs_path')) { + $fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'file storage source path'); + LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: false); + } else { + $fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path'); + } + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()], + ], 422); + } + $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, 'mount_path' => $mountPath, diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index ed3e18ae17..38c304f3f1 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -4290,7 +4290,7 @@ class DatabasesController extends Controller 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], - 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'], ], additionalProperties: false, ), @@ -4410,9 +4410,9 @@ class DatabasesController extends Controller } try { - $fsPath = confinePathToBase(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path'); + $fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path'); $mountPath = validateFileMountPath($request->mount_path, 'storage destination path'); - LocalFileVolume::assertRemotePathIsConfined($database->workdir(), $fsPath, $database->destination->server); + LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: true); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -4464,7 +4464,6 @@ class DatabasesController extends Controller } else { try { $mountPath = validateFileMountPath($request->mount_path, 'file storage path'); - $fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path'); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -4472,6 +4471,20 @@ class DatabasesController extends Controller ], 422); } + try { + if ($request->filled('fs_path')) { + $fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'file storage source path'); + LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: false); + } else { + $fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path'); + } + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()], + ], 422); + } + $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, 'mount_path' => $mountPath, diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 33bd21da4f..419366d95f 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Application\CleanupCancelledDeployment; use App\Actions\Database\StartDatabase; use App\Actions\Service\StartService; use App\Enums\ApplicationDeploymentStatus; @@ -250,10 +251,6 @@ class DeployController extends Controller $deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id); try { - $deployment_uuid = $deployment->deployment_uuid; - $kill_command = "docker rm -f {$deployment_uuid}"; - $build_server_id = $deployment->build_server_id ?? $deployment->server_id; - // Mark deployment as cancelled $updated = ApplicationDeploymentQueue::whereKey($deployment->getKey()) ->whereIn('status', $cancellableStatuses) @@ -270,31 +267,9 @@ class DeployController extends Controller $deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value; $cancelled = true; - // Get the server - $server = Server::whereTeamId($teamId)->find($build_server_id); - try { - if ($server) { - // Add cancellation log entry - $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); - - // Check if container exists and kill it - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process([$kill_command], $server); - $deployment->addLogEntry('Deployment container stopped.'); - } else { - $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); - } - - // Kill running process if process ID exists - if ($deployment->current_process_id) { - $processKillCommand = "kill -9 {$deployment->current_process_id}"; - instant_remote_process([$processKillCommand], $server); - } - } + $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); + CleanupCancelledDeployment::run($deployment, (int) $teamId); } catch (\Throwable $e) { \Log::warning("Failed to clean up cancelled deployment {$deployment->id}: {$e->getMessage()}"); } diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 910be871ad..a5c8b4411f 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -70,7 +70,7 @@ class ScheduledTasksController extends Controller 'command' => 'required|string', 'frequency' => 'required|string', 'container' => 'string|nullable', - 'timeout' => 'integer|min:1', + 'timeout' => 'integer|min:1|max:36000', 'enabled' => 'boolean', ]); @@ -146,7 +146,7 @@ class ScheduledTasksController extends Controller 'command' => 'string', 'frequency' => 'string', 'container' => 'string|nullable', - 'timeout' => 'integer|min:1', + 'timeout' => 'integer|min:1|max:36000', 'enabled' => 'boolean', ]); @@ -350,7 +350,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -437,7 +437,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -713,7 +713,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), @@ -800,7 +800,7 @@ class ScheduledTasksController extends Controller 'command' => ['type' => 'string', 'description' => 'The command to execute.'], 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], - 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], ], ), diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php index 77bc16c1c2..53d90c7617 100644 --- a/app/Http/Controllers/Api/ServerSentinelController.php +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -191,10 +191,10 @@ class ServerSentinelController extends Controller 'sentinel_metrics_history_days' => 'integer|min:1', 'sentinel_push_interval_seconds' => 'integer|min:10', 'sentinel_custom_url' => 'nullable|url', - 'traffic_topn' => 'integer|min:1', + 'traffic_topn' => 'integer|min:1|max:1000', 'traffic_sample_threshold' => 'integer|min:0', - 'traffic_retention_1h_days' => 'integer|min:1', - 'traffic_retention_1d_days' => 'integer|min:1', + 'traffic_retention_1h_days' => 'integer|min:1|max:365', + 'traffic_retention_1d_days' => 'integer|min:1|max:3650', 'is_geoip_enabled' => 'boolean', 'geoip_refresh_days' => 'integer|min:1', 'geoip_maxmind_license_key' => 'nullable|string|max:255', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 74872c17ae..7ad3684f43 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -2476,7 +2476,7 @@ class ServicesController extends Controller 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], - 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'], ], additionalProperties: false, ), @@ -2605,9 +2605,9 @@ class ServicesController extends Controller } try { - $fsPath = confinePathToBase(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path'); + $fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path'); $mountPath = validateFileMountPath($request->mount_path, 'storage destination path'); - LocalFileVolume::assertRemotePathIsConfined($service->workdir(), $fsPath, $service->server); + LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: true); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -2659,7 +2659,6 @@ class ServicesController extends Controller } else { try { $mountPath = validateFileMountPath($request->mount_path, 'file storage path'); - $fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path'); } catch (\Throwable $e) { return response()->json([ 'message' => 'Validation failed.', @@ -2667,6 +2666,20 @@ class ServicesController extends Controller ], 422); } + try { + if ($request->filled('fs_path')) { + $fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'file storage source path'); + LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: false); + } else { + $fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path'); + } + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()], + ], 422); + } + $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, 'mount_path' => $mountPath, diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 588ae21633..e06a14e0bf 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -82,10 +82,6 @@ class Bitbucket extends Controller ]); } $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'bitbucket', $full_name, $branch); - if ($this->hasTooManyManualWebhookFailures($failure_key)) { - return $this->tooManyManualWebhookFailuresResponse($failure_key); - } - // A redelivery of the same signed payload is one guess. $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_bitbucket_token); $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name); diff --git a/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php index b3a8421581..7e48d1a637 100644 --- a/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php +++ b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php @@ -18,9 +18,7 @@ use Illuminate\Support\Facades\RateLimiter; * IPs; one misconfigured repository (wrong secret, deleted application, * untracked branch) therefore cannot lock out deliveries for other repositories. * - * After the failure limit is reached, the scope rejects all deliveries before - * reading application secrets or verifying signatures. Correctly signed - * deliveries in that scope must wait until the failure window expires. + * Valid deliveries are processed while locked: every sender shares the proxy's address. * * In one failure window, only distinct failed attempts are counted. An attempt * is identified by what the secret check depends on: the GitLab token, or the diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 92eddb5759..b3c0b22a48 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -74,10 +74,6 @@ class Gitea extends Controller } $matched_branch = $x_gitea_event === 'pull_request' ? $base_branch : $branch; $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'gitea', $full_name, $matched_branch); - if ($this->hasTooManyManualWebhookFailures($failure_key)) { - return $this->tooManyManualWebhookFailuresResponse($failure_key); - } - // A redelivery of the same signed payload is one guess. $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_hub_signature_256); $applications = Application::query(); diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 564bb515cd..1dec6b71d9 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -95,10 +95,6 @@ class Github extends Controller default => $base_branch, }; $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'github', $full_name, $matched_branch); - if ($this->hasTooManyManualWebhookFailures($failure_key)) { - return $this->tooManyManualWebhookFailuresResponse($failure_key); - } - // A redelivery of the same signed payload is one guess. $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_hub_signature_256); $applications = Application::query(); @@ -276,6 +272,11 @@ class Github extends Controller if ($jobId <= 0) { return response('Nothing to do. No workflow job found.'); } + // A public App can be installed by other accounts; only its own installation may use the runners. + $installationId = data_get($payload, 'installation.id'); + if (blank($githubApp->installation_id) || ! is_numeric($installationId) || (int) $installationId !== (int) $githubApp->installation_id) { + return response('Nothing to do. The job belongs to another installation of the GitHub App.'); + } $jobDetails = array_filter([ 'workflow_job_id' => $jobId, @@ -294,13 +295,15 @@ class Github extends Controller if ($matchingConfigs->isEmpty()) { return response('Nothing to do. No runner configuration matches the job labels.'); } - if (! $matchingConfigs->contains('allow_pull_requests', true) && $this->isPullRequestWorkflowJob($githubApp, $payload)) { + $isPullRequest = $matchingConfigs->contains(fn (GithubRunnerConfig $config) => ! $config->allow_pull_requests) + && $this->isPullRequestWorkflowJob($githubApp, $payload); + if ($isPullRequest && ! $matchingConfigs->contains('allow_pull_requests', true)) { return response('Nothing to do. The runner configuration does not allow pull request jobs.'); } $execution = GithubRunnerExecution::createOrFirst( ['github_app_id' => $githubApp->id, 'trigger_workflow_job_id' => $jobId], - [...$jobDetails, 'labels' => $labels, 'status' => GithubRunnerStatus::Queued, 'queued_at' => now()], + [...$jobDetails, 'labels' => $labels, 'is_pull_request' => $isPullRequest, 'status' => GithubRunnerStatus::Queued, 'queued_at' => now()], ); if ($execution->wasRecentlyCreated) { ProvisionGithubRunnerJob::dispatch($execution->id); diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 760e7cc701..c135aef328 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -430,10 +430,6 @@ class Gitlab extends Controller } $matched_branch = $x_gitlab_event === 'merge_request' ? $base_branch : $branch; $failure_key = $this->manualWebhookFailureRateLimitKey($request, 'gitlab', $full_name, $matched_branch); - if ($this->hasTooManyManualWebhookFailures($failure_key)) { - return $this->tooManyManualWebhookFailuresResponse($failure_key); - } - // GitLab sends a static token. A repeated wrong token is one guess. $failure_attempt = $this->manualWebhookTokenAttempt($x_gitlab_token); $applications = Application::query(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f75e23bfe6..7ffafc33c8 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -155,6 +155,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue /** @var array|null */ private ?array $remote_secrets_cache = null; + private ?bool $has_secret_manager_source = null; + private $env_nixpacks_args; private $env_railpack_args; @@ -466,7 +468,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($mustBuildElsewhere && ! $this->restart_only) { throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, and no usable build server was found. Add a build server or change the server role."); } - if (! $team->is_build_server_fallback_enabled) { + // A restart that has to rebuild selects again in should_skip_build(). + if (! $team->is_build_server_fallback_enabled && ! $this->restart_only) { throw new DeploymentException('No available dedicated build server was found. Enable a usable build server for this team or allow fallback to the deployment server in the team settings.'); } @@ -1484,6 +1487,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } if ($this->restart_only) { $this->restart_only = false; + if (! $this->use_build_server) { + // The restart skipped build server selection. + $this->selectBuildServer(); + if ($this->use_build_server) { + $this->detectBuildKitCapabilities(); + } + } $this->decide_what_to_do(); } @@ -1498,8 +1508,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $environmentVariables ->where('is_buildtime', true) - ->get(['value']) - ->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value)); + ->get(['value', 'is_literal']) + ->contains(fn (EnvironmentVariable $environmentVariable) => $this->uses_remote_secret_references($environmentVariable)); } private function check_image_locally_or_remotely() @@ -1555,6 +1565,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets."); } + $this->application_deployment_queue->redactRemoteSecrets($secrets); $this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()})."); return $this->remote_secrets_cache = $secrets; @@ -1610,16 +1621,38 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer); + if ($env->is_literal && ! $this->has_secret_manager_source()) { + return $value ?? ''; + } + return $this->substitute_remote_secrets($value ?? '', $env->key); } + /** + * Without a source, a literal keeps "{{vault.KEY}}" as text; other references fail closed. + */ + private function uses_remote_secret_references(EnvironmentVariable $env): bool + { + if (! RemoteSecretReferences::containsReference($env->value)) { + return false; + } + + return ! $env->is_literal || $this->has_secret_manager_source(); + } + + private function has_secret_manager_source(): bool + { + return $this->has_secret_manager_source ??= $this->remote_secrets_cache !== null + || $this->application->secretManagerLink()->exists(); + } + /** * Resolve a runtime variable to its dotenv representation. Values with * secret references are substituted and written as literals. */ private function resolve_environment_variable(EnvironmentVariable $env): ?string { - if (! RemoteSecretReferences::containsReference($env->value)) { + if (! $this->uses_remote_secret_references($env)) { return $env->getResolvedValueWithServer($this->mainServer); } @@ -2055,7 +2088,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } - if (RemoteSecretReferences::containsReference($env->value)) { + if ($this->uses_remote_secret_references($env)) { $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); continue; @@ -2113,7 +2146,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } - if (RemoteSecretReferences::containsReference($env->value)) { + if ($this->uses_remote_secret_references($env)) { $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); continue; @@ -3219,7 +3252,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string { - if (RemoteSecretReferences::containsReference($environmentVariable->value)) { + if ($this->uses_remote_secret_references($environmentVariable)) { $resolved = $this->resolve_environment_variable_raw($environmentVariable); return $resolved === '' ? null : $resolved; @@ -3772,7 +3805,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = RemoteSecretReferences::containsReference($env->value) + $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { @@ -3790,7 +3823,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = RemoteSecretReferences::containsReference($env->value) + $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 44c9d5c7c7..0cb2b4d9b0 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -32,6 +32,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Spatie\Activitylog\Models\Activity; @@ -41,6 +42,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Lets a backup that runs too long fail inside handle() before the worker times out. + */ + public const WORKER_TIMEOUT_MARGIN_SECONDS = 120; + public $maxExceptions = 1; public ?Team $team = null; @@ -79,14 +85,20 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public ?S3Storage $s3 = null; - public $timeout = 3600; + public $timeout = 3600 + self::WORKER_TIMEOUT_MARGIN_SECONDS; public ?string $backup_log_uuid = null; + /** + * Queued with the payload, so failed() finds this run's executions after a worker timeout. + */ + public ?string $executionUuidPrefix = null; + public function __construct(public ScheduledDatabaseBackup $backup, public ?string $occurrenceUuid = null) { $this->onQueue(crons_queue()); - $this->timeout = $backup->timeout ?? 3600; + $this->timeout = ($backup->timeout ?? 3600) + self::WORKER_TIMEOUT_MARGIN_SECONDS; + $this->executionUuidPrefix = new_public_id(16); } public function middleware(): array @@ -314,7 +326,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue // Generate unique UUID for each database backup execution $attempts = 0; do { - $this->backup_log_uuid = new_public_id(); + $this->backup_log_uuid = $this->executionUuidPrefix === null ? new_public_id() : $this->executionUuidPrefix.new_public_id(8); $exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists(); $attempts++; if ($attempts >= 3 && $exists) { @@ -547,6 +559,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + private function commandTimeout(): int + { + return (int) ($this->backup->timeout ?? 3600); + } + private function backupFilenamePart(string $database): string { return preg_replace('/[^A-Za-z0-9_.-]/', '-', $database); @@ -617,7 +634,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } } - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -674,7 +691,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } $commands[] = $backupCommand; - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -701,7 +718,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec {$escapedContainerName} mysqldump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}"; } - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -728,7 +745,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec {$escapedContainerName} mariadb-dump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}"; } - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -751,7 +768,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue backupDirectory: $this->backup_dir, ); - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -775,7 +792,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $script = 'f=$(mktemp) && sqlite3 -readonly '.escapeshellarg(StandaloneSqlite::DATA_DIRECTORY.'/'.$database).' \'.timeout 10000\' "VACUUM INTO \'$f\'" && cat "$f"; s=$?; rm -f "$f"; exit $s'; $dumpCommand = 'docker exec '.escapeshellarg($this->container_name).' sh -c '.escapeshellarg($script); $commands[] = $this->buildCompressedDumpCommand($dumpCommand, escapeshellarg($this->backup_location)); - $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -865,7 +882,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $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 {$escapedBackupLocation} {$escapedS3Destination}"; - instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); + instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->s3_uploaded = true; } catch (Throwable $e) { @@ -1016,29 +1033,32 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'trace' => $exception?->getTraceAsString(), ]); - $log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first(); + // After a worker timeout this runs on a fresh job copy without the state handle() set. + $this->team ??= Team::find($this->backup->team_id); + $executions = $this->executionsOfThisRun(); - if ($log) { - // Don't overwrite a successful backup status — a post-backup error - // (e.g. notification failure) should not retroactively mark the backup - // as failed (see GitHub issue #9088) - if ($log->status !== 'success') { - $log->update([ - 'status' => 'failed', - 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), - 'size' => 0, - 'filename' => null, - 'finished_at' => Carbon::now(), - ]); - } + // Don't overwrite a finished execution: handle() already reported it, and a post-backup + // error (e.g. notification failure) must not mark a successful backup as failed (#9088). + $unfinished = $executions->whereNotIn('status', ['success', 'failed']); + foreach ($unfinished as $execution) { + $execution->update([ + 'status' => 'failed', + 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), + 'size' => 0, + 'filename' => null, + 'finished_at' => Carbon::now(), + ]); } - // Notify team about permanent failure (only if backup didn't already succeed) - if ($this->team && $log?->status !== 'success') { - $databaseName = $log?->database_name ?? 'unknown'; - $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'; + if ($executions->isNotEmpty() && $unfinished->isEmpty()) { + return; + } + + $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'; + $databaseNames = $unfinished->isEmpty() ? ['unknown'] : $unfinished->map(fn (ScheduledDatabaseBackupExecution $execution) => $execution->database_name ?? 'unknown'); + foreach ($databaseNames as $databaseName) { try { - $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName)); + $this->team?->notify(new BackupFailed($this->backup, $this->backup->database, $output, $databaseName)); } catch (Throwable $e) { Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [ 'backup_id' => $this->backup->uuid, @@ -1047,4 +1067,21 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } } + + /** + * @return Collection + */ + private function executionsOfThisRun(): Collection + { + if ($this->executionUuidPrefix === null && $this->backup_log_uuid === null) { + return collect(); + } + + return ScheduledDatabaseBackupExecution::query() + ->where('scheduled_database_backup_id', $this->backup->id) + ->where(fn ($query) => $query + ->when($this->executionUuidPrefix !== null, fn ($query) => $query->orWhere('uuid', 'like', $this->executionUuidPrefix.'%')) + ->when($this->backup_log_uuid !== null, fn ($query) => $query->orWhere('uuid', $this->backup_log_uuid))) + ->get(); + } } diff --git a/app/Jobs/DeregisterGithubRunnerJob.php b/app/Jobs/DeregisterGithubRunnerJob.php new file mode 100644 index 0000000000..d9c595ee80 --- /dev/null +++ b/app/Jobs/DeregisterGithubRunnerJob.php @@ -0,0 +1,43 @@ +onQueue('high'); + } + + public function handle(): void + { + $githubApp = GithubApp::query()->find($this->githubAppId); + if (! $githubApp) { + return; + } + + try { + (new GithubRunnerApi($githubApp))->deleteRunner($this->runnerId); + } catch (\Throwable $e) { + Log::warning('Could not remove GitHub runner', ['github_app_id' => $this->githubAppId, 'runner_id' => $this->runnerId, 'error' => $e->getMessage()]); + } + } +} diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php index b417635748..f170c34c3b 100644 --- a/app/Jobs/ProvisionGithubRunnerJob.php +++ b/app/Jobs/ProvisionGithubRunnerJob.php @@ -150,6 +150,15 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue private function stopIfFinished(GithubRunnerExecution $execution): bool { + // The execution is deleted together with its server. + if (! GithubRunnerExecution::query()->whereKey($execution->id)->exists()) { + if ($execution->runner_id) { + DeregisterGithubRunnerJob::dispatch($execution->github_app_id, $execution->runner_id); + } + + return true; + } + $execution->refresh(); if ($execution->isActive()) { return false; @@ -181,6 +190,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue ->get() ->filter(fn (GithubRunnerConfig $config) => $config->server !== null && $config->matchesLabels($execution->labels ?? []) + && ($config->allow_pull_requests || ! $execution->is_pull_request) && $config->server->isBuildServer() && ! $config->server->isLocalhost() && $config->server->isFunctional()) diff --git a/app/Jobs/ReconcileGithubRunnersJob.php b/app/Jobs/ReconcileGithubRunnersJob.php index 459dd681c0..61566e9e6e 100644 --- a/app/Jobs/ReconcileGithubRunnersJob.php +++ b/app/Jobs/ReconcileGithubRunnersJob.php @@ -49,9 +49,22 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue ->filter(fn (Server $server) => $server->isFunctional()) ->each(fn (Server $server) => $this->reconcileServer($server)); + $this->removeRunnersOfDeletedServers(); $this->reconcileQueued(); } + /** + * Older versions left occupying executions of a deleted server active, which blocked deleting the App. + */ + private function removeRunnersOfDeletedServers(): void + { + GithubRunnerExecution::deleteAndDeregister( + GithubRunnerExecution::query() + ->whereNull('server_id') + ->whereIn('status', GithubRunnerStatus::occupying()) + ); + } + private function reconcileServer(Server $server): void { $output = instant_remote_process([GithubRunnerContainer::listCommand()], $server, false); diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index e3a352ebb1..8538760db8 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -28,6 +28,11 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024; + /** + * Lets a task that runs too long fail inside handle() before the worker times out. + */ + public const WORKER_TIMEOUT_MARGIN_SECONDS = 120; + /** * The number of times the job may be attempted. */ @@ -41,7 +46,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue /** * The number of seconds the job can run before timing out. */ - public $timeout = 300; + public $timeout = 300 + self::WORKER_TIMEOUT_MARGIN_SECONDS; public ?Team $team = null; @@ -54,9 +59,9 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public ?ScheduledTaskExecution $task_log = null; /** - * Store execution ID to survive job serialization for timeout handling. + * Queued with the payload, so failed() finds this run's execution after a worker timeout. */ - protected ?int $executionId = null; + public ?string $executionUuid = null; public string $task_status = 'failed'; @@ -71,7 +76,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue $this->onQueue(crons_queue()); $this->task = $task; - $this->timeout = $this->task->timeout ?? 300; + $this->timeout = ($this->task->timeout ?? 300) + self::WORKER_TIMEOUT_MARGIN_SECONDS; + $this->executionUuid = new_public_id(); } private function initializeExecutionContext(): void @@ -126,15 +132,17 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue try { $this->initializeExecutionContext(); - $this->task_log = ScheduledTaskExecution::create([ + // A retried job reuses the queued payload and its execution uuid. + if ($this->executionUuid === null || ScheduledTaskExecution::query()->where('uuid', $this->executionUuid)->exists()) { + $this->executionUuid = new_public_id(); + } + $this->task_log = ScheduledTaskExecution::forceCreate([ + 'uuid' => $this->executionUuid, 'scheduled_task_id' => $this->task->id, 'started_at' => $startTime, 'retry_count' => $this->attempts() - 1, ]); - // Store execution ID for timeout handling - $this->executionId = $this->task_log->id; - if ($this->resource->type() === 'application') { $containers = getCurrentApplicationContainerStatus($this->server, $this->resource, 0); if ($containers->count() > 0) { @@ -170,7 +178,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 - $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true); + $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->task->timeout ?? 300, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, @@ -265,30 +273,11 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue 'trace' => $exception?->getTraceAsString(), ]); - // Reload execution log from database - // When a job times out, failed() is called in a fresh process with the original - // queue payload, so $executionId will be null. We need to query for the latest execution. - $execution = null; - - // Try to find execution using stored ID first (works for non-timeout failures) - if ($this->executionId) { - $execution = ScheduledTaskExecution::find($this->executionId); - } - - // If no stored ID or not found, query for the most recent unfinished execution log for this - // task. A finished execution belongs to another run and must keep its result. - if (! $execution) { - $execution = ScheduledTaskExecution::query() - ->where('scheduled_task_id', $this->task->id) - ->whereNull('finished_at') - ->orderBy('created_at', 'desc') - ->first(); - } - - // Last resort: check task_log property - if (! $execution && $this->task_log) { - $execution = $this->task_log; - } + // After a worker timeout only the queued uuid identifies this run's execution. + $execution = $this->executionUuid === null ? null : ScheduledTaskExecution::query() + ->where('uuid', $this->executionUuid) + ->whereNull('finished_at') + ->first(); if ($execution) { $errorMessage = 'Job permanently failed after '.$this->attempts().' attempts'; @@ -304,7 +293,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue ]); } else { Log::channel('scheduled-errors')->warning('Could not find execution log to update', [ - 'execution_id' => $this->executionId, + 'execution_uuid' => $this->executionUuid, 'task_id' => $this->task->uuid, ]); } diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index 961bd545d6..327d9d220b 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -13,6 +13,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Livewire\Attributes\Lazy; +use Livewire\Attributes\Locked; use Livewire\Attributes\On; use Livewire\Attributes\Url; use Livewire\Component; @@ -22,8 +23,10 @@ class Analytics extends Component { use BuildsTrafficChartPayload; + #[Locked] public string $chartId = 'global-analytics'; + #[Locked] public ?string $scopedServerUuid = null; /** Traffic-enabled servers owned by the current team. */ diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php index f5e8e103c5..e136bc4d1b 100644 --- a/app/Livewire/Concerns/InteractsWithDnsProviders.php +++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php @@ -241,8 +241,22 @@ trait InteractsWithDnsProviders $this->persistDomainDnsStatuses(); } + /** + * The payload comes from the browser; members without update access also receive it and are ignored. + */ public function dnsRecordConfigurationFinished(array $event): void { + if (! auth()->user()?->can('update', $this->dnsResource())) { + return; + } + + if (! is_string($event['hostname'] ?? null) || ! is_string($event['resourceType'] ?? null) + || ! is_scalar($event['resourceId'] ?? null) || ! is_bool($event['successful'] ?? null) + || ! is_string($event['credential'] ?? null) || ! is_string($event['message'] ?? null) + || ! is_numeric($event['teamId'] ?? null) || (int) $event['teamId'] !== $this->dnsTeamId()) { + return; + } + $resource = $this->dnsResourceForHostname($event['hostname']); if ($resource === null || $resource->getMorphClass() !== $event['resourceType'] || (string) $resource->getKey() !== (string) $event['resourceId']) { diff --git a/app/Livewire/Dashboard/TrafficAnalytics.php b/app/Livewire/Dashboard/TrafficAnalytics.php index 2f67705ca8..047f461d32 100644 --- a/app/Livewire/Dashboard/TrafficAnalytics.php +++ b/app/Livewire/Dashboard/TrafficAnalytics.php @@ -9,6 +9,7 @@ use App\Services\TrafficAnalyticsAggregator; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Livewire\Attributes\Lazy; +use Livewire\Attributes\Locked; use Livewire\Component; #[Lazy] @@ -16,6 +17,7 @@ class TrafficAnalytics extends Component { use BuildsTrafficChartPayload; + #[Locked] public string $chartId = 'dashboard-traffic'; public Collection $servers; diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index da94470872..e7edb958ba 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -15,6 +15,9 @@ class Advanced extends Component { use AuthorizesRequests; + /** The format that saveCustomNamePrefix() produces with str()->slug(). */ + private const CONTAINER_NAME_PREFIX_PATTERN = '/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/'; + public Application $application; #[Validate(['boolean'])] @@ -102,6 +105,7 @@ class Advanced extends Component { if ($toModel) { $this->validate(); + $this->validateChangedContainerNamePrefix(); $this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled; $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; @@ -159,6 +163,29 @@ class Advanced extends Component $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? ''; } + /** + * An unchanged stored prefix is skipped, so older values do not block other settings. + */ + private function validateChangedContainerNamePrefix(): void + { + $prefix = $this->customContainerNamePrefix; + if (blank($prefix) || $prefix === $this->application->settings->custom_container_name_prefix) { + return; + } + + if (preg_match(self::CONTAINER_NAME_PREFIX_PATTERN, $prefix) !== 1) { + throw ValidationException::withMessages([ + 'customContainerNamePrefix' => 'The container name prefix may only contain lowercase letters, numbers, and single hyphens between them.', + ]); + } + + if (ApplicationSetting::isContainerNamePrefixInUse($prefix, $this->application->destination->server, $this->application->id)) { + throw ValidationException::withMessages([ + 'customContainerNamePrefix' => 'This container name prefix is already in use by another application on this Coolify instance.', + ]); + } + } + private function resetDefaultLabels() { if ($this->application->settings->is_container_label_readonly_enabled === false) { diff --git a/app/Livewire/Project/Application/Backup/Create.php b/app/Livewire/Project/Application/Backup/Create.php index 9b80c8832a..31a2951f4d 100644 --- a/app/Livewire/Project/Application/Backup/Create.php +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -113,7 +113,7 @@ class Create extends Component try { $backup = $target->scheduledBackups()->firstOrNew(); $backup->fill([ - 'team_id' => currentTeam()->id, + 'team_id' => $this->application->environment->project->team_id, 'frequency' => $this->frequency, 'enabled' => true, ]); diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 05b0165080..2f8592ced2 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -2,13 +2,13 @@ namespace App\Livewire\Project\Application; +use App\Actions\Application\CleanupCancelledDeployment; use App\Enums\ApplicationDeploymentStatus; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Server; use App\Traits\AuditsApplicationSettings; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Carbon; use Livewire\Component; class DeploymentNavbar extends Component @@ -101,69 +101,20 @@ class DeploymentNavbar extends Component } catch (\Throwable $e) { return handleError($e, $this); } - $deployment_uuid = $this->application_deployment_queue->deployment_uuid; - $kill_command = "docker rm -f {$deployment_uuid}"; - $build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id; $server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id; + $server = Server::ownedByCurrentTeam()->find($server_id); // First, mark the deployment as cancelled to prevent further processing $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); try { - if ($this->application->settings->is_build_server_enabled) { - $server = Server::ownedByCurrentTeam()->find($build_server_id); - } else { - $server = Server::ownedByCurrentTeam()->find($server_id); - } - - // Add cancellation log entry - if ($this->application_deployment_queue->logs) { - $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); - - $new_log_entry = [ - 'command' => $kill_command, - 'output' => 'Deployment cancelled by user.', - 'type' => 'stderr', - 'order' => count($previous_logs) + 1, - 'timestamp' => Carbon::now('UTC'), - 'hidden' => false, - ]; - $previous_logs[] = $new_log_entry; - $this->application_deployment_queue->update([ - 'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR), - ]); - } - - // Try to stop the helper container if it exists - // Check if container exists first - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - // Container exists, kill it - instant_remote_process([$kill_command], $server); - } else { - // Container hasn't started yet - $this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.'); - } - - // Also try to kill any running process if we have a process ID - if ($this->application_deployment_queue->current_process_id) { - try { - $processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}"; - instant_remote_process([$processKillCommand], $server); - } catch (\Throwable $e) { - // Process might already be gone, that's ok - } - } + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user.', 'stderr'); + CleanupCancelledDeployment::run($this->application_deployment_queue, currentTeam()->id); } catch (\Throwable $e) { // Still mark as cancelled even if cleanup fails return handleError($e, $this); } finally { - $this->application_deployment_queue->update([ - 'current_process_id' => null, - ]); next_after_cancel($server); } } diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 235eec202e..8c7c49c2e2 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -69,7 +69,7 @@ class CreateScheduledBackup extends Component 's3_storage_id' => null, 'database_id' => $database->id, 'database_type' => $database->getMorphClass(), - 'team_id' => currentTeam()->id, + 'team_id' => $database->team()->id, ]; if ($database->type() === 'standalone-postgresql') { diff --git a/app/Livewire/Project/Database/Sqlite/ConnectApplication.php b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php index b2dadd0d18..c600810c1c 100644 --- a/app/Livewire/Project/Database/Sqlite/ConnectApplication.php +++ b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php @@ -10,6 +10,7 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Computed; +use Livewire\Attributes\Locked; use Livewire\Component; use Symfony\Component\Yaml\Yaml; @@ -25,8 +26,9 @@ class ConnectApplication extends Component public string $mountPath = StandaloneSqlite::DATA_DIRECTORY; /** - * Docker volume that holds the database files. + * Docker volume that holds the database files. Set on the server only: it becomes a mount source. */ + #[Locked] public string $volumeName = ''; /** @@ -73,9 +75,11 @@ class ConnectApplication extends Component $this->validate([ 'applicationUuid' => 'required|string', 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'volumeName' => ValidationPatterns::volumeNameRules(), ], [ 'applicationUuid.required' => 'Select an application first.', 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.', + ...ValidationPatterns::volumeNameMessages('volumeName'), ]); $application = $this->selectedApplication(); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 0c19e73915..87acbda781 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -247,9 +247,9 @@ class FileStorage extends Component } elseif ($this->fileStorage->is_host_file) { $message = 'Host file mount removed.'; } - $deletedFromServer = $this->permanently_delete && ! $this->fileStorage->is_host_file; + $deletedFromServer = $this->canDeleteFromServer() && in_array('permanently_delete', $selectedActions, true); if ($deletedFromServer) { - $message = 'Directory deleted from the server.'; + $message = $this->fileStorage->is_directory ? 'Directory deleted from the server.' : 'File deleted from the server.'; $this->fileStorage->deleteStorageOnServer(); } $this->fileStorage->delete(); @@ -340,18 +340,38 @@ class FileStorage extends Component ], fn ($value) => $value !== null)); } + /** + * Only mounts inside the resource directory are ever deleted on the server. + */ + private function canDeleteFromServer(): bool + { + return ! $this->fileStorage->is_host_file && ! $this->fileStorage->isOutsideResourceDirectory(); + } + public function render() { + $kind = $this->fileStorage->is_directory ? 'directory' : 'file'; + $deletionActions = [ + $this->fileStorage->is_host_file + ? 'The mount will be removed from the container.' + : "The selected {$kind} will be permanently deleted from the container.", + ]; + $deletionCheckboxes = []; + + if ($this->canDeleteFromServer()) { + $deletionCheckboxes[] = [ + 'id' => 'permanently_delete', + 'label' => $this->fileStorage->is_directory + ? 'The selected directory and all its contents will be permanently deleted from the server.' + : 'The selected file will be permanently deleted from the server.', + ]; + } else { + $deletionActions[] = "Only the mount configuration will be removed. The {$kind} at {$this->fileStorage->fs_path} is not deleted on the server."; + } + return view('livewire.project.service.file-storage', [ - 'directoryDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permanently deleted from the server.'], - ], - 'fileDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted from the server.'], - ], - 'hostFileDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'], - ], + 'deletionCheckboxes' => $deletionCheckboxes, + 'deletionActions' => $deletionActions, ]); } } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 3509fbcad6..b6b92dfe48 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -7,6 +7,7 @@ use App\Livewire\Project\Shared\Storages\All as StorageList; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; +use App\Models\Server; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use App\Support\ValidationPatterns; @@ -35,6 +36,11 @@ class Storage extends Component public string $file_storage_path = ''; + /** + * Optional host path of a new file mount; empty means the resource directory. + */ + public string $file_storage_source = ''; + public ?string $file_storage_content = null; public string $host_file_storage_source = ''; @@ -311,12 +317,18 @@ class Storage extends Component $this->validate([ 'file_storage_path' => 'required|string', + 'file_storage_source' => 'nullable|string', 'file_storage_content' => 'nullable|string', ]); $this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path'); - $fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path'); + if (filled($this->file_storage_source)) { + $fs_path = LocalFileVolume::resolveHostPath($this->fileStorageHostPath(), $this->file_storage_source, 'file storage source path'); + LocalFileVolume::assertHostPathOnServer($this->fileStorageHostPath(), $fs_path, $this->storageServer(), isDirectory: false); + } else { + $fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path'); + } $fileVolume = LocalFileVolume::create([ 'fs_path' => $fs_path, @@ -384,7 +396,7 @@ class Storage extends Component 'file_storage_directory_destination' => 'required|string', ]); - $this->file_storage_directory_source = confinePathToBase( + $this->file_storage_directory_source = LocalFileVolume::resolveHostPath( $this->fileStorageHostPath(), $this->file_storage_directory_source, 'storage source path' @@ -393,8 +405,7 @@ class Storage extends Component $this->file_storage_directory_destination, 'storage destination path' ); - $server = $this->resource->service?->server ?? $this->resource->destination->server; - LocalFileVolume::assertRemotePathIsConfined($this->fileStorageHostPath(), $this->file_storage_directory_source, $server); + LocalFileVolume::assertHostPathOnServer($this->fileStorageHostPath(), $this->file_storage_directory_source, $this->storageServer(), isDirectory: true); $directoryVolume = LocalFileVolume::create([ 'fs_path' => $this->file_storage_directory_source, @@ -421,6 +432,7 @@ class Storage extends Component $this->name = $this->generateDefaultVolumeName(); $this->mount_path = ''; $this->file_storage_path = ''; + $this->file_storage_source = ''; $this->file_storage_content = null; $this->file_storage_directory_destination = ''; $this->host_file_storage_source = ''; @@ -457,8 +469,18 @@ class Storage extends Component return ($name ?: 'volume').'-data'; } + private function storageServer(): Server + { + return $this->resource->service?->server ?? $this->resource->destination->server; + } + public function fileStoragePreviewPath(): string { + $source = str($this->file_storage_source)->trim(); + if ($source->isNotEmpty()) { + return $source->startsWith('/') ? $source->value() : $this->fileStorageHostPath().'/'.preg_replace('#^\./#', '', $source->value()); + } + $path = str($this->file_storage_path)->trim(); if ($path->isEmpty()) { diff --git a/app/Livewire/Project/Service/VolumeBackup/Create.php b/app/Livewire/Project/Service/VolumeBackup/Create.php index 560139f71f..289aec5015 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Create.php +++ b/app/Livewire/Project/Service/VolumeBackup/Create.php @@ -70,7 +70,7 @@ class Create extends Component try { $backup = $target->scheduledBackups()->firstOrNew(); $backup->fill([ - 'team_id' => currentTeam()->id, + 'team_id' => $this->service->environment->project->team_id, 'frequency' => $this->frequency, 'enabled' => true, ]); diff --git a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php index a8a750f17e..4d5e5b9220 100644 --- a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php +++ b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php @@ -10,7 +10,9 @@ use App\Services\TrafficAnalyticsAggregator; use App\Services\TrafficResource; use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; /** * Traffic analytics tab of one resource (Application or Service). A resource can record @@ -28,6 +30,7 @@ abstract class ResourceTrafficAnalytics extends Component public bool $enabled = false; + #[Locked] public ?string $analyticsServerUuid = null; // Realtime refresh. Off by default (click "Live" to arm it). Only meaningful on the @@ -89,6 +92,14 @@ abstract class ResourceTrafficAnalytics extends Component } } + /** + * Subclasses redeclare $chartId, which drops #[Locked], so the lock is enforced here. + */ + public function updatingChartId(): void + { + throw new CannotUpdateLockedPropertyException('chartId'); + } + public function setRange(string $range): void { $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 125aaf42bb..9768d67a55 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -421,7 +421,7 @@ class VolumeBackups extends Component { $backup = $this->storage->scheduledBackups()->firstOrNew(); $backup->fill([ - 'team_id' => currentTeam()->id, + 'team_id' => $this->resource->environment->project->team_id, 'frequency' => $this->frequency, 'enabled' => $enabled, 'save_s3' => $this->saveToS3, diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index a7e2858e5b..b1a37f4846 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -220,9 +220,18 @@ class Proxy extends Component } } + /** + * The file can hold secrets; non-editors get no content instead of a 403 because x-init calls this. + */ public function loadProxyConfiguration() { try { + if (! auth()->user()?->can('update', $this->server)) { + $this->proxySettings = null; + + return; + } + $this->proxySettings = GetProxyConfiguration::run($this->server); $this->clearAppliedTraefikBranchWarning(); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php index d2cc5f76eb..6c782cff6e 100644 --- a/app/Livewire/Server/TrafficAnalyticsSettings.php +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -19,16 +19,16 @@ class TrafficAnalyticsSettings extends Component public bool $isTrafficAnalyticsEnabled; - #[Validate(['required', 'integer', 'min:1'])] + #[Validate(['required', 'integer', 'min:1', 'max:1000'])] public int|string $trafficTopn; #[Validate(['required', 'integer', 'min:0'])] public int|string $trafficSampleThreshold; - #[Validate(['required', 'integer', 'min:1'])] + #[Validate(['required', 'integer', 'min:1', 'max:365'])] public int|string $trafficRetention1hDays; - #[Validate(['required', 'integer', 'min:1'])] + #[Validate(['required', 'integer', 'min:1', 'max:3650'])] public int|string $trafficRetention1dDays; public bool $isGeoipEnabled; diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 43563e7c83..a7e8b89488 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -53,7 +53,8 @@ class SettingsBackup extends Component } $settings = instanceSettings(); $this->server = Server::findOrFail(0); - $this->database = StandalonePostgresql::whereName('coolify-db')->first(); + // The instance database is always id 0; a team database can also be named coolify-db. + $this->database = StandalonePostgresql::find(0); $s3s = S3Storage::whereTeamId(0)->get() ?? []; if ($this->database) { $this->uuid = $this->database->uuid; @@ -81,7 +82,7 @@ class SettingsBackup extends Component { try { $this->authorize('update', $this->settings); - $this->database = StandalonePostgresql::whereName('coolify-db')->first(); + $this->database = StandalonePostgresql::find(0); if (! $this->database) { $server = Server::findOrFail(0); $out = instant_remote_process(['docker inspect coolify-db'], $server); diff --git a/app/Mcp/Tools/CancelDeployment.php b/app/Mcp/Tools/CancelDeployment.php index 1133f34291..5dcee3ed38 100644 --- a/app/Mcp/Tools/CancelDeployment.php +++ b/app/Mcp/Tools/CancelDeployment.php @@ -2,6 +2,7 @@ namespace App\Mcp\Tools; +use App\Actions\Application\CleanupCancelledDeployment; use App\Enums\ApplicationDeploymentStatus; use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; @@ -54,7 +55,6 @@ class CancelDeployment extends Tool ApplicationDeploymentStatus::QUEUED->value, ApplicationDeploymentStatus::IN_PROGRESS->value, ]; - $deploymentUuid = $deployment->deployment_uuid; $updated = ApplicationDeploymentQueue::whereKey($deployment->getKey()) ->whereIn('status', $cancellable) @@ -69,32 +69,10 @@ class CancelDeployment extends Tool $deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value; try { - $buildServerId = $deployment->build_server_id ?? $deployment->server_id; - $server = Server::whereTeamId($teamId)->find($buildServerId); - if ($server) { - $deployment->addLogEntry('Deployment cancelled by user via MCP.', 'stderr'); - - $checkCommand = "docker ps -a --filter name={$deploymentUuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process(["docker rm -f {$deploymentUuid}"], $server); - $deployment->addLogEntry('Deployment container stopped.'); - } else { - $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); - } - - // Parity with REST cancel_deployment: stop the remote build process if known. - if ($deployment->current_process_id) { - try { - instant_remote_process(["kill -9 {$deployment->current_process_id}"], $server); - } catch (\Throwable) { - // Process might already be gone. - } - } - } + $deployment->addLogEntry('Deployment cancelled by user via MCP.', 'stderr'); + CleanupCancelledDeployment::run($deployment, (int) $teamId); } catch (\Throwable) { - // Cancellation is still recorded even if remote kill fails. + // Cancellation is still recorded even if the helper container cleanup fails. } auditLog('mcp.deployment.cancelled', [ diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index bc844d826d..6ebdf255ae 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -46,6 +46,13 @@ use OpenApi\Attributes as OA; )] class ApplicationDeploymentQueue extends Model { + /** + * Kept in memory only, never saved. + * + * @var array + */ + private array $remoteSecretsForRedaction = []; + protected static function booted(): void { static::created(function (ApplicationDeploymentQueue $deployment): void { @@ -179,6 +186,14 @@ class ApplicationDeploymentQueue extends Model return str($this->commit_message)->value(); } + /** + * @param array $secrets + */ + public function redactRemoteSecrets(array $secrets): void + { + $this->remoteSecretsForRedaction = $secrets; + } + private function redactSensitiveInfo($text) { try { @@ -209,6 +224,8 @@ class ApplicationDeploymentQueue extends Model ); } + $lockedVars = $lockedVars->merge(EnvironmentVariable::remoteSecretLogRedactionValues($this->remoteSecretsForRedaction)); + foreach ($lockedVars as $key => $value) { $escapedValue = preg_quote($value, '/'); $text = preg_replace( diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 5d2ccd9e0e..f6a017dbb7 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use App\Support\RemoteSecretValueFormatter; use App\Support\ValidationPatterns; use App\Traits\Auditable; use Illuminate\Database\Eloquent\Builder; @@ -334,17 +335,50 @@ class EnvironmentVariable extends BaseModel $unquoted = str_starts_with($value, "'") && str_ends_with($value, "'") ? substr($value, 1, -1) : $value; - $values[] = $unquoted; - $values[] = escapeBashEnvValue($unquoted); - $values[] = str_replace(["\r\n", "\r", "\n"], ['\\n', '\\n', '\\n'], $unquoted); - if ($this->is_multiline) { - $values = array_merge($values, preg_split('/\r\n|\r|\n/', $unquoted) ?: []); - } + $values = array_merge($values, self::logRedactionVariants($unquoted, (bool) $this->is_multiline)); } return array_values(array_unique(array_filter($values, static fn (string $item): bool => $item !== ''))); } + /** + * @param array $secrets + * @return array + */ + public static function remoteSecretLogRedactionValues(array $secrets): array + { + return collect($secrets) + ->filter(static fn (mixed $secret): bool => is_string($secret) && $secret !== '') + ->flatMap(static fn (string $secret): array => [ + ...self::logRedactionVariants($secret, preg_match('/\r|\n/', $secret) === 1), + RemoteSecretValueFormatter::composeFile($secret), + ]) + ->filter(static fn (string $item): bool => $item !== '') + ->unique() + ->values() + ->all(); + } + + /** + * Also covers the value inside a quoted argument such as 'KEY=value'. + * + * @return array + */ + private static function logRedactionVariants(string $value, bool $splitLines): array + { + $values = [ + $value, + escapeBashEnvValue($value), + str_replace("'", "'\\''", $value), + str_replace(["\r\n", "\r", "\n"], ['\\n', '\\n', '\\n'], $value), + ]; + if ($splitLines) { + $values = array_merge($values, preg_split('/\r\n|\r|\n/', $value) ?: []); + } + + return $values; + } + public function resolveReferencedValue(): ?string { $value = $this->value; diff --git a/app/Models/GithubRunnerExecution.php b/app/Models/GithubRunnerExecution.php index af334209d2..77a55fd075 100644 --- a/app/Models/GithubRunnerExecution.php +++ b/app/Models/GithubRunnerExecution.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Enums\GithubRunnerStatus; +use App\Jobs\DeregisterGithubRunnerJob; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; class GithubRunnerExecution extends BaseModel @@ -19,6 +21,7 @@ class GithubRunnerExecution extends BaseModel 'job_name', 'repository_full_name', 'labels', + 'is_pull_request', 'runner_name', 'runner_id', 'conclusion', @@ -39,6 +42,7 @@ class GithubRunnerExecution extends BaseModel 'runner_id' => 'integer', 'provision_attempts' => 'integer', 'labels' => 'array', + 'is_pull_request' => 'boolean', 'queued_at' => 'datetime', 'ready_at' => 'datetime', 'started_at' => 'datetime', @@ -61,6 +65,22 @@ class GithubRunnerExecution extends BaseModel return $this->belongsTo(Server::class); } + /** + * Deregistering the runners from GitHub is best effort and never blocks the deletion. + * + * @param Builder $query + */ + public static function deleteAndDeregister(Builder $query): void + { + (clone $query) + ->whereIn('status', GithubRunnerStatus::occupying()) + ->whereNotNull('runner_id') + ->get(['github_app_id', 'runner_id']) + ->each(fn (GithubRunnerExecution $execution) => DeregisterGithubRunnerJob::dispatch($execution->github_app_id, $execution->runner_id)); + + (clone $query)->delete(); + } + public function isActive(): bool { return $this->status->isActive(); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 8e758566e6..a74514c471 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -174,8 +174,11 @@ class LocalFileVolume extends BaseModel $path = $this->resolvedFsPath($workdir); if (! $this->isAdminControlledComposeMount()) { - $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); - $this->assertRemotePathIsConfined($workdir, $path->value(), $server); + [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); + if ($resourceDirectory !== null) { + $this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server); + } + $path = str($hostPath); } // Validate and escape path to prevent command injection @@ -237,6 +240,7 @@ class LocalFileVolume extends BaseModel /** * Without a server, Coolify deletes the file on every server of the resource. + * Host paths outside the resource directory are never deleted. */ public function deleteStorageOnServer(?Server $server = null) { @@ -253,6 +257,10 @@ class LocalFileVolume extends BaseModel $commands = collect([]); $path = $this->resolvedFsPath($workdir); + if ($this->isOutsideResourceDirectory()) { + return null; + } + if (! $this->isAdminControlledComposeMount()) { $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); $this->assertRemotePathIsConfined($workdir, $path->value(), $server); @@ -279,6 +287,7 @@ class LocalFileVolume extends BaseModel /** * Without a server, Coolify writes the file on every server of the resource. + * Outside the resource directory, nothing is deleted or written through a symlink. */ public function saveStorageOnServer(?Server $server = null) { @@ -299,12 +308,25 @@ class LocalFileVolume extends BaseModel $content = data_get($this, 'content'); $writesContent = $this->writesContentOnServer(); - if ($writesContent) { - $path = str($this->confinedContentPath($path->value(), $server)); - } elseif (! $this->isAdminControlledComposeMount()) { - $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); - $this->assertRemotePathIsConfined($workdir, $path->value(), $server); + if ($this->isAdminControlledComposeMount()) { + // Compose `content:` stays in the resource directory: the Compose file can come from a Git repository. + if ($writesContent) { + $path = str($this->confinedContentPath($path->value(), $server)); + } + } else { + [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); + if ($resourceDirectory === null) { + if (! $this->is_directory) { + self::assertRemotePathIsNotSymlink($hostPath, $server); + } + } elseif ($writesContent) { + $hostPath = $this->confinedContentPath($hostPath, $server); + } else { + $this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server); + } + $path = str($hostPath); } + $isOutsideResourceDirectory = $this->isOutsideResourceDirectory(); if ($this->is_directory) { validateShellSafePath($path, 'storage path'); @@ -344,8 +366,8 @@ class LocalFileVolume extends BaseModel throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file.

Please delete the directory on the server or mark it as directory.'); } // Docker creates a missing bind source as an empty directory. Replace only an empty - // directory; never delete files that are on the server. - if (self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') { + // directory inside the resource directory; never delete files that are on the server. + if ($isOutsideResourceDirectory || self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') { throw new \Exception("The following file is a directory on the server, but you are trying to mark it as a file: {$path}

Please delete the directory on the server or mark it as directory."); } $replacesEmptyDirectory = true; @@ -419,9 +441,124 @@ class LocalFileVolume extends BaseModel */ public function contentPathOnServer(): string { + if (! $this->isAdminControlledComposeMount()) { + [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); + if ($resourceDirectory === null) { + return $hostPath; + } + } + return $this->localConfinedContentPath($this->resolvedFsPath($this->ownerResource()->workdir())->value())[1]; } + /** + * `~` is not allowed because it depends on the home directory of the SSH user. + * + * @param string|list $resourceDirectories The first directory resolves relative paths. + * + * @throws \Exception If the path is not allowed + */ + public static function resolveHostPath(string|array $resourceDirectories, string $path, string $context = 'storage path'): string + { + $resourceDirectories = array_map(normalizeUnixPath(...), (array) $resourceDirectories); + $path = trim($path); + if ($path === '') { + throw new \Exception("Invalid {$context}: the path is empty."); + } + if (str_starts_with($path, '~')) { + throw new \Exception("Invalid {$context}: use an absolute path instead of ~."); + } + validateShellSafePath($path, $context); + + $isAbsolute = str_starts_with($path, '/'); + $resolvedPath = normalizeUnixPath($isAbsolute ? $path : $resourceDirectories[0].'/'.$path); + if (self::resourceDirectoryContaining($resolvedPath, $resourceDirectories) !== null) { + return $resolvedPath; + } + + if (! $isAbsolute) { + throw new \Exception("Invalid {$context}: a relative path must stay inside the resource directory."); + } + if (array_intersect(explode('/', $path), ['.', '..']) !== []) { + throw new \Exception("Invalid {$context}: '.' and '..' segments are not allowed."); + } + if ($resolvedPath === '/') { + throw new \Exception("Invalid {$context}: the root directory cannot be mounted."); + } + + return $resolvedPath; + } + + /** + * @throws \RuntimeException If Coolify must not use the path + */ + public static function assertHostPathOnServer(string $resourceDirectory, string $path, Server $server, bool $isDirectory): void + { + if (self::resourceDirectoryContaining($path, [normalizeUnixPath($resourceDirectory)]) !== null) { + self::assertRemotePathIsConfined($resourceDirectory, $path, $server); + } elseif (! $isDirectory) { + self::assertRemotePathIsNotSymlink($path, $server); + } + } + + /** + * @throws \RuntimeException If the path is a symbolic link on the server + */ + public static function assertRemotePathIsNotSymlink(string $path, Server $server): void + { + $escapedPath = escapeshellarg($path); + $result = instant_remote_process(["test -L {$escapedPath} && echo LINK || echo OK"], $server); + + if (trim((string) $result) !== 'OK') { + throw new \RuntimeException("{$path} is a symbolic link on the server. Coolify does not write a file through a symbolic link. Remove the link or use another path."); + } + } + + /** + * A path that cannot be resolved counts as outside. + */ + public function isOutsideResourceDirectory(): bool + { + $directories = $this->contentBaseDirectories(); + try { + $path = normalizeUnixPath($this->resolvedFsPath($directories[0])->value()); + $directories = array_map(normalizeUnixPath(...), $directories); + } catch (\Throwable) { + return true; + } + + return str_starts_with($path, '~') || self::resourceDirectoryContaining($path, $directories) === null; + } + + /** + * The resource directory is null when the host path is outside it. + * + * @return array{0: string, 1: string|null} + * + * @throws \Exception If the path is not allowed + */ + protected function hostPathAndResourceDirectory(): array + { + $directories = $this->contentBaseDirectories(); + $path = self::resolveHostPath($directories, $this->resolvedFsPath($directories[0])->value()); + + return [$path, self::resourceDirectoryContaining($path, array_map(normalizeUnixPath(...), $directories))]; + } + + /** + * @param list $directories Normalized directories + */ + protected static function resourceDirectoryContaining(string $path, array $directories): ?string + { + foreach ($directories as $directory) { + if ($path === $directory || str_starts_with($path, $directory.'/')) { + return $directory; + } + } + + return null; + } + /** * The host path of this mount. Like a relative bind source in Compose, a relative path is inside * the resource directory. Remote commands run in the home directory of the SSH user, so they diff --git a/app/Models/Server.php b/app/Models/Server.php index 88f3ecc15a..16511a2d73 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -236,6 +236,9 @@ class Server extends BaseModel $server->destinations()->each(function ($destination) { $destination->delete(); }); + // Leftover active runner rows would block deleting the GitHub App. + GithubRunnerExecution::deleteAndDeregister(GithubRunnerExecution::query()->where('server_id', $server->id)); + $server->githubRunnerConfig()->delete(); $server->settings()->delete(); $server->sslCertificates()->delete(); $server->notificationThrottles()->delete(); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 7c44c4553e..e9da72d442 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -44,7 +44,7 @@ class EventServiceProvider extends ServiceProvider }); Event::listen(Failed::class, function (Failed $event): void { auditLog('auth.user.login_failed', [ - 'attempted_email' => data_get($event->credentials, 'email'), + 'attempted_email' => $this->attemptedEmailForAudit(data_get($event->credentials, 'email')), 'guard' => $event->guard, ], 'warning'); }); @@ -74,6 +74,18 @@ class EventServiceProvider extends ServiceProvider }); } + /** + * Users sometimes type a password into the email field, so only a valid email address is stored. + */ + private function attemptedEmailForAudit(mixed $email): ?string + { + if (blank($email)) { + return null; + } + + return is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL) !== false ? $email : '[invalid]'; + } + private function authContext(?object $user): array { return [ diff --git a/app/Services/ProxyPortParser.php b/app/Services/ProxyPortParser.php index efc5a551f5..7a1263f476 100644 --- a/app/Services/ProxyPortParser.php +++ b/app/Services/ProxyPortParser.php @@ -37,6 +37,33 @@ class ProxyPortParser * @return list */ public static function fromConfiguration(string $configuration): array + { + $ports = []; + + foreach (self::configuredPorts($configuration) as $configuredPort) { + $publishedPort = self::publishedPort($configuredPort); + if ($publishedPort !== null) { + $ports[] = $publishedPort; + } + } + + return array_values(array_unique($ports)); + } + + /** + * For example, when every port uses a variable without a default. + */ + public static function publishesOnlyUncheckedPorts(string $configuration): bool + { + return self::configuredPorts($configuration) !== [] && self::fromConfiguration($configuration) === []; + } + + /** + * An empty `ports:` key (YAML null) is an empty list, as in Docker Compose. + * + * @return list + */ + private static function configuredPorts(string $configuration): array { try { $parsed = self::withoutTags(Yaml::parse($configuration, Yaml::PARSE_CUSTOM_TAGS)); @@ -51,26 +78,19 @@ class ProxyPortParser $ports = []; foreach (['traefik', 'caddy'] as $proxyService) { - $path = "services.{$proxyService}.ports"; - - if (! data_has($parsed, $path)) { + $configuredPorts = data_get($parsed, "services.{$proxyService}.ports"); + if ($configuredPorts === null) { continue; } - $configuredPorts = data_get($parsed, $path); if (! is_array($configuredPorts) || ! array_is_list($configuredPorts)) { self::invalid(); } - foreach ($configuredPorts as $configuredPort) { - $publishedPort = self::publishedPort($configuredPort); - if ($publishedPort !== null) { - $ports[] = $publishedPort; - } - } + array_push($ports, ...$configuredPorts); } - return array_values(array_unique($ports)); + return $ports; } private static function withoutTags(mixed $value): mixed diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index b7f4a7733e..9264e3421f 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -6,10 +6,12 @@ use App\Data\Traffic\TrafficBreakdownData; use App\Data\Traffic\TrafficOverviewData; use App\Data\Traffic\TrafficPathData; use App\Data\Traffic\TrafficSeriesBucketData; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Process; class SentinelTrafficClient { @@ -24,6 +26,17 @@ class SentinelTrafficClient */ public const RESOURCE_SCOPE_ABSENCE_TTL = 300; + /** + * Matches the "Live" poll interval, so a dead server costs at most one timed-out fetch per minute. + */ + public const UNAVAILABLE_TTL = 60; + + /** Upper bound in seconds of one SSH round-trip (connect, docker exec, every curl). */ + private const REMOTE_TIMEOUT = 20; + + /** Bounds each curl inside the Sentinel container, so a stuck Sentinel cannot hold the SSH session. */ + private const CURL_TIMEOUT_OPTIONS = '--connect-timeout 3 --max-time 10'; + /** Path segment of the per-key routes: `/api/app/{key}/traffic/...`. */ private const SCOPE_APP = 'app'; @@ -749,11 +762,12 @@ class SentinelTrafficClient protected function remoteFetch(string $url): string { + $this->ensureRemoteAvailable(); $token = $this->server->settings->ensureValidSentinelToken(); // Throw the real SSH/docker error (for example, an unreadable SSH key or a missing - // container). A literal "null" body comes back as null; guard() rejects the empty string. - return instant_remote_process([$this->buildFetchCommand($token, $url)], $this->server) ?? ''; + // container). A literal "null" body comes back empty; guard() rejects the empty string. + return $this->runRemote($this->buildFetchCommand($token, $url)); } /** @@ -761,9 +775,55 @@ class SentinelTrafficClient */ protected function batchRemoteFetch(array $urls): string { + $this->ensureRemoteAvailable(); $token = $this->server->settings->ensureValidSentinelToken(); - return instant_remote_process([$this->buildBatchCommand($token, $urls)], $this->server) ?? ''; + return $this->runRemote($this->buildBatchCommand($token, $urls)); + } + + /** + * Page loads and "Live" polls must not wait on a dead server. + */ + private function ensureRemoteAvailable(): void + { + if (Cache::get($this->unavailableKey()) === true) { + throw new \RuntimeException('Traffic analytics is temporarily unavailable on this server.'); + } + + if (! $this->server->isFunctional()) { + throw new \RuntimeException('Server is not reachable.'); + } + } + + /** + * Unlike instant_remote_process(), there are no SSH retries; a failure marks the server unavailable. + */ + private function runRemote(string $command): string + { + $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) + ); + + if ($process->exitCode() !== 0) { + excludeCertainErrors($process->errorOutput(), $process->exitCode()); + } + } catch (\Throwable $e) { + Cache::put($this->unavailableKey(), true, self::UNAVAILABLE_TTL); + + throw $e; + } + + $output = trim($process->output()); + + return $output === 'null' ? '' : sanitize_utf8_text($output); + } + + private function unavailableKey(): string + { + return 'traffic:unavailable:'.$this->server->uuid; } /** @@ -777,7 +837,7 @@ class SentinelTrafficClient */ protected function buildFetchCommand(string $token, string $url): string { - return "docker exec -i coolify-sentinel curl -sS -H @- \"{$url}\"".$this->authorizationHeredoc($token); + return 'docker exec -i coolify-sentinel curl -sS '.self::CURL_TIMEOUT_OPTIONS." -H @- \"{$url}\"".$this->authorizationHeredoc($token); } /** @@ -791,7 +851,7 @@ class SentinelTrafficClient protected function buildBatchCommand(string $token, array $urls): string { $script = implode("\n", array_map( - fn ($url) => "curl -s -H @- \"{$url}\"".$this->authorizationHeredoc($token)."\nprintf '\\036'", + fn ($url) => 'curl -s '.self::CURL_TIMEOUT_OPTIONS." -H @- \"{$url}\"".$this->authorizationHeredoc($token)."\nprintf '\\036'", $urls )); diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 2c1de948ba..b7052411aa 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -23,6 +23,12 @@ class DatabaseBackupFileValidator 'bz2', 'xz', 'dmp', + 'db', + 'db.gz', + 'sqlite', + 'sqlite.gz', + 'sqlite3', + 'sqlite3.gz', ]; private const DANGEROUS_EXTENSIONS = [ @@ -136,7 +142,7 @@ class DatabaseBackupFileValidator return match ($extension) { 'sql' => self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample), - 'sql.gz', 'gz', 'tar.gz', 'tgz', 'bson.gz', 'archive.gz' => str_starts_with($sample, "\x1f\x8b"), + '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'), @@ -144,6 +150,7 @@ class DatabaseBackupFileValidator 'dump', 'bak', 'archive', 'dmp' => str_starts_with($sample, 'PGDMP') || (self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample)), 'bson' => self::looksLikeBson($path, $sample), + 'db', 'sqlite', 'sqlite3' => str_starts_with($sample, "SQLite format 3\0"), default => false, }; } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index ce3ec8ae3b..71f05e2cf2 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -52,10 +52,7 @@ trait ExecuteRemoteCommand } if (isset($this->remote_secrets_cache)) { - $lockedVars = $lockedVars->merge(array_values(array_filter( - $this->remote_secrets_cache, - static fn (mixed $value): bool => is_string($value) && $value !== '' - ))); + $lockedVars = $lockedVars->merge(EnvironmentVariable::remoteSecretLogRedactionValues($this->remote_secrets_cache)); } foreach ($lockedVars as $key => $value) { @@ -84,6 +81,9 @@ trait ExecuteRemoteCommand if ($this->server instanceof Server === false) { throw new \RuntimeException('Server is not set or is not an instance of Server model'); } + if (isset($this->application_deployment_queue, $this->remote_secrets_cache)) { + $this->application_deployment_queue->redactRemoteSecrets($this->remote_secrets_cache); + } $commandsText->each(function ($single_command) { $command = data_get($single_command, 'command') ?? $single_command[0] ?? null; if ($command === null) { diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php index 4e5d066261..45a1d8eb3d 100644 --- a/app/Traits/HasSecretManager.php +++ b/app/Traits/HasSecretManager.php @@ -136,7 +136,7 @@ trait HasSecretManager return null; } - if (RemoteSecretReferences::containsReference($value)) { + if ($this->resolvesRemoteSecretReferences($environmentVariable, $value)) { $secrets = $this->secretManagerValues(); $missing = RemoteSecretReferences::missingKeys($value, $secrets); @@ -152,11 +152,26 @@ trait HasSecretManager public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool { - return RemoteSecretReferences::containsReference( + return $this->resolvesRemoteSecretReferences( + $environmentVariable, $this->resolvedEnvironmentVariableValue($environmentVariable), ); } + /** + * Without a source, a literal keeps "{{vault.KEY}}" as text; other references fail closed. + */ + private function resolvesRemoteSecretReferences(EnvironmentVariable $environmentVariable, ?string $value): bool + { + if (! RemoteSecretReferences::containsReference($value)) { + return false; + } + + return ! $environmentVariable->is_literal + || $this->resolvedSecretManagerValues !== null + || $this->secretManagerLink()->exists(); + } + private function resolvedEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string { return $environmentVariable->get_real_environment_variables_with_server( diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php index b210b0be29..afff696a67 100644 --- a/app/Traits/HasSecretManagerAutocomplete.php +++ b/app/Traits/HasSecretManagerAutocomplete.php @@ -38,7 +38,8 @@ trait HasSecretManagerAutocomplete return []; } - $this->authorize('view', $link->resourceable); + // Same ability as SecretManagerLinks::loadKeys(): key names are for users who can manage the source. + $this->authorize('update', $link->resourceable); // Only key names are cached. Another token or other settings use another cache entry. $cacheKey = 'secret-manager-keys:'.$link->id.':'.hash('sha256', json_encode([ diff --git a/database/migrations/2026_10_05_064824_add_is_pull_request_to_github_runner_executions_table.php b/database/migrations/2026_10_05_064824_add_is_pull_request_to_github_runner_executions_table.php new file mode 100644 index 0000000000..97f91e1519 --- /dev/null +++ b/database/migrations/2026_10_05_064824_add_is_pull_request_to_github_runner_executions_table.php @@ -0,0 +1,22 @@ +boolean('is_pull_request')->default(false)->after('labels'); + }); + } + + public function down(): void + { + Schema::table('github_runner_executions', function (Blueprint $table) { + $table->dropColumn('is_pull_request'); + }); + } +}; diff --git a/database/migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php b/database/migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php new file mode 100644 index 0000000000..d18be7e42c --- /dev/null +++ b/database/migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php @@ -0,0 +1,31 @@ +where('status', 'enqueued') + ->where('enqueued_at', '<', now()->subMinutes(self::STALE_AFTER_MINUTES)) + ->update([ + 'status' => 'skipped', + 'updated_at' => now(), + ]); + } + + public function down(): void + { + // + } +}; diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 753b2f6555..6500b0ce28 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -50,8 +50,8 @@
@if ($fileStorage->is_host_file) @@ -72,9 +72,7 @@ @endif @@ -92,7 +90,7 @@ Load from server diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index ab15ccd621..11c510a9a2 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -182,7 +182,12 @@ x-data="{ hostPath: @js($this->fileStorageHostPath()), filePath: @entangle('file_storage_path'), + sourcePath: @entangle('file_storage_source'), previewPath() { + const source = (this.sourcePath || '').trim(); + if (source !== '') { + return source.startsWith('/') ? source : `${this.hostPath}/${source.replace(/^\.\//, '')}`; + } const path = (this.filePath || '').trim(); return this.hostPath + (path === '' ? '/' : (path.startsWith('/') ? path : `/${path}`)); @@ -202,6 +207,11 @@ label="Destination Path" required x-on:input="filePath = $event.target.value" helper="File location inside the container" /> +
@@ -326,7 +336,7 @@ + required helper="Directory on the host: an absolute path anywhere on the host, or a path relative to the resource directory. Coolify creates it when it is missing, and never deletes a directory outside the resource directory." /> ({ position: 'bottom', labels: { colors: textColor } }); const escapeHtml = value => String(value).replace(/[&<>'"]/g, character => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"', })[character]); + // ApexCharts writes legend text with innerHTML; device labels come from request data. + const legend = () => ({ position: 'bottom', labels: { colors: textColor }, formatter: seriesName => escapeHtml(seriesName) }); const chart = new ApexCharts(el, { chart: { type: 'donut', height: 240, background: 'transparent', animations: { enabled: false } }, diff --git a/templates/compose/9router.yaml b/templates/compose/9router.yaml index 31b71a3ddd..ddf87ea687 100644 --- a/templates/compose/9router.yaml +++ b/templates/compose/9router.yaml @@ -13,9 +13,9 @@ services: environment: - SERVICE_URL_9ROUTER_20128 - DATA_DIR=/app/data - - INITIAL_PASSWORD=${SERVICE_PASSWORDWITHSYMBOLS_PASSWORD} - - JWT_SECRET=${SERVICE_PASSWORDWITHSYMBOLS_64_JWTSECRET} - - API_KEY_SECRET=${SERVICE_PASSWORDWITHSYMBOLS_KEYSECRET} + - INITIAL_PASSWORD=${SERVICE_PASSWORD_PASSWORD} + - JWT_SECRET=${SERVICE_PASSWORD_64_JWTSECRET} + - API_KEY_SECRET=${SERVICE_PASSWORD_KEYSECRET} - MACHINE_ID_SALT=${SERVICE_HEX_64_HMACSALT} healthcheck: test: diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 5c172edacc..7e7ba6d322 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -2,7 +2,7 @@ "9router": { "documentation": "https://github.com/decolua/9router?utm_source=coolify.io", "slogan": "Free AI coding gateway. Routes Claude Code, Codex, Cursor, Cline, Copilot requests to 40+ LLM providers with auto-fallback and token savings.", - "compose": "c2VydmljZXM6CiAgOXJvdXRlcjoKICAgIGltYWdlOiAnZGVjb2x1YS85cm91dGVyOjAuNS42NScKICAgIHZvbHVtZXM6CiAgICAgIC0gJzlyb3V0ZXI6L2FwcC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfOVJPVVRFUl8yMDEyOAogICAgICAtIERBVEFfRElSPS9hcHAvZGF0YQogICAgICAtICdJTklUSUFMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRFdJVEhTWU1CT0xTX1BBU1NXT1JEfScKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRXSVRIU1lNQk9MU182NF9KV1RTRUNSRVR9JwogICAgICAtICdBUElfS0VZX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRXSVRIU1lNQk9MU19LRVlTRUNSRVR9JwogICAgICAtICdNQUNISU5FX0lEX1NBTFQ9JHtTRVJWSUNFX0hFWF82NF9ITUFDU0FMVH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MjAxMjgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgOXJvdXRlcjoKICAgIGltYWdlOiAnZGVjb2x1YS85cm91dGVyOjAuNS42NScKICAgIHZvbHVtZXM6CiAgICAgIC0gJzlyb3V0ZXI6L2FwcC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfOVJPVVRFUl8yMDEyOAogICAgICAtIERBVEFfRElSPS9hcHAvZGF0YQogICAgICAtICdJTklUSUFMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QQVNTV09SRH0nCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0pXVFNFQ1JFVH0nCiAgICAgIC0gJ0FQSV9LRVlfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9LRVlTRUNSRVR9JwogICAgICAtICdNQUNISU5FX0lEX1NBTFQ9JHtTRVJWSUNFX0hFWF82NF9ITUFDU0FMVH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MjAxMjgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", "tags": [ "ai", "gateway", diff --git a/templates/service-templates.json b/templates/service-templates.json index 87a6e2d3b4..778dd2597a 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -2,7 +2,7 @@ "9router": { "documentation": "https://github.com/decolua/9router?utm_source=coolify.io", "slogan": "Free AI coding gateway. Routes Claude Code, Codex, Cursor, Cline, Copilot requests to 40+ LLM providers with auto-fallback and token savings.", - "compose": "c2VydmljZXM6CiAgOXJvdXRlcjoKICAgIGltYWdlOiAnZGVjb2x1YS85cm91dGVyOjAuNS42NScKICAgIHZvbHVtZXM6CiAgICAgIC0gJzlyb3V0ZXI6L2FwcC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROXzlST1VURVJfMjAxMjgKICAgICAgLSBEQVRBX0RJUj0vYXBwL2RhdGEKICAgICAgLSAnSU5JVElBTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRXSVRIU1lNQk9MU19QQVNTV09SRH0nCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEV0lUSFNZTUJPTFNfNjRfSldUU0VDUkVUfScKICAgICAgLSAnQVBJX0tFWV9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEV0lUSFNZTUJPTFNfS0VZU0VDUkVUfScKICAgICAgLSAnTUFDSElORV9JRF9TQUxUPSR7U0VSVklDRV9IRVhfNjRfSE1BQ1NBTFR9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjIwMTI4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgOXJvdXRlcjoKICAgIGltYWdlOiAnZGVjb2x1YS85cm91dGVyOjAuNS42NScKICAgIHZvbHVtZXM6CiAgICAgIC0gJzlyb3V0ZXI6L2FwcC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROXzlST1VURVJfMjAxMjgKICAgICAgLSBEQVRBX0RJUj0vYXBwL2RhdGEKICAgICAgLSAnSU5JVElBTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUEFTU1dPUkR9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9KV1RTRUNSRVR9JwogICAgICAtICdBUElfS0VZX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0VZU0VDUkVUfScKICAgICAgLSAnTUFDSElORV9JRF9TQUxUPSR7U0VSVklDRV9IRVhfNjRfSE1BQ1NBTFR9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjIwMTI4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "ai", "gateway", diff --git a/tests/Feature/Api/ScheduledTaskApiTest.php b/tests/Feature/Api/ScheduledTaskApiTest.php index 86066b277f..3c77661ab8 100644 --- a/tests/Feature/Api/ScheduledTaskApiTest.php +++ b/tests/Feature/Api/ScheduledTaskApiTest.php @@ -369,3 +369,60 @@ describe('Service scheduled tasks API', function () { $response->assertJson(['message' => 'Scheduled task deleted.']); }); }); + +describe('scheduled task timeout limit', function () { + test('accepts a timeout of up to 36000 seconds on create and update', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Long task', + 'command' => 'echo test', + 'frequency' => '* * * * *', + 'timeout' => 36000, + ]) + ->assertStatus(201); + $task = ScheduledTask::query()->sole(); + + $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", ['timeout' => 36000]) + ->assertStatus(200); + + expect($task->fresh()->timeout)->toBe(36000); + }); + + test('rejects a timeout above 36000 seconds on create and update', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + 'timeout' => 300, + ]); + + $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Too long task', + 'command' => 'echo test', + 'frequency' => '* * * * *', + 'timeout' => 36001, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('timeout'); + + $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", ['timeout' => 36001]) + ->assertStatus(422) + ->assertJsonValidationErrors('timeout'); + + expect(ScheduledTask::query()->count())->toBe(1) + ->and($task->fresh()->timeout)->toBe(300); + }); +}); diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index ea527024ad..d9ad7f9f5c 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -181,6 +181,18 @@ it('keeps deployment logging available if value formatting fails', function () { ->toBe(REDACTED); }); +it('redacts shell-escaped remote secrets from command output', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'remote_secrets_cache' => ['DB_PASS' => "s3cr'et-value"], + ]); + + $redacted = invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', "docker build --build-arg DB_PASS='s3cr'\\''et-value' --env 'DB_PASS=s3cr'\\''et-value' ."); + + expect($redacted)->not->toContain('s3cr') + ->toContain('--build-arg DB_PASS='.REDACTED); +}); + it('ignores empty and non-string remote secrets when redacting command output', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ diff --git a/tests/Feature/Auth/LoginFailedAuditTest.php b/tests/Feature/Auth/LoginFailedAuditTest.php new file mode 100644 index 0000000000..f2e7e3d474 --- /dev/null +++ b/tests/Feature/Auth/LoginFailedAuditTest.php @@ -0,0 +1,42 @@ +withoutDefer(); + config()->set('app.maintenance.store', 'array'); + + InstanceSettings::forceCreate(['id' => 0]); + RateLimiter::clear('login'); + Once::flush(); + Log::spy(); +}); + +test('a failed login with a non-email identifier does not store the typed text', function () { + $this->post('/login', [ + 'email' => 'MySecretPassw0rd!', + 'password' => 'wrong-password', + ])->assertRedirect(); + + $failed = AuditEvent::query()->where('event', 'auth.user.login_failed')->sole(); + + expect(json_encode($failed->metadata))->not->toContain('MySecretPassw0rd!') + ->and($failed->metadata['attempted_email'])->toBe('[invalid]'); +}); + +test('a failed login with a valid email address records the attempted email', function () { + $this->post('/login', [ + 'email' => 'person@example.com', + 'password' => 'wrong-password', + ])->assertRedirect(); + + expect(AuditEvent::query()->where('event', 'auth.user.login_failed')->sole()->metadata['attempted_email']) + ->toBe('person@example.com'); +}); diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php index d1fb7480af..0348fc1858 100644 --- a/tests/Feature/BackupRunSafetyTest.php +++ b/tests/Feature/BackupRunSafetyTest.php @@ -20,11 +20,13 @@ use App\Models\Service; use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\Team; +use App\Notifications\Database\BackupFailed; use App\Services\ScheduledJobDeliveryService; use App\Support\DatabaseOperationReservation; use App\Support\ResourceStartActivity; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Queue\TimeoutExceededException; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -275,6 +277,50 @@ it('stores service database backups in the folder of earlier releases', function ->toContain("/rallly-rallly_db-{$service->uuid}/pg-dump-rallly-"); }); +// --- Worker timeout (M16) --- + +it('stops the backup command at the backup timeout, before the worker timeout', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']); + $backup = backupRunSafetySchedule($database, $this->team, ['timeout' => 600]); + $commands = backupRunSafetyFakeRemoteCommands(); + $job = new DatabaseBackupJob($backup); + + $job->handle(); + + expect($commands->first(fn (string $command) => str_contains($command, 'pg_dump')))->toStartWith('timeout 600 ssh ') + ->and($job->timeout)->toBeGreaterThan(600); +}); + +it('fails the running backup execution and notifies the team when the worker timeout stops the job', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination, ['status' => 'running:healthy']); + $backup = backupRunSafetySchedule($database, $this->team, ['timeout' => 600]); + $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'backup_failure_email_notifications' => true]); + $job = new DatabaseBackupJob($backup); + // At the worker timeout, failed() runs on a copy rebuilt from the queued payload. + $queuedCopy = unserialize(serialize($job)); + $team = $this->team; + $atTimeout = null; + Process::fake(function ($process) use ($queuedCopy, $team, &$atTimeout) { + if (str_contains($process->command, 'pg_dump')) { + $queuedCopy->failed(new TimeoutExceededException('App\Jobs\DatabaseBackupJob has timed out.')); + $execution = ScheduledDatabaseBackupExecution::query()->sole(); + $atTimeout = [ + 'status' => $execution->status, + 'finished' => $execution->finished_at !== null, + 'notifications' => Notification::sent($team, BackupFailed::class)->count(), + ]; + + throw new RuntimeException('The worker stopped the job.'); + } + + return Process::result(output: ''); + }); + + $job->handle(); + + expect($atTimeout)->toBe(['status' => 'failed', 'finished' => true, 'notifications' => 1]); +}); + function backupRunSafetyVolume(Environment $environment, StandaloneDocker $destination): LocalPersistentVolume { $application = Application::factory()->create([ diff --git a/tests/Feature/BuildServerFallbackPolicyTest.php b/tests/Feature/BuildServerFallbackPolicyTest.php index a983402447..aee3c54384 100644 --- a/tests/Feature/BuildServerFallbackPolicyTest.php +++ b/tests/Feature/BuildServerFallbackPolicyTest.php @@ -430,3 +430,54 @@ test('additional servers that can build fail instead of building when the regist 'deployments and builds, restart' => ['both', true], 'builds only, deploy' => ['build', false], ]); + +test('strict teams restart on the deployment server when no build server is available', function (string $role, bool $buildServerEnabled) { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, $role); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: $buildServerEnabled, restartOnly: true); + + $deploymentQueue->shouldNotReceive('setAttribute'); + $deploymentQueue->shouldReceive('addLogEntry')->once()->with('No suitable build server found. Using the deployment server.'); + + invokeBuildServerSelection($job); + + expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeFalse(); +})->with([ + 'deployments-only server' => ['deployment', false], + 'application uses a build server' => ['both', true], +]); + +test('strict teams restart on an available build server', function () { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, 'deployment'); + $buildServer = makeRoleServer($team, 'build'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: false, restartOnly: true); + + $deploymentQueue->shouldReceive('setAttribute')->with('build_server_id', $buildServer->id)->once()->andReturnSelf(); + $deploymentQueue->shouldReceive('addLogEntry')->once()->with("Found a suitable build server ({$buildServer->name})."); + + invokeBuildServerSelection($job); + + expect(selectedBuildServer($job)->is($buildServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeTrue(); +}); + +test('a restart that has to rebuild applies the build server rules again', function (string $role, bool $buildServerEnabled, string $message) { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, $role); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], buildServerEnabled: $buildServerEnabled, restartOnly: true); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'is_this_additional_server'))->setValue($job, false); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->setValue($job, collect()); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'pull_request_id'))->setValue($job, 0); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'production_image_name'))->setValue($job, 'ghcr.io/coollabsio/app:abc123'); + + $deploymentQueue->shouldReceive('addLogEntry'); + invokeBuildServerSelection($job); + + expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'should_skip_build'))->invoke($job)) + ->toThrow(DeploymentException::class, $message); +})->with([ + 'deployments-only server' => ['deployment', false, 'is set to deployments only, and no usable build server was found.'], + 'application uses a build server' => ['both', true, 'No available dedicated build server was found.'], +]); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index 18e6e33d0a..071fdd2035 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -261,3 +261,52 @@ it('rejects scheduled backups for unsupported database types', function () { expect(ScheduledDatabaseBackup::count())->toBe(0); }); + +it('assigns a new database backup schedule to the team of the database, not the current team', function (string $databaseType, bool $rootTeam) { + if ($rootTeam) { + $rootTeamModel = Team::factory()->create(['id' => 0]); + $this->user->teams()->attach($rootTeamModel, ['role' => 'owner']); + $this->project->update(['team_id' => 0]); + } + $resourceTeamId = $this->project->fresh()->team_id; + $otherTeam = Team::factory()->create(); + $this->user->teams()->attach($otherTeam, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + + if ($databaseType === 'service') { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + $database = ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'postgres', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + } else { + $database = StandalonePostgresql::create([ + 'name' => 'postgres', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + } + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', 'daily') + ->call('submit') + ->assertHasNoErrors(); + + expect(ScheduledDatabaseBackup::query()->sole()->team_id)->toBe($resourceTeamId); +})->with([ + 'standalone database' => ['standalone', false], + 'service database' => ['service', false], + 'standalone database in the root team' => ['standalone', true], +]); diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php index 82833747bc..455fe84610 100644 --- a/tests/Feature/DatabaseBackupUploadValidationTest.php +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -102,6 +102,12 @@ test('hasAllowedExtension accepts supported extensions', function (string $name) 'archive.gz' => ['data.archive.gz'], 'bz2' => ['data.bz2'], 'xz' => ['data.xz'], + 'sqlite db' => ['app.db'], + 'sqlite' => ['app.sqlite'], + 'sqlite3' => ['app.sqlite3'], + 'gzip sqlite db' => ['app.db.gz'], + 'gzip sqlite' => ['app.sqlite.gz'], + 'gzip sqlite3' => ['app.sqlite3.gz'], ]); test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) { @@ -136,6 +142,23 @@ test('backup validator rejects content that does not match the backup extension' expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse(); }); +test('backup validator accepts SQLite database files and their gzip form', function (string $name, string $content) { + expect(DatabaseBackupFileValidator::isUploadAllowed(makeTemporaryUpload($name, $content), 10 * 1024 * 1024))->toBeTrue(); +})->with([ + 'db' => ['app.db', "SQLite format 3\0".str_repeat("\0", 84)], + 'sqlite' => ['app.sqlite', "SQLite format 3\0".str_repeat("\0", 84)], + 'sqlite3' => ['app.sqlite3', "SQLite format 3\0".str_repeat("\0", 84)], + 'gzip sqlite' => ['app.sqlite.gz', gzencode("SQLite format 3\0".str_repeat("\0", 84))], +]); + +test('backup validator rejects SQLite extensions with content that is not a SQLite database', function (string $name, string $content) { + expect(DatabaseBackupFileValidator::isUploadAllowed(makeTemporaryUpload($name, $content), 10 * 1024 * 1024))->toBeFalse(); +})->with([ + 'sql text as db' => ['app.db', "CREATE TABLE users (id integer);\n"], + 'script as sqlite' => ['app.sqlite', "#!/bin/sh\nid\n"], + 'plain sqlite as sqlite.gz' => ['app.sqlite.gz', "SQLite format 3\0".str_repeat("\0", 84)], +]); + test('backup validator accepts valid plain sql and gzip backup content', function () { $plainSql = makeTemporaryUpload('backup.sql', "CREATE TABLE users (id integer);\n"); $gzipSql = makeTemporaryUpload('backup.sql.gz', gzencode("CREATE TABLE users (id integer);\n")); diff --git a/tests/Feature/DeploymentCancellationCleanupTest.php b/tests/Feature/DeploymentCancellationCleanupTest.php new file mode 100644 index 0000000000..065b0b3057 --- /dev/null +++ b/tests/Feature/DeploymentCancellationCleanupTest.php @@ -0,0 +1,156 @@ + false, + 'cache.default' => 'array', + 'app.maintenance.store' => 'array', + ]); + + $settings = new InstanceSettings(['is_api_enabled' => true, 'is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'ip' => '203.0.113.10']); + $this->buildServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'ip' => '203.0.113.20']); + + $destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + // A deployments-only server uses the build server even with this setting off. + $this->application->settings->update(['is_build_server_enabled' => false]); + + $this->deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'cancel-cleanup-uuid', + 'server_id' => $this->server->id, + 'build_server_id' => $this->buildServer->id, + 'destination_id' => $destination->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + 'current_process_id' => '4242', + ]); + + $this->remoteCommands = []; + Process::fake(function (PendingProcess $process) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $this->remoteCommands[] = $command; + + return Process::result(str_contains($command, 'docker ps -a') ? 'cancel-cleanup-uuid' : ''); + }); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function cancelCleanupCommandsOn(array $commands, string $ip, string $needle): array +{ + return array_values(array_filter($commands, fn (string $command) => str_contains($command, "@'{$ip}'") && str_contains($command, $needle))); +} + +function assertCancelCleanupRanCorrectly(array $commands, ApplicationDeploymentQueue $deployment): void +{ + $killCommands = array_filter($commands, fn (string $command) => preg_match('/\bkill\s+-9\b/', $command) === 1 || str_contains($command, 'kill -9 4242')); + + expect($killCommands)->toBe([]) + ->and(cancelCleanupCommandsOn($commands, '203.0.113.20', 'docker rm -f cancel-cleanup-uuid'))->not->toBeEmpty() + ->and($deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value) + ->and($deployment->fresh()->current_process_id)->toBeNull(); +} + +test('cancelling from the UI removes the helper container on the build server and never kills a remote pid', function () { + $this->actingAs($this->user); + + Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]) + ->call('cancel'); + + assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); +}); + +test('cancelling through the API removes the helper container on the build server and never kills a remote pid', function () { + $token = $this->user->createToken('cancel-cleanup', ['*'])->plainTextToken; + + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson("/api/v1/deployments/{$this->deployment->deployment_uuid}/cancel") + ->assertSuccessful(); + + assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); +}); + +test('cancelling through MCP removes the helper container on the build server and never kills a remote pid', function () { + $token = $this->user->createToken('cancel-cleanup', ['read', 'deploy'])->plainTextToken; + + $this->withHeaders([ + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => 'cancel_deployment', 'arguments' => ['uuid' => $this->deployment->deployment_uuid]], + ])->assertOk(); + + assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); +}); + +test('cancelling a deployment without a build server removes the helper container on the deployment server', function () { + $this->deployment->update(['build_server_id' => null]); + $this->actingAs($this->user); + + Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]) + ->call('cancel'); + + expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.10', 'docker rm -f cancel-cleanup-uuid'))->not->toBeEmpty() + ->and(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.20', 'docker'))->toBeEmpty() + ->and(array_filter($this->remoteCommands, fn (string $command) => str_contains($command, 'kill -9')))->toBe([]); +}); + +test('cancelling does not touch servers of another team', function () { + $otherTeam = Team::factory()->create(); + $otherKey = PrivateKey::factory()->create(['team_id' => $otherTeam->id]); + $foreignServer = Server::factory()->create(['team_id' => $otherTeam->id, 'private_key_id' => $otherKey->id, 'ip' => '203.0.113.30']); + $this->deployment->update(['build_server_id' => $foreignServer->id]); + $this->actingAs($this->user); + + Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]) + ->call('cancel'); + + expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.30', 'docker'))->toBeEmpty() + ->and($this->deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); +}); diff --git a/tests/Feature/DeploymentSensitiveCommandFailureTest.php b/tests/Feature/DeploymentSensitiveCommandFailureTest.php index 2fa3798d2e..3274b76e1b 100644 --- a/tests/Feature/DeploymentSensitiveCommandFailureTest.php +++ b/tests/Feature/DeploymentSensitiveCommandFailureTest.php @@ -6,6 +6,7 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -14,6 +15,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Process\PendingProcess; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; @@ -64,8 +66,11 @@ function makeSensitiveCommandFailureJob(array $environmentVariables = []): array $project = Project::create(['name' => 'Sensitive Failure Project', 'team_id' => $team->id]); $environment = Environment::where('project_id', $project->id)->firstOrFail(); + $destination = $server->standaloneDockers()->firstOrFail(); $application = Application::factory()->create([ 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), 'build_pack' => 'dockerfile', ]); $application->settings()->update([ @@ -447,3 +452,87 @@ function readSensitiveFailureJobProperty(object $job, ReflectionClass $reflectio return $reflectionProperty->getValue($job); } + +it('redacts shell-escaped remote secrets in command logs and later deployment log entries', function () { + [$job, $reflection, $queue] = makeSensitiveCommandFailureJob(); + $remoteSecrets = $reflection->getProperty('remote_secrets_cache'); + $remoteSecrets->setValue($job, ['DB_PASS' => "s3cr'et-value"]); + $escapedSecret = "'s3cr'\\''et-value'"; + + Process::fake(fn () => Process::result(output: "DB_PASS={$escapedSecret}")); + + invokeSensitiveFailureJobMethod($job, $reflection, 'execute_remote_command', ["echo DB_PASS={$escapedSecret}"]); + $queue->addLogEntry("Build argument DB_PASS={$escapedSecret}"); + + $storedLogs = collect(json_decode((string) $queue->fresh()->logs, true)); + expect($storedLogs)->toHaveCount(2) + ->and($storedLogs->toJson())->not->toContain('s3cr') + ->and($storedLogs->pluck('output')->all())->each->toContain('DB_PASS='.REDACTED); +}); + +function linkSensitiveFailureSecretManager(Application $application): void +{ + $token = IntegrationToken::query()->create([ + 'team_id' => $application->team()->id, + 'provider' => 'doppler', + 'name' => 'Doppler', + 'token' => 'the-secret-token', + 'capabilities' => ['secrets'], + ]); + $application->secretManagerLink()->create([ + 'integration_token_id' => $token->id, + 'settings' => ['project' => 'app', 'config' => 'prd'], + ]); +} + +it('redacts fetched remote secrets in log entries the deployment adds before its next remote command', function () { + [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob(); + linkSensitiveFailureSecretManager($application); + Http::fake(['https://api.doppler.com/*' => Http::response(['DB_PASS' => 'fetched-remote-secret-5521'])]); + + invokeSensitiveFailureJobMethod($job, $reflection, 'remote_secrets'); + $queue->addLogEntry('Resolved DB_PASS=fetched-remote-secret-5521'); + + $storedLogs = (string) $queue->fresh()->logs; + expect($storedLogs)->not->toContain('fetched-remote-secret-5521') + ->toContain('DB_PASS='.REDACTED); +}); + +it('keeps a literal vault reference as text when the application has no secret manager source', function () { + [$job, $reflection, $queue] = makeSensitiveCommandFailureJob([ + ['key' => 'LITERAL_REF', 'value' => '{{vault.FOO}}', 'is_literal' => true], + ]); + Http::fake(); + + $runtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + $buildtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + invokeSensitiveFailureJobMethod($job, $reflection, 'generate_env_variables'); + + expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='{{vault.FOO}}'") + ->and($buildtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='{{vault.FOO}}'") + ->and(readSensitiveFailureJobProperty($job, $reflection, 'env_args')->get('LITERAL_REF'))->toBe("'{{vault.FOO}}'") + ->and(invokeSensitiveFailureJobMethod($job, $reflection, 'has_remote_buildtime_secret_references'))->toBeFalse(); + Http::assertNothingSent(); +}); + +it('still fails a deployment when a non-literal vault reference has no secret manager source', function () { + [$job, $reflection] = makeSensitiveCommandFailureJob([ + ['key' => 'REMOTE_REF', 'value' => '{{vault.FOO}}', 'is_literal' => false], + ]); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables')); + + expect($exception->getMessage())->toContain('no secret manager source is configured'); +}); + +it('resolves a literal vault reference when the application has a secret manager source', function () { + [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob([ + ['key' => 'LITERAL_REF', 'value' => '{{vault.FOO}}', 'is_literal' => true], + ]); + linkSensitiveFailureSecretManager($application); + Http::fake(['https://api.doppler.com/*' => Http::response(['FOO' => 'resolved-remote-value'])]); + + $runtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='resolved-remote-value'"); +}); diff --git a/tests/Feature/DnsProviderManagementTest.php b/tests/Feature/DnsProviderManagementTest.php index c12da66b41..cf7f2d5975 100644 --- a/tests/Feature/DnsProviderManagementTest.php +++ b/tests/Feature/DnsProviderManagementTest.php @@ -21,6 +21,7 @@ use App\Models\StandaloneDocker; use App\Models\Team; use App\Models\User; use App\Services\Dns\CloudflareDnsProvider; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; @@ -825,3 +826,89 @@ function prepareManagedDnsApplication(): array return ['application' => $application, 'zone' => $zone]; } + +function dnsRecordFinishedPayload(Model $resource, int $teamId, string $hostname = 'app.example.com'): array +{ + return [ + 'teamId' => $teamId, + 'resourceType' => $resource->getMorphClass(), + 'resourceId' => $resource->getKey(), + 'hostname' => $hostname, + 'successful' => true, + 'credential' => 'Production DNS', + 'message' => "DNS record added for {$hostname}.", + ]; +} + +function actingAsDnsTeamMember(Team $team): void +{ + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member']); + test()->actingAs($member); + session(['currentTeam' => $team]); +} + +test('a dns record completion event marks the domain managed for a team admin', function () { + ['application' => $application] = prepareManagedDnsApplication(); + $teamId = $application->team()->id; + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('dnsRecordConfigurationFinished', dnsRecordFinishedPayload($application, $teamId)) + ->assertDispatched('success', 'DNS record added for app.example.com.'); + + expect(collect($application->fresh()->domain_dns_statuses)->pluck('status')->all())->toContain('ok'); +}); + +test('a team member cannot mark a domain managed with a forged dns completion event', function () { + ['application' => $application] = prepareManagedDnsApplication(); + $teamId = $application->team()->id; + $before = $application->fresh()->domain_dns_statuses; + actingAsDnsTeamMember($application->team()); + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('dnsRecordConfigurationFinished', dnsRecordFinishedPayload($application, $teamId)) + ->assertNotDispatched('success'); + + expect($application->fresh()->domain_dns_statuses)->toBe($before); +}); + +test('a dns completion event of another team or a malformed event is ignored', function (string $case) { + ['application' => $application] = prepareManagedDnsApplication(); + $before = $application->fresh()->domain_dns_statuses; + $payload = $case === 'other team' + ? dnsRecordFinishedPayload($application, Team::factory()->create()->id) + : ['hostname' => 'app.example.com', 'successful' => true]; + + Livewire::test(Domains::class, ['application' => $application->fresh()]) + ->call('dnsRecordConfigurationFinished', $payload) + ->assertNotDispatched('success'); + + expect($application->fresh()->domain_dns_statuses)->toBe($before); +})->with(['other team', 'missing fields']); + +test('a team member cannot mark a service domain managed with a forged dns completion event', function () { + ['application' => $application] = prepareManagedDnsApplication(); + $team = $application->team(); + $service = Service::factory()->create([ + 'server_id' => $application->destination->server->id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + 'environment_id' => $application->environment_id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + $webApp = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'service_id' => $service->id, + 'name' => 'web', + 'human_name' => 'Web', + 'image' => 'nginx:alpine', + 'fqdn' => 'https://web.example.com', + ]); + actingAsDnsTeamMember($team); + + Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) + ->call('dnsRecordConfigurationFinished', dnsRecordFinishedPayload($webApp, $team->id, 'web.example.com')) + ->assertNotDispatched('success'); + + expect($webApp->fresh()->domain_dns_statuses)->toBeEmpty(); +}); diff --git a/tests/Feature/FileStorageMountPathTest.php b/tests/Feature/FileStorageMountPathTest.php index fb83da1990..920c708ef0 100644 --- a/tests/Feature/FileStorageMountPathTest.php +++ b/tests/Feature/FileStorageMountPathTest.php @@ -87,15 +87,14 @@ test('livewire file storage rejects parent segments and does not create a local expect(LocalFileVolume::query()->count())->toBe(0); }); -test('livewire directory storage rejects a server absolute path and has no side effects', function () { +test('livewire directory storage accepts a server absolute path for an administrator', function () { Livewire::test(Storage::class, ['resource' => $this->application]) ->set('file_storage_directory_source', '/root/.ssh') ->set('file_storage_directory_destination', '/data') ->call('submitFileStorageDirectory') - ->assertDispatched('error'); + ->assertDispatched('success'); - expect(LocalFileVolume::query()->count())->toBe(0); - Bus::assertNotDispatched(ServerStorageSaveJob::class); + expect(LocalFileVolume::query()->sole()->fs_path)->toBe('/root/.ssh'); }); test('livewire directory storage accepts a path inside the application root', function () { diff --git a/tests/Feature/GithubRunnerFlowTest.php b/tests/Feature/GithubRunnerFlowTest.php index fe28fc2f55..c452f3b7bd 100644 --- a/tests/Feature/GithubRunnerFlowTest.php +++ b/tests/Feature/GithubRunnerFlowTest.php @@ -1,7 +1,9 @@ $action, @@ -111,6 +113,7 @@ function sendWorkflowJobWebhook($test, GithubApp $githubApp, string $secret, str ], 'repository' => ['id' => 99, 'full_name' => 'acme/api'], 'organization' => ['login' => 'acme'], + 'installation' => ['id' => $installationId ?? $githubApp->installation_id], ], JSON_THROW_ON_ERROR); return $test->call('POST', '/webhooks/source/github/events', [], [], [], [ @@ -211,6 +214,38 @@ describe('workflow_job webhook', function () { Http::assertNothingSent(); }); + it('ignores jobs from another installation of the GitHub App', function (string $action) { + $execution = runnerTestExecution($this->githubApp, [ + 'trigger_workflow_job_id' => 5001, + 'status' => GithubRunnerStatus::Running, + 'server_id' => $this->server->id, + 'runner_name' => 'coolify-runner-abc', + ]); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', $action, [ + 'id' => 6001, + 'runner_name' => 'coolify-runner-abc', + 'conclusion' => 'success', + ], installationId: 987654) + ->assertOk() + ->assertSee('another installation'); + + expect(GithubRunnerExecution::count())->toBe(1) + ->and($execution->fresh()->status)->toBe(GithubRunnerStatus::Running); + Queue::assertNothingPushed(); + })->with(['queued', 'completed']); + + it('marks a pull request job when only some matching configurations allow pull request jobs', function () { + Http::preventStrayRequests(); + fakeWorkflowRunApi($this->githubApp, Http::response(['id' => 321, 'event' => 'pull_request'])); + runnerTestConfig(runnerTestServer($this->team), $this->githubApp, ['allow_pull_requests' => true]); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', ['run_id' => 321])->assertOk(); + + expect(GithubRunnerExecution::sole()->is_pull_request)->toBeTrue(); + Queue::assertPushed(ProvisionGithubRunnerJob::class, 1); + }); + it('rejects an invalid signature', function () { sendWorkflowJobWebhook($this, $this->githubApp, 'wrong-secret', 'queued', [])->assertSee('Invalid signature'); @@ -366,6 +401,58 @@ describe('provisioning', function () { expect($execution->fresh()->server_id)->toBe($server->id); }); + it('places a pull request job only on a server whose configuration allows pull request jobs', function () { + Queue::fake(); + fakeRunnerGithubApi($this->githubApp); + Process::fake(['*' => Process::result(output: '29.8.0')]); + $refusingServer = runnerTestServer($this->team); + runnerTestConfig($refusingServer, $this->githubApp, ['allow_pull_requests' => false]); + $allowingServer = runnerTestServer($this->team); + $allowingConfig = runnerTestConfig($allowingServer, $this->githubApp, ['allow_pull_requests' => true]); + runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $allowingServer->id, 'github_runner_config_id' => $allowingConfig->id]); + $execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]); + + (new ProvisionGithubRunnerJob($execution->id))->handle(); + + expect($execution->fresh()->server_id)->toBe($allowingServer->id); + }); + + it('keeps a pull request job queued when no configuration with capacity allows pull request jobs', function () { + Queue::fake(); + Process::fake(); + runnerTestConfig(runnerTestServer($this->team), $this->githubApp, ['allow_pull_requests' => false]); + $execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]); + + (new ProvisionGithubRunnerJob($execution->id))->handle(); + + expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued); + Process::assertNothingRan(); + }); + + it('stops provisioning when the server is deleted while GitHub registers the runner', function () { + Queue::fake(); + Process::fake(['*' => Process::result(output: '29.8.0')]); + $server = runnerTestServer($this->team); + runnerTestConfig($server, $this->githubApp); + $execution = runnerTestExecution($this->githubApp); + Http::fake([ + 'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201), + 'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($server) { + DeleteServer::run($server->id); + + return Http::response(['runner' => ['id' => 42], 'encoded_jit_config' => 'SECRET-JIT-CONFIG'], 201); + }, + ]); + + (new ProvisionGithubRunnerJob($execution->id))->handle(); + + expect(GithubRunnerExecution::find($execution->id))->toBeNull(); + Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, '/home/runner/run.sh')); + Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); + }); + it('does not start runners on the Coolify host', function () { Queue::fake(); Process::fake(); @@ -624,6 +711,72 @@ describe('cleanup and reconciliation', function () { }); }); +describe('server deletion', function () { + beforeEach(function () { + $this->server = runnerTestServer($this->team); + $this->config = runnerTestConfig($this->server, $this->githubApp); + }); + + it('deletes the runner configuration and executions of the server and lets the GitHub App be deleted', function () { + Queue::fake(); + $running = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'runner_id' => 42]); + $provisioning = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id]); + $completed = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $this->server->id, 'runner_id' => 41]); + $otherServer = runnerTestServer($this->team); + $otherConfig = runnerTestConfig($otherServer, $this->githubApp, ['is_enabled' => false]); + $other = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $otherServer->id]); + $queued = runnerTestExecution($this->githubApp); + + $this->server->delete(); + DeleteServer::run($this->server->id); + + expect(Server::withTrashed()->find($this->server->id))->toBeNull() + ->and(GithubRunnerConfig::find($this->config->id))->toBeNull() + ->and(GithubRunnerExecution::whereKey([$running->id, $provisioning->id, $completed->id])->exists())->toBeFalse() + ->and($other->fresh())->not->toBeNull() + ->and($otherConfig->fresh())->not->toBeNull() + ->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued); + Queue::assertPushed(DeregisterGithubRunnerJob::class, 1); + Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42); + + $this->githubApp->delete(); + expect(GithubApp::find($this->githubApp->id))->toBeNull(); + }); + + it('removes the runner from GitHub and ignores GitHub errors', function (int $status) { + Http::fake([ + 'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201), + 'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, $status), + ]); + + (new DeregisterGithubRunnerJob($this->githubApp->id, 42))->handle(); + + Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/orgs/acme/actions/runners/42')); + })->with([204, 500]); + + it('removes runners of servers deleted by older versions during reconciliation', function () { + Queue::fake(); + Process::fake(['*' => Process::result(output: '')]); + $orphan = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'runner_id' => 42]); + $orphanWithoutRunner = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning]); + $queued = runnerTestExecution($this->githubApp); + $finished = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::TimedOut]); + + (new ReconcileGithubRunnersJob)->handle(); + + expect(GithubRunnerExecution::whereKey([$orphan->id, $orphanWithoutRunner->id])->exists())->toBeFalse() + ->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued) + ->and($finished->fresh()->status)->toBe(GithubRunnerStatus::TimedOut); + Queue::assertPushed(DeregisterGithubRunnerJob::class, 1); + Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->runnerId === 42); + + $this->config->update(['is_enabled' => false]); + $this->githubApp->delete(); + expect(GithubApp::find($this->githubApp->id))->toBeNull(); + }); +}); + function fakeRejectedJitConfig(): void { Http::fake([ diff --git a/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php b/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php index 6f0e25f794..823017c0a8 100644 --- a/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php +++ b/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php @@ -155,3 +155,43 @@ it('marks only databases that exist before the migration as legacy', function () ->and($newKeydb->fresh()->legacy_password_quoting)->toBeFalse() ->and($newDragonfly->fresh()->legacy_password_quoting)->toBeFalse(); }); + +it('keeps the exact v4.3.23 start command when Docker Compose split or cut the unquoted legacy password', function (string $create, string $start, string $passwordColumn, string $commandFormat, string $password) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => $password]); + DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]); + + $start::run($database->fresh(), new Activity); + + expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command']) + ->toBe(sprintf($commandFormat, $password)); +})->with('legacy-password-quoting-engines')->with([ + 'semicolon' => 'ab;cd', + 'space' => 'a b', + 'tab' => "a\tb", + 'pipe' => 'x|y', + 'ampersand' => 'p&q', + 'redirects' => 'a', +]); + +it('quotes a plain legacy password, which gives Docker Compose the same argument as v4.3.23', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'abc']); + DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]); + + $start::run($database->fresh(), new Activity); + + expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command']) + ->toBe(sprintf($commandFormat, "'abc'")); +})->with('legacy-password-quoting-engines'); + +it('quotes shell control characters in the password of a database created after the upgrade', function (string $create, string $start, string $passwordColumn, string $commandFormat, string $password) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => $password]); + + $start::run($database->fresh(), new Activity); + + expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command']) + ->toBe(sprintf($commandFormat, escapeshellarg($password))); +})->with('legacy-password-quoting-engines')->with([ + 'semicolon' => 'ab;cd', + 'space' => 'a b', + 'pipe' => 'x|y', +]); diff --git a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php index 97659c3210..e410aeb799 100644 --- a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php +++ b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php @@ -182,3 +182,69 @@ it('saves a unique custom container name', function () { expect($application->settings()->first()->custom_internal_name)->toBe('unique-name'); }); + +it('rejects an invalid container name prefix on every save path', function (string $method, string $prefix) { + $application = createApplicationForContainerNamingTest(); + $application->settings->update(['custom_container_name_prefix' => 'original-prefix']); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('customContainerNamePrefix', $prefix) + ->call($method) + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($application->settings()->first()->custom_container_name_prefix)->toBe('original-prefix'); +})->with([ + 'submit' => ['submit'], + 'instant save' => ['instantSave'], +])->with([ + 'shell characters' => ['Bad Name;id'], + 'uppercase' => ['MyApp'], + 'leading hyphen' => ['-api'], +]); + +it('rejects a container name prefix already used on the server on every save path', function (string $method) { + $application = createApplicationForContainerNamingTest(); + Application::factory()->create([ + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ])->settings->update(['custom_container_name_prefix' => 'shared-prefix']); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('customContainerNamePrefix', 'shared-prefix') + ->call($method) + ->assertDispatched('error', 'This container name prefix is already in use by another application on this Coolify instance.') + ->assertNotDispatched('success'); + + expect($application->settings()->first()->custom_container_name_prefix)->toBeNull(); +})->with(['submit', 'instantSave']); + +it('saves a valid container name prefix on every save path', function (string $method) { + $application = createApplicationForContainerNamingTest(); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('customContainerNamePrefix', 'my-api-2') + ->call($method) + ->assertDispatched('success'); + + expect($application->settings()->first()->custom_container_name_prefix)->toBe('my-api-2'); +})->with(['submit', 'instantSave']); + +it('keeps saving other settings when a stored prefix predates the format rule', function () { + $application = createApplicationForContainerNamingTest(); + $application->settings->update(['custom_container_name_prefix' => 'Legacy_Prefix']); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('isGitLfsEnabled', true) + ->call('instantSave') + ->assertDispatched('success'); + + $settings = $application->settings()->first(); + expect($settings->is_git_lfs_enabled)->toBeTrue() + ->and($settings->custom_container_name_prefix)->toBe('Legacy_Prefix'); +}); diff --git a/tests/Feature/NineRouterServiceTemplateTest.php b/tests/Feature/NineRouterServiceTemplateTest.php new file mode 100644 index 0000000000..9027a115c6 --- /dev/null +++ b/tests/Feature/NineRouterServiceTemplateTest.php @@ -0,0 +1,51 @@ + Process::result(output: 'NOK')]); +}); + +test('the 9router template generates secrets that docker compose reads unchanged from the .env file', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => file_get_contents(base_path('templates/compose/9router.yaml')), + ]); + + $service->parse(); + + $secrets = $service->environment_variables() + ->where('key', 'like', 'SERVICE_PASSWORD%') + ->pluck('value', 'key'); + + // INITIAL_PASSWORD, JWT_SECRET and API_KEY_SECRET; the admin logs in with the shown password. + expect($secrets)->toHaveCount(3); + foreach ($secrets as $key => $value) { + expect($value)->toMatch('/^[A-Za-z0-9]+$/', "{$key} contains characters that compose interpolates"); + } +}); diff --git a/tests/Feature/Proxy/ProxyPortVariablesTest.php b/tests/Feature/Proxy/ProxyPortVariablesTest.php index 193de09421..c6af6871a3 100644 --- a/tests/Feature/Proxy/ProxyPortVariablesTest.php +++ b/tests/Feature/Proxy/ProxyPortVariablesTest.php @@ -96,6 +96,32 @@ it('asks to start a stopped proxy whose ports use variables and checks only conc Process::assertDidntRun(fn ($process) => str_contains($process->command, 'sport = ') && str_contains($process->command, '${')); }); +it('asks to start a stopped proxy whose ports all use variables without defaults', function () { + $this->server->proxy->last_saved_proxy_configuration = "services:\n traefik:\n image: traefik:v3.6\n ports:\n - '\${HTTP_PORT}:80'\n - '\${HTTPS_PORT}:443'\n"; + $this->server->save(); + + expect(CheckProxy::run($this->server))->toBeTrue(); + + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'sport = ')); +}); + +it('does not ask to start a stopped proxy without published ports', function () { + $this->server->proxy->last_saved_proxy_configuration = "services:\n traefik:\n image: traefik:v3.6\n ports:\n"; + $this->server->save(); + + expect(CheckProxy::run($this->server))->toBeFalse(); +}); + +it('saves and starts a proxy whose ports key is empty', function () { + $configuration = "services:\n traefik:\n image: traefik:v3.6\n ports:\n"; + + SaveProxyConfiguration::run($this->server, $configuration); + StartProxy::run($this->server->fresh(), async: false, force: true); + + expect($this->server->fresh()->proxy->last_saved_proxy_configuration)->toBe($configuration); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker compose -f /data/coolify/proxy/docker-compose.yml up -d --wait --remove-orphans')); +}); + it('configures traffic analytics for a proxy whose ports use variables', function () { Queue::fake(); StartSentinel::partialMock()->shouldReceive('handle')->atLeast()->once(); diff --git a/tests/Feature/ScheduledJobManagerDispatchTest.php b/tests/Feature/ScheduledJobManagerDispatchTest.php index 4e201b90be..82f024c848 100644 --- a/tests/Feature/ScheduledJobManagerDispatchTest.php +++ b/tests/Feature/ScheduledJobManagerDispatchTest.php @@ -29,6 +29,7 @@ use App\Notifications\Server\DockerCleanupFailed; use App\Services\ScheduledJobDeliveryService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Queue\Middleware\WithoutOverlapping; +use Illuminate\Queue\TimeoutExceededException; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Cache; @@ -36,6 +37,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; uses(RefreshDatabase::class); @@ -359,7 +361,7 @@ it('does not query relationships when constructing scheduled task jobs', functio expect(DB::getQueryLog())->toBeEmpty() ->and($job->queue)->toBe(crons_queue()) - ->and($job->timeout)->toBe(300); + ->and($job->timeout)->toBe(300 + ScheduledTaskJob::WORKER_TIMEOUT_MARGIN_SECONDS); }); it('reads current server settings in each queued manager run of the same worker', function () { @@ -803,6 +805,40 @@ it('leaves recently enqueued occurrences alone', function () { expect($occurrence->fresh()->enqueued_at->toDateTimeString())->toBe('2026-09-17 11:01:00'); }); +it('closes occurrences that an earlier release left enqueued without failed executions or notifications', function () { + config(['constants.coolify.self_hosted' => true]); + Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC')); + Queue::fake(); + Notification::fake(); + Event::fake([DockerCleanupDone::class]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + $task = createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => 'daily']); + $team = Team::find($task->team_id); + $team->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true, 'docker_cleanup_failure_email_notifications' => true]); + $server = $task->application->destination->server; + $backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']); + + $legacy = [ + createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 10, 0, 0, 0, 'UTC'), minutesAgo: 7 * 24 * 60), + createStaleEnqueuedOccurrence("docker-cleanup:{$server->id}", 'docker-cleanup', $server->id, Carbon::create(2026, 9, 17, 9, 0, 0, 'UTC'), minutesAgo: 180), + createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC')), + ]; + // A recent job can still be waiting in the queue. + $recent = createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 17, 11, 55, 0, 'UTC'), minutesAgo: 5); + + (require database_path('migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php'))->up(); + (new ScheduledJobManager)->handle(); + + foreach ($legacy as $occurrence) { + expect($occurrence->fresh()->status)->toBe('skipped'); + } + expect($recent->fresh()->status)->toBe('enqueued') + ->and(ScheduledTaskExecution::query()->exists())->toBeFalse() + ->and(DockerCleanupExecution::query()->exists())->toBeFalse(); + Queue::assertNotPushed(DatabaseBackupJob::class); + Notification::assertNothingSent(); +}); + it('dispatches only the schedules of its own type', function (string $type, array $expected) { config(['constants.coolify.self_hosted' => true]); Carbon::setTestNow(Carbon::create(2026, 9, 17, 1, 0, 0, 'UTC')); @@ -997,18 +1033,51 @@ it('does not run a scheduled task again when Redis hands it out after its worker expect(ScheduledTaskExecution::query()->where('scheduled_task_id', $task->id)->exists())->toBeFalse(); }); -it('marks only the unfinished execution as failed when a scheduled task job fails without its execution id', function () { +it('fails only its own execution and notifies the team when the worker timeout stops a scheduled task job', function () { InstanceSettings::forceCreate(['id' => 0]); Notification::fake(); - $task = createScheduledApplicationTask(createScheduledTaskApplication()); - $interrupted = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'running', 'started_at' => now()->subHours(2)]); - $interrupted->forceFill(['created_at' => now()->subHours(2)])->save(); - $later = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'success', 'started_at' => now()->subHour(), 'finished_at' => now()->subMinutes(59)]); + Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC')); + $application = createScheduledTaskApplication(); + $task = createScheduledApplicationTask($application, ['timeout' => 300]); + $team = Team::find($task->team_id); + $team->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true]); + $finished = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'success', 'started_at' => now()->subHour(), 'finished_at' => now()->subMinutes(59)]); + $job = new ScheduledTaskJob($task); + // At the worker timeout, failed() runs on a copy rebuilt from the queued payload. + $queuedCopy = unserialize(serialize($job)); + $commands = collect(); + $atTimeout = null; + Process::fake(function ($process) use ($application, $task, $queuedCopy, $team, $commands, &$atTimeout) { + $commands->push($process->command); + if (str_contains($process->command, 'docker ps')) { + return Process::result(output: json_encode(['Names' => 'app-'.$application->uuid, 'Labels' => "coolify.applicationUuid={$application->uuid}", 'State' => 'running'])); + } + if (str_contains($process->command, 'docker exec')) { + Carbon::setTestNow(now()->addMinute()); + $newerRun = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'running', 'started_at' => now()]); + $ownRun = ScheduledTaskExecution::query()->whereKeyNot([$newerRun->id])->where('status', 'running')->sole(); - (new ScheduledTaskJob($task))->failed(new RuntimeException('worker killed')); + $queuedCopy->failed(new TimeoutExceededException('App\Jobs\ScheduledTaskJob has timed out.')); - expect($interrupted->fresh()->status)->toBe('failed') - ->and($later->fresh()->status)->toBe('success'); + $atTimeout = [ + 'own' => $ownRun->fresh()->status, + 'own_finished' => $ownRun->fresh()->finished_at !== null, + 'newer' => $newerRun->fresh()->status, + 'notifications' => Notification::sent($team, TaskFailed::class)->count(), + ]; + + throw new RuntimeException('The worker stopped the job.'); + } + + return Process::result(output: ''); + }); + + expect(fn () => $job->handle())->toThrow(RuntimeException::class); + + expect($atTimeout)->toBe(['own' => 'failed', 'own_finished' => true, 'newer' => 'running', 'notifications' => 1]) + ->and($finished->fresh()->status)->toBe('success') + ->and($commands->first(fn (string $command) => str_contains($command, 'docker exec')))->toStartWith('timeout 300 ssh ') + ->and($job->timeout)->toBeGreaterThan(300); }); it('runs a fixed-time schedule once when daylight saving time repeats its local time', function () { diff --git a/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php b/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php index c93d3a1849..986bec92e9 100644 --- a/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php +++ b/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php @@ -9,6 +9,7 @@ use App\Actions\Service\StopService; use App\Enums\ActivityTypes; use App\Enums\ProcessStatus; use App\Events\DatabaseStatusChanged; +use App\Exceptions\RemoteSecretException; use App\Jobs\ApplicationDeploymentJob; use App\Jobs\DatabaseStartJob; use App\Livewire\Project\Shared\EnvironmentVariable\Show; @@ -210,6 +211,28 @@ test('a service restart keeps the service running when remote secrets cannot be ->toThrow(RuntimeException::class, 'Doppler is down'); }); +test('a literal service variable keeps reference text when no secret manager source is configured', function () { + Http::fake(); + $service = remoteSecretLifecycleService(); + $service->secretManagerLink()->delete(); + $service->environment_variables()->create(['key' => 'TEMPLATE', 'value' => 'Hello {{vault.NAME}}', 'is_literal' => true]); + + $service->ensureRemoteSecretsResolvable($service->environment_variables()->get()); + + expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain("TEMPLATE='Hello {{vault.NAME}}'"); + Http::assertNothingSent(); +}); + +test('a service start stops before the service when a non-literal variable references a secret without a source', function () { + StopService::shouldRun()->never(); + $service = remoteSecretLifecycleService(); + $service->secretManagerLink()->delete(); + $service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}']); + + expect(fn () => StartService::run($service, stopBeforeStart: true)) + ->toThrow(RemoteSecretException::class, 'no secret manager source is configured'); +}); + test('a service restart fetches remote secrets only once', function () { Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => 'value'])]); Process::fake(['*' => Process::result(output: '')]); diff --git a/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php b/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php index 1900ddb588..d1a667fdaf 100644 --- a/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php +++ b/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php @@ -12,6 +12,7 @@ use App\Models\Project; use App\Models\Server; use App\Models\Team; use App\Models\User; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Illuminate\Support\Js; @@ -382,6 +383,35 @@ test('the edit modal value autocomplete offers the vault scope with lazy key fet expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']); }); +test('the value autocomplete lists secret manager keys only for users who can update the resource', function (string $role, bool $allowed) { + Http::fake([ + 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([ + 'DB_PASSWORD' => 'super-secret-value', + ]), + ]); + $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]); + $env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']); + + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => $role]); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']); + + if ($allowed) { + expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']); + + return; + } + + expect(fn () => $component->instance()->fetchSecretManagerKeys())->toThrow(AuthorizationException::class); + Http::assertNothingSent(); +})->with([ + 'member' => ['member', false], + 'admin' => ['admin', true], +]); + test('the edit modal value autocomplete reports secret provider failures', function () { Http::fake([ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([], 503), diff --git a/tests/Feature/ServerProxyConfigurationAuthorizationTest.php b/tests/Feature/ServerProxyConfigurationAuthorizationTest.php new file mode 100644 index 0000000000..698d194e06 --- /dev/null +++ b/tests/Feature/ServerProxyConfigurationAuthorizationTest.php @@ -0,0 +1,58 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->proxyConfiguration = "services:\n traefik:\n image: traefik:v3.6\n environment:\n - CF_DNS_API_TOKEN=zzfix-cloudflare-secret\n ports:\n - '80:80'\n"; + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => 'exited', + 'last_saved_proxy_configuration' => $this->proxyConfiguration, + ], + ]); +}); + +function actingAsProxyTeamUser(Team $team, string $role): User +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role]); + test()->actingAs($user); + session(['currentTeam' => $team]); + + return $user; +} + +it('does not load the proxy configuration for a team member', function () { + actingAsProxyTeamUser($this->team, 'member'); + + $component = Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadProxyConfiguration') + ->assertOk() + ->assertNotDispatched('error') + ->assertSet('proxySettings', null); + + expect($component->html())->not->toContain('zzfix-cloudflare-secret'); +}); + +it('loads the proxy configuration for a team admin', function () { + actingAsProxyTeamUser($this->team, 'admin'); + + Livewire::test(Proxy::class, ['server' => $this->server]) + ->call('loadProxyConfiguration') + ->assertSet('proxySettings', $this->proxyConfiguration); +}); diff --git a/tests/Feature/SettingsBackupEmptyStateTest.php b/tests/Feature/SettingsBackupEmptyStateTest.php index 7bdf94341e..605350beb8 100644 --- a/tests/Feature/SettingsBackupEmptyStateTest.php +++ b/tests/Feature/SettingsBackupEmptyStateTest.php @@ -2,6 +2,7 @@ use App\Livewire\SettingsBackup; use App\Models\InstanceSettings; +use App\Models\PrivateKey; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandaloneDocker; @@ -11,6 +12,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Once; use Livewire\Livewire; @@ -161,3 +163,64 @@ test('configure backup creates only the missing schedule for an existing instanc ->and($backup->frequency)->toBe('0 0 * * *') ->and(ScheduledDatabaseBackup::query()->count())->toBe(1); }); + +/** + * A team database that is also named coolify-db, but is not the instance database (id 0). + */ +function settingsBackupTenantDatabaseNamedCoolifyDb(): StandalonePostgresql +{ + $tenantTeam = Team::factory()->create(); + $tenantServer = Server::factory()->create(['team_id' => $tenantTeam->id]); + $tenantDestination = StandaloneDocker::query()->where('server_id', $tenantServer->id)->firstOrFail(); + + $database = new StandalonePostgresql; + $database->forceFill([ + 'name' => 'coolify-db', + 'postgres_user' => 'tenant', + 'postgres_password' => 'tenant-secret', + 'postgres_db' => 'tenant', + 'status' => 'running', + 'destination_type' => StandaloneDocker::class, + 'destination_id' => $tenantDestination->id, + ]); + $database->save(); + + return $database; +} + +test('instance backup settings ignore a team database named coolify-db', function () { + settingsBackupInstanceDatabaseWithoutSchedule()->delete(); + $tenantDatabase = settingsBackupTenantDatabaseNamedCoolifyDb(); + + Livewire::test(SettingsBackup::class) + ->assertOk() + ->assertSet('database', null) + ->assertSee('Backup is not configured') + ->assertDontSee('tenant-secret'); + + expect($tenantDatabase->fresh()->scheduledBackups()->exists())->toBeFalse(); +}); + +test('configure backup creates the instance database instead of using a team database named coolify-db', function () { + settingsBackupInstanceDatabaseWithoutSchedule()->forceDelete(); + $tenantDatabase = settingsBackupTenantDatabaseNamedCoolifyDb(); + Storage::fake('ssh-keys'); + Storage::fake('ssh-mux'); + Server::query()->whereKey(0)->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => 0])->id]); + Server::flushIdentityMap(); + Process::fake([ + '*docker inspect coolify-db*' => Process::result(output: json_encode([[ + 'Config' => ['Env' => ['POSTGRES_USER=coolify', 'POSTGRES_PASSWORD=instance-secret', 'POSTGRES_DB=coolify']], + ]])), + ]); + + Livewire::test(SettingsBackup::class) + ->call('addCoolifyDatabase') + ->assertHasNoErrors() + ->assertSet('postgres_password', 'instance-secret'); + + $instanceDatabase = StandalonePostgresql::query()->findOrFail(0); + expect($instanceDatabase->scheduledBackups()->sole()->team_id)->toBe(0) + ->and($tenantDatabase->fresh()->scheduledBackups()->exists())->toBeFalse() + ->and($tenantDatabase->fresh()->postgres_password)->toBe('tenant-secret'); +}); diff --git a/tests/Feature/SqliteConnectApplicationTest.php b/tests/Feature/SqliteConnectApplicationTest.php index 258ac78b94..9757498c80 100644 --- a/tests/Feature/SqliteConnectApplicationTest.php +++ b/tests/Feature/SqliteConnectApplicationTest.php @@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -219,6 +220,28 @@ it('forbids team members from mounting the volume into an application', function expect($this->application->persistentStorages()->count())->toBe(0); }); +it('rejects a client-supplied volume name so the mount cannot become a host bind mount', function (string $volumeName) { + expect(fn () => Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]) + ->set('applicationUuid', $this->application->uuid) + ->set('volumeName', $volumeName) + ->call('connect')) + ->toThrow(CannotUpdateLockedPropertyException::class); + + expect($this->application->persistentStorages()->count())->toBe(0); +})->with(['/', '../x', '/etc']); + +it('does not mount a stored data volume whose name is not a docker volume name', function () { + $this->sqlite->persistentStorages()->update(['name' => '/etc']); + + Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]) + ->set('applicationUuid', $this->application->uuid) + ->call('connect') + ->assertHasErrors('volumeName') + ->assertNoRedirect(); + + expect($this->application->persistentStorages()->count())->toBe(0); +}); + it('keeps a docker volume shared with another resource when the application volumes are deleted', function () { Process::fake(); diff --git a/tests/Feature/StartDatabaseImportBackupPreparationTest.php b/tests/Feature/StartDatabaseImportBackupPreparationTest.php index 4016fb6dac..b49d607a84 100644 --- a/tests/Feature/StartDatabaseImportBackupPreparationTest.php +++ b/tests/Feature/StartDatabaseImportBackupPreparationTest.php @@ -11,6 +11,7 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; use App\Models\Team; +use App\Support\DatabaseImport\DatabaseImportException; use App\Support\DatabaseImport\DatabaseImportSource; use App\Support\RemoteProcessCommand; use App\Support\ResourceStartActivity; @@ -88,6 +89,56 @@ test('plain and gzip server backups are copied into the database container uncha 'custom archive' => ['/srv/backups/app.dmp', '5047444d5001'], ]); +test('server backups without a known backup extension can be restored', function (string $path, string $magicHex) { + $command = importServerBackup($this, $path, $magicHex); + + expect($command)->toContain("docker cp '{$path}' '{$this->database->uuid}:/tmp/restore_"); +})->with([ + 'no extension' => ['/srv/backups/pgdump', '5047444d5001'], + 'date suffix' => ['/srv/backups/app.sql.2026-10-01', '2d2d20506f73'], + 'sqlite' => ['/srv/backups/app.sqlite', '53514c697465'], +]); + +test('server backup paths with shell characters are still rejected', function (string $path) { + expect(fn () => importServerBackup($this, $path, '2d2d20506f73')) + ->toThrow(DatabaseImportException::class, 'The server path is invalid.'); +})->with([ + 'relative' => ['srv/backups/pgdump'], + 'traversal' => ['/srv/backups/../../etc/shadow'], + 'command substitution' => ['/srv/backups/$(id)'], + 'semicolon' => ['/srv/backups/a;id'], + 'quote' => ["/srv/backups/a'b"], +]); + +test('s3 backups still need a known backup extension', function (string $path, bool $allowed) { + $storage = S3Storage::create([ + 'name' => 'Import S3', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://8.8.8.8', + 'is_usable' => true, + 'team_id' => $this->team->id, + ]); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('exists')->andReturn(false); + $filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial(); + $filesystem->shouldReceive('build')->andReturn($disk); + Storage::swap($filesystem); + Process::fake(); + + expect(fn () => app(StartDatabaseImport::class)->handle( + $this->database, + new DatabaseImportSource('s3', path: $path, s3StorageUuid: $storage->uuid), + $this->team->id, + ))->toThrow(DatabaseImportException::class, $allowed ? 'The S3 backup was not found' : 'The S3 path is invalid.'); +})->with([ + 'no extension' => ['backups/pgdump', false], + 'sqlite' => ['backups/app.sqlite', true], + 'gzip sqlite3' => ['backups/app.sqlite3.gz', true], +]); + test('bz2, xz, and zip server backups are prepared in the helper image', function (string $path, string $magicHex) { $command = importServerBackup($this, $path, $magicHex); diff --git a/tests/Feature/StorageAndServiceAuditTest.php b/tests/Feature/StorageAndServiceAuditTest.php index 0ec3dcee9a..e0520fa867 100644 --- a/tests/Feature/StorageAndServiceAuditTest.php +++ b/tests/Feature/StorageAndServiceAuditTest.php @@ -248,7 +248,7 @@ test('converting and deleting a file storage records storage events', function ( ]); Livewire::test(FileStorage::class, ['fileStorage' => $file->fresh()]) - ->call('delete', 'password') + ->call('delete', 'password', ['permanently_delete']) ->assertDispatched('success'); expect($file->fresh())->toBeNull() diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php index 30d4849b1c..2b0774d8ff 100644 --- a/tests/Feature/StorageApiTest.php +++ b/tests/Feature/StorageApiTest.php @@ -184,7 +184,7 @@ describe('GET /api/v1/applications/{uuid}/storages', function () { }); describe('POST /api/v1/applications/{uuid}/storages', function () { - test('rejects an application directory mount outside its managed root without side effects', function () { + test('creates an application directory mount outside its resource directory for an owner', function () { $app = createTestApplication($this); $this->withHeaders([ @@ -194,10 +194,9 @@ describe('POST /api/v1/applications/{uuid}/storages', function () { 'is_directory' => true, 'fs_path' => '/root/.ssh/authorized_keys', 'mount_path' => '/data', - ])->assertUnprocessable(); + ])->assertCreated(); - expect($app->fileStorages()->exists())->toBeFalse(); - Bus::assertNotDispatched(ServerStorageSaveJob::class); + expect($app->fileStorages()->sole()->fs_path)->toBe('/root/.ssh/authorized_keys'); }); test('creates a persistent storage', function () { @@ -685,7 +684,7 @@ test('rejects host paths when creating persistent storage through the API', func expect($resource->persistentStorages()->count())->toBe($storageCountBefore); })->with(['application', 'database', 'service']); -test('rejects directory mounts outside each resource configuration root', function (string $resourceType) { +test('creates directory mounts outside each resource configuration root for an owner', function (string $resourceType) { if ($resourceType === 'database') { $resource = createTestDatabase($this); $url = "/api/v1/databases/{$resource->uuid}/storages"; @@ -703,10 +702,9 @@ test('rejects directory mounts outside each resource configuration root', functi 'is_directory' => true, 'fs_path' => '/etc/shadow', 'mount_path' => '/data', - ]])->assertUnprocessable(); + ]])->assertCreated(); - expect($resource->fileStorages()->exists())->toBeFalse(); - Bus::assertNotDispatched(ServerStorageSaveJob::class); + expect($resource->fileStorages()->sole()->fs_path)->toBe('/etc/shadow'); })->with(['database', 'service']); test('rejects a directory mount through a remote symlink before creating storage', function (string $resourceType) { diff --git a/tests/Feature/StorageHostPathTest.php b/tests/Feature/StorageHostPathTest.php new file mode 100644 index 0000000000..166cd07563 --- /dev/null +++ b/tests/Feature/StorageHostPathTest.php @@ -0,0 +1,365 @@ + 0, 'is_api_enabled' => true]); + config([ + 'app.maintenance.store' => 'array', + 'cache.default' => 'array', + 'constants.ssh.mux_enabled' => false, + ]); + + $this->team = Team::factory()->create(); + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + storageHostPathFakeServer(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); +}); + +/** + * `test -L` prints $symlink and `test -f`/`test -d` print $exists. + */ +function storageHostPathFakeServer(string $symlink = 'OK', string $exists = 'NOK'): void +{ + Process::fake(fn ($process) => Process::result(output: match (true) { + str_contains($process->command, 'test -L') => $symlink, + str_contains($process->command, 'readlink -f') => 'OK', + str_contains($process->command, 'test -') => $exists, + default => '', + })); +} + +function storageHostPathToken(User $user): string +{ + // A token request must not use the session user of the Livewire tests. + auth()->forgetGuards(); + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'host-path-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => test()->team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +/** + * @return array{0: string, 1: array, 2: mixed} + */ +function storageHostPathApiTarget(string $resourceType): array +{ + if ($resourceType === 'application') { + return ['/api/v1/applications/'.test()->application->uuid.'/storages', [], test()->application]; + } + if ($resourceType === 'database') { + $database = StandalonePostgresql::create([ + 'name' => 'host-path-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ]); + + return ["/api/v1/databases/{$database->uuid}/storages", [], $database]; + } + + $service = Service::factory()->create([ + 'environment_id' => test()->environment->id, + 'server_id' => test()->server->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n", + ]); + $serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]); + + return ["/api/v1/services/{$service->uuid}/storages", ['resource_uuid' => $serviceApplication->uuid], $serviceApplication]; +} + +function storageHostPathFileVolume(string $fsPath, ?string $content, bool $isDirectory = false): LocalFileVolume +{ + return LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => '/mnt/'.md5($fsPath), + 'content' => $content, + 'is_directory' => $isDirectory, + 'resource_id' => test()->application->id, + 'resource_type' => test()->application->getMorphClass(), + ])->fresh(); +} + +function storageHostPathAssertNoDelete(): void +{ + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -') || str_contains($process->command, 'rmdir')); +} + +test('an admin creates a directory mount anywhere on the host in the UI', function () { + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_directory_source', '/srv/zz') + ->set('file_storage_directory_destination', '/data') + ->call('submitFileStorageDirectory') + ->assertDispatched('success'); + + $volume = LocalFileVolume::query()->sole(); + expect($volume->fs_path)->toBe('/srv/zz') + ->and($volume->is_directory)->toBeTrue(); + + $volume->saveStorageOnServer(); + Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/srv/zz'")); +}); + +test('an admin creates a file mount with content anywhere on the host in the UI', function () { + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_source', '/etc/zz/app.conf') + ->set('file_storage_path', '/etc/app.conf') + ->set('file_storage_content', 'listen 8080;') + ->call('submitFileStorage') + ->assertDispatched('success'); + + $volume = LocalFileVolume::query()->sole(); + expect($volume->fs_path)->toBe('/etc/zz/app.conf') + ->and($volume->mount_path)->toBe('/etc/app.conf'); + + $volume->saveStorageOnServer(); + Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/etc/zz'")); + Process::assertRan(fn ($process) => preg_match("#base64 -d \\| (sudo )?tee '/etc/zz/app.conf' > /dev/null#", $process->command) === 1); + expect($volume->contentPathOnServer())->toBe('/etc/zz/app.conf'); +}); + +test('an admin creates directory and file mounts anywhere on the host through the API', function (string $resourceType) { + [$url, $payload, $resource] = storageHostPathApiTarget($resourceType); + $token = storageHostPathToken($this->admin); + + $this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson($url, [...$payload, ...[ + 'type' => 'file', + 'is_directory' => true, + 'fs_path' => '/srv/zz', + 'mount_path' => '/data', + ]])->assertCreated()->assertJsonPath('fs_path', '/srv/zz'); + + $this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson($url, [...$payload, ...[ + 'type' => 'file', + 'fs_path' => '/etc/zz/app.conf', + 'mount_path' => '/etc/app.conf', + 'content' => 'listen 8080;', + ]])->assertCreated()->assertJsonPath('fs_path', '/etc/zz/app.conf'); + + expect($resource->fileStorages()->orderBy('id')->pluck('fs_path')->all())->toBe(['/srv/zz', '/etc/zz/app.conf']); +})->with(['application', 'database', 'service']); + +test('members cannot create host path mounts', function () { + $this->actingAs($this->member); + + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_directory_source', '/srv/zz') + ->set('file_storage_directory_destination', '/data') + ->call('submitFileStorageDirectory') + ->assertDispatched('error'); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->member)]) + ->postJson("/api/v1/applications/{$this->application->uuid}/storages", [ + 'type' => 'file', + 'fs_path' => '/etc/zz/app.conf', + 'mount_path' => '/etc/app.conf', + 'content' => 'listen 8080;', + ])->assertForbidden(); + + expect(LocalFileVolume::query()->count())->toBe(0); +}); + +test('host paths with traversal, home or shell characters are rejected', function (string $path) { + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_directory_source', $path) + ->set('file_storage_directory_destination', '/data') + ->call('submitFileStorageDirectory') + ->assertDispatched('error'); + + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_source', $path) + ->set('file_storage_path', '/etc/app.conf') + ->set('file_storage_content', 'listen 8080;') + ->call('submitFileStorage') + ->assertDispatched('error'); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)]) + ->postJson("/api/v1/applications/{$this->application->uuid}/storages", [ + 'type' => 'file', + 'is_directory' => true, + 'fs_path' => $path, + 'mount_path' => '/data', + ])->assertUnprocessable(); + + expect(LocalFileVolume::query()->count())->toBe(0); +})->with([ + 'parent segment' => '/srv/../etc/zz', + 'relative escape' => '../../../etc/zz', + 'home directory' => '~/zz', + 'command separator' => '/srv/zz;id', + 'command substitution' => '/srv/$(id)', +]); + +test('a symbolic link at a file path outside the resource directory is refused', function () { + storageHostPathFakeServer(symlink: 'LINK'); + $volume = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;'); + + expect(fn () => $volume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, '/etc/zz/app.conf is a symbolic link on the server.'); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'base64 -d')); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'touch ')); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)]) + ->postJson("/api/v1/applications/{$this->application->uuid}/storages", [ + 'type' => 'file', + 'fs_path' => '/etc/zz/other.conf', + 'mount_path' => '/etc/other.conf', + 'content' => 'listen 8080;', + ])->assertUnprocessable(); + + expect(LocalFileVolume::query()->count())->toBe(1); +}); + +test('deleting a mount outside the resource directory never deletes on the host', function () { + storageHostPathFakeServer(exists: 'OK'); + $directory = storageHostPathFileVolume('/srv/zz', null, isDirectory: true); + $file = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;'); + + $directory->deleteStorageOnServer(); + $file->deleteStorageOnServer(); + storageHostPathAssertNoDelete(); + + Livewire::test(FileStorage::class, ['fileStorage' => $file->fresh()]) + ->set('permanently_delete', true) + ->call('delete', 'password') + ->assertDispatched('success', 'File deleted.'); + + storageHostPathAssertNoDelete(); + expect(LocalFileVolume::query()->pluck('fs_path')->all())->toBe(['/srv/zz']); +}); + +test('deleting a mount inside the resource directory still deletes it on the host', function () { + storageHostPathFakeServer(exists: 'OK'); + $path = $this->application->workdir().'/data/zz'; + + storageHostPathFileVolume($path, null, isDirectory: true)->deleteStorageOnServer(); + + Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$path}'")); +}); + +test('the UI deletes a mount inside the resource directory on the host only when the user keeps the option checked', function (bool $isDirectory, array $selectedActions, bool $deletesOnHost, string $message) { + storageHostPathFakeServer(exists: 'OK'); + $path = $this->application->workdir().'/data/zz'; + $volume = storageHostPathFileVolume($path, $isDirectory ? null : 'listen 8080;', isDirectory: $isDirectory); + + Livewire::test(FileStorage::class, ['fileStorage' => $volume]) + ->call('delete', 'password', $selectedActions) + ->assertDispatched('success', $message); + + expect($volume->fresh())->toBeNull(); + if ($deletesOnHost) { + Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$path}'")); + } else { + storageHostPathAssertNoDelete(); + } +})->with([ + 'directory, option checked' => [true, ['permanently_delete'], true, 'Directory deleted from the server.'], + 'directory, option unchecked' => [true, [], false, 'Directory deleted.'], + 'file, option checked' => [false, ['permanently_delete'], true, 'File deleted from the server.'], + 'file, option unchecked' => [false, [], false, 'File deleted.'], +]); + +test('the delete dialog offers deletion on the host only for mounts inside the resource directory', function () { + $inside = storageHostPathFileVolume($this->application->workdir().'/data/zz', 'listen 8080;'); + $outside = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;'); + $outsideDirectory = storageHostPathFileVolume('/srv/zz', null, isDirectory: true); + + $insideDialog = Livewire::test(FileStorage::class, ['fileStorage' => $inside]); + expect($insideDialog->viewData('deletionCheckboxes'))->toHaveCount(1) + ->and($insideDialog->viewData('deletionCheckboxes')[0]['id'])->toBe('permanently_delete'); + + foreach ([$outside, $outsideDirectory] as $volume) { + $dialog = Livewire::test(FileStorage::class, ['fileStorage' => $volume]); + expect($dialog->viewData('deletionCheckboxes'))->toBe([]) + ->and(implode(' ', $dialog->viewData('deletionActions')))->toContain('is not deleted on the server'); + } +}); + +test('a file outside the resource directory never replaces a directory on the host', function () { + Process::fake(fn ($process) => Process::result(output: match (true) { + str_contains($process->command, 'test -L') => 'OK', + str_contains($process->command, 'test -d') => 'OK', + str_contains($process->command, 'test -f') => 'NOK', + str_contains($process->command, 'sh -c') => '1:empty-directory', + default => '', + })); + + expect(fn () => storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;')->saveStorageOnServer()) + ->toThrow(Exception::class, 'is a directory on the server'); + storageHostPathAssertNoDelete(); +}); + +test('relative host paths stay inside the resource directory', function () { + $workdir = $this->application->workdir(); + + Livewire::test(Storage::class, ['resource' => $this->application]) + ->set('file_storage_directory_source', 'data/zz') + ->set('file_storage_directory_destination', '/data') + ->call('submitFileStorageDirectory') + ->assertDispatched('success'); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)]) + ->postJson("/api/v1/applications/{$this->application->uuid}/storages", [ + 'type' => 'file', + 'fs_path' => './config/app.conf', + 'mount_path' => '/etc/app.conf', + 'content' => 'listen 8080;', + ])->assertCreated(); + + expect(LocalFileVolume::query()->orderBy('id')->pluck('fs_path')->all()) + ->toBe([$workdir.'/data/zz', $workdir.'/config/app.conf']); + Process::assertRan(fn ($process) => str_contains($process->command, 'readlink -f') + && str_contains($process->command, "'{$workdir}' '{$workdir}/data/zz'")); +}); diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php index c3388e3a02..320993b9c5 100644 --- a/tests/Feature/TraefikVersionStateTest.php +++ b/tests/Feature/TraefikVersionStateTest.php @@ -178,6 +178,9 @@ it('still offers a newer Traefik branch than the configured image', function () it('clears the stale minor warning after the configured branch is applied', function () { $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); $server = Server::factory()->create([ 'team_id' => $team->id, 'proxy' => [ diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php new file mode 100644 index 0000000000..e489f8d80b --- /dev/null +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php @@ -0,0 +1,94 @@ + false, 'constants.ssh.retry_base_delay' => 0]); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_traffic_analytics_enabled' => true]); +}); + +function markTrafficServerReachable(Server $server): void +{ + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $server->refresh(); +} + +it('does not run a remote command for a server that is not reachable', function () { + Process::fake(fn () => Process::result(output: '{"requests":1}')); + $this->server->settings->update(['is_reachable' => false]); + $client = new SentinelTrafficClient($this->server->fresh()); + [$from, $to] = SentinelTrafficClient::rangeWindow('24h'); + + expect(fn () => $client->overview(null, $from, $to))->toThrow(RuntimeException::class); + $client->prefetchAppOverviews(['app'], $from, $to); + + Process::assertNothingRan(); +}); + +it('skips the dashboard fetch of an unreachable server without a remote call', function () { + Process::fake(fn () => Process::result(output: '{"requests":1}')); + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + loadLazy(Livewire::test(TrafficAnalytics::class)) + ->assertOk() + ->assertSet('overview', null); + + Process::assertNothingRan(); +}); + +it('tries a failing server once and skips it until the failure window ends', function () { + markTrafficServerReachable($this->server); + Process::fake(fn () => Process::result( + errorOutput: "ssh: connect to host {$this->server->ip} port 22: Connection timed out", + exitCode: 255, + )); + $client = new SentinelTrafficClient($this->server); + + expect(fn () => $client->overview(null, 'a', 'b'))->toThrow(RuntimeException::class); + Process::assertRanTimes(fn () => true, 1); + + expect(fn () => $client->overview('app', 'a', 'b'))->toThrow(RuntimeException::class); + Process::assertRanTimes(fn () => true, 1); + + $this->travel(SentinelTrafficClient::UNAVAILABLE_TTL + 1)->seconds(); + + expect(fn () => $client->overview('app', 'a', 'b'))->toThrow(RuntimeException::class); + Process::assertRanTimes(fn () => true, 2); +}); + +it('fetches a healthy server with a bounded ssh and curl timeout', function () { + markTrafficServerReachable($this->server); + Process::fake(fn () => Process::result(output: '{"requests":7}')); + + $overview = (new SentinelTrafficClient($this->server))->overview(null, 'a', 'b'); + + expect($overview->requests)->toBe(7); + Process::assertRan(fn (PendingProcess $process) => $process->timeout <= 30 + && str_contains($process->command, '--max-time')); +}); diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php index 233ac6c7d7..2b5312b26f 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php @@ -42,6 +42,8 @@ it('surfaces the real remote error instead of a TypeError when the fetch fails', 'team_id' => $team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, ]); + // Only a functional server is queried. + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); expect(fn () => (new SentinelTrafficClient($server))->overview('app', 'a', 'b')) ->toThrow(RuntimeException::class, $message); diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php index a804036682..7919c5e212 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php @@ -25,6 +25,8 @@ beforeEach(function () { 'team_id' => $team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, ]); + // Only a functional server is queried. + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); $this->client = new class($this->server) extends SentinelTrafficClient { diff --git a/tests/Feature/TrafficAnalytics/TrafficLockedPropertiesTest.php b/tests/Feature/TrafficAnalytics/TrafficLockedPropertiesTest.php new file mode 100644 index 0000000000..15d68461df --- /dev/null +++ b/tests/Feature/TrafficAnalytics/TrafficLockedPropertiesTest.php @@ -0,0 +1,66 @@ + 0]); + $this->user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->update(['is_traffic_analytics_enabled' => false]); +}); + +it('locks the scoped server and chart id of the analytics page', function (string $property, string $value) { + $component = loadLazy(Livewire::test(Analytics::class, ['scopedServerUuid' => $this->server->uuid])); + + expect(fn () => $component->set($property, $value))->toThrow(CannotUpdateLockedPropertyException::class); +})->with([ + 'scoped server' => ['scopedServerUuid', 'other-server'], + 'chart id' => ['chartId', "x');alert(1);//"], +]); + +it('locks the server uuid and chart id of the resource analytics tab', function (string $property, string $value) { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-locked-test']); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + ]); + + $component = Livewire::test(ApplicationAnalytics::class, ['application' => $application, 'lazy' => false]); + + expect(fn () => $component->set($property, $value))->toThrow(CannotUpdateLockedPropertyException::class); +})->with([ + 'analytics server' => ['analyticsServerUuid', 'other-server'], + 'chart id' => ['chartId', "x');alert(1);//"], +]); + +it('locks the chart id of the dashboard traffic summary', function () { + $component = loadLazy(Livewire::test(TrafficAnalytics::class)); + + expect(fn () => $component->set('chartId', "x');alert(1);//"))->toThrow(CannotUpdateLockedPropertyException::class); +}); diff --git a/tests/Feature/TrafficAnalytics/TrafficSettingsLimitsTest.php b/tests/Feature/TrafficAnalytics/TrafficSettingsLimitsTest.php new file mode 100644 index 0000000000..7e11e04c13 --- /dev/null +++ b/tests/Feature/TrafficAnalytics/TrafficSettingsLimitsTest.php @@ -0,0 +1,74 @@ + 'array', + 'cache.default' => 'array', + ]); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->update(['is_traffic_analytics_enabled' => false]); +}); + +dataset('traffic setting limits', [ + 'top-n' => ['trafficTopn', 'traffic_topn', 1000], + 'hourly retention' => ['trafficRetention1hDays', 'traffic_retention_1h_days', 365], + 'daily retention' => ['trafficRetention1dDays', 'traffic_retention_1d_days', 3650], +]); + +it('rejects traffic settings above the limit in the UI', function (string $property, string $column, int $max) { + $this->actingAs($this->user); + $before = $this->server->settings->fresh()->{$column}; + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $this->server]) + ->set($property, $max + 1) + ->call('saveTrafficAnalyticsSettings') + ->assertHasErrors($property); + + expect($this->server->settings->fresh()->{$column})->toBe($before); + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $this->server]) + ->set($property, $max) + ->call('saveTrafficAnalyticsSettings') + ->assertHasNoErrors(); + + expect($this->server->settings->fresh()->{$column})->toBe($max); +})->with('traffic setting limits'); + +it('rejects traffic settings above the limit in the API', function (string $property, string $column, int $max) { + $token = $this->user->createToken('traffic-limits', ['*'])->plainTextToken; + $headers = ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json']; + $before = $this->server->settings->fresh()->{$column}; + + $this->withHeaders($headers) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [$column => $max + 1]) + ->assertUnprocessable() + ->assertJsonValidationErrors($column); + + expect($this->server->settings->fresh()->{$column})->toBe($before); + + $this->withHeaders($headers) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [$column => $max]) + ->assertOk() + ->assertJsonPath($column, $max); +})->with('traffic setting limits'); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 2fc39486d5..55aec44db1 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -209,6 +209,61 @@ it('shows readable service storage backup target labels', function () { ->assertSee('Directus: directus-templates'); }); +it('assigns a new volume backup schedule to the team of its resource, not the current team', function (string $resourceType) { + $team = Team::factory()->create(); + $user = signInForVolumeBackups($this, $team); + [$application, $volume, $server] = createVolumeBackupApplication($team); + $otherTeam = Team::factory()->create(); + $user->teams()->attach($otherTeam, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + + if ($resourceType === 'service') { + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); + $serviceApplication = ServiceApplication::create(['uuid' => new_public_id(), 'name' => 'app', 'service_id' => $service->id]); + $volume = LocalPersistentVolume::create([ + 'name' => $service->uuid.'_app-data', + 'mount_path' => '/data', + 'resource_id' => $serviceApplication->id, + 'resource_type' => $serviceApplication->getMorphClass(), + ]); + $component = Livewire::test(CreateServiceVolumeBackup::class, ['service' => $service, 'selectedTargetKey' => 'volume:'.$volume->id]); + } else { + $component = Livewire::test(CreateScheduledVolumeBackup::class, ['application' => $application, 'selectedTargetKey' => 'volume:'.$volume->id]); + } + + $component->set('frequency', 'daily') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect(ScheduledVolumeBackup::query()->sole()->team_id)->toBe($team->id); +})->with(['application', 'service']); + +it('assigns a volume backup schedule saved from its settings page to the team of its resource, not the current team', function (bool $rootTeam) { + $team = $rootTeam ? Team::factory()->create(['id' => 0]) : Team::factory()->create(); + $user = signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $otherTeam = Team::factory()->create(); + $user->teams()->attach($otherTeam, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->set('frequency', 'daily') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect(ScheduledVolumeBackup::query()->sole()->team_id)->toBe($team->id); +})->with([ + 'team' => [false], + 'root team' => [true], +]); + it('handles scheduled backup persistence failures', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index 4b924b5f35..f62697554f 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -124,24 +124,18 @@ function makeWebhookApplicationServerFunctional(Application $application): Appli } describe('Manual Webhook Failed Authentication Rate Limiting', function () { - test('a locked failure scope rejects deliveries before reading application secrets', function (string $provider, bool $validSignature) { - $application = createApplicationWithWebhook(); + test('a locked failure scope rejects invalid deliveries', function (string $provider) { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); lockOutManualWebhookRepository($this, $provider, $application); - DB::enableQueryLog(); - DB::flushQueryLog(); - sendManualWebhookPush($this, $provider, $application, validSignature: $validSignature) + sendManualWebhookPush($this, $provider, $application, validSignature: false) ->assertStatus(429) ->assertHeader('Retry-After'); - $applicationQueries = collect(DB::getQueryLog()) - ->filter(fn (array $query): bool => str_contains($query['query'], '"applications"')); - DB::disableQueryLog(); - - expect($applicationQueries)->toBeEmpty(); expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); - })->with(['github', 'gitlab', 'bitbucket', 'gitea'])->with([false, true]); + })->with(['github', 'gitlab', 'bitbucket', 'gitea']); test('valid signed deliveries do not consume the failure limit', function (string $provider) { $application = createApplicationWithWebhook(); @@ -156,20 +150,18 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () { expect(RateLimiter::attempts(manualWebhookFailureKey($provider)))->toBe(0); })->with(['github', 'gitlab', 'bitbucket', 'gitea']); - test('a signed delivery is blocked during lockout and processed after the failure window expires', function (string $provider) { + test('a signed delivery is processed while failed deliveries from the same proxy address lock the scope', function (string $provider) { Queue::fake(); $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); + // On self-hosted instances every delivery reaches PHP from the proxy container. + $proxyAddress = '10.0.1.5'; - lockOutManualWebhookRepository($this, $provider, $application); + for ($i = 0; $i < 31; $i++) { + sendManualWebhookPush($this, $provider, $application, validSignature: false, ip: $proxyAddress, extraServer: ['HTTP_X_FORWARDED_FOR' => '198.51.100.66']); + } + sendManualWebhookPush($this, $provider, $application, validSignature: false, ip: $proxyAddress)->assertStatus(429); - sendManualWebhookPush($this, $provider, $application) - ->assertStatus(429) - ->assertHeader('Retry-After'); - expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); - - $this->travel(61)->seconds(); - - $response = sendManualWebhookPush($this, $provider, $application); + $response = sendManualWebhookPush($this, $provider, $application, ip: $proxyAddress, extraServer: ['HTTP_X_FORWARDED_FOR' => '140.82.115.1']); $response->assertOk(); expect($response->getContent())->toContain('Deployment queued'); @@ -182,7 +174,7 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () { lockOutManualWebhookRepository($this, $provider, $application); $attempts = RateLimiter::attempts(manualWebhookFailureKey($provider)); - sendManualWebhookPush($this, $provider, $application)->assertStatus(429); + sendManualWebhookPush($this, $provider, $application)->assertOk(); sendManualWebhookPush($this, $provider, $application, validSignature: false) ->assertStatus(429) ->assertHeader('Retry-After') @@ -1353,8 +1345,7 @@ describe('Manual Webhook Repeated Failed Deliveries', function () { expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); - sendManualWebhookPush($this, 'gitlab', $application)->assertStatus(429); - expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + sendManualWebhookPush($this, 'gitlab', $application)->assertOk()->assertSee('Deployment queued'); }); test('repeated tokens do not extend the guess limit', function () { @@ -1435,8 +1426,7 @@ describe('Manual Webhook Repeated Failed Deliveries', function () { sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'guess-30')->assertStatus(429); expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); - sendManualWebhookPush($this, $provider, $application)->assertStatus(429); - expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + sendManualWebhookPush($this, $provider, $application)->assertOk()->assertSee('Deployment queued'); })->with(['github', 'bitbucket', 'gitea']); test('the same wrong signature for different payloads counts every payload', function (string $provider) { diff --git a/tests/Unit/ProxyPortValidationTest.php b/tests/Unit/ProxyPortValidationTest.php index ef2edcadaf..7a569de6fc 100644 --- a/tests/Unit/ProxyPortValidationTest.php +++ b/tests/Unit/ProxyPortValidationTest.php @@ -110,10 +110,28 @@ it('rejects invalid proxy port collection shapes', function (mixed $ports) { })->with([ 'scalar' => ['80:80'], 'map' => [['published' => 80]], - 'null' => [null], 'boolean' => [true], ]); +it('accepts an empty ports key like v4.3.23 did', function (string $configuration) { + expect(ProxyPortParser::fromConfiguration($configuration))->toBe([]) + ->and(ProxyPortParser::publishesOnlyUncheckedPorts($configuration))->toBeFalse(); +})->with([ + 'empty value' => ["services:\n traefik:\n image: traefik:v3.6\n ports:\n"], + 'explicit null' => ["services:\n caddy:\n ports: null\n"], +]); + +it('reports proxies whose published ports cannot be checked for conflicts', function (string $configuration, bool $expected) { + expect(ProxyPortParser::publishesOnlyUncheckedPorts($configuration))->toBe($expected); +})->with([ + 'variables without defaults' => ["services:\n traefik:\n ports: ['\${HTTP_PORT}:80', '\${HTTPS_PORT}:443']\n", true], + 'a concrete port' => ["services:\n traefik:\n ports: ['\${HTTP_PORT}:80', '443:443']\n", false], + 'only port ranges' => ["services:\n traefik:\n ports: ['10000-10100:10000-10100']\n", true], + 'a variable with a default' => ["services:\n traefik:\n ports: ['\${HTTP_PORT:-80}:80']\n", false], + 'no ports' => ["services:\n traefik:\n image: traefik:v3.6\n", false], + 'empty ports' => ["services:\n traefik:\n ports: []\n", false], +]); + it('accepts Docker Compose merge tags in proxy configurations', function (string $configuration, array $expected) { expect(ProxyPortParser::fromConfiguration($configuration))->toBe($expected); })->with([ From 2aead9c914ef4ef050b8ca6008db52a1329bf04a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:28:03 +0200 Subject: [PATCH 2/3] refactor(analytics): remove application traffic overview card --- .../Project/Application/TrafficOverview.php | 86 ------------- .../application/traffic-overview.blade.php | 66 ---------- .../ApplicationTrafficOverviewTest.php | 121 ------------------ .../ResourceScopeTrafficTest.php | 23 +--- .../ResourceTrafficKeysTest.php | 5 +- 5 files changed, 3 insertions(+), 298 deletions(-) delete mode 100644 app/Livewire/Project/Application/TrafficOverview.php delete mode 100644 resources/views/livewire/project/application/traffic-overview.blade.php delete mode 100644 tests/Feature/TrafficAnalytics/ApplicationTrafficOverviewTest.php diff --git a/app/Livewire/Project/Application/TrafficOverview.php b/app/Livewire/Project/Application/TrafficOverview.php deleted file mode 100644 index 6cc30605a0..0000000000 --- a/app/Livewire/Project/Application/TrafficOverview.php +++ /dev/null @@ -1,86 +0,0 @@ -application->destination?->server; - $this->serverUuid = $server?->uuid; - $this->enabled = (bool) $server?->isTrafficAnalyticsEnabled(); - $this->eligible = $server ? (! $server->isSwarm() && ! $server->isBuildServer()) : false; - - if ($this->enabled && $server) { - try { - $client = app(SentinelTrafficClient::class, ['server' => $server]); - [$from, $to] = SentinelTrafficClient::rangeWindow('24h'); - // Compose services and previews record under their own keys. Sentinel's resource - // scope merges them exactly; an older Sentinel gets every key summed here. - $overview = $client->tryResourceOverview($this->application->uuid, $from, $to); - if ($overview === null) { - $keys = $client->resourceKeys($this->application->uuid); - if (count($keys) > 1) { - $client->prefetchAppOverviews($keys, $from, $to); - } - $overviews = array_map(fn (string $key) => $client->overview($key, $from, $to), $keys); - $overview = TrafficAnalyticsAggregator::sumOverviews($overviews)['overview']; - } - $this->overview = $overview->toArray(); - } catch (\Throwable $e) { - $this->overview = null; - } - } - } - - public function hasData(): bool - { - return $this->overview !== null && (int) ($this->overview['requests'] ?? 0) > 0; - } - - public function errorRate(): float - { - if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) { - return 0.0; - } - - $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0); - - return round(($errors / $this->overview['requests']) * 100, 2); - } - - public function placeholder(): string - { - return <<<'HTML' -
- HTML; - } - - public function render() - { - return view('livewire.project.application.traffic-overview'); - } -} diff --git a/resources/views/livewire/project/application/traffic-overview.blade.php b/resources/views/livewire/project/application/traffic-overview.blade.php deleted file mode 100644 index 831a372ec2..0000000000 --- a/resources/views/livewire/project/application/traffic-overview.blade.php +++ /dev/null @@ -1,66 +0,0 @@ -@php - $analyticsRoute = route('project.application.analytics', [ - 'project_uuid' => $application->environment->project->uuid, - 'environment_uuid' => $application->environment->uuid, - 'application_uuid' => $application->uuid, - ]); -@endphp - -
- @if (! $enabled) - @if ($eligible && $serverUuid) -
-
-
-

Traffic analytics

-

- Enable Sentinel traffic analytics on this server to see requests, visitors, and - geography for this application. Restarts the proxy + Sentinel. -

-
- - Server settings - - -
-
- @endif - @else -
-
-
-

Traffic (last 24h)

-

Request activity for this application

-
- - View full analytics → - -
- - @if (! $this->hasData()) -

- No traffic recorded in the last 24h yet. -

- @else -
-
- Requests - {{ number_format($overview['requests'] ?? 0) }} -
-
- Unique visitors - {{ number_format($overview['uniqueVisitors'] ?? 0) }} -
-
- Error rate - {{ $this->errorRate() }}% -
-
- p95 latency - {{ number_format($overview['latencyP95'] ?? 0, 1) }} ms -
-
- @endif -
- @endif -
diff --git a/tests/Feature/TrafficAnalytics/ApplicationTrafficOverviewTest.php b/tests/Feature/TrafficAnalytics/ApplicationTrafficOverviewTest.php deleted file mode 100644 index 4c958f1a79..0000000000 --- a/tests/Feature/TrafficAnalytics/ApplicationTrafficOverviewTest.php +++ /dev/null @@ -1,121 +0,0 @@ -responses as $needle => $response) { - if (str_contains($url, $needle)) { - return $response; - } - } - - return '{}'; - } -} - -beforeEach(function () { - // Servers are memoized via once()/cache; clear both so DB-id reuse across tests doesn't bleed a stale enabled server. - Cache::flush(); - Server::flushIdentityMap(); - $this->team = Team::factory()->create(); - $this->user = User::factory()->create(); - $this->team->members()->attach($this->user->id, ['role' => 'owner']); - $this->actingAs($this->user); - session(['currentTeam' => $this->team]); - $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); -}); - -function makeAppOnServer(bool $analyticsEnabled): Application -{ - $server = Server::factory()->create([ - 'team_id' => test()->team->id, - 'private_key_id' => test()->privateKey->id, - ]); - $server->settings->is_traffic_analytics_enabled = $analyticsEnabled; - $server->settings->save(); - - $project = Project::factory()->create(['team_id' => test()->team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $destination = StandaloneDocker::where('server_id', $server->id)->first() - ?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']); - - return Application::factory()->create([ - 'name' => 'Widget App', - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => StandaloneDocker::class, - ]); -} - -it('shows last-24h KPIs and a link to full analytics when enabled with data', function () { - $application = makeAppOnServer(true); - - app()->bind(SentinelTrafficClient::class, function ($app, $params) { - $client = new FakeAppOverviewTrafficClient($params['server']); - $client->responses = [ - '/traffic/overview' => json_encode([ - 'requests' => 4200, - 'bytes_in' => 5000, - 'bytes_out' => 25000, - 'status' => ['s2xx' => 4000, 's3xx' => 100, 's4xx' => 80, 's5xx' => 20], - 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1], - 'unique_visitors' => 1234, - ]), - ]; - - return $client; - }); - - $component = loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application])); - $component->assertOk() - ->assertSet('enabled', true) - ->assertSee('Traffic (last 24h)') - ->assertSee('4,200') - ->assertSee('1,234') - ->assertSee('View full analytics'); -}); - -it('shows the muted no-data note when enabled but no traffic recorded', function () { - $application = makeAppOnServer(true); - - app()->bind(SentinelTrafficClient::class, function ($app, $params) { - $client = new FakeAppOverviewTrafficClient($params['server']); - $client->responses = ['/traffic/overview' => json_encode(['requests' => 0])]; - - return $client; - }); - - loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application])) - ->assertOk() - ->assertSee('No traffic recorded in the last 24h yet'); -}); - -it('shows the enable nudge when analytics is disabled on an eligible server', function () { - $application = makeAppOnServer(false); - - loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application])) - ->assertOk() - ->assertSet('enabled', false) - ->assertSee('Traffic analytics') - ->assertSee('Server settings') - ->assertDontSee('Traffic (last 24h)'); -}); diff --git a/tests/Feature/TrafficAnalytics/ResourceScopeTrafficTest.php b/tests/Feature/TrafficAnalytics/ResourceScopeTrafficTest.php index 36ef965c08..2b6f6589e7 100644 --- a/tests/Feature/TrafficAnalytics/ResourceScopeTrafficTest.php +++ b/tests/Feature/TrafficAnalytics/ResourceScopeTrafficTest.php @@ -2,7 +2,6 @@ use App\Livewire\Analytics as GlobalAnalytics; use App\Livewire\Project\Application\Analytics as ApplicationAnalytics; -use App\Livewire\Project\Application\TrafficOverview; use App\Livewire\Project\Service\Analytics as ServiceAnalytics; use App\Models\Application; use App\Models\Environment; @@ -369,31 +368,11 @@ it('falls back to the per-key merge with approximate badges when Sentinel lacks // The absence is cached: a refresh within the ttl goes straight to the per-key path. $component->call('loadData')->assertSet('uniquesApproximate', true); - loadLazy(Livewire::test(TrafficOverview::class, ['application' => $this->application]))->assertOk(); + loadLazy(Livewire::test(ApplicationAnalytics::class, ['application' => $this->application]))->assertOk(); expect(requestedResourceScopeUrls('/resource/'))->toHaveCount($probes); }); -it('shows the resource overview in the application traffic card', function () { - bindResourceScopeFake(resourceScopeResponses($this->application->uuid, resourceScopeOverview(4200, 33))); - - $component = loadLazy(Livewire::test(TrafficOverview::class, ['application' => $this->application])) - ->assertOk() - ->assertSee('4,200'); - - expect($component->instance()->overview['uniqueVisitors'])->toBe(33) - ->and(requestedResourceScopeUrls('/resource/'))->toHaveCount(1) - ->and(requestedResourceScopeUrls('/traffic/dashboard'))->toBeEmpty(); -}); - -it('sums the keys in the application traffic card when Sentinel lacks the resource routes', function () { - bindResourceScopeFake(perKeyFallbackResponses($this->application->uuid, $this->apiKey, $this->webKey)); - - loadLazy(Livewire::test(TrafficOverview::class, ['application' => $this->application])) - ->assertOk() - ->assertSee('150'); -}); - it('uses the resource scope when the global analytics filter selects a resource', function () { bindResourceScopeFake([ ...resourceScopeResponses($this->application->uuid, resourceScopeOverview(150, 12)), diff --git a/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php b/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php index a2d904796b..4b2f6ce84e 100644 --- a/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php +++ b/tests/Feature/TrafficAnalytics/ResourceTrafficKeysTest.php @@ -6,7 +6,6 @@ use App\Data\Traffic\TrafficPathData; use App\Data\Traffic\TrafficSeriesBucketData; use App\Livewire\Analytics as GlobalAnalytics; use App\Livewire\Project\Application\Analytics as ApplicationAnalytics; -use App\Livewire\Project\Application\TrafficOverview; use App\Models\Application; use App\Models\Environment; use App\Models\PrivateKey; @@ -286,7 +285,7 @@ it('shows a compose application with the data of all its compose service keys', ->and($paths['/checkout']['domain'])->toBe('www.shop.test'); }); -it('sums every compose key in the application traffic card', function () { +it('sums every compose key on the application analytics page', function () { $application = makeTrafficComposeApplication(); $apiKey = $application->uuid.'-'.traefikSafeServiceNameSegment('api'); $webKey = $application->uuid.'-'.traefikSafeServiceNameSegment('web'); @@ -297,7 +296,7 @@ it('sums every compose key in the application traffic card', function () { "/app/{$webKey}/traffic/overview" => resourceKeysOverview(200), ]); - loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application])) + loadLazy(Livewire::test(ApplicationAnalytics::class, ['application' => $application])) ->assertOk() ->assertSee('4,200'); }); From 4227ec6ebfb2d9cfe19fb66bab8e15eae06e4dc3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:03:37 +0200 Subject: [PATCH 3/3] fix(storage): require deploy permission for external host writes Require deploy or root token access when creating managed mounts outside resource directories or changing their file contents through the API. Synchronize KeyDB and Dragonfly passwords with local and shared REDIS_PASSWORD values while preserving secret manager resolution. --- app/Actions/Database/StartDragonfly.php | 9 +- app/Actions/Database/StartKeydb.php | 9 +- .../Api/ApplicationsController.php | 31 +++- .../RequiresDeployForOutsideHostPaths.php | 41 +++++ .../Controllers/Api/DatabasesController.php | 31 +++- .../Controllers/Api/ServicesController.php | 31 +++- app/Http/Controllers/Webhook/Github.php | 13 +- app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Jobs/ScheduledJobManager.php | 8 +- app/Jobs/VolumeBackupRecoveryJob.php | 54 +++---- .../Project/Shared/Storages/VolumeBackups.php | 2 - app/Models/GithubRunnerExecution.php | 62 ++++++++ app/Models/ScheduledVolumeBackupExecution.php | 4 - .../VolumeBackup/RecoveryFailed.php | 134 ++++++++++++++++ bootstrap/helpers/parsers.php | 76 +++++---- ...heduled_volume_backup_executions_table.php | 6 +- .../volume-backup-recovery-failed.blade.php | 7 + ...licationComposeInjectionValidationTest.php | 21 +++ tests/Feature/GithubRunnerFlowTest.php | 148 ++++++++++++++++++ ...eydbDragonflyLegacyPasswordQuotingTest.php | 73 ++++++++- tests/Feature/MultiServerApplicationTest.php | 7 + tests/Feature/StorageHostPathTest.php | 90 ++++++++++- .../VolumeBackupRecoveryBoundsTest.php | 90 ++++++----- tests/Feature/VolumeBackupTest.php | 1 + ...ockerComposeInjectionCompatibilityTest.php | 41 +++++ 25 files changed, 863 insertions(+), 128 deletions(-) create mode 100644 app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php create mode 100644 app/Notifications/VolumeBackup/RecoveryFailed.php create mode 100644 resources/views/emails/volume-backup-recovery-failed.blade.php diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 20f69bb715..8327cf2dfe 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -251,14 +251,21 @@ class StartDragonfly $environment_variables = collect(); $this->resolvedRedisPassword = (string) $this->database->dragonfly_password; foreach ($this->database->runtime_environment_variables as $env) { + $usesSecretManager = $this->database->environmentVariableUsesSecretManager($env); + if ($env->key === 'REDIS_PASSWORD' && ! $env->is_shared && ! $usesSecretManager) { + $env->update(['value' => $this->database->dragonfly_password]); + } $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); // Credentials below are placed directly in the compose file (healthcheck, command). $composeFileValue = $this->database->formatComposeFileValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'REDIS_PASSWORD') { + if ($env->is_shared && ! $usesSecretManager) { + $this->database->update(['dragonfly_password' => $rawValue]); + } $this->resolvedRedisPassword = $composeFileValue; - $this->redisPasswordFromSecretManager = $this->database->environmentVariableUsesSecretManager($env); + $this->redisPasswordFromSecretManager = $usesSecretManager; } } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index f6c2ff0bc7..6bd3c4b236 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -251,14 +251,21 @@ class StartKeydb $environment_variables = collect(); $this->resolvedRedisPassword = (string) $this->database->keydb_password; foreach ($this->database->runtime_environment_variables as $env) { + $usesSecretManager = $this->database->environmentVariableUsesSecretManager($env); + if ($env->key === 'REDIS_PASSWORD' && ! $env->is_shared && ! $usesSecretManager) { + $env->update(['value' => $this->database->keydb_password]); + } $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); // Credentials below are placed directly in the compose file (healthcheck, command). $composeFileValue = $this->database->formatComposeFileValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'REDIS_PASSWORD') { + if ($env->is_shared && ! $usesSecretManager) { + $this->database->update(['keydb_password' => $rawValue]); + } $this->resolvedRedisPassword = $composeFileValue; - $this->redisPasswordFromSecretManager = $this->database->environmentVariableUsesSecretManager($env); + $this->redisPasswordFromSecretManager = $usesSecretManager; } } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index b2d84113f2..238a318ce3 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -40,6 +40,7 @@ use Symfony\Component\Yaml\Yaml; class ApplicationsController extends Controller { use Concerns\HandlesTagsApi; + use Concerns\RequiresDeployForOutsideHostPaths; private const APPLICATION_SETTING_FIELDS = [ 'is_git_submodules_enabled', @@ -5042,7 +5043,7 @@ class ApplicationsController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'], ], additionalProperties: false, ), @@ -5067,6 +5068,10 @@ class ApplicationsController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy permission to change the content of a file outside the resource directory.', + ), new OA\Response( response: 422, ref: '#/components/responses/422', @@ -5173,6 +5178,10 @@ class ApplicationsController extends Controller } } + if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) { + return $forbidden; + } + // Always allowed if ($request->has('is_preview_suffix_enabled')) { $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; @@ -5243,7 +5252,7 @@ class ApplicationsController extends Controller 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], - 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'], + 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'], ], additionalProperties: false, ), @@ -5259,6 +5268,7 @@ class ApplicationsController extends Controller new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] )] @@ -5369,6 +5379,15 @@ class ApplicationsController extends Controller try { $fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path'); $mountPath = validateFileMountPath($request->mount_path, 'storage destination path'); + $forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([ + 'fs_path' => $fsPath, + 'is_directory' => true, + 'resource_id' => $application->id, + 'resource_type' => get_class($application), + ])); + if ($forbidden) { + return $forbidden; + } LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: true); } catch (\Throwable $e) { return response()->json([ @@ -5431,6 +5450,14 @@ class ApplicationsController extends Controller try { if ($request->filled('fs_path')) { $fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'file storage source path'); + $forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([ + 'fs_path' => $fsPath, + 'resource_id' => $application->id, + 'resource_type' => get_class($application), + ])); + if ($forbidden) { + return $forbidden; + } LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: false); } else { $fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path'); diff --git a/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php b/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php new file mode 100644 index 0000000000..90d3a136fa --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php @@ -0,0 +1,41 @@ +is_host_file || ! $storage->isOutsideResourceDirectory()) { + return null; + } + if ($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) { + return null; + } + + return response()->json([ + 'message' => 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.', + ], 403); + } + + private function outsideContentChangeForbiddenResponse(Request $request, LocalFileVolume|LocalPersistentVolume $storage): ?JsonResponse + { + if (! $storage instanceof LocalFileVolume || $storage->is_directory || ! $request->has('content')) { + return null; + } + if ((string) $request->input('content') === (string) $storage->content) { + return null; + } + + return $this->outsideHostPathForbiddenResponse($request, $storage); + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 38c304f3f1..b32a16fc61 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -36,6 +36,7 @@ class DatabasesController extends Controller { use Concerns\HandlesDatabaseImportsApi; use Concerns\HandlesTagsApi; + use Concerns\RequiresDeployForOutsideHostPaths; #[OA\Post( path: '/databases/{uuid}/imports/uploads', @@ -4290,7 +4291,7 @@ class DatabasesController extends Controller 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], - 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'], + 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'], ], additionalProperties: false, ), @@ -4306,6 +4307,7 @@ class DatabasesController extends Controller new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] )] @@ -4412,6 +4414,15 @@ class DatabasesController extends Controller try { $fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path'); $mountPath = validateFileMountPath($request->mount_path, 'storage destination path'); + $forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([ + 'fs_path' => $fsPath, + 'is_directory' => true, + 'resource_id' => $database->id, + 'resource_type' => get_class($database), + ])); + if ($forbidden) { + return $forbidden; + } LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: true); } catch (\Throwable $e) { return response()->json([ @@ -4474,6 +4485,14 @@ class DatabasesController extends Controller try { if ($request->filled('fs_path')) { $fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'file storage source path'); + $forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([ + 'fs_path' => $fsPath, + 'resource_id' => $database->id, + 'resource_type' => get_class($database), + ])); + if ($forbidden) { + return $forbidden; + } LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: false); } else { $fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path'); @@ -4543,7 +4562,7 @@ class DatabasesController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'], ], additionalProperties: false, ), @@ -4568,6 +4587,10 @@ class DatabasesController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy permission to change the content of a file outside the resource directory.', + ), new OA\Response( response: 422, ref: '#/components/responses/422', @@ -4671,6 +4694,10 @@ class DatabasesController extends Controller } } + if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) { + return $forbidden; + } + // Always allowed if ($request->has('is_preview_suffix_enabled')) { $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 7ad3684f43..521e848a88 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -31,6 +31,7 @@ use Symfony\Component\Yaml\Yaml; class ServicesController extends Controller { use Concerns\HandlesTagsApi; + use Concerns\RequiresDeployForOutsideHostPaths; protected function findTaggableResource(string $uuid, int|string $teamId): mixed { @@ -2476,7 +2477,7 @@ class ServicesController extends Controller 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], - 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'], + 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'], ], additionalProperties: false, ), @@ -2492,6 +2493,7 @@ class ServicesController extends Controller new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] )] @@ -2607,6 +2609,15 @@ class ServicesController extends Controller try { $fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path'); $mountPath = validateFileMountPath($request->mount_path, 'storage destination path'); + $forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([ + 'fs_path' => $fsPath, + 'is_directory' => true, + 'resource_id' => $subResource->id, + 'resource_type' => get_class($subResource), + ])); + if ($forbidden) { + return $forbidden; + } LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: true); } catch (\Throwable $e) { return response()->json([ @@ -2669,6 +2680,14 @@ class ServicesController extends Controller try { if ($request->filled('fs_path')) { $fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'file storage source path'); + $forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([ + 'fs_path' => $fsPath, + 'resource_id' => $subResource->id, + 'resource_type' => get_class($subResource), + ])); + if ($forbidden) { + return $forbidden; + } LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: false); } else { $fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path'); @@ -2738,7 +2757,7 @@ class ServicesController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'], ], additionalProperties: false, ), @@ -2763,6 +2782,10 @@ class ServicesController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy permission to change the content of a file outside the resource directory.', + ), new OA\Response( response: 422, ref: '#/components/responses/422', @@ -2896,6 +2919,10 @@ class ServicesController extends Controller } } + if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) { + return $forbidden; + } + // Always allowed if ($request->has('is_preview_suffix_enabled')) { $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 1dec6b71d9..8f44152306 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -316,10 +316,15 @@ class Github extends Controller ? GithubRunnerExecution::query()->where('github_app_id', $githubApp->id)->where('runner_name', $runnerName)->first() : null; - if ($action === 'in_progress' && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) { - $execution->update([...$jobDetails, 'status' => GithubRunnerStatus::Running, 'started_at' => now()]); - - return response('Runner marked running.'); + // The completed hook can arrive before the in_progress hook. + if (in_array($action, ['in_progress', 'completed'], true) && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) { + $replacement = $execution->assignJob($jobId, $jobDetails); + if ($replacement) { + ProvisionGithubRunnerJob::dispatch($replacement->id); + } + if ($action === 'in_progress') { + return response('Runner marked running.'); + } } if ($action === 'completed') { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7ffafc33c8..3379b267d4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2265,7 +2265,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue */ private function ensureRegistryImageForMultipleServers(): void { - if ($this->pull_request_id !== 0) { + if ($this->pull_request_id !== 0 || $this->application->build_pack === 'dockercompose') { return; } if ($this->application->additional_servers()->doesntExist()) { diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index e1411412fb..4c3beb97cd 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -497,15 +497,17 @@ class ScheduledJobManager implements ShouldQueue ->where('stop_recovery_pending', true) ->orWhere('s3_cleanup_pending', true)) ->where('recovery_needs_attention', false) - ->where(fn (Builder $query) => $query - ->whereNull('recovery_next_retry_at') - ->orWhere('recovery_next_retry_at', '<=', now())) + ->with('scheduledVolumeBackup.backupable.resource') ->chunkById(self::CHUNK_SIZE, function ($executions) use (&$dispatched): bool { foreach ($executions as $execution) { if ($dispatched >= self::VOLUME_BACKUP_RECOVERY_MAX_DISPATCHES) { return false; } + if ($execution->stop_recovery_pending && $execution->scheduledVolumeBackup?->server()?->isFunctional() === false) { + continue; + } + if (Cache::add(VolumeBackupRecoveryJob::dispatchCacheKey($execution->id), true, now()->addMinutes(self::VOLUME_BACKUP_RECOVERY_INTERVAL_MINUTES))) { VolumeBackupRecoveryJob::dispatch($execution); $dispatched++; diff --git a/app/Jobs/VolumeBackupRecoveryJob.php b/app/Jobs/VolumeBackupRecoveryJob.php index 7f475d24ce..bd3310712e 100644 --- a/app/Jobs/VolumeBackupRecoveryJob.php +++ b/app/Jobs/VolumeBackupRecoveryJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\ScheduledVolumeBackupExecution; +use App\Notifications\VolumeBackup\RecoveryFailed; use Aws\S3\Exception\S3Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -21,7 +22,7 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** - * ScheduledJobManager dispatches pending recovery again after a backoff, so this job does not retry by itself. + * ScheduledJobManager dispatches recovery again once an unreachable server is functional, so this job does not retry by itself. */ public int $tries = 1; @@ -29,12 +30,6 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou public int $uniqueFor = 300; - public const MAX_ATTEMPTS = 8; - - public const BACKOFF_BASE_MINUTES = 5; - - public const BACKOFF_CAP_MINUTES = 360; - public const CATEGORY_S3_AUTH = 's3_auth'; public const CATEGORY_S3_BUCKET = 's3_bucket'; @@ -86,8 +81,8 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou } /** - * Runs one recovery attempt and records its outcome instead of throwing, so a persistent failure backs off - * and eventually needs attention rather than creating a failed job every few minutes. + * Runs one recovery attempt and records its outcome instead of throwing. An unreachable server waits for the + * server to be functional again; any other failure needs attention and notifies the team. */ public static function recoverWithBounds(ScheduledVolumeBackupExecution $execution): void { @@ -157,16 +152,6 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou return $failure; } - /** - * Returns the minutes to wait before the next automatic attempt after the given number of failed attempts. - */ - public static function backoffMinutes(int $attempts): int - { - $exponent = max(0, min($attempts - 1, 16)); - - return (int) min(self::BACKOFF_BASE_MINUTES * (2 ** $exponent), self::BACKOFF_CAP_MINUTES); - } - public static function dispatchCacheKey(int $executionId): string { return "volume-backup-recovery-dispatched:{$executionId}"; @@ -175,9 +160,7 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou private static function recordSuccess(ScheduledVolumeBackupExecution $execution): void { $execution->update([ - 'recovery_attempts' => 0, 'recovery_last_attempt_at' => null, - 'recovery_next_retry_at' => null, 'recovery_error' => null, 'recovery_needs_attention' => false, ]); @@ -185,18 +168,18 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou private static function recordFailure(ScheduledVolumeBackupExecution $execution, string $category): void { - $recorded = $execution->getConnection()->transaction(function () use ($execution, $category): ?ScheduledVolumeBackupExecution { + $needsAttention = $category !== self::CATEGORY_SERVER_UNREACHABLE; + $enteredNeedsAttention = false; + + $recorded = $execution->getConnection()->transaction(function () use ($execution, $category, $needsAttention, &$enteredNeedsAttention): ?ScheduledVolumeBackupExecution { $current = ScheduledVolumeBackupExecution::query()->whereKey($execution->id)->lockForUpdate()->first(); if (! $current) { return null; } - $attempts = $current->recovery_attempts + 1; - $needsAttention = $attempts >= self::MAX_ATTEMPTS; + $enteredNeedsAttention = $needsAttention && ! $current->recovery_needs_attention; $current->update([ - 'recovery_attempts' => $attempts, 'recovery_last_attempt_at' => now(), - 'recovery_next_retry_at' => $needsAttention ? null : now()->addMinutes(self::backoffMinutes($attempts)), 'recovery_error' => $category, 'recovery_needs_attention' => $needsAttention, ]); @@ -214,10 +197,25 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou 'execution_id' => $recorded->id, 'execution_uuid' => $recorded->uuid, 'category' => $category, - 'attempts' => $recorded->recovery_attempts, 'needs_attention' => $recorded->recovery_needs_attention, - 'next_retry_at' => $recorded->recovery_next_retry_at?->toIso8601String(), ]); + + if ($enteredNeedsAttention) { + self::notifyTeam($recorded); + } + } + + private static function notifyTeam(ScheduledVolumeBackupExecution $execution): void + { + try { + $execution->loadMissing('scheduledVolumeBackup.team'); + $execution->scheduledVolumeBackup?->team?->notify(new RecoveryFailed($execution)); + } catch (Throwable $exception) { + Log::channel('scheduled-errors')->error('Failed to send volume backup recovery notification', [ + 'execution_id' => $execution->id, + 'error' => $exception->getMessage(), + ]); + } } private static function categorizeS3Failure(Throwable $exception): string diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 9768d67a55..4ceffef502 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -316,8 +316,6 @@ class VolumeBackups extends Component } $execution->update([ - 'recovery_attempts' => 0, - 'recovery_next_retry_at' => null, 'recovery_needs_attention' => false, ]); Cache::forget(VolumeBackupRecoveryJob::dispatchCacheKey($execution->id)); diff --git a/app/Models/GithubRunnerExecution.php b/app/Models/GithubRunnerExecution.php index 77a55fd075..e7c36d7d8a 100644 --- a/app/Models/GithubRunnerExecution.php +++ b/app/Models/GithubRunnerExecution.php @@ -4,8 +4,11 @@ namespace App\Models; use App\Enums\GithubRunnerStatus; use App\Jobs\DeregisterGithubRunnerJob; +use App\Jobs\ProvisionGithubRunnerJob; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class GithubRunnerExecution extends BaseModel { @@ -81,6 +84,65 @@ class GithubRunnerExecution extends BaseModel (clone $query)->delete(); } + /** + * Marks the runner running. When a job without a pending runner took it, returns a new queued execution for the original job. + * + * @param array $jobDetails + */ + public function assignJob(int $jobId, array $jobDetails): ?self + { + $replacement = DB::transaction(function () use ($jobId, $jobDetails): ?self { + $execution = self::query()->whereKey($this->id)->lockForUpdate()->first(); + if (! $execution || ! in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) { + return null; + } + + $running = [...$jobDetails, 'workflow_job_id' => $jobId, 'status' => GithubRunnerStatus::Running, 'started_at' => now()]; + $jobExecution = $execution->trigger_workflow_job_id === $jobId ? $execution : self::query() + ->where('github_app_id', $execution->github_app_id) + ->where('trigger_workflow_job_id', $jobId) + ->lockForUpdate() + ->first(); + if ($jobExecution?->isActive()) { + $execution->update($running); + + return null; + } + + $replacement = [ + ...$execution->only(['github_app_id', 'trigger_workflow_job_id', 'workflow_job_html_url', 'workflow_name', 'job_name', 'repository_full_name', 'labels', 'is_pull_request', 'provision_attempts']), + 'workflow_job_id' => $execution->trigger_workflow_job_id, + 'status' => GithubRunnerStatus::Queued, + 'queued_at' => now(), + ]; + $jobExecution?->delete(); + $execution->update([...$running, 'trigger_workflow_job_id' => $jobId]); + + if ($replacement['provision_attempts'] >= ProvisionGithubRunnerJob::MAX_ATTEMPTS) { + Log::warning('GitHub runner taken by another job too often; no new runner is started.', [ + 'github_app_id' => $execution->github_app_id, + 'workflow_job_id' => $replacement['trigger_workflow_job_id'], + 'attempts' => $replacement['provision_attempts'], + ]); + + return null; + } + + $hasMatchingConfig = GithubRunnerConfig::query() + ->where('github_app_id', $execution->github_app_id) + ->where('is_enabled', true) + ->get() + ->contains(fn (GithubRunnerConfig $config) => $config->matchesLabels($replacement['labels'] ?? []) + && ($config->allow_pull_requests || ! $replacement['is_pull_request'])); + + return $hasMatchingConfig ? self::create($replacement) : null; + }); + + $this->refresh(); + + return $replacement; + } + public function isActive(): bool { return $this->status->isActive(); diff --git a/app/Models/ScheduledVolumeBackupExecution.php b/app/Models/ScheduledVolumeBackupExecution.php index eec799218e..664efa11c1 100644 --- a/app/Models/ScheduledVolumeBackupExecution.php +++ b/app/Models/ScheduledVolumeBackupExecution.php @@ -21,9 +21,7 @@ class ScheduledVolumeBackupExecution extends BaseModel 'local_storage_deleted', 's3_storage_deleted', 's3_uploaded', - 'recovery_attempts', 'recovery_last_attempt_at', - 'recovery_next_retry_at', 'recovery_error', 'recovery_needs_attention', ]; @@ -39,9 +37,7 @@ class ScheduledVolumeBackupExecution extends BaseModel 'local_storage_deleted' => 'boolean', 's3_storage_deleted' => 'boolean', 's3_uploaded' => 'boolean', - 'recovery_attempts' => 'integer', 'recovery_last_attempt_at' => 'datetime', - 'recovery_next_retry_at' => 'datetime', 'recovery_needs_attention' => 'boolean', ]; } diff --git a/app/Notifications/VolumeBackup/RecoveryFailed.php b/app/Notifications/VolumeBackup/RecoveryFailed.php new file mode 100644 index 0000000000..ecb2220ec5 --- /dev/null +++ b/app/Notifications/VolumeBackup/RecoveryFailed.php @@ -0,0 +1,134 @@ +onQueue('high'); + $backup = $execution->scheduledVolumeBackup; + $resource = $backup?->targetResource(); + $this->resourceName = $resource?->name ?? 'Unknown resource'; + $this->target = $backup ? $backup->targetType().' '.$backup->targetName() : 'Unknown storage'; + $this->reason = match ($execution->recovery_error) { + VolumeBackupRecoveryJob::CATEGORY_S3_AUTH => 'S3 credentials were rejected', + VolumeBackupRecoveryJob::CATEGORY_S3_BUCKET => 'S3 bucket is missing', + VolumeBackupRecoveryJob::CATEGORY_REMOTE_COMMAND => 'A command on the server failed', + default => 'Unknown error', + }; + $this->containersStopped = (bool) $execution->stop_recovery_pending; + + $linkable = $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase ? $resource->service : $resource; + if ($linkable && method_exists($linkable, 'link')) { + $this->url = $linkable->link(); + } + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_failure'); + } + + public function summary(): string + { + $summary = "Recovery after the storage backup of {$this->target} ({$this->resourceName}) failed: {$this->reason}."; + + return $this->containersStopped + ? $summary.' The containers stopped for the backup are still stopped, and new backups of this storage are skipped until you retry the recovery.' + : $summary.' The partial S3 upload was not removed. Retry the recovery after fixing the S3 storage.'; + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: [ACTION REQUIRED] Storage backup recovery FAILED for {$this->resourceName}"); + $mail->view('emails.volume-backup-recovery-failed', [ + 'summary' => $this->summary(), + 'url' => $this->url, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $message = new DiscordMessage( + title: ':cross_mark: Storage backup recovery failed', + description: $this->summary(), + color: DiscordMessage::errorColor(), + isCritical: $this->containersStopped, + ); + + if ($this->url) { + $message->addField('Resource', '[Link]('.$this->url.')'); + } + + return $message; + } + + public function toTelegram(): array + { + $data = ['message' => "Coolify: [ACTION REQUIRED] {$this->summary()}"]; + + if ($this->url) { + $data['buttons'] = [['text' => 'Open resource in Coolify', 'url' => $this->url]]; + } + + return $data; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage( + title: 'Storage backup recovery failed', + level: 'error', + message: "[ACTION REQUIRED] {$this->summary()}", + buttons: $this->url ? [['text' => 'Open resource in Coolify', 'url' => $this->url]] : [], + ); + } + + public function toSlack(): SlackMessage + { + return new SlackMessage( + title: 'Coolify: [ACTION REQUIRED] Storage backup recovery failed', + description: $this->summary().($this->url ? "\n\n<{$this->url}|Open resource in Coolify>" : ''), + color: SlackMessage::errorColor(), + ); + } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'Storage backup recovery failed', + 'event' => 'volume_backup_recovery_failed', + 'resource_name' => $this->resourceName, + 'storage' => $this->target, + 'execution_uuid' => $this->execution->uuid, + 'reason' => $this->execution->recovery_error, + 'containers_stopped' => $this->containersStopped, + 'url' => $this->url, + ]; + } +} diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 7f39cfb408..8947b3964b 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -87,9 +87,10 @@ function validateDockerComposeForInjection(string $composeYaml, ?string $resourc if (is_array($serviceConfig) && isset($serviceConfig['networks']) && is_array($serviceConfig['networks'])) { foreach ($serviceConfig['networks'] as $networkKey => $networkDetails) { if (is_int($networkKey) && (is_string($networkDetails) || is_int($networkDetails))) { - validateComposeNetworkName((string) $networkDetails, 'service network'); + validateComposeNetworkNameWithVariables((string) $networkDetails, 'service network', (string) $serviceName); } elseif (is_string($networkKey) || is_int($networkKey)) { - validateComposeNetworkName((string) $networkKey, 'service network'); + // Compose does not interpolate keys, so a key must be a plain network name. + validateComposeNetworkName((string) $networkKey, 'service network', (string) $serviceName); } } } @@ -259,40 +260,44 @@ function ensureComposeNetworkNameVariables(Application|Service $resource, iterab } /** - * A network `name:` may be such a variable: only Docker Compose reads this value and it never runs a - * shell; Coolify's own network commands use the network keys. The default must still be a valid - * network name, and nothing else is allowed around the variable. + * A network `name:` may contain variables: only Docker Compose reads this value and it never runs a + * shell; Coolify's own network commands use the network keys. * - * @throws Exception If the value is not a valid network name or such a variable + * @throws Exception If the value is not a valid network name with safe variables */ function validateComposeNetworkNameField(string $name): void +{ + validateComposeNetworkNameWithVariables($name, 'network name field'); +} + +/** + * Allows $VAR, ${VAR}, ${VAR:-default} and ${VAR-default} with safe defaults in a network value that Compose interpolates. + */ +function validateComposeNetworkNameWithVariables(string $name, string $context, ?string $serviceName = null): void { $variable = composeNetworkNameVariable($name); if ($variable !== null) { - if ($variable['default'] !== null) { - validateComposeNetworkName($variable['default'], 'network name field'); - } + $isValid = $variable['default'] === null || ValidationPatterns::isValidDockerNetwork($variable['default']); + } else { + $hasSafeDefaults = true; + $withoutVariables = preg_replace_callback( + '/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$[A-Za-z_][A-Za-z0-9_]*/', + function (array $matches) use (&$hasSafeDefaults): string { + $default = $matches[2] ?? ''; + if ($default !== '' && preg_match('/\A[A-Za-z0-9_.-]+\z/', $default) !== 1) { + $hasSafeDefaults = false; + } - return; + return 'x'; + }, + $name, + ); + $isValid = $hasSafeDefaults && $withoutVariables !== null && ValidationPatterns::isValidDockerNetwork($withoutVariables); } - // Compose also resolves variables inside a longer name, for example ${COMPOSE_PROJECT_NAME}_default. - // Each variable must be $VAR, ${VAR}, ${VAR:-default} or ${VAR-default} with a safe default; the name - // is then checked with each variable replaced, so no shell syntax can remain. - $withoutVariables = preg_replace_callback( - '/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$[A-Za-z_][A-Za-z0-9_]*/', - function (array $matches): string { - $default = $matches[2] ?? ''; - if ($default !== '' && preg_match('/\A[A-Za-z0-9_.-]+\z/', $default) !== 1) { - throw new Exception('Invalid Docker Compose network name field. Variable defaults may contain only alphanumeric characters, dots, hyphens, and underscores.'); - } - - return 'x'; - }, - $name, - ); - - validateComposeNetworkName($withoutVariables ?? $name, 'network name field'); + if (! $isValid) { + throw invalidComposeNetworkNameException($name, $context, $serviceName, allowsVariables: true); + } } /** @@ -300,16 +305,23 @@ function validateComposeNetworkNameField(string $name): void * * @throws Exception If the network name is not a valid Docker network identifier */ -function validateComposeNetworkName(string $networkName, string $context = 'network name'): void +function validateComposeNetworkName(string $networkName, string $context = 'network name', ?string $serviceName = null): void { if ($networkName === '' || ! ValidationPatterns::isValidDockerNetwork($networkName)) { - throw new Exception( - 'Invalid Docker Compose '.$context. - '. Network names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.' - ); + throw invalidComposeNetworkNameException($networkName, $context, $serviceName); } } +function invalidComposeNetworkNameException(string $name, string $context, ?string $serviceName, bool $allowsVariables = false): Exception +{ + $location = $serviceName === null ? '' : " in service {$serviceName}"; + + return new Exception( + "Invalid Docker Compose {$context} \"{$name}\"{$location}. Network names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores" + .($allowsVariables ? ', and can use variables such as ${NETWORK:-default}.' : '.') + ); +} + /** * Tells if a top-level Compose volume declaration is external: `external: true` or the old * `external: {name: x}` syntax. Docker Compose does not create or remove an external volume. diff --git a/database/migrations/2026_10_03_172204_add_recovery_bounds_to_scheduled_volume_backup_executions_table.php b/database/migrations/2026_10_03_172204_add_recovery_bounds_to_scheduled_volume_backup_executions_table.php index f10f334912..2a2d518019 100644 --- a/database/migrations/2026_10_03_172204_add_recovery_bounds_to_scheduled_volume_backup_executions_table.php +++ b/database/migrations/2026_10_03_172204_add_recovery_bounds_to_scheduled_volume_backup_executions_table.php @@ -12,14 +12,12 @@ return new class extends Migration public function up(): void { Schema::table('scheduled_volume_backup_executions', function (Blueprint $table) { - $table->unsignedSmallInteger('recovery_attempts')->default(0); $table->timestampTz('recovery_last_attempt_at')->nullable(); - $table->timestampTz('recovery_next_retry_at')->nullable(); $table->string('recovery_error', 32)->nullable(); $table->boolean('recovery_needs_attention')->default(false); $table->index( - ['stop_recovery_pending', 's3_cleanup_pending', 'recovery_needs_attention', 'recovery_next_retry_at'], + ['stop_recovery_pending', 's3_cleanup_pending', 'recovery_needs_attention'], 'scheduled_volume_executions_recovery_index', ); }); @@ -33,9 +31,7 @@ return new class extends Migration Schema::table('scheduled_volume_backup_executions', function (Blueprint $table) { $table->dropIndex('scheduled_volume_executions_recovery_index'); $table->dropColumn([ - 'recovery_attempts', 'recovery_last_attempt_at', - 'recovery_next_retry_at', 'recovery_error', 'recovery_needs_attention', ]); diff --git a/resources/views/emails/volume-backup-recovery-failed.blade.php b/resources/views/emails/volume-backup-recovery-failed.blade.php new file mode 100644 index 0000000000..a1653341d1 --- /dev/null +++ b/resources/views/emails/volume-backup-recovery-failed.blade.php @@ -0,0 +1,7 @@ + +{{ $summary }} + +@if ($url) +Click [here]({{ $url }}) to open the resource. +@endif + diff --git a/tests/Feature/ApplicationComposeInjectionValidationTest.php b/tests/Feature/ApplicationComposeInjectionValidationTest.php index aee515917f..4db2d25dad 100644 --- a/tests/Feature/ApplicationComposeInjectionValidationTest.php +++ b/tests/Feature/ApplicationComposeInjectionValidationTest.php @@ -356,3 +356,24 @@ test('the Compose deployment validates the repository file before any command us ->and($loadPosition)->toBeLessThan(strpos($body, 'base64 -d')) ->and($body)->toContain('"stat -c \'%F\' ".escapeshellarg($realPathInGit)'); }); + +test('loading a Compose file with a variable service network saves it', function () { + $compose = "services:\n web:\n image: nginx\n networks:\n - \${NET:-proxy}\nnetworks:\n proxy:\n external: true\n"; + fakeRepositoryCompose($compose); + + $this->application->loadComposeFile(); + + expect($this->application->fresh()->docker_compose_raw)->toBe(trim($compose)); +}); + +test('the deployment log names the service and network that failed validation', function () { + fakeRepositoryCompose("services:\n web:\n image: nginx\n networks:\n - '\$(id)'\n"); + $logEntries = []; + $job = composeDeploymentJob($this->application, 0, $logEntries); + + expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'loadComposeFileForDeployment'))->invoke($job)) + ->toThrow(DeploymentException::class); + + expect(collect($logEntries)->pluck(0)->implode("\n")) + ->toContain('Invalid Docker Compose service network "$(id)" in service web.'); +}); diff --git a/tests/Feature/GithubRunnerFlowTest.php b/tests/Feature/GithubRunnerFlowTest.php index c452f3b7bd..37663a26a9 100644 --- a/tests/Feature/GithubRunnerFlowTest.php +++ b/tests/Feature/GithubRunnerFlowTest.php @@ -270,6 +270,154 @@ describe('workflow_job webhook', function () { ->and($execution->started_at)->not->toBeNull(); }); + it('starts a new runner for the job whose runner another job took', function () { + $stolen = runnerTestExecution($this->githubApp, [ + 'trigger_workflow_job_id' => 5001, + 'workflow_job_id' => 5001, + 'job_name' => 'build', + 'status' => GithubRunnerStatus::Idle, + 'server_id' => $this->server->id, + 'runner_name' => 'coolify-runner-a', + 'provision_attempts' => 1, + ]); + + foreach ([1, 2] as $delivery) { + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [ + 'id' => 6001, + 'name' => 'pr-test', + 'runner_name' => 'coolify-runner-a', + ])->assertOk(); + } + + $stolen->refresh(); + $replacement = GithubRunnerExecution::query()->where('trigger_workflow_job_id', 5001)->sole(); + expect($stolen->status)->toBe(GithubRunnerStatus::Running) + ->and($stolen->trigger_workflow_job_id)->toBe(6001) + ->and($stolen->workflow_job_id)->toBe(6001) + ->and($replacement->id)->not->toBe($stolen->id) + ->and($replacement->status)->toBe(GithubRunnerStatus::Queued) + ->and($replacement->job_name)->toBe('build') + ->and($replacement->labels)->toBe(['self-hosted', 'coolify']) + ->and($replacement->provision_attempts)->toBe(1); + Queue::assertPushed(ProvisionGithubRunnerJob::class, 1); + Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $replacement->id); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [ + 'id' => 6001, + 'runner_name' => 'coolify-runner-a', + 'conclusion' => 'success', + ])->assertOk(); + + expect($stolen->fresh()->status)->toBe(GithubRunnerStatus::Completed) + ->and($replacement->fresh()->status)->toBe(GithubRunnerStatus::Queued); + }); + + it('starts a new runner when the completed hook of the other job arrives before its in_progress hook', function () { + $stolen = runnerTestExecution($this->githubApp, [ + 'trigger_workflow_job_id' => 5001, + 'status' => GithubRunnerStatus::Idle, + 'server_id' => $this->server->id, + 'runner_name' => 'coolify-runner-a', + 'provision_attempts' => 1, + ]); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [ + 'id' => 6001, + 'runner_name' => 'coolify-runner-a', + 'conclusion' => 'success', + ])->assertOk(); + + expect($stolen->fresh()->status)->toBe(GithubRunnerStatus::Completed) + ->and($stolen->fresh()->trigger_workflow_job_id)->toBe(6001) + ->and(GithubRunnerExecution::query()->where('trigger_workflow_job_id', 5001)->sole()->status)->toBe(GithubRunnerStatus::Queued); + Queue::assertPushed(ProvisionGithubRunnerJob::class, 1); + }); + + it('replaces a stale execution of the job that took the runner', function () { + $stale = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 6001, 'status' => GithubRunnerStatus::TimedOut]); + $stolen = runnerTestExecution($this->githubApp, [ + 'trigger_workflow_job_id' => 5001, + 'status' => GithubRunnerStatus::Idle, + 'server_id' => $this->server->id, + 'runner_name' => 'coolify-runner-a', + 'provision_attempts' => 1, + ]); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [ + 'id' => 6001, + 'runner_name' => 'coolify-runner-a', + ])->assertOk(); + + expect($stale->fresh())->toBeNull() + ->and($stolen->fresh()->trigger_workflow_job_id)->toBe(6001) + ->and(GithubRunnerExecution::query()->where('trigger_workflow_job_id', 5001)->sole()->status)->toBe(GithubRunnerStatus::Queued); + Queue::assertPushed(ProvisionGithubRunnerJob::class, 1); + }); + + it('does not start a new runner when the runner took another job with its own pending runner', function () { + $first = runnerTestExecution($this->githubApp, [ + 'trigger_workflow_job_id' => 5001, + 'status' => GithubRunnerStatus::Idle, + 'server_id' => $this->server->id, + 'runner_name' => 'coolify-runner-a', + ]); + $second = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 6001]); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [ + 'id' => 6001, + 'runner_name' => 'coolify-runner-a', + ])->assertOk(); + + expect(GithubRunnerExecution::count())->toBe(2) + ->and($first->fresh()->status)->toBe(GithubRunnerStatus::Running) + ->and($first->fresh()->workflow_job_id)->toBe(6001) + ->and($second->fresh()->status)->toBe(GithubRunnerStatus::Queued); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); + }); + + it('does not start a new runner for a job whose runner was taken', function (Closure $setup, array $attributes) { + $setup(); + $stolen = runnerTestExecution($this->githubApp, [ + 'trigger_workflow_job_id' => 5001, + 'status' => GithubRunnerStatus::Idle, + 'server_id' => $this->server->id, + 'runner_name' => 'coolify-runner-a', + 'provision_attempts' => 1, + ...$attributes, + ]); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [ + 'id' => 6001, + 'runner_name' => 'coolify-runner-a', + ])->assertOk(); + + expect(GithubRunnerExecution::count())->toBe(1) + ->and($stolen->fresh()->status)->toBe(GithubRunnerStatus::Running); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); + })->with([ + 'runner limit of the job reached' => [fn () => null, ['provision_attempts' => ProvisionGithubRunnerJob::MAX_ATTEMPTS]], + 'configuration disabled' => [fn () => GithubRunnerConfig::query()->update(['is_enabled' => false]), []], + 'pull request job no longer allowed' => [fn () => null, ['is_pull_request' => true]], + ]); + + it('marks the runner running without a new runner when it took the job it was started for', function () { + $execution = runnerTestExecution($this->githubApp, [ + 'trigger_workflow_job_id' => 5001, + 'status' => GithubRunnerStatus::Idle, + 'server_id' => $this->server->id, + 'runner_name' => 'coolify-runner-a', + ]); + + sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', ['runner_name' => 'coolify-runner-a'])->assertOk(); + + $execution->refresh(); + expect(GithubRunnerExecution::count())->toBe(1) + ->and($execution->status)->toBe(GithubRunnerStatus::Running) + ->and($execution->trigger_workflow_job_id)->toBe(5001) + ->and($execution->workflow_job_id)->toBe(5001); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); + }); + it('completes the runner and queues its cleanup', function () { $execution = runnerTestExecution($this->githubApp, [ 'status' => GithubRunnerStatus::Running, diff --git a/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php b/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php index 823017c0a8..703a52f306 100644 --- a/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php +++ b/tests/Feature/KeydbDragonflyLegacyPasswordQuotingTest.php @@ -8,6 +8,7 @@ use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Models\SharedEnvironmentVariable; use App\Models\StandaloneDocker; use App\Models\Team; use App\Services\DatabaseStartCommandExecutor; @@ -51,7 +52,7 @@ beforeEach(function () { }); /** - * @return array{command: string, healthcheck: list} + * @return array{command: string, healthcheck: list, environment: list} */ function legacyPasswordQuotingStartedService(object $executor, string $uuid): array { @@ -59,7 +60,7 @@ function legacyPasswordQuotingStartedService(object $executor, string $uuid): ar if (preg_match("#^echo '([^']+)' \| base64 -d \| tee \S+/docker-compose\.yml#", $command, $matches)) { $service = Yaml::parse(base64_decode($matches[1]))['services'][$uuid]; - return ['command' => $service['command'], 'healthcheck' => $service['healthcheck']['test']]; + return ['command' => $service['command'], 'healthcheck' => $service['healthcheck']['test'], 'environment' => $service['environment']]; } } @@ -195,3 +196,71 @@ it('quotes shell control characters in the password of a database created after 'space' => 'a b', 'pipe' => 'x|y', ]); + +it('runs with the stored password and syncs a plain REDIS_PASSWORD variable to it', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']); + $env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'envpass']); + + $start::run($database->fresh(), new Activity); + + $service = legacyPasswordQuotingStartedService($this->executor, $database->uuid); + expect($service['command'])->toBe(sprintf($commandFormat, "'storedpass'")) + ->and($service['healthcheck'][3])->toBe('storedpass') + ->and($service['environment'])->toContain('REDIS_PASSWORD=storedpass') + ->and($env->fresh()->value)->toBe('storedpass') + ->and($database->fresh()->{$passwordColumn})->toBe('storedpass'); +})->with('legacy-password-quoting-engines'); + +it('keeps the exact v4.3.23 command of a legacy database with a different plain REDIS_PASSWORD variable', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'stored;pass']); + DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]); + $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'envpass']); + + $start::run($database->fresh(), new Activity); + + $service = legacyPasswordQuotingStartedService($this->executor, $database->uuid); + expect($service['command'])->toBe(sprintf($commandFormat, 'stored;pass')) + ->and($service['healthcheck'][3])->toBe('stored;pass') + ->and($database->fresh()->legacy_password_quoting)->toBeTrue(); +})->with('legacy-password-quoting-engines'); + +it('runs with a remote secret REDIS_PASSWORD without storing it', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + Http::fake(['https://api.doppler.com/*' => Http::response(['REDIS_PASSWORD' => 'secretpass'])]); + $token = IntegrationToken::query()->create([ + 'team_id' => $this->environment->project->team_id, + 'provider' => 'doppler', + 'name' => 'Doppler', + 'token' => 'the-secret-token', + 'capabilities' => ['secrets'], + ]); + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']); + $database->secretManagerLink()->create(['integration_token_id' => $token->id]); + $env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '{{vault.REDIS_PASSWORD}}']); + + $start::run($database->fresh(), new Activity); + + $service = legacyPasswordQuotingStartedService($this->executor, $database->uuid); + expect($service['command'])->toBe(sprintf($commandFormat, "'secretpass'")) + ->and($service['healthcheck'][3])->toBe('secretpass') + ->and($env->fresh()->value)->toBe('{{vault.REDIS_PASSWORD}}') + ->and($database->fresh()->{$passwordColumn})->toBe('storedpass'); +})->with('legacy-password-quoting-engines'); + +it('copies a shared REDIS_PASSWORD variable into the stored password', function (string $create, string $start, string $passwordColumn, string $commandFormat) { + SharedEnvironmentVariable::query()->create([ + 'key' => 'REDIS_PASSWORD', + 'value' => 'sharedpass', + 'type' => 'team', + 'team_id' => $this->environment->project->team_id, + ]); + $database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']); + $env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '{{team.REDIS_PASSWORD}}']); + + $start::run($database->fresh(), new Activity); + + $service = legacyPasswordQuotingStartedService($this->executor, $database->uuid); + expect($service['command'])->toBe(sprintf($commandFormat, "'sharedpass'")) + ->and($service['healthcheck'][3])->toBe('sharedpass') + ->and($env->fresh()->value)->toBe('{{team.REDIS_PASSWORD}}') + ->and($database->fresh()->{$passwordColumn})->toBe('sharedpass'); +})->with('legacy-password-quoting-engines'); diff --git a/tests/Feature/MultiServerApplicationTest.php b/tests/Feature/MultiServerApplicationTest.php index 4e7214ffca..2f586e07b8 100644 --- a/tests/Feature/MultiServerApplicationTest.php +++ b/tests/Feature/MultiServerApplicationTest.php @@ -112,6 +112,13 @@ describe('registry image guard', function () { callMultiServerDeploymentJob($job, 'ensureRegistryImageForMultipleServers'); })->throwsNoExceptions(); + test('allows docker compose applications, which build on each server', function () { + $this->application->update(['build_pack' => 'dockercompose']); + $job = makeMultiServerDeploymentJob(['application' => $this->application->fresh()]); + + callMultiServerDeploymentJob($job, 'ensureRegistryImageForMultipleServers'); + })->throwsNoExceptions(); + test('allows preview deployments, which run only on the main server', function () { $job = makeMultiServerDeploymentJob(['application' => $this->application, 'pull_request_id' => 7]); diff --git a/tests/Feature/StorageHostPathTest.php b/tests/Feature/StorageHostPathTest.php index 166cd07563..c22cce8ca1 100644 --- a/tests/Feature/StorageHostPathTest.php +++ b/tests/Feature/StorageHostPathTest.php @@ -1,5 +1,6 @@ $abilities + */ +function storageHostPathToken(User $user, array $abilities = ['*']): string { // A token request must not use the session user of the Livewire tests. auth()->forgetGuards(); @@ -78,7 +82,7 @@ function storageHostPathToken(User $user): string $token = $user->tokens()->create([ 'name' => 'host-path-token', 'token' => hash('sha256', $plainTextToken), - 'abilities' => ['*'], + 'abilities' => $abilities, 'team_id' => test()->team->id, ]); @@ -363,3 +367,85 @@ test('relative host paths stay inside the resource directory', function () { Process::assertRan(fn ($process) => str_contains($process->command, 'readlink -f') && str_contains($process->command, "'{$workdir}' '{$workdir}/data/zz'")); }); + +test('a token without deploy cannot create a mount outside the resource directory', function (string $resourceType, array $mount) { + [$url, $payload] = storageHostPathApiTarget($resourceType); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])]) + ->postJson($url, [...$payload, 'type' => 'file', ...$mount]) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.'); + + expect(LocalFileVolume::query()->count())->toBe(0); + Bus::assertNotDispatched(ServerStorageSaveJob::class); + Process::assertNothingRan(); +})->with(['application', 'database', 'service'])->with([ + 'directory' => [['is_directory' => true, 'fs_path' => '/root/.ssh', 'mount_path' => '/data']], + 'file with content' => [['fs_path' => '/root/.ssh/authorized_keys', 'mount_path' => '/etc/keys', 'content' => 'ssh-ed25519 AAAA']], + 'empty file' => [['fs_path' => '/etc/zz/app.conf', 'mount_path' => '/etc/app.conf']], +]); + +test('a deploy or root token creates a mount outside the resource directory', function (string $resourceType, array $abilities) { + [$url, $payload, $resource] = storageHostPathApiTarget($resourceType); + $token = storageHostPathToken($this->admin, $abilities); + + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => '/srv/zz', 'mount_path' => '/data']) + ->assertCreated(); + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => '/etc/zz/app.conf', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;']) + ->assertCreated(); + + expect($resource->fileStorages()->orderBy('id')->pluck('fs_path')->all())->toBe(['/srv/zz', '/etc/zz/app.conf']); +})->with(['application', 'database', 'service'])->with([ + 'deploy' => [['read', 'write', 'deploy']], + 'root' => [['root']], +]); + +test('a write token creates mounts inside the resource directory', function (string $resourceType) { + [$url, $payload, $resource] = storageHostPathApiTarget($resourceType); + $token = storageHostPathToken($this->admin, ['read', 'write']); + $workdir = $resource instanceof ServiceApplication ? $resource->service->workdir() : $resource->workdir(); + + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => 'data/zz', 'mount_path' => '/data']) + ->assertCreated(); + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => $workdir.'/config/app.conf', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;']) + ->assertCreated(); + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/other.conf', 'content' => 'listen 8081;']) + ->assertCreated(); + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson($url, [...$payload, 'type' => 'file', 'is_host_file' => true, 'fs_path' => '/etc/hosts', 'mount_path' => '/etc/hosts']) + ->assertCreated(); + + expect($resource->fileStorages()->count())->toBe(4); +})->with(['application', 'database', 'service']); + +test('changing the content of a mount outside the resource directory needs deploy', function (string $resourceType) { + [$url, , $resource] = storageHostPathApiTarget($resourceType); + $storage = LocalFileVolume::create([ + 'fs_path' => '/etc/zz/app.conf', + 'mount_path' => '/etc/app.conf', + 'content' => 'listen 8080;', + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])]) + ->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'content' => 'ssh-ed25519 AAAA']) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.'); + expect($storage->fresh()->content)->toBe('listen 8080;'); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])]) + ->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'mount_path' => '/etc/app2.conf', 'content' => 'listen 8080;']) + ->assertOk(); + expect($storage->fresh()->mount_path)->toBe('/etc/app2.conf'); + + $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write', 'deploy'])]) + ->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'content' => 'listen 9090;']) + ->assertOk(); + expect($storage->fresh()->content)->toBe('listen 9090;'); +})->with(['application', 'database', 'service']); diff --git a/tests/Feature/VolumeBackupRecoveryBoundsTest.php b/tests/Feature/VolumeBackupRecoveryBoundsTest.php index f65c17ae63..ff3ed2d031 100644 --- a/tests/Feature/VolumeBackupRecoveryBoundsTest.php +++ b/tests/Feature/VolumeBackupRecoveryBoundsTest.php @@ -17,12 +17,15 @@ use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\Team; use App\Models\User; +use App\Notifications\Channels\DiscordChannel; +use App\Notifications\VolumeBackup\RecoveryFailed; use Aws\Command; use Aws\S3\Exception\S3Exception; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; @@ -33,6 +36,7 @@ uses(RefreshDatabase::class); beforeEach(function () { Server::flushIdentityMap(); + Notification::fake(); config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); }); @@ -130,9 +134,22 @@ function makeRecoveryBoundsServerFunctional(Server $server): void ]); } -it('doubles the recovery backoff and caps it at six hours', function () { - expect(collect(range(1, 9))->map(fn (int $attempts) => VolumeBackupRecoveryJob::backoffMinutes($attempts))->all()) - ->toBe([5, 10, 20, 40, 80, 160, 320, 360, 360]); +it('notifies the team once when recovery needs attention', function () { + $team = Team::factory()->create(); + $team->discordNotificationSettings()->update(['discord_enabled' => true, 'backup_failure_discord_notifications' => true]); + $team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'backup_failure_webhook_notifications' => false]); + [, , , $backup] = createRecoveryBoundsBackup($team); + $execution = createRecoveryBoundsExecution($backup); + failRecoveryBoundsS3Delete('AccessDenied'); + + VolumeBackupRecoveryJob::recoverWithBounds($execution); + VolumeBackupRecoveryJob::recoverWithBounds($execution); + + Notification::assertSentToTimes($team, RecoveryFailed::class, 1); + Notification::assertSentTo($team, RecoveryFailed::class, fn (RecoveryFailed $notification, array $channels) => $notification->execution->is($execution) + && $channels === [DiscordChannel::class] + && str_contains($notification->toDiscord()->description, 'S3 credentials were rejected') + && str_contains((string) $notification->toMail()->render(), 'S3 credentials were rejected')); }); it('records a categorized S3 cleanup failure without throwing or leaking the S3 message', function () { @@ -148,11 +165,9 @@ it('records a categorized S3 cleanup failure without throwing or leaking the S3 $execution->refresh(); expect($execution->s3_cleanup_pending)->toBeTrue() ->and($execution->s3_storage_deleted)->toBeFalse() - ->and($execution->recovery_attempts)->toBe(1) ->and($execution->recovery_error)->toBe('s3_auth') ->and($execution->recovery_last_attempt_at->equalTo(now()))->toBeTrue() - ->and($execution->recovery_next_retry_at->equalTo(now()->addMinutes(5)))->toBeTrue() - ->and($execution->recovery_needs_attention)->toBeFalse() + ->and($execution->recovery_needs_attention)->toBeTrue() ->and($execution->message)->toBe('Worker timed out'); $logger->shouldHaveReceived('warning')->once()->with( 'Volume backup recovery failed', @@ -162,38 +177,37 @@ it('records a categorized S3 cleanup failure without throwing or leaking the S3 ); }); -it('categorizes a missing S3 bucket and grows the backoff on each failure', function () { +it('categorizes a missing S3 bucket and does not try again after the first failure', function () { Carbon::setTestNow('2026-10-03 12:00:00'); [, , , $backup] = createRecoveryBoundsBackup(Team::factory()->create()); $execution = createRecoveryBoundsExecution($backup); - failRecoveryBoundsS3Delete('NoSuchBucket'); + $disk = Mockery::mock(); + $disk->shouldReceive('delete')->once()->andThrow(UnableToDeleteFile::atLocation('partial.tar.gz', '', new S3Exception( + 'The specified bucket does not exist', + new Command('DeleteObject'), + ['code' => 'NoSuchBucket'], + ))); + Storage::shouldReceive('build')->once()->andReturn($disk); VolumeBackupRecoveryJob::recoverWithBounds($execution); VolumeBackupRecoveryJob::recoverWithBounds($execution); $execution->refresh(); expect($execution->recovery_error)->toBe('s3_bucket') - ->and($execution->recovery_attempts)->toBe(2) - ->and($execution->recovery_next_retry_at->equalTo(now()->addMinutes(10)))->toBeTrue(); + ->and($execution->recovery_needs_attention)->toBeTrue(); }); -it('needs attention after the maximum attempts and stops automatic recovery', function () { +it('stops automatic recovery after the first failure that is not an unreachable server', function () { Carbon::setTestNow('2026-10-03 12:00:00'); config(['constants.coolify.self_hosted' => true]); Queue::fake(); [, , , $backup] = createRecoveryBoundsBackup(Team::factory()->create()); - $execution = createRecoveryBoundsExecution($backup, [ - 'recovery_attempts' => VolumeBackupRecoveryJob::MAX_ATTEMPTS - 1, - ]); + $execution = createRecoveryBoundsExecution($backup); failRecoveryBoundsS3Delete('AccessDenied'); - VolumeBackupRecoveryJob::recoverWithBounds($execution); VolumeBackupRecoveryJob::recoverWithBounds($execution); - $execution->refresh(); - expect($execution->recovery_needs_attention)->toBeTrue() - ->and($execution->recovery_attempts)->toBe(VolumeBackupRecoveryJob::MAX_ATTEMPTS) - ->and($execution->recovery_next_retry_at)->toBeNull(); + expect($execution->fresh()->recovery_needs_attention)->toBeTrue(); Carbon::setTestNow('2026-10-04 12:00:00'); (new ScheduledJobManager)->handle(); @@ -201,20 +215,27 @@ it('needs attention after the maximum attempts and stops automatic recovery', fu Queue::assertNotPushed(VolumeBackupRecoveryJob::class); }); -it('waits for the backoff before dispatching recovery again', function () { +it('dispatches container recovery again only when the server is functional, once per execution', function () { Carbon::setTestNow('2026-10-03 12:00:00'); config(['constants.coolify.self_hosted' => true]); + Storage::fake('ssh-mux'); Queue::fake(); - [, , , $backup] = createRecoveryBoundsBackup(Team::factory()->create()); + [, , $server, $backup] = createRecoveryBoundsBackup(Team::factory()->create()); + $server->settings()->update(['is_reachable' => false]); createRecoveryBoundsExecution($backup, [ - 'recovery_attempts' => 3, - 'recovery_next_retry_at' => now()->addMinutes(20), + 'stop_recovery_pending' => true, + 'recovery_error' => 'server_unreachable', + 'recovery_last_attempt_at' => now(), ]); + (new ScheduledJobManager)->handle(); + Carbon::setTestNow('2026-10-04 12:00:00'); (new ScheduledJobManager)->handle(); Queue::assertNotPushed(VolumeBackupRecoveryJob::class); - Carbon::setTestNow('2026-10-03 12:20:00'); + makeRecoveryBoundsServerFunctional($server); + Server::flushIdentityMap(); + (new ScheduledJobManager)->handle(); (new ScheduledJobManager)->handle(); Queue::assertPushed(VolumeBackupRecoveryJob::class, 1); }); @@ -242,16 +263,15 @@ it('cleans the S3 upload even when container recovery fails', function () { ->and($execution->s3_cleanup_pending)->toBeFalse() ->and($execution->s3_storage_deleted)->toBeTrue() ->and($execution->recovery_error)->toBe('server_unreachable') - ->and($execution->recovery_attempts)->toBe(1); + ->and($execution->recovery_needs_attention)->toBeFalse(); + Notification::assertNothingSent(); }); it('resets the recovery state after a successful attempt', function () { [, , , $backup] = createRecoveryBoundsBackup(Team::factory()->create()); $execution = createRecoveryBoundsExecution($backup, [ - 'recovery_attempts' => 4, - 'recovery_error' => 's3_auth', + 'recovery_error' => 'server_unreachable', 'recovery_last_attempt_at' => now()->subHour(), - 'recovery_next_retry_at' => now()->subMinute(), ]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->andReturnTrue(); @@ -262,9 +282,9 @@ it('resets the recovery state after a successful attempt', function () { $execution->refresh(); expect($execution->s3_cleanup_pending)->toBeFalse() ->and($execution->s3_storage_deleted)->toBeTrue() - ->and($execution->recovery_attempts)->toBe(0) ->and($execution->recovery_error)->toBeNull() - ->and($execution->recovery_next_retry_at)->toBeNull(); + ->and($execution->recovery_last_attempt_at)->toBeNull() + ->and($execution->recovery_needs_attention)->toBeFalse(); }); it('only blocks new scheduled backups while stopped containers await recovery', function (array $pending, bool $dispatched) { @@ -340,7 +360,6 @@ it('shows the recovery state and retries recovery manually', function () { signInForRecoveryBounds($this, $team); [$application, $volume, , $backup] = createRecoveryBoundsBackup($team); $execution = createRecoveryBoundsExecution($backup, [ - 'recovery_attempts' => VolumeBackupRecoveryJob::MAX_ATTEMPTS, 'recovery_error' => 's3_auth', 'recovery_needs_attention' => true, ]); @@ -354,10 +373,9 @@ it('shows the recovery state and retries recovery manually', function () { ->assertSee('Recovery pending'); $execution->refresh(); - expect($execution->recovery_attempts)->toBe(0) - ->and($execution->recovery_needs_attention)->toBeFalse() - ->and($execution->recovery_next_retry_at)->toBeNull() + expect($execution->recovery_needs_attention)->toBeFalse() ->and(Cache::has(VolumeBackupRecoveryJob::dispatchCacheKey($execution->id)))->toBeFalse(); + Queue::assertPushed(VolumeBackupRecoveryJob::class, 1); Queue::assertPushed(VolumeBackupRecoveryJob::class, fn (VolumeBackupRecoveryJob $job) => $job->execution->is($execution)); }); @@ -365,7 +383,6 @@ it('prevents another team from retrying recovery', function () { Queue::fake(); [$application, $volume, , $backup] = createRecoveryBoundsBackup(Team::factory()->create()); $execution = createRecoveryBoundsExecution($backup, [ - 'recovery_attempts' => VolumeBackupRecoveryJob::MAX_ATTEMPTS, 'recovery_needs_attention' => true, ]); $otherTeam = Team::factory()->create(); @@ -378,7 +395,6 @@ it('prevents another team from retrying recovery', function () { ->call('retryRecovery', $execution->id) ->assertDispatched('error'); - expect($execution->fresh()->recovery_needs_attention)->toBeTrue() - ->and($execution->fresh()->recovery_attempts)->toBe(VolumeBackupRecoveryJob::MAX_ATTEMPTS); + expect($execution->fresh()->recovery_needs_attention)->toBeTrue(); Queue::assertNotPushed(VolumeBackupRecoveryJob::class); }); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 55aec44db1..a4d2c2baac 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -2676,6 +2676,7 @@ it('dispatches pending recovery for an execution at most once every five minutes Queue::fake(); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); + $application->destination->server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', diff --git a/tests/Unit/DockerComposeInjectionCompatibilityTest.php b/tests/Unit/DockerComposeInjectionCompatibilityTest.php index 21db47628e..8192a41ddd 100644 --- a/tests/Unit/DockerComposeInjectionCompatibilityTest.php +++ b/tests/Unit/DockerComposeInjectionCompatibilityTest.php @@ -229,3 +229,44 @@ test('Compose injection payloads are rejected', function (string $compose, strin 'mixed network name with an unsafe default' => ["services:\n web:\n image: nginx\nnetworks:\n edge:\n name: 'app-\${ENV:-x y}'\n", 'Invalid Docker Compose network name field'], 'network name variable with an unsafe default' => ["services:\n web:\n image: nginx\nnetworks:\n edge:\n name: '\${NET:-x\$(touch /tmp/pwned)}'\n", 'Invalid Docker Compose network name field'], ]); + +test('service network list entries accept the same variables as network names', function (string $network) { + $compose = "services:\n web:\n image: nginx\n networks:\n - '{$network}'\nnetworks:\n proxy:\n external: true\n"; + + expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class); +})->with([ + 'variable with a default' => ['${NET:-proxy}'], + 'variable with an unset-only default' => ['${NET-proxy}'], + 'braced variable' => ['${NET}'], + 'plain variable' => ['$NET'], + 'variable mixed with text' => ['${COMPOSE_PROJECT_NAME}_default'], + 'text with a variable default' => ['app-${ENV:-prod}'], +]); + +test('unsafe service network list entries with variables are rejected', function (string $network) { + $compose = "services:\n web:\n image: nginx\n networks:\n - '{$network}'\n"; + + expect(fn () => validateDockerComposeForInjection($compose))->toThrow(Exception::class, 'Invalid Docker Compose service network'); +})->with([ + 'command substitution' => ['$(id)'], + 'backticks' => ['`id`'], + 'variable with command substitution' => ['${NET}$(id)'], + 'default with command substitution' => ['${NET:-$(id)}'], + 'default with a space' => ['${NET:-x y}'], + 'default with backticks' => ['${NET:-`id`}'], +]); + +test('a service network key with a variable is rejected because Compose does not interpolate keys', function () { + $compose = "services:\n web:\n image: nginx\n networks:\n '\${NET:-proxy}': {}\n"; + + expect(fn () => validateDockerComposeForInjection($compose))->toThrow(Exception::class, 'Invalid Docker Compose service network'); +}); + +test('network errors name the service and the invalid value', function () { + $compose = "services:\n web:\n image: nginx\n networks:\n - 'net;id'\n"; + + expect(fn () => validateDockerComposeForInjection($compose)) + ->toThrow(Exception::class, 'Invalid Docker Compose service network "net;id" in service web. Network names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores, and can use variables such as ${NETWORK:-default}.'); + expect(fn () => validateDockerComposeForInjection("services:\n web:\n image: nginx\nnetworks:\n 'net;id': {}\n")) + ->toThrow(Exception::class, 'Invalid Docker Compose network name "net;id".'); +});