mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix: support host storage paths and clean up cancelled deployments
Allow validated host file and directory mounts outside resource directories while protecting external paths from deletion. Clean up cancelled deployment containers and clear tracked process IDs. Preserve legacy KeyDB and Dragonfly password parsing, accept server backup paths without extension restrictions, and allow Compose variable proxy ports.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,14 +272,14 @@ class StartDragonfly
|
||||
/**
|
||||
* The password argument of the start command. Docker Compose splits the command like a shell, so the
|
||||
* password is quoted. Databases created before this release keep their unquoted v4.3.23 argument when
|
||||
* quoting would change the password the server receives (backslashes and quotes), so their server
|
||||
* password stays the same.
|
||||
* quoting would change it (quotes, backslashes, whitespace, ; & | < >): Compose splits or cuts the old
|
||||
* command there, so quoting now would change the password or options and can lose data.
|
||||
*/
|
||||
private function requirePassArgument(): string
|
||||
{
|
||||
$keepsUnquotedPassword = $this->database->legacy_password_quoting
|
||||
&& ! $this->redisPasswordFromSecretManager
|
||||
&& strpbrk($this->resolvedRedisPassword, '\\\'"') !== false;
|
||||
&& strpbrk($this->resolvedRedisPassword, "\\'\";&|<> \t\r\n") !== false;
|
||||
|
||||
return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword);
|
||||
}
|
||||
|
||||
@@ -318,14 +318,14 @@ class StartKeydb
|
||||
/**
|
||||
* The password argument of the start command. Docker Compose splits the command like a shell, so the
|
||||
* password is quoted. Databases created before this release keep their unquoted v4.3.23 argument when
|
||||
* quoting would change the password the server receives (backslashes and quotes), so their server
|
||||
* password stays the same.
|
||||
* quoting would change it (quotes, backslashes, whitespace, ; & | < >): Compose splits or cuts the old
|
||||
* command there, so quoting now would change the password or options and can lose data.
|
||||
*/
|
||||
private function requirePassArgument(): string
|
||||
{
|
||||
$keepsUnquotedPassword = $this->database->legacy_password_quoting
|
||||
&& ! $this->redisPasswordFromSecretManager
|
||||
&& strpbrk($this->resolvedRedisPassword, '\\\'"') !== false;
|
||||
&& strpbrk($this->resolvedRedisPassword, "\\'\";&|<> \t\r\n") !== false;
|
||||
|
||||
return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5243,7 +5243,7 @@ class ApplicationsController extends Controller
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -5367,9 +5367,9 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = confinePathToBase(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path');
|
||||
$fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'storage destination path');
|
||||
LocalFileVolume::assertRemotePathIsConfined($application->workdir(), $fsPath, $application->destination->server);
|
||||
LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -5421,7 +5421,6 @@ class ApplicationsController extends Controller
|
||||
} else {
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -5429,6 +5428,20 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($request->filled('fs_path')) {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'file storage source path');
|
||||
LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: false);
|
||||
} else {
|
||||
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
|
||||
@@ -4290,7 +4290,7 @@ class DatabasesController extends Controller
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -4410,9 +4410,9 @@ class DatabasesController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = confinePathToBase(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path');
|
||||
$fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'storage destination path');
|
||||
LocalFileVolume::assertRemotePathIsConfined($database->workdir(), $fsPath, $database->destination->server);
|
||||
LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -4464,7 +4464,6 @@ class DatabasesController extends Controller
|
||||
} else {
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -4472,6 +4471,20 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($request->filled('fs_path')) {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'file storage source path');
|
||||
LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: false);
|
||||
} else {
|
||||
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2476,7 +2476,7 @@ class ServicesController extends Controller
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -2605,9 +2605,9 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = confinePathToBase(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path');
|
||||
$fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'storage destination path');
|
||||
LocalFileVolume::assertRemotePathIsConfined($service->workdir(), $fsPath, $service->server);
|
||||
LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -2659,7 +2659,6 @@ class ServicesController extends Controller
|
||||
} else {
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -2667,6 +2666,20 @@ class ServicesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($request->filled('fs_path')) {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'file storage source path');
|
||||
LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: false);
|
||||
} else {
|
||||
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -421,7 +421,7 @@ class VolumeBackups extends Component
|
||||
{
|
||||
$backup = $this->storage->scheduledBackups()->firstOrNew();
|
||||
$backup->fill([
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->resource->environment->project->team_id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => $enabled,
|
||||
'save_s3' => $this->saveToS3,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Jobs\DeregisterGithubRunnerJob;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GithubRunnerExecution extends BaseModel
|
||||
@@ -19,6 +21,7 @@ class GithubRunnerExecution extends BaseModel
|
||||
'job_name',
|
||||
'repository_full_name',
|
||||
'labels',
|
||||
'is_pull_request',
|
||||
'runner_name',
|
||||
'runner_id',
|
||||
'conclusion',
|
||||
@@ -39,6 +42,7 @@ class GithubRunnerExecution extends BaseModel
|
||||
'runner_id' => 'integer',
|
||||
'provision_attempts' => 'integer',
|
||||
'labels' => 'array',
|
||||
'is_pull_request' => 'boolean',
|
||||
'queued_at' => 'datetime',
|
||||
'ready_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
@@ -61,6 +65,22 @@ class GithubRunnerExecution extends BaseModel
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregistering the runners from GitHub is best effort and never blocks the deletion.
|
||||
*
|
||||
* @param Builder<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();
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status->isActive();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([
|
||||
|
||||
+22
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+31
@@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"',
|
||||
})[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 } },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -366,6 +401,58 @@ describe('provisioning', function () {
|
||||
expect($execution->fresh()->server_id)->toBe($server->id);
|
||||
});
|
||||
|
||||
it('places a pull request job only on a server whose configuration allows pull request jobs', function () {
|
||||
Queue::fake();
|
||||
fakeRunnerGithubApi($this->githubApp);
|
||||
Process::fake(['*' => Process::result(output: '29.8.0')]);
|
||||
$refusingServer = runnerTestServer($this->team);
|
||||
runnerTestConfig($refusingServer, $this->githubApp, ['allow_pull_requests' => false]);
|
||||
$allowingServer = runnerTestServer($this->team);
|
||||
$allowingConfig = runnerTestConfig($allowingServer, $this->githubApp, ['allow_pull_requests' => true]);
|
||||
runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $allowingServer->id, 'github_runner_config_id' => $allowingConfig->id]);
|
||||
$execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]);
|
||||
|
||||
(new ProvisionGithubRunnerJob($execution->id))->handle();
|
||||
|
||||
expect($execution->fresh()->server_id)->toBe($allowingServer->id);
|
||||
});
|
||||
|
||||
it('keeps a pull request job queued when no configuration with capacity allows pull request jobs', function () {
|
||||
Queue::fake();
|
||||
Process::fake();
|
||||
runnerTestConfig(runnerTestServer($this->team), $this->githubApp, ['allow_pull_requests' => false]);
|
||||
$execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]);
|
||||
|
||||
(new ProvisionGithubRunnerJob($execution->id))->handle();
|
||||
|
||||
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('stops provisioning when the server is deleted while GitHub registers the runner', function () {
|
||||
Queue::fake();
|
||||
Process::fake(['*' => Process::result(output: '29.8.0')]);
|
||||
$server = runnerTestServer($this->team);
|
||||
runnerTestConfig($server, $this->githubApp);
|
||||
$execution = runnerTestExecution($this->githubApp);
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
|
||||
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($server) {
|
||||
DeleteServer::run($server->id);
|
||||
|
||||
return Http::response(['runner' => ['id' => 42], 'encoded_jit_config' => 'SECRET-JIT-CONFIG'], 201);
|
||||
},
|
||||
]);
|
||||
|
||||
(new ProvisionGithubRunnerJob($execution->id))->handle();
|
||||
|
||||
expect(GithubRunnerExecution::find($execution->id))->toBeNull();
|
||||
Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, '/home/runner/run.sh'));
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42);
|
||||
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
|
||||
});
|
||||
|
||||
it('does not start runners on the Coolify host', function () {
|
||||
Queue::fake();
|
||||
Process::fake();
|
||||
@@ -624,6 +711,72 @@ describe('cleanup and reconciliation', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('server deletion', function () {
|
||||
beforeEach(function () {
|
||||
$this->server = runnerTestServer($this->team);
|
||||
$this->config = runnerTestConfig($this->server, $this->githubApp);
|
||||
});
|
||||
|
||||
it('deletes the runner configuration and executions of the server and lets the GitHub App be deleted', function () {
|
||||
Queue::fake();
|
||||
$running = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'runner_id' => 42]);
|
||||
$provisioning = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id]);
|
||||
$completed = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $this->server->id, 'runner_id' => 41]);
|
||||
$otherServer = runnerTestServer($this->team);
|
||||
$otherConfig = runnerTestConfig($otherServer, $this->githubApp, ['is_enabled' => false]);
|
||||
$other = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $otherServer->id]);
|
||||
$queued = runnerTestExecution($this->githubApp);
|
||||
|
||||
$this->server->delete();
|
||||
DeleteServer::run($this->server->id);
|
||||
|
||||
expect(Server::withTrashed()->find($this->server->id))->toBeNull()
|
||||
->and(GithubRunnerConfig::find($this->config->id))->toBeNull()
|
||||
->and(GithubRunnerExecution::whereKey([$running->id, $provisioning->id, $completed->id])->exists())->toBeFalse()
|
||||
->and($other->fresh())->not->toBeNull()
|
||||
->and($otherConfig->fresh())->not->toBeNull()
|
||||
->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued);
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, 1);
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42);
|
||||
|
||||
$this->githubApp->delete();
|
||||
expect(GithubApp::find($this->githubApp->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('removes the runner from GitHub and ignores GitHub errors', function (int $status) {
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
|
||||
'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, $status),
|
||||
]);
|
||||
|
||||
(new DeregisterGithubRunnerJob($this->githubApp->id, 42))->handle();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/orgs/acme/actions/runners/42'));
|
||||
})->with([204, 500]);
|
||||
|
||||
it('removes runners of servers deleted by older versions during reconciliation', function () {
|
||||
Queue::fake();
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
$orphan = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'runner_id' => 42]);
|
||||
$orphanWithoutRunner = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning]);
|
||||
$queued = runnerTestExecution($this->githubApp);
|
||||
$finished = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::TimedOut]);
|
||||
|
||||
(new ReconcileGithubRunnersJob)->handle();
|
||||
|
||||
expect(GithubRunnerExecution::whereKey([$orphan->id, $orphanWithoutRunner->id])->exists())->toBeFalse()
|
||||
->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued)
|
||||
->and($finished->fresh()->status)->toBe(GithubRunnerStatus::TimedOut);
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, 1);
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->runnerId === 42);
|
||||
|
||||
$this->config->update(['is_enabled' => false]);
|
||||
$this->githubApp->delete();
|
||||
expect(GithubApp::find($this->githubApp->id))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeRejectedJitConfig(): void
|
||||
{
|
||||
Http::fake([
|
||||
|
||||
@@ -155,3 +155,43 @@ it('marks only databases that exist before the migration as legacy', function ()
|
||||
->and($newKeydb->fresh()->legacy_password_quoting)->toBeFalse()
|
||||
->and($newDragonfly->fresh()->legacy_password_quoting)->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps the exact v4.3.23 start command when Docker Compose split or cut the unquoted legacy password', function (string $create, string $start, string $passwordColumn, string $commandFormat, string $password) {
|
||||
$database = $create($this->environment->id, $this->destination, [$passwordColumn => $password]);
|
||||
DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]);
|
||||
|
||||
$start::run($database->fresh(), new Activity);
|
||||
|
||||
expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command'])
|
||||
->toBe(sprintf($commandFormat, $password));
|
||||
})->with('legacy-password-quoting-engines')->with([
|
||||
'semicolon' => 'ab;cd',
|
||||
'space' => 'a b',
|
||||
'tab' => "a\tb",
|
||||
'pipe' => 'x|y',
|
||||
'ampersand' => 'p&q',
|
||||
'redirects' => 'a<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',
|
||||
]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
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 => '',
|
||||
}));
|
||||
}
|
||||
|
||||
function storageHostPathToken(User $user): string
|
||||
{
|
||||
// A token request must not use the session user of the Livewire tests.
|
||||
auth()->forgetGuards();
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'host-path-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => test()->team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: array<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'"));
|
||||
});
|
||||
@@ -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' => [
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Dashboard\TrafficAnalytics;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Process\PendingProcess;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* A dead server must not stall analytics pages that poll every Sentinel over SSH.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Cache::flush();
|
||||
config(['constants.ssh.mux_enabled' => false, 'constants.ssh.retry_base_delay' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
||||
]);
|
||||
$this->server->settings->update(['is_traffic_analytics_enabled' => true]);
|
||||
});
|
||||
|
||||
function markTrafficServerReachable(Server $server): void
|
||||
{
|
||||
$server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
||||
$server->refresh();
|
||||
}
|
||||
|
||||
it('does not run a remote command for a server that is not reachable', function () {
|
||||
Process::fake(fn () => Process::result(output: '{"requests":1}'));
|
||||
$this->server->settings->update(['is_reachable' => false]);
|
||||
$client = new SentinelTrafficClient($this->server->fresh());
|
||||
[$from, $to] = SentinelTrafficClient::rangeWindow('24h');
|
||||
|
||||
expect(fn () => $client->overview(null, $from, $to))->toThrow(RuntimeException::class);
|
||||
$client->prefetchAppOverviews(['app'], $from, $to);
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('skips the dashboard fetch of an unreachable server without a remote call', function () {
|
||||
Process::fake(fn () => Process::result(output: '{"requests":1}'));
|
||||
$user = User::factory()->create();
|
||||
$this->team->members()->attach($user->id, ['role' => 'owner']);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
loadLazy(Livewire::test(TrafficAnalytics::class))
|
||||
->assertOk()
|
||||
->assertSet('overview', null);
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('tries a failing server once and skips it until the failure window ends', function () {
|
||||
markTrafficServerReachable($this->server);
|
||||
Process::fake(fn () => Process::result(
|
||||
errorOutput: "ssh: connect to host {$this->server->ip} port 22: Connection timed out",
|
||||
exitCode: 255,
|
||||
));
|
||||
$client = new SentinelTrafficClient($this->server);
|
||||
|
||||
expect(fn () => $client->overview(null, 'a', 'b'))->toThrow(RuntimeException::class);
|
||||
Process::assertRanTimes(fn () => true, 1);
|
||||
|
||||
expect(fn () => $client->overview('app', 'a', 'b'))->toThrow(RuntimeException::class);
|
||||
Process::assertRanTimes(fn () => true, 1);
|
||||
|
||||
$this->travel(SentinelTrafficClient::UNAVAILABLE_TTL + 1)->seconds();
|
||||
|
||||
expect(fn () => $client->overview('app', 'a', 'b'))->toThrow(RuntimeException::class);
|
||||
Process::assertRanTimes(fn () => true, 2);
|
||||
});
|
||||
|
||||
it('fetches a healthy server with a bounded ssh and curl timeout', function () {
|
||||
markTrafficServerReachable($this->server);
|
||||
Process::fake(fn () => Process::result(output: '{"requests":7}'));
|
||||
|
||||
$overview = (new SentinelTrafficClient($this->server))->overview(null, 'a', 'b');
|
||||
|
||||
expect($overview->requests)->toBe(7);
|
||||
Process::assertRan(fn (PendingProcess $process) => $process->timeout <= 30
|
||||
&& str_contains($process->command, '--max-time'));
|
||||
});
|
||||
@@ -42,6 +42,8 @@ it('surfaces the real remote error instead of a TypeError when the fetch fails',
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
|
||||
]);
|
||||
// Only a functional server is queried.
|
||||
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
|
||||
expect(fn () => (new SentinelTrafficClient($server))->overview('app', 'a', 'b'))
|
||||
->toThrow(RuntimeException::class, $message);
|
||||
|
||||
@@ -25,6 +25,8 @@ beforeEach(function () {
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
|
||||
]);
|
||||
// Only a functional server is queried.
|
||||
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
|
||||
$this->client = new class($this->server) extends SentinelTrafficClient
|
||||
{
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Analytics;
|
||||
use App\Livewire\Dashboard\TrafficAnalytics;
|
||||
use App\Livewire\Project\Application\Analytics as ApplicationAnalytics;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* The chart id is printed into inline scripts, so clients must not change it.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = $this->user->teams()->first();
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server->settings->update(['is_traffic_analytics_enabled' => false]);
|
||||
});
|
||||
|
||||
it('locks the scoped server and chart id of the analytics page', function (string $property, string $value) {
|
||||
$component = loadLazy(Livewire::test(Analytics::class, ['scopedServerUuid' => $this->server->uuid]));
|
||||
|
||||
expect(fn () => $component->set($property, $value))->toThrow(CannotUpdateLockedPropertyException::class);
|
||||
})->with([
|
||||
'scoped server' => ['scopedServerUuid', 'other-server'],
|
||||
'chart id' => ['chartId', "x');alert(1);//"],
|
||||
]);
|
||||
|
||||
it('locks the server uuid and chart id of the resource analytics tab', function (string $property, string $value) {
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $this->server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-locked-test']);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ApplicationAnalytics::class, ['application' => $application, 'lazy' => false]);
|
||||
|
||||
expect(fn () => $component->set($property, $value))->toThrow(CannotUpdateLockedPropertyException::class);
|
||||
})->with([
|
||||
'analytics server' => ['analyticsServerUuid', 'other-server'],
|
||||
'chart id' => ['chartId', "x');alert(1);//"],
|
||||
]);
|
||||
|
||||
it('locks the chart id of the dashboard traffic summary', function () {
|
||||
$component = loadLazy(Livewire::test(TrafficAnalytics::class));
|
||||
|
||||
expect(fn () => $component->set('chartId', "x');alert(1);//"))->toThrow(CannotUpdateLockedPropertyException::class);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\TrafficAnalyticsSettings;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Unbounded values would let one setting grow Sentinel's storage without limit.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Queue::fake();
|
||||
config([
|
||||
'app.maintenance.store' => 'array',
|
||||
'cache.default' => 'array',
|
||||
]);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = $this->user->teams()->first();
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server->settings->update(['is_traffic_analytics_enabled' => false]);
|
||||
});
|
||||
|
||||
dataset('traffic setting limits', [
|
||||
'top-n' => ['trafficTopn', 'traffic_topn', 1000],
|
||||
'hourly retention' => ['trafficRetention1hDays', 'traffic_retention_1h_days', 365],
|
||||
'daily retention' => ['trafficRetention1dDays', 'traffic_retention_1d_days', 3650],
|
||||
]);
|
||||
|
||||
it('rejects traffic settings above the limit in the UI', function (string $property, string $column, int $max) {
|
||||
$this->actingAs($this->user);
|
||||
$before = $this->server->settings->fresh()->{$column};
|
||||
|
||||
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $this->server])
|
||||
->set($property, $max + 1)
|
||||
->call('saveTrafficAnalyticsSettings')
|
||||
->assertHasErrors($property);
|
||||
|
||||
expect($this->server->settings->fresh()->{$column})->toBe($before);
|
||||
|
||||
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $this->server])
|
||||
->set($property, $max)
|
||||
->call('saveTrafficAnalyticsSettings')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($this->server->settings->fresh()->{$column})->toBe($max);
|
||||
})->with('traffic setting limits');
|
||||
|
||||
it('rejects traffic settings above the limit in the API', function (string $property, string $column, int $max) {
|
||||
$token = $this->user->createToken('traffic-limits', ['*'])->plainTextToken;
|
||||
$headers = ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'];
|
||||
$before = $this->server->settings->fresh()->{$column};
|
||||
|
||||
$this->withHeaders($headers)
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [$column => $max + 1])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors($column);
|
||||
|
||||
expect($this->server->settings->fresh()->{$column})->toBe($before);
|
||||
|
||||
$this->withHeaders($headers)
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [$column => $max])
|
||||
->assertOk()
|
||||
->assertJsonPath($column, $max);
|
||||
})->with('traffic setting limits');
|
||||
@@ -209,6 +209,61 @@ it('shows readable service storage backup target labels', function () {
|
||||
->assertSee('Directus: directus-templates');
|
||||
});
|
||||
|
||||
it('assigns a new volume backup schedule to the team of its resource, not the current team', function (string $resourceType) {
|
||||
$team = Team::factory()->create();
|
||||
$user = signInForVolumeBackups($this, $team);
|
||||
[$application, $volume, $server] = createVolumeBackupApplication($team);
|
||||
$otherTeam = Team::factory()->create();
|
||||
$user->teams()->attach($otherTeam, ['role' => 'owner']);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
|
||||
if ($resourceType === 'service') {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'environment_id' => $application->environment_id,
|
||||
'destination_id' => $application->destination_id,
|
||||
'destination_type' => $application->destination_type,
|
||||
]);
|
||||
$serviceApplication = ServiceApplication::create(['uuid' => new_public_id(), 'name' => 'app', 'service_id' => $service->id]);
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'name' => $service->uuid.'_app-data',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $serviceApplication->id,
|
||||
'resource_type' => $serviceApplication->getMorphClass(),
|
||||
]);
|
||||
$component = Livewire::test(CreateServiceVolumeBackup::class, ['service' => $service, 'selectedTargetKey' => 'volume:'.$volume->id]);
|
||||
} else {
|
||||
$component = Livewire::test(CreateScheduledVolumeBackup::class, ['application' => $application, 'selectedTargetKey' => 'volume:'.$volume->id]);
|
||||
}
|
||||
|
||||
$component->set('frequency', 'daily')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->sole()->team_id)->toBe($team->id);
|
||||
})->with(['application', 'service']);
|
||||
|
||||
it('assigns a volume backup schedule saved from its settings page to the team of its resource, not the current team', function (bool $rootTeam) {
|
||||
$team = $rootTeam ? Team::factory()->create(['id' => 0]) : Team::factory()->create();
|
||||
$user = signInForVolumeBackups($this, $team);
|
||||
[$application, $volume] = createVolumeBackupApplication($team);
|
||||
$otherTeam = Team::factory()->create();
|
||||
$user->teams()->attach($otherTeam, ['role' => 'owner']);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
|
||||
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
||||
->set('frequency', 'daily')
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->sole()->team_id)->toBe($team->id);
|
||||
})->with([
|
||||
'team' => [false],
|
||||
'root team' => [true],
|
||||
]);
|
||||
|
||||
it('handles scheduled backup persistence failures', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
|
||||
@@ -124,24 +124,18 @@ function makeWebhookApplicationServerFunctional(Application $application): Appli
|
||||
}
|
||||
|
||||
describe('Manual Webhook Failed Authentication Rate Limiting', function () {
|
||||
test('a locked failure scope rejects deliveries before reading application secrets', function (string $provider, bool $validSignature) {
|
||||
$application = createApplicationWithWebhook();
|
||||
test('a locked failure scope rejects invalid deliveries', function (string $provider) {
|
||||
Queue::fake();
|
||||
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
|
||||
|
||||
lockOutManualWebhookRepository($this, $provider, $application);
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: $validSignature)
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: false)
|
||||
->assertStatus(429)
|
||||
->assertHeader('Retry-After');
|
||||
|
||||
$applicationQueries = collect(DB::getQueryLog())
|
||||
->filter(fn (array $query): bool => str_contains($query['query'], '"applications"'));
|
||||
DB::disableQueryLog();
|
||||
|
||||
expect($applicationQueries)->toBeEmpty();
|
||||
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
|
||||
})->with(['github', 'gitlab', 'bitbucket', 'gitea'])->with([false, true]);
|
||||
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
|
||||
|
||||
test('valid signed deliveries do not consume the failure limit', function (string $provider) {
|
||||
$application = createApplicationWithWebhook();
|
||||
@@ -156,20 +150,18 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () {
|
||||
expect(RateLimiter::attempts(manualWebhookFailureKey($provider)))->toBe(0);
|
||||
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
|
||||
|
||||
test('a signed delivery is blocked during lockout and processed after the failure window expires', function (string $provider) {
|
||||
test('a signed delivery is processed while failed deliveries from the same proxy address lock the scope', function (string $provider) {
|
||||
Queue::fake();
|
||||
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
|
||||
// On self-hosted instances every delivery reaches PHP from the proxy container.
|
||||
$proxyAddress = '10.0.1.5';
|
||||
|
||||
lockOutManualWebhookRepository($this, $provider, $application);
|
||||
for ($i = 0; $i < 31; $i++) {
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: false, ip: $proxyAddress, extraServer: ['HTTP_X_FORWARDED_FOR' => '198.51.100.66']);
|
||||
}
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: false, ip: $proxyAddress)->assertStatus(429);
|
||||
|
||||
sendManualWebhookPush($this, $provider, $application)
|
||||
->assertStatus(429)
|
||||
->assertHeader('Retry-After');
|
||||
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
|
||||
|
||||
$this->travel(61)->seconds();
|
||||
|
||||
$response = sendManualWebhookPush($this, $provider, $application);
|
||||
$response = sendManualWebhookPush($this, $provider, $application, ip: $proxyAddress, extraServer: ['HTTP_X_FORWARDED_FOR' => '140.82.115.1']);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Deployment queued');
|
||||
@@ -182,7 +174,7 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () {
|
||||
lockOutManualWebhookRepository($this, $provider, $application);
|
||||
$attempts = RateLimiter::attempts(manualWebhookFailureKey($provider));
|
||||
|
||||
sendManualWebhookPush($this, $provider, $application)->assertStatus(429);
|
||||
sendManualWebhookPush($this, $provider, $application)->assertOk();
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: false)
|
||||
->assertStatus(429)
|
||||
->assertHeader('Retry-After')
|
||||
@@ -1353,8 +1345,7 @@ describe('Manual Webhook Repeated Failed Deliveries', function () {
|
||||
|
||||
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
|
||||
|
||||
sendManualWebhookPush($this, 'gitlab', $application)->assertStatus(429);
|
||||
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
|
||||
sendManualWebhookPush($this, 'gitlab', $application)->assertOk()->assertSee('Deployment queued');
|
||||
});
|
||||
|
||||
test('repeated tokens do not extend the guess limit', function () {
|
||||
@@ -1435,8 +1426,7 @@ describe('Manual Webhook Repeated Failed Deliveries', function () {
|
||||
sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'guess-30')->assertStatus(429);
|
||||
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
|
||||
|
||||
sendManualWebhookPush($this, $provider, $application)->assertStatus(429);
|
||||
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
|
||||
sendManualWebhookPush($this, $provider, $application)->assertOk()->assertSee('Deployment queued');
|
||||
})->with(['github', 'bitbucket', 'gitea']);
|
||||
|
||||
test('the same wrong signature for different payloads counts every payload', function (string $provider) {
|
||||
|
||||
@@ -110,10 +110,28 @@ it('rejects invalid proxy port collection shapes', function (mixed $ports) {
|
||||
})->with([
|
||||
'scalar' => ['80:80'],
|
||||
'map' => [['published' => 80]],
|
||||
'null' => [null],
|
||||
'boolean' => [true],
|
||||
]);
|
||||
|
||||
it('accepts an empty ports key like v4.3.23 did', function (string $configuration) {
|
||||
expect(ProxyPortParser::fromConfiguration($configuration))->toBe([])
|
||||
->and(ProxyPortParser::publishesOnlyUncheckedPorts($configuration))->toBeFalse();
|
||||
})->with([
|
||||
'empty value' => ["services:\n traefik:\n image: traefik:v3.6\n ports:\n"],
|
||||
'explicit null' => ["services:\n caddy:\n ports: null\n"],
|
||||
]);
|
||||
|
||||
it('reports proxies whose published ports cannot be checked for conflicts', function (string $configuration, bool $expected) {
|
||||
expect(ProxyPortParser::publishesOnlyUncheckedPorts($configuration))->toBe($expected);
|
||||
})->with([
|
||||
'variables without defaults' => ["services:\n traefik:\n ports: ['\${HTTP_PORT}:80', '\${HTTPS_PORT}:443']\n", true],
|
||||
'a concrete port' => ["services:\n traefik:\n ports: ['\${HTTP_PORT}:80', '443:443']\n", false],
|
||||
'only port ranges' => ["services:\n traefik:\n ports: ['10000-10100:10000-10100']\n", true],
|
||||
'a variable with a default' => ["services:\n traefik:\n ports: ['\${HTTP_PORT:-80}:80']\n", false],
|
||||
'no ports' => ["services:\n traefik:\n image: traefik:v3.6\n", false],
|
||||
'empty ports' => ["services:\n traefik:\n ports: []\n", false],
|
||||
]);
|
||||
|
||||
it('accepts Docker Compose merge tags in proxy configurations', function (string $configuration, array $expected) {
|
||||
expect(ProxyPortParser::fromConfiguration($configuration))->toBe($expected);
|
||||
})->with([
|
||||
|
||||
Reference in New Issue
Block a user