Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-05 10:29:31 +00:00
111 changed files with 3783 additions and 778 deletions
@@ -0,0 +1,70 @@
<?php
namespace App\Actions\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;
/**
* `current_process_id` is the PID of the local SSH client, so it is never killed on a remote server.
*/
class CleanupCancelledDeployment
{
use AsAction;
public function handle(ApplicationDeploymentQueue $deployment, int $teamId): void
{
$deploymentUuid = $deployment->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<int, Server>
*/
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();
}
}
+4 -1
View File
@@ -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.');
}
}
+11 -4
View File
@@ -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;
}
}
@@ -272,14 +279,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);
}
+11 -4
View File
@@ -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;
}
}
@@ -318,14 +325,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);
}
+4 -1
View File
@@ -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
@@ -41,6 +41,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',
@@ -5086,7 +5087,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,
),
@@ -5111,6 +5112,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',
@@ -5217,6 +5222,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;
@@ -5287,7 +5296,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. 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,
),
@@ -5303,6 +5312,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'),
]
)]
@@ -5411,9 +5421,18 @@ 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);
$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([
'message' => 'Validation failed.',
@@ -5465,7 +5484,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.',
@@ -5473,6 +5491,28 @@ 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');
$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');
}
} 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,
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Coolify writes mounts outside the resource directory on the host without a deployment, so a token
* needs the deploy permission (or root) for them.
*/
trait RequiresDeployForOutsideHostPaths
{
private function outsideHostPathForbiddenResponse(Request $request, LocalFileVolume $storage): ?JsonResponse
{
if ($storage->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);
}
}
@@ -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 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. 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'),
]
)]
@@ -4410,9 +4412,18 @@ 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);
$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([
'message' => 'Validation failed.',
@@ -4464,7 +4475,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 +4482,28 @@ 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');
$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');
}
} 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,
@@ -4530,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,
),
@@ -4555,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',
@@ -4658,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;
+3 -28
View File
@@ -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()}");
}
@@ -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],
],
),
@@ -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',
@@ -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 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. 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'),
]
)]
@@ -2605,9 +2607,18 @@ 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);
$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([
'message' => 'Validation failed.',
@@ -2659,7 +2670,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 +2677,28 @@ 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');
$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');
}
} 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,
@@ -2725,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,
),
@@ -2750,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',
@@ -2883,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;
@@ -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);
@@ -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
-4
View File
@@ -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();
+18 -10
View File
@@ -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);
@@ -313,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') {
-4
View File
@@ -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();
+43 -10
View File
@@ -155,6 +155,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
/** @var array<string, string>|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;
@@ -2232,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()) {
@@ -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)) {
+66 -29
View File
@@ -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<int, ScheduledDatabaseBackupExecution>
*/
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();
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Jobs;
use App\Models\GithubApp;
use App\Services\GithubRunner\GithubRunnerApi;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Best effort: a failure is logged and never blocks the deletion.
*/
class DeregisterGithubRunnerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 120;
public function __construct(public int $githubAppId, public int $runnerId)
{
$this->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()]);
}
}
}
+10
View File
@@ -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())
+13
View File
@@ -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);
+5 -3
View File
@@ -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++;
+23 -34
View File
@@ -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,
]);
}
+26 -28
View File
@@ -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
+3
View File
@@ -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. */
@@ -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']) {
@@ -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;
@@ -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) {
@@ -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,
]);
@@ -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);
}
}
@@ -1,86 +0,0 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\Application;
use App\Services\SentinelTrafficClient;
use App\Services\TrafficAnalyticsAggregator;
use Livewire\Attributes\Lazy;
use Livewire\Component;
/**
* Compact last-24h traffic KPI card for the application General page. Lazy-loaded so
* the General page isn't blocked by Sentinel's docker-exec round-trip.
*/
#[Lazy]
class TrafficOverview extends Component
{
public Application $application;
public bool $enabled = false;
public bool $eligible = false;
public ?string $serverUuid = null;
public ?array $overview = null;
public function mount(): void
{
// Runs in the deferred lazy-load request, so the Sentinel fetch never blocks
// the initial General-page render.
$server = $this->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'
<div class="h-24 w-full animate-pulse rounded-xl border border-neutral-200 bg-neutral-50 dark:border-white/[0.08] dark:bg-white/[0.02]"></div>
HTML;
}
public function render()
{
return view('livewire.project.application.traffic-overview');
}
}
@@ -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') {
@@ -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();
+31 -11
View File
@@ -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,
]);
}
}
+26 -4
View File
@@ -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()) {
@@ -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,
]);
@@ -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';
@@ -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));
@@ -421,7 +419,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,
+9
View File
@@ -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) {
@@ -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;
+3 -2
View File
@@ -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);
+4 -26
View File
@@ -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', [
+17
View File
@@ -46,6 +46,13 @@ use OpenApi\Attributes as OA;
)]
class ApplicationDeploymentQueue extends Model
{
/**
* Kept in memory only, never saved.
*
* @var array<array-key, mixed>
*/
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<array-key, mixed> $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(
+40 -6
View File
@@ -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<array-key, mixed> $secrets
* @return array<int, string>
*/
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<int, string>
*/
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;
+82
View File
@@ -3,7 +3,12 @@
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
{
@@ -19,6 +24,7 @@ class GithubRunnerExecution extends BaseModel
'job_name',
'repository_full_name',
'labels',
'is_pull_request',
'runner_name',
'runner_id',
'conclusion',
@@ -39,6 +45,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 +68,81 @@ class GithubRunnerExecution extends BaseModel
return $this->belongsTo(Server::class);
}
/**
* Deregistering the runners from GitHub is best effort and never blocks the deletion.
*
* @param Builder<GithubRunnerExecution> $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();
}
/**
* Marks the runner running. When a job without a pending runner took it, returns a new queued execution for the original job.
*
* @param array<string, mixed> $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();
+146 -9
View File
@@ -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. <br><br>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}<br><br>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<string> $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<string> $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
@@ -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',
];
}
+3
View File
@@ -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();
@@ -0,0 +1,134 @@
<?php
namespace App\Notifications\VolumeBackup;
use App\Jobs\VolumeBackupRecoveryJob;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class RecoveryFailed extends CustomEmailNotification
{
public string $resourceName;
public string $target;
public string $reason;
public bool $containersStopped;
public ?string $url = null;
public function __construct(public ScheduledVolumeBackupExecution $execution)
{
$this->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,
];
}
}
+13 -1
View File
@@ -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 [
+31 -11
View File
@@ -37,6 +37,33 @@ class ProxyPortParser
* @return list<int>
*/
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<mixed>
*/
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
+65 -5
View File
@@ -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
));
+8 -1
View File
@@ -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,
};
}
+4 -4
View File
@@ -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) {
+17 -2
View File
@@ -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(
+2 -1
View File
@@ -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([
+44 -32
View File
@@ -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.
@@ -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',
]);
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('github_runner_executions', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* ScheduledJobDeliveryService::ENQUEUED_STALE_AFTER_MINUTES when this migration was written.
*/
private const STALE_AFTER_MINUTES = 60;
/**
* v4.3.23 left dropped overlapping jobs enqueued; stale recovery would report them as missed runs.
*/
public function up(): void
{
DB::table('scheduled_job_deliveries')
->where('status', 'enqueued')
->where('enqueued_at', '<', now()->subMinutes(self::STALE_AFTER_MINUTES))
->update([
'status' => 'skipped',
'updated_at' => now(),
]);
}
public function down(): void
{
//
}
};
@@ -0,0 +1,7 @@
<x-emails.layout>
{{ $summary }}
@if ($url)
Click [here]({{ $url }}) to open the resource.
@endif
</x-emails.layout>
@@ -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
<div>
@if (! $enabled)
@if ($eligible && $serverUuid)
<section class="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<h3 class="text-[13px] font-semibold text-black dark:text-fg">Traffic analytics</h3>
<p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-dim">
Enable Sentinel traffic analytics on this server to see requests, visitors, and
geography for this application. Restarts the proxy + Sentinel.
</p>
</div>
<a class="button shrink-0" href="{{ route('server.analytics', ['server_uuid' => $serverUuid]) }}" {{ wireNavigate() }}>
Server settings
<x-external-link />
</a>
</div>
</section>
@endif
@else
<section class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div class="flex items-center justify-between gap-3 border-b border-neutral-200 px-4 py-2.5 dark:border-white/[0.08]">
<div>
<h3 class="text-[12px]! leading-4! font-semibold! text-black dark:text-fg">Traffic (last 24h)</h3>
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">Request activity for this application</p>
</div>
<a class="text-[12px] font-medium text-coollabs hover:underline dark:text-fg" href="{{ $analyticsRoute }}" {{ wireNavigate() }}>
View full analytics &rarr;
</a>
</div>
@if (! $this->hasData())
<p class="px-4 py-4 text-[12px] text-neutral-500 dark:text-fg-dim">
No traffic recorded in the last 24h yet.
</p>
@else
<div class="grid grid-cols-2 gap-px bg-neutral-200 sm:grid-cols-4 dark:bg-white/[0.07]">
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Requests</span>
<span class="text-lg font-semibold text-black dark:text-fg">{{ number_format($overview['requests'] ?? 0) }}</span>
</div>
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Unique visitors</span>
<span class="text-lg font-semibold text-black dark:text-fg">{{ number_format($overview['uniqueVisitors'] ?? 0) }}</span>
</div>
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Error rate</span>
<span class="text-lg font-semibold text-black dark:text-fg">{{ $this->errorRate() }}%</span>
</div>
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">p95 latency</span>
<span class="text-lg font-semibold text-black dark:text-fg">{{ number_format($overview['latencyP95'] ?? 0, 1) }} ms</span>
</div>
</div>
@endif
</section>
@endif
</div>
@@ -50,8 +50,8 @@
<div class="flex flex-wrap items-center gap-2">
@if ($fileStorage->is_host_file)
<x-modal-confirmation :ignoreWire="false" title="Confirm Host File Mount Removal?"
buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$hostFileDeletionCheckboxes"
:actions="['Only the mount configuration will be removed. The host file will not be deleted.']"
buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$deletionCheckboxes"
:actions="$deletionActions"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@@ -72,9 +72,7 @@
</x-modal-input>
@endif
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
'The selected directory and all its contents will be permanently deleted from the container.',
]"
isErrorButton submitAction="delete" :checkboxes="$deletionCheckboxes" :actions="$deletionActions"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@@ -92,7 +90,7 @@
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
server</x-forms.button>
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']"
isErrorButton submitAction="delete" :checkboxes="$deletionCheckboxes" :actions="$deletionActions"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@@ -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" />
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/srv/config/app.conf" id="file_storage_source"
label="Source Path"
x-on:input="sourcePath = $event.target.value"
helper="Optional. File location on the host: an absolute path anywhere on the host, or a path relative to the resource directory. Empty: Coolify keeps the file in the resource directory. Coolify never deletes a file outside the resource directory." />
<x-forms.textarea canGate="update" :canResource="$resource" label="Content"
id="file_storage_content"></x-forms.textarea>
<div class="flex justify-end pt-2">
@@ -326,7 +336,7 @@
<x-forms.input canGate="update" :canResource="$resource"
placeholder="{{ application_configuration_dir() }}/{{ $resource->uuid }}/etc/nginx"
id="file_storage_directory_source" label="Source Directory"
required helper="Directory on the host system." />
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." />
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/etc/nginx" id="file_storage_directory_destination"
label="Destination Directory" required
@@ -27,10 +27,11 @@
if (!el) { return; }
const palette = ['#3b82f6', '#f59e0b', '#ec4899', '#8b5cf6', '#10b981', '#14b8a6', '#6b7280'];
const legend = () => ({ position: 'bottom', labels: { colors: textColor } });
const escapeHtml = value => String(value).replace(/[&<>'"]/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#039;', '"': '&quot;',
})[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 } },
+3 -3
View File
@@ -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:
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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);
});
});
@@ -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.');
});
@@ -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, [
@@ -0,0 +1,42 @@
<?php
use App\Models\AuditEvent;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});
+46
View File
@@ -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([
@@ -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.'],
]);
@@ -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],
]);
@@ -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"));
@@ -0,0 +1,156 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Project\Application\DeploymentNavbar;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Queue::fake();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
config([
'constants.ssh.mux_enabled' => 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);
});
@@ -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'");
});
@@ -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();
});
+3 -4
View File
@@ -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 () {
+302 -1
View File
@@ -1,7 +1,9 @@
<?php
use App\Actions\Server\DeleteServer;
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\DeregisterGithubRunnerJob;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Jobs\ReconcileGithubRunnersJob;
use App\Models\GithubApp;
@@ -96,7 +98,7 @@ function runnerTestExecution(GithubApp $githubApp, array $attributes = []): Gith
]);
}
function sendWorkflowJobWebhook($test, GithubApp $githubApp, string $secret, string $action, array $job)
function sendWorkflowJobWebhook($test, GithubApp $githubApp, string $secret, string $action, array $job, ?int $installationId = null)
{
$body = json_encode([
'action' => $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');
@@ -235,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,
@@ -366,6 +549,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 +859,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([
@@ -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<string>}
* @return array{command: string, healthcheck: list<string>, environment: list<string>}
*/
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']];
}
}
@@ -155,3 +156,111 @@ 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<b>',
]);
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',
]);
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');
@@ -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');
});
@@ -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]);
@@ -0,0 +1,51 @@
<?php
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
Process::fake(['*' => 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");
}
});
@@ -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();
@@ -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 () {
@@ -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: '')]);
@@ -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),
@@ -0,0 +1,58 @@
<?php
use App\Enums\ProxyTypes;
use App\Livewire\Server\Proxy;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 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);
});
@@ -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');
});
@@ -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();
@@ -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);
+1 -1
View File
@@ -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()
+6 -8
View File
@@ -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) {
+451
View File
@@ -0,0 +1,451 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Service\FileStorage;
use App\Livewire\Project\Service\Storage;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake();
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 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 => '',
}));
}
/**
* @param list<string> $abilities
*/
function storageHostPathToken(User $user, array $abilities = ['*']): 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' => $abilities,
'team_id' => test()->team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
/**
* @return array{0: string, 1: array<string, string>, 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'"));
});
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']);
@@ -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' => [
@@ -1,121 +0,0 @@
<?php
use App\Livewire\Project\Application\TrafficOverview;
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\SentinelTrafficClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
uses(RefreshDatabase::class);
class FakeAppOverviewTrafficClient extends SentinelTrafficClient
{
public array $responses = [];
protected function raw(string $url): string
{
foreach ($this->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)');
});
@@ -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)),

Some files were not shown because too many files have changed in this diff Show More