mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/main' into next
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,14 +251,21 @@ class StartDragonfly
|
||||
$environment_variables = collect();
|
||||
$this->resolvedRedisPassword = (string) $this->database->dragonfly_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$usesSecretManager = $this->database->environmentVariableUsesSecretManager($env);
|
||||
if ($env->key === 'REDIS_PASSWORD' && ! $env->is_shared && ! $usesSecretManager) {
|
||||
$env->update(['value' => $this->database->dragonfly_password]);
|
||||
}
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
// Credentials below are placed directly in the compose file (healthcheck, command).
|
||||
$composeFileValue = $this->database->formatComposeFileValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
if ($env->is_shared && ! $usesSecretManager) {
|
||||
$this->database->update(['dragonfly_password' => $rawValue]);
|
||||
}
|
||||
$this->resolvedRedisPassword = $composeFileValue;
|
||||
$this->redisPasswordFromSecretManager = $this->database->environmentVariableUsesSecretManager($env);
|
||||
$this->redisPasswordFromSecretManager = $usesSecretManager;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,14 +279,14 @@ class StartDragonfly
|
||||
/**
|
||||
* The password argument of the start command. Docker Compose splits the command like a shell, so the
|
||||
* password is quoted. Databases created before this release keep their unquoted v4.3.23 argument when
|
||||
* quoting would change the password the server receives (backslashes and quotes), so their server
|
||||
* password stays the same.
|
||||
* quoting would change it (quotes, backslashes, whitespace, ; & | < >): Compose splits or cuts the old
|
||||
* command there, so quoting now would change the password or options and can lose data.
|
||||
*/
|
||||
private function requirePassArgument(): string
|
||||
{
|
||||
$keepsUnquotedPassword = $this->database->legacy_password_quoting
|
||||
&& ! $this->redisPasswordFromSecretManager
|
||||
&& strpbrk($this->resolvedRedisPassword, '\\\'"') !== false;
|
||||
&& strpbrk($this->resolvedRedisPassword, "\\'\";&|<> \t\r\n") !== false;
|
||||
|
||||
return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword);
|
||||
}
|
||||
|
||||
@@ -251,14 +251,21 @@ class StartKeydb
|
||||
$environment_variables = collect();
|
||||
$this->resolvedRedisPassword = (string) $this->database->keydb_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$usesSecretManager = $this->database->environmentVariableUsesSecretManager($env);
|
||||
if ($env->key === 'REDIS_PASSWORD' && ! $env->is_shared && ! $usesSecretManager) {
|
||||
$env->update(['value' => $this->database->keydb_password]);
|
||||
}
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
// Credentials below are placed directly in the compose file (healthcheck, command).
|
||||
$composeFileValue = $this->database->formatComposeFileValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
if ($env->is_shared && ! $usesSecretManager) {
|
||||
$this->database->update(['keydb_password' => $rawValue]);
|
||||
}
|
||||
$this->resolvedRedisPassword = $composeFileValue;
|
||||
$this->redisPasswordFromSecretManager = $this->database->environmentVariableUsesSecretManager($env);
|
||||
$this->redisPasswordFromSecretManager = $usesSecretManager;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,14 +325,14 @@ class StartKeydb
|
||||
/**
|
||||
* The password argument of the start command. Docker Compose splits the command like a shell, so the
|
||||
* password is quoted. Databases created before this release keep their unquoted v4.3.23 argument when
|
||||
* quoting would change the password the server receives (backslashes and quotes), so their server
|
||||
* password stays the same.
|
||||
* quoting would change it (quotes, backslashes, whitespace, ; & | < >): Compose splits or cuts the old
|
||||
* command there, so quoting now would change the password or options and can lose data.
|
||||
*/
|
||||
private function requirePassArgument(): string
|
||||
{
|
||||
$keepsUnquotedPassword = $this->database->legacy_password_quoting
|
||||
&& ! $this->redisPasswordFromSecretManager
|
||||
&& strpbrk($this->resolvedRedisPassword, '\\\'"') !== false;
|
||||
&& strpbrk($this->resolvedRedisPassword, "\\'\";&|<> \t\r\n") !== false;
|
||||
|
||||
return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword);
|
||||
}
|
||||
|
||||
@@ -65,11 +65,13 @@ class CheckProxy
|
||||
}
|
||||
|
||||
$portsToCheck = [];
|
||||
$publishesOnlyUncheckedPorts = false;
|
||||
|
||||
try {
|
||||
if ($server->proxyType() !== ProxyTypes::NONE->value) {
|
||||
$proxyCompose = GetProxyConfiguration::run($server);
|
||||
$portsToCheck = ProxyPortParser::fromConfiguration($proxyCompose);
|
||||
$publishesOnlyUncheckedPorts = $portsToCheck === [] && ProxyPortParser::publishesOnlyUncheckedPorts($proxyCompose);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Error checking proxy: '.$e->getMessage());
|
||||
@@ -77,7 +79,8 @@ class CheckProxy
|
||||
return false;
|
||||
}
|
||||
if (count($portsToCheck) === 0) {
|
||||
return false;
|
||||
// Variable ports such as `${HTTP_PORT}:80` are only resolved by Compose on start.
|
||||
return $publishesOnlyUncheckedPorts;
|
||||
}
|
||||
$portsToCheck = array_values(array_unique($portsToCheck));
|
||||
// Check port conflicts in parallel
|
||||
|
||||
@@ -41,6 +41,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
class ApplicationsController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
use Concerns\RequiresDeployForOutsideHostPaths;
|
||||
|
||||
private const APPLICATION_SETTING_FIELDS = [
|
||||
'is_git_submodules_enabled',
|
||||
@@ -5086,7 +5087,7 @@ class ApplicationsController extends Controller
|
||||
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -5111,6 +5112,10 @@ class ApplicationsController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy permission to change the content of a file outside the resource directory.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
@@ -5217,6 +5222,10 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) {
|
||||
return $forbidden;
|
||||
}
|
||||
|
||||
// Always allowed
|
||||
if ($request->has('is_preview_suffix_enabled')) {
|
||||
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
|
||||
@@ -5287,7 +5296,7 @@ class ApplicationsController extends Controller
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -5303,6 +5312,7 @@ class ApplicationsController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
@@ -5411,9 +5421,18 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = confinePathToBase(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path');
|
||||
$fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'storage destination path');
|
||||
LocalFileVolume::assertRemotePathIsConfined($application->workdir(), $fsPath, $application->destination->server);
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => get_class($application),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -5465,7 +5484,6 @@ class ApplicationsController extends Controller
|
||||
} else {
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -5473,6 +5491,28 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($request->filled('fs_path')) {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'file storage source path');
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => get_class($application),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: false);
|
||||
} else {
|
||||
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Coolify writes mounts outside the resource directory on the host without a deployment, so a token
|
||||
* needs the deploy permission (or root) for them.
|
||||
*/
|
||||
trait RequiresDeployForOutsideHostPaths
|
||||
{
|
||||
private function outsideHostPathForbiddenResponse(Request $request, LocalFileVolume $storage): ?JsonResponse
|
||||
{
|
||||
if ($storage->is_host_file || ! $storage->isOutsideResourceDirectory()) {
|
||||
return null;
|
||||
}
|
||||
if ($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
private function outsideContentChangeForbiddenResponse(Request $request, LocalFileVolume|LocalPersistentVolume $storage): ?JsonResponse
|
||||
{
|
||||
if (! $storage instanceof LocalFileVolume || $storage->is_directory || ! $request->has('content')) {
|
||||
return null;
|
||||
}
|
||||
if ((string) $request->input('content') === (string) $storage->content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->outsideHostPathForbiddenResponse($request, $storage);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class DatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesDatabaseImportsApi;
|
||||
use Concerns\HandlesTagsApi;
|
||||
use Concerns\RequiresDeployForOutsideHostPaths;
|
||||
|
||||
#[OA\Post(
|
||||
path: '/databases/{uuid}/imports/uploads',
|
||||
@@ -4290,7 +4291,7 @@ class DatabasesController extends Controller
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -4306,6 +4307,7 @@ class DatabasesController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
@@ -4410,9 +4412,18 @@ class DatabasesController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = confinePathToBase(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path');
|
||||
$fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'storage destination path');
|
||||
LocalFileVolume::assertRemotePathIsConfined($database->workdir(), $fsPath, $database->destination->server);
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => get_class($database),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -4464,7 +4475,6 @@ class DatabasesController extends Controller
|
||||
} else {
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -4472,6 +4482,28 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($request->filled('fs_path')) {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'file storage source path');
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => get_class($database),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: false);
|
||||
} else {
|
||||
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
@@ -4530,7 +4562,7 @@ class DatabasesController extends Controller
|
||||
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -4555,6 +4587,10 @@ class DatabasesController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy permission to change the content of a file outside the resource directory.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
@@ -4658,6 +4694,10 @@ class DatabasesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) {
|
||||
return $forbidden;
|
||||
}
|
||||
|
||||
// Always allowed
|
||||
if ($request->has('is_preview_suffix_enabled')) {
|
||||
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Application\CleanupCancelledDeployment;
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
@@ -250,10 +251,6 @@ class DeployController extends Controller
|
||||
$deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id);
|
||||
|
||||
try {
|
||||
$deployment_uuid = $deployment->deployment_uuid;
|
||||
$kill_command = "docker rm -f {$deployment_uuid}";
|
||||
$build_server_id = $deployment->build_server_id ?? $deployment->server_id;
|
||||
|
||||
// Mark deployment as cancelled
|
||||
$updated = ApplicationDeploymentQueue::whereKey($deployment->getKey())
|
||||
->whereIn('status', $cancellableStatuses)
|
||||
@@ -270,31 +267,9 @@ class DeployController extends Controller
|
||||
$deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value;
|
||||
$cancelled = true;
|
||||
|
||||
// Get the server
|
||||
$server = Server::whereTeamId($teamId)->find($build_server_id);
|
||||
|
||||
try {
|
||||
if ($server) {
|
||||
// Add cancellation log entry
|
||||
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
|
||||
|
||||
// Check if container exists and kill it
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process([$kill_command], $server);
|
||||
$deployment->addLogEntry('Deployment container stopped.');
|
||||
} else {
|
||||
$deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.');
|
||||
}
|
||||
|
||||
// Kill running process if process ID exists
|
||||
if ($deployment->current_process_id) {
|
||||
$processKillCommand = "kill -9 {$deployment->current_process_id}";
|
||||
instant_remote_process([$processKillCommand], $server);
|
||||
}
|
||||
}
|
||||
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
|
||||
CleanupCancelledDeployment::run($deployment, (int) $teamId);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning("Failed to clean up cancelled deployment {$deployment->id}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class ScheduledTasksController extends Controller
|
||||
'command' => 'required|string',
|
||||
'frequency' => 'required|string',
|
||||
'container' => 'string|nullable',
|
||||
'timeout' => 'integer|min:1',
|
||||
'timeout' => 'integer|min:1|max:36000',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
@@ -146,7 +146,7 @@ class ScheduledTasksController extends Controller
|
||||
'command' => 'string',
|
||||
'frequency' => 'string',
|
||||
'container' => 'string|nullable',
|
||||
'timeout' => 'integer|min:1',
|
||||
'timeout' => 'integer|min:1|max:36000',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
@@ -350,7 +350,7 @@ class ScheduledTasksController extends Controller
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
@@ -437,7 +437,7 @@ class ScheduledTasksController extends Controller
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
@@ -713,7 +713,7 @@ class ScheduledTasksController extends Controller
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
@@ -800,7 +800,7 @@ class ScheduledTasksController extends Controller
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -191,10 +191,10 @@ class ServerSentinelController extends Controller
|
||||
'sentinel_metrics_history_days' => 'integer|min:1',
|
||||
'sentinel_push_interval_seconds' => 'integer|min:10',
|
||||
'sentinel_custom_url' => 'nullable|url',
|
||||
'traffic_topn' => 'integer|min:1',
|
||||
'traffic_topn' => 'integer|min:1|max:1000',
|
||||
'traffic_sample_threshold' => 'integer|min:0',
|
||||
'traffic_retention_1h_days' => 'integer|min:1',
|
||||
'traffic_retention_1d_days' => 'integer|min:1',
|
||||
'traffic_retention_1h_days' => 'integer|min:1|max:365',
|
||||
'traffic_retention_1d_days' => 'integer|min:1|max:3650',
|
||||
'is_geoip_enabled' => 'boolean',
|
||||
'geoip_refresh_days' => 'integer|min:1',
|
||||
'geoip_maxmind_license_key' => 'nullable|string|max:255',
|
||||
|
||||
@@ -31,6 +31,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
class ServicesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
use Concerns\RequiresDeployForOutsideHostPaths;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
@@ -2476,7 +2477,7 @@ class ServicesController extends Controller
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -2492,6 +2493,7 @@ class ServicesController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
@@ -2605,9 +2607,18 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = confinePathToBase(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path');
|
||||
$fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'storage destination path');
|
||||
LocalFileVolume::assertRemotePathIsConfined($service->workdir(), $fsPath, $service->server);
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $subResource->id,
|
||||
'resource_type' => get_class($subResource),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -2659,7 +2670,6 @@ class ServicesController extends Controller
|
||||
} else {
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -2667,6 +2677,28 @@ class ServicesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($request->filled('fs_path')) {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'file storage source path');
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'resource_id' => $subResource->id,
|
||||
'resource_type' => get_class($subResource),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: false);
|
||||
} else {
|
||||
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$request->filled('fs_path') ? 'fs_path' : 'mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
@@ -2725,7 +2757,7 @@ class ServicesController extends Controller
|
||||
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -2750,6 +2782,10 @@ class ServicesController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy permission to change the content of a file outside the resource directory.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
@@ -2883,6 +2919,10 @@ class ServicesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) {
|
||||
return $forbidden;
|
||||
}
|
||||
|
||||
// Always allowed
|
||||
if ($request->has('is_preview_suffix_enabled')) {
|
||||
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
|
||||
|
||||
@@ -82,10 +82,6 @@ class Bitbucket extends Controller
|
||||
]);
|
||||
}
|
||||
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'bitbucket', $full_name, $branch);
|
||||
if ($this->hasTooManyManualWebhookFailures($failure_key)) {
|
||||
return $this->tooManyManualWebhookFailuresResponse($failure_key);
|
||||
}
|
||||
|
||||
// A redelivery of the same signed payload is one guess.
|
||||
$failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_bitbucket_token);
|
||||
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
|
||||
|
||||
@@ -18,9 +18,7 @@ use Illuminate\Support\Facades\RateLimiter;
|
||||
* IPs; one misconfigured repository (wrong secret, deleted application,
|
||||
* untracked branch) therefore cannot lock out deliveries for other repositories.
|
||||
*
|
||||
* After the failure limit is reached, the scope rejects all deliveries before
|
||||
* reading application secrets or verifying signatures. Correctly signed
|
||||
* deliveries in that scope must wait until the failure window expires.
|
||||
* Valid deliveries are processed while locked: every sender shares the proxy's address.
|
||||
*
|
||||
* In one failure window, only distinct failed attempts are counted. An attempt
|
||||
* is identified by what the secret check depends on: the GitLab token, or the
|
||||
|
||||
@@ -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);
|
||||
@@ -313,10 +316,15 @@ class Github extends Controller
|
||||
? GithubRunnerExecution::query()->where('github_app_id', $githubApp->id)->where('runner_name', $runnerName)->first()
|
||||
: null;
|
||||
|
||||
if ($action === 'in_progress' && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
|
||||
$execution->update([...$jobDetails, 'status' => GithubRunnerStatus::Running, 'started_at' => now()]);
|
||||
|
||||
return response('Runner marked running.');
|
||||
// The completed hook can arrive before the in_progress hook.
|
||||
if (in_array($action, ['in_progress', 'completed'], true) && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
|
||||
$replacement = $execution->assignJob($jobId, $jobDetails);
|
||||
if ($replacement) {
|
||||
ProvisionGithubRunnerJob::dispatch($replacement->id);
|
||||
}
|
||||
if ($action === 'in_progress') {
|
||||
return response('Runner marked running.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'completed') {
|
||||
|
||||
@@ -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;
|
||||
@@ -2232,7 +2265,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
private function ensureRegistryImageForMultipleServers(): void
|
||||
{
|
||||
if ($this->pull_request_id !== 0) {
|
||||
if ($this->pull_request_id !== 0 || $this->application->build_pack === 'dockercompose') {
|
||||
return;
|
||||
}
|
||||
if ($this->application->additional_servers()->doesntExist()) {
|
||||
@@ -3219,7 +3252,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
|
||||
{
|
||||
if (RemoteSecretReferences::containsReference($environmentVariable->value)) {
|
||||
if ($this->uses_remote_secret_references($environmentVariable)) {
|
||||
$resolved = $this->resolve_environment_variable_raw($environmentVariable);
|
||||
|
||||
return $resolved === '' ? null : $resolved;
|
||||
@@ -3772,7 +3805,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
|
||||
foreach ($envs as $env) {
|
||||
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
|
||||
$resolvedValue = $this->uses_remote_secret_references($env)
|
||||
? $this->resolve_environment_variable_raw($env)
|
||||
: $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
@@ -3790,7 +3823,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
|
||||
foreach ($envs as $env) {
|
||||
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
|
||||
$resolvedValue = $this->uses_remote_secret_references($env)
|
||||
? $this->resolve_environment_variable_raw($env)
|
||||
: $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -497,15 +497,17 @@ class ScheduledJobManager implements ShouldQueue
|
||||
->where('stop_recovery_pending', true)
|
||||
->orWhere('s3_cleanup_pending', true))
|
||||
->where('recovery_needs_attention', false)
|
||||
->where(fn (Builder $query) => $query
|
||||
->whereNull('recovery_next_retry_at')
|
||||
->orWhere('recovery_next_retry_at', '<=', now()))
|
||||
->with('scheduledVolumeBackup.backupable.resource')
|
||||
->chunkById(self::CHUNK_SIZE, function ($executions) use (&$dispatched): bool {
|
||||
foreach ($executions as $execution) {
|
||||
if ($dispatched >= self::VOLUME_BACKUP_RECOVERY_MAX_DISPATCHES) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($execution->stop_recovery_pending && $execution->scheduledVolumeBackup?->server()?->isFunctional() === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Cache::add(VolumeBackupRecoveryJob::dispatchCacheKey($execution->id), true, now()->addMinutes(self::VOLUME_BACKUP_RECOVERY_INTERVAL_MINUTES))) {
|
||||
VolumeBackupRecoveryJob::dispatch($execution);
|
||||
$dispatched++;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Notifications\VolumeBackup\RecoveryFailed;
|
||||
use Aws\S3\Exception\S3Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
@@ -21,7 +22,7 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* ScheduledJobManager dispatches pending recovery again after a backoff, so this job does not retry by itself.
|
||||
* ScheduledJobManager dispatches recovery again once an unreachable server is functional, so this job does not retry by itself.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
@@ -29,12 +30,6 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
|
||||
|
||||
public int $uniqueFor = 300;
|
||||
|
||||
public const MAX_ATTEMPTS = 8;
|
||||
|
||||
public const BACKOFF_BASE_MINUTES = 5;
|
||||
|
||||
public const BACKOFF_CAP_MINUTES = 360;
|
||||
|
||||
public const CATEGORY_S3_AUTH = 's3_auth';
|
||||
|
||||
public const CATEGORY_S3_BUCKET = 's3_bucket';
|
||||
@@ -86,8 +81,8 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one recovery attempt and records its outcome instead of throwing, so a persistent failure backs off
|
||||
* and eventually needs attention rather than creating a failed job every few minutes.
|
||||
* Runs one recovery attempt and records its outcome instead of throwing. An unreachable server waits for the
|
||||
* server to be functional again; any other failure needs attention and notifies the team.
|
||||
*/
|
||||
public static function recoverWithBounds(ScheduledVolumeBackupExecution $execution): void
|
||||
{
|
||||
@@ -157,16 +152,6 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
|
||||
return $failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the minutes to wait before the next automatic attempt after the given number of failed attempts.
|
||||
*/
|
||||
public static function backoffMinutes(int $attempts): int
|
||||
{
|
||||
$exponent = max(0, min($attempts - 1, 16));
|
||||
|
||||
return (int) min(self::BACKOFF_BASE_MINUTES * (2 ** $exponent), self::BACKOFF_CAP_MINUTES);
|
||||
}
|
||||
|
||||
public static function dispatchCacheKey(int $executionId): string
|
||||
{
|
||||
return "volume-backup-recovery-dispatched:{$executionId}";
|
||||
@@ -175,9 +160,7 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
|
||||
private static function recordSuccess(ScheduledVolumeBackupExecution $execution): void
|
||||
{
|
||||
$execution->update([
|
||||
'recovery_attempts' => 0,
|
||||
'recovery_last_attempt_at' => null,
|
||||
'recovery_next_retry_at' => null,
|
||||
'recovery_error' => null,
|
||||
'recovery_needs_attention' => false,
|
||||
]);
|
||||
@@ -185,18 +168,18 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
|
||||
|
||||
private static function recordFailure(ScheduledVolumeBackupExecution $execution, string $category): void
|
||||
{
|
||||
$recorded = $execution->getConnection()->transaction(function () use ($execution, $category): ?ScheduledVolumeBackupExecution {
|
||||
$needsAttention = $category !== self::CATEGORY_SERVER_UNREACHABLE;
|
||||
$enteredNeedsAttention = false;
|
||||
|
||||
$recorded = $execution->getConnection()->transaction(function () use ($execution, $category, $needsAttention, &$enteredNeedsAttention): ?ScheduledVolumeBackupExecution {
|
||||
$current = ScheduledVolumeBackupExecution::query()->whereKey($execution->id)->lockForUpdate()->first();
|
||||
if (! $current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attempts = $current->recovery_attempts + 1;
|
||||
$needsAttention = $attempts >= self::MAX_ATTEMPTS;
|
||||
$enteredNeedsAttention = $needsAttention && ! $current->recovery_needs_attention;
|
||||
$current->update([
|
||||
'recovery_attempts' => $attempts,
|
||||
'recovery_last_attempt_at' => now(),
|
||||
'recovery_next_retry_at' => $needsAttention ? null : now()->addMinutes(self::backoffMinutes($attempts)),
|
||||
'recovery_error' => $category,
|
||||
'recovery_needs_attention' => $needsAttention,
|
||||
]);
|
||||
@@ -214,10 +197,25 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
|
||||
'execution_id' => $recorded->id,
|
||||
'execution_uuid' => $recorded->uuid,
|
||||
'category' => $category,
|
||||
'attempts' => $recorded->recovery_attempts,
|
||||
'needs_attention' => $recorded->recovery_needs_attention,
|
||||
'next_retry_at' => $recorded->recovery_next_retry_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
if ($enteredNeedsAttention) {
|
||||
self::notifyTeam($recorded);
|
||||
}
|
||||
}
|
||||
|
||||
private static function notifyTeam(ScheduledVolumeBackupExecution $execution): void
|
||||
{
|
||||
try {
|
||||
$execution->loadMissing('scheduledVolumeBackup.team');
|
||||
$execution->scheduledVolumeBackup?->team?->notify(new RecoveryFailed($execution));
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('scheduled-errors')->error('Failed to send volume backup recovery notification', [
|
||||
'execution_id' => $execution->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function categorizeS3Failure(Throwable $exception): string
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
@@ -22,8 +23,10 @@ class Analytics extends Component
|
||||
{
|
||||
use BuildsTrafficChartPayload;
|
||||
|
||||
#[Locked]
|
||||
public string $chartId = 'global-analytics';
|
||||
|
||||
#[Locked]
|
||||
public ?string $scopedServerUuid = null;
|
||||
|
||||
/** Traffic-enabled servers owned by the current team. */
|
||||
|
||||
@@ -241,8 +241,22 @@ trait InteractsWithDnsProviders
|
||||
$this->persistDomainDnsStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload comes from the browser; members without update access also receive it and are ignored.
|
||||
*/
|
||||
public function dnsRecordConfigurationFinished(array $event): void
|
||||
{
|
||||
if (! auth()->user()?->can('update', $this->dnsResource())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($event['hostname'] ?? null) || ! is_string($event['resourceType'] ?? null)
|
||||
|| ! is_scalar($event['resourceId'] ?? null) || ! is_bool($event['successful'] ?? null)
|
||||
|| ! is_string($event['credential'] ?? null) || ! is_string($event['message'] ?? null)
|
||||
|| ! is_numeric($event['teamId'] ?? null) || (int) $event['teamId'] !== $this->dnsTeamId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resource = $this->dnsResourceForHostname($event['hostname']);
|
||||
if ($resource === null || $resource->getMorphClass() !== $event['resourceType']
|
||||
|| (string) $resource->getKey() !== (string) $event['resourceId']) {
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Services\TrafficAnalyticsAggregator;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Lazy]
|
||||
@@ -16,6 +17,7 @@ class TrafficAnalytics extends Component
|
||||
{
|
||||
use BuildsTrafficChartPayload;
|
||||
|
||||
#[Locked]
|
||||
public string $chartId = 'dashboard-traffic';
|
||||
|
||||
public Collection $servers;
|
||||
|
||||
@@ -15,6 +15,9 @@ class Advanced extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/** The format that saveCustomNamePrefix() produces with str()->slug(). */
|
||||
private const CONTAINER_NAME_PREFIX_PATTERN = '/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/';
|
||||
|
||||
public Application $application;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
@@ -102,6 +105,7 @@ class Advanced extends Component
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->validateChangedContainerNamePrefix();
|
||||
$this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled;
|
||||
$this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled;
|
||||
$this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled;
|
||||
@@ -159,6 +163,29 @@ class Advanced extends Component
|
||||
$this->stopGracePeriod = $this->application->settings->stop_grace_period ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* An unchanged stored prefix is skipped, so older values do not block other settings.
|
||||
*/
|
||||
private function validateChangedContainerNamePrefix(): void
|
||||
{
|
||||
$prefix = $this->customContainerNamePrefix;
|
||||
if (blank($prefix) || $prefix === $this->application->settings->custom_container_name_prefix) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preg_match(self::CONTAINER_NAME_PREFIX_PATTERN, $prefix) !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'customContainerNamePrefix' => 'The container name prefix may only contain lowercase letters, numbers, and single hyphens between them.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (ApplicationSetting::isContainerNamePrefixInUse($prefix, $this->application->destination->server, $this->application->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'customContainerNamePrefix' => 'This container name prefix is already in use by another application on this Coolify instance.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function resetDefaultLabels()
|
||||
{
|
||||
if ($this->application->settings->is_container_label_readonly_enabled === false) {
|
||||
|
||||
@@ -113,7 +113,7 @@ class Create extends Component
|
||||
try {
|
||||
$backup = $target->scheduledBackups()->firstOrNew();
|
||||
$backup->fill([
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->application->environment->project->team_id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Actions\Application\CleanupCancelledDeployment;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Server;
|
||||
use App\Traits\AuditsApplicationSettings;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Component;
|
||||
|
||||
class DeploymentNavbar extends Component
|
||||
@@ -101,69 +101,20 @@ class DeploymentNavbar extends Component
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;
|
||||
$kill_command = "docker rm -f {$deployment_uuid}";
|
||||
$build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id;
|
||||
$server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id;
|
||||
$server = Server::ownedByCurrentTeam()->find($server_id);
|
||||
|
||||
// First, mark the deployment as cancelled to prevent further processing
|
||||
$this->application_deployment_queue->update([
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
try {
|
||||
if ($this->application->settings->is_build_server_enabled) {
|
||||
$server = Server::ownedByCurrentTeam()->find($build_server_id);
|
||||
} else {
|
||||
$server = Server::ownedByCurrentTeam()->find($server_id);
|
||||
}
|
||||
|
||||
// Add cancellation log entry
|
||||
if ($this->application_deployment_queue->logs) {
|
||||
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$new_log_entry = [
|
||||
'command' => $kill_command,
|
||||
'output' => 'Deployment cancelled by user.',
|
||||
'type' => 'stderr',
|
||||
'order' => count($previous_logs) + 1,
|
||||
'timestamp' => Carbon::now('UTC'),
|
||||
'hidden' => false,
|
||||
];
|
||||
$previous_logs[] = $new_log_entry;
|
||||
$this->application_deployment_queue->update([
|
||||
'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
|
||||
// Try to stop the helper container if it exists
|
||||
// Check if container exists first
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
// Container exists, kill it
|
||||
instant_remote_process([$kill_command], $server);
|
||||
} else {
|
||||
// Container hasn't started yet
|
||||
$this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
|
||||
}
|
||||
|
||||
// Also try to kill any running process if we have a process ID
|
||||
if ($this->application_deployment_queue->current_process_id) {
|
||||
try {
|
||||
$processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}";
|
||||
instant_remote_process([$processKillCommand], $server);
|
||||
} catch (\Throwable $e) {
|
||||
// Process might already be gone, that's ok
|
||||
}
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user.', 'stderr');
|
||||
CleanupCancelledDeployment::run($this->application_deployment_queue, currentTeam()->id);
|
||||
} catch (\Throwable $e) {
|
||||
// Still mark as cancelled even if cleanup fails
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->application_deployment_queue->update([
|
||||
'current_process_id' => null,
|
||||
]);
|
||||
next_after_cancel($server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use App\Services\TrafficAnalyticsAggregator;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Compact last-24h traffic KPI card for the application General page. Lazy-loaded so
|
||||
* the General page isn't blocked by Sentinel's docker-exec round-trip.
|
||||
*/
|
||||
#[Lazy]
|
||||
class TrafficOverview extends Component
|
||||
{
|
||||
public Application $application;
|
||||
|
||||
public bool $enabled = false;
|
||||
|
||||
public bool $eligible = false;
|
||||
|
||||
public ?string $serverUuid = null;
|
||||
|
||||
public ?array $overview = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Runs in the deferred lazy-load request, so the Sentinel fetch never blocks
|
||||
// the initial General-page render.
|
||||
$server = $this->application->destination?->server;
|
||||
$this->serverUuid = $server?->uuid;
|
||||
$this->enabled = (bool) $server?->isTrafficAnalyticsEnabled();
|
||||
$this->eligible = $server ? (! $server->isSwarm() && ! $server->isBuildServer()) : false;
|
||||
|
||||
if ($this->enabled && $server) {
|
||||
try {
|
||||
$client = app(SentinelTrafficClient::class, ['server' => $server]);
|
||||
[$from, $to] = SentinelTrafficClient::rangeWindow('24h');
|
||||
// Compose services and previews record under their own keys. Sentinel's resource
|
||||
// scope merges them exactly; an older Sentinel gets every key summed here.
|
||||
$overview = $client->tryResourceOverview($this->application->uuid, $from, $to);
|
||||
if ($overview === null) {
|
||||
$keys = $client->resourceKeys($this->application->uuid);
|
||||
if (count($keys) > 1) {
|
||||
$client->prefetchAppOverviews($keys, $from, $to);
|
||||
}
|
||||
$overviews = array_map(fn (string $key) => $client->overview($key, $from, $to), $keys);
|
||||
$overview = TrafficAnalyticsAggregator::sumOverviews($overviews)['overview'];
|
||||
}
|
||||
$this->overview = $overview->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$this->overview = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function hasData(): bool
|
||||
{
|
||||
return $this->overview !== null && (int) ($this->overview['requests'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
public function errorRate(): float
|
||||
{
|
||||
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
|
||||
|
||||
return round(($errors / $this->overview['requests']) * 100, 2);
|
||||
}
|
||||
|
||||
public function placeholder(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<div class="h-24 w-full animate-pulse rounded-xl border border-neutral-200 bg-neutral-50 dark:border-white/[0.08] dark:bg-white/[0.02]"></div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.traffic-overview');
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ class CreateScheduledBackup extends Component
|
||||
's3_storage_id' => null,
|
||||
'database_id' => $database->id,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $database->team()->id,
|
||||
];
|
||||
|
||||
if ($database->type() === 'standalone-postgresql') {
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
@@ -25,8 +26,9 @@ class ConnectApplication extends Component
|
||||
public string $mountPath = StandaloneSqlite::DATA_DIRECTORY;
|
||||
|
||||
/**
|
||||
* Docker volume that holds the database files.
|
||||
* Docker volume that holds the database files. Set on the server only: it becomes a mount source.
|
||||
*/
|
||||
#[Locked]
|
||||
public string $volumeName = '';
|
||||
|
||||
/**
|
||||
@@ -73,9 +75,11 @@ class ConnectApplication extends Component
|
||||
$this->validate([
|
||||
'applicationUuid' => 'required|string',
|
||||
'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'volumeName' => ValidationPatterns::volumeNameRules(),
|
||||
], [
|
||||
'applicationUuid.required' => 'Select an application first.',
|
||||
'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
|
||||
...ValidationPatterns::volumeNameMessages('volumeName'),
|
||||
]);
|
||||
|
||||
$application = $this->selectedApplication();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -316,8 +316,6 @@ class VolumeBackups extends Component
|
||||
}
|
||||
|
||||
$execution->update([
|
||||
'recovery_attempts' => 0,
|
||||
'recovery_next_retry_at' => null,
|
||||
'recovery_needs_attention' => false,
|
||||
]);
|
||||
Cache::forget(VolumeBackupRecoveryJob::dispatchCacheKey($execution->id));
|
||||
@@ -421,7 +419,7 @@ class VolumeBackups extends Component
|
||||
{
|
||||
$backup = $this->storage->scheduledBackups()->firstOrNew();
|
||||
$backup->fill([
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->resource->environment->project->team_id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => $enabled,
|
||||
'save_s3' => $this->saveToS3,
|
||||
|
||||
@@ -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,7 +3,12 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Jobs\DeregisterGithubRunnerJob;
|
||||
use App\Jobs\ProvisionGithubRunnerJob;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GithubRunnerExecution extends BaseModel
|
||||
{
|
||||
@@ -19,6 +24,7 @@ class GithubRunnerExecution extends BaseModel
|
||||
'job_name',
|
||||
'repository_full_name',
|
||||
'labels',
|
||||
'is_pull_request',
|
||||
'runner_name',
|
||||
'runner_id',
|
||||
'conclusion',
|
||||
@@ -39,6 +45,7 @@ class GithubRunnerExecution extends BaseModel
|
||||
'runner_id' => 'integer',
|
||||
'provision_attempts' => 'integer',
|
||||
'labels' => 'array',
|
||||
'is_pull_request' => 'boolean',
|
||||
'queued_at' => 'datetime',
|
||||
'ready_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
@@ -61,6 +68,81 @@ class GithubRunnerExecution extends BaseModel
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregistering the runners from GitHub is best effort and never blocks the deletion.
|
||||
*
|
||||
* @param Builder<GithubRunnerExecution> $query
|
||||
*/
|
||||
public static function deleteAndDeregister(Builder $query): void
|
||||
{
|
||||
(clone $query)
|
||||
->whereIn('status', GithubRunnerStatus::occupying())
|
||||
->whereNotNull('runner_id')
|
||||
->get(['github_app_id', 'runner_id'])
|
||||
->each(fn (GithubRunnerExecution $execution) => DeregisterGithubRunnerJob::dispatch($execution->github_app_id, $execution->runner_id));
|
||||
|
||||
(clone $query)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the runner running. When a job without a pending runner took it, returns a new queued execution for the original job.
|
||||
*
|
||||
* @param array<string, mixed> $jobDetails
|
||||
*/
|
||||
public function assignJob(int $jobId, array $jobDetails): ?self
|
||||
{
|
||||
$replacement = DB::transaction(function () use ($jobId, $jobDetails): ?self {
|
||||
$execution = self::query()->whereKey($this->id)->lockForUpdate()->first();
|
||||
if (! $execution || ! in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$running = [...$jobDetails, 'workflow_job_id' => $jobId, 'status' => GithubRunnerStatus::Running, 'started_at' => now()];
|
||||
$jobExecution = $execution->trigger_workflow_job_id === $jobId ? $execution : self::query()
|
||||
->where('github_app_id', $execution->github_app_id)
|
||||
->where('trigger_workflow_job_id', $jobId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($jobExecution?->isActive()) {
|
||||
$execution->update($running);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$replacement = [
|
||||
...$execution->only(['github_app_id', 'trigger_workflow_job_id', 'workflow_job_html_url', 'workflow_name', 'job_name', 'repository_full_name', 'labels', 'is_pull_request', 'provision_attempts']),
|
||||
'workflow_job_id' => $execution->trigger_workflow_job_id,
|
||||
'status' => GithubRunnerStatus::Queued,
|
||||
'queued_at' => now(),
|
||||
];
|
||||
$jobExecution?->delete();
|
||||
$execution->update([...$running, 'trigger_workflow_job_id' => $jobId]);
|
||||
|
||||
if ($replacement['provision_attempts'] >= ProvisionGithubRunnerJob::MAX_ATTEMPTS) {
|
||||
Log::warning('GitHub runner taken by another job too often; no new runner is started.', [
|
||||
'github_app_id' => $execution->github_app_id,
|
||||
'workflow_job_id' => $replacement['trigger_workflow_job_id'],
|
||||
'attempts' => $replacement['provision_attempts'],
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$hasMatchingConfig = GithubRunnerConfig::query()
|
||||
->where('github_app_id', $execution->github_app_id)
|
||||
->where('is_enabled', true)
|
||||
->get()
|
||||
->contains(fn (GithubRunnerConfig $config) => $config->matchesLabels($replacement['labels'] ?? [])
|
||||
&& ($config->allow_pull_requests || ! $replacement['is_pull_request']));
|
||||
|
||||
return $hasMatchingConfig ? self::create($replacement) : null;
|
||||
});
|
||||
|
||||
$this->refresh();
|
||||
|
||||
return $replacement;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status->isActive();
|
||||
|
||||
@@ -174,8 +174,11 @@ class LocalFileVolume extends BaseModel
|
||||
$path = $this->resolvedFsPath($workdir);
|
||||
|
||||
if (! $this->isAdminControlledComposeMount()) {
|
||||
$path = str(confinePathToBase($workdir, $path->value(), 'storage path'));
|
||||
$this->assertRemotePathIsConfined($workdir, $path->value(), $server);
|
||||
[$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory();
|
||||
if ($resourceDirectory !== null) {
|
||||
$this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server);
|
||||
}
|
||||
$path = str($hostPath);
|
||||
}
|
||||
|
||||
// Validate and escape path to prevent command injection
|
||||
@@ -237,6 +240,7 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
/**
|
||||
* Without a server, Coolify deletes the file on every server of the resource.
|
||||
* Host paths outside the resource directory are never deleted.
|
||||
*/
|
||||
public function deleteStorageOnServer(?Server $server = null)
|
||||
{
|
||||
@@ -253,6 +257,10 @@ class LocalFileVolume extends BaseModel
|
||||
$commands = collect([]);
|
||||
$path = $this->resolvedFsPath($workdir);
|
||||
|
||||
if ($this->isOutsideResourceDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isAdminControlledComposeMount()) {
|
||||
$path = str(confinePathToBase($workdir, $path->value(), 'storage path'));
|
||||
$this->assertRemotePathIsConfined($workdir, $path->value(), $server);
|
||||
@@ -279,6 +287,7 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
/**
|
||||
* Without a server, Coolify writes the file on every server of the resource.
|
||||
* Outside the resource directory, nothing is deleted or written through a symlink.
|
||||
*/
|
||||
public function saveStorageOnServer(?Server $server = null)
|
||||
{
|
||||
@@ -299,12 +308,25 @@ class LocalFileVolume extends BaseModel
|
||||
$content = data_get($this, 'content');
|
||||
$writesContent = $this->writesContentOnServer();
|
||||
|
||||
if ($writesContent) {
|
||||
$path = str($this->confinedContentPath($path->value(), $server));
|
||||
} elseif (! $this->isAdminControlledComposeMount()) {
|
||||
$path = str(confinePathToBase($workdir, $path->value(), 'storage path'));
|
||||
$this->assertRemotePathIsConfined($workdir, $path->value(), $server);
|
||||
if ($this->isAdminControlledComposeMount()) {
|
||||
// Compose `content:` stays in the resource directory: the Compose file can come from a Git repository.
|
||||
if ($writesContent) {
|
||||
$path = str($this->confinedContentPath($path->value(), $server));
|
||||
}
|
||||
} else {
|
||||
[$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory();
|
||||
if ($resourceDirectory === null) {
|
||||
if (! $this->is_directory) {
|
||||
self::assertRemotePathIsNotSymlink($hostPath, $server);
|
||||
}
|
||||
} elseif ($writesContent) {
|
||||
$hostPath = $this->confinedContentPath($hostPath, $server);
|
||||
} else {
|
||||
$this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server);
|
||||
}
|
||||
$path = str($hostPath);
|
||||
}
|
||||
$isOutsideResourceDirectory = $this->isOutsideResourceDirectory();
|
||||
|
||||
if ($this->is_directory) {
|
||||
validateShellSafePath($path, 'storage path');
|
||||
@@ -344,8 +366,8 @@ class LocalFileVolume extends BaseModel
|
||||
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file. <br><br>Please delete the directory on the server or mark it as directory.');
|
||||
}
|
||||
// Docker creates a missing bind source as an empty directory. Replace only an empty
|
||||
// directory; never delete files that are on the server.
|
||||
if (self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') {
|
||||
// directory inside the resource directory; never delete files that are on the server.
|
||||
if ($isOutsideResourceDirectory || self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') {
|
||||
throw new \Exception("The following file is a directory on the server, but you are trying to mark it as a file: {$path}<br><br>Please delete the directory on the server or mark it as directory.");
|
||||
}
|
||||
$replacesEmptyDirectory = true;
|
||||
@@ -419,9 +441,124 @@ class LocalFileVolume extends BaseModel
|
||||
*/
|
||||
public function contentPathOnServer(): string
|
||||
{
|
||||
if (! $this->isAdminControlledComposeMount()) {
|
||||
[$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory();
|
||||
if ($resourceDirectory === null) {
|
||||
return $hostPath;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->localConfinedContentPath($this->resolvedFsPath($this->ownerResource()->workdir())->value())[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* `~` is not allowed because it depends on the home directory of the SSH user.
|
||||
*
|
||||
* @param string|list<string> $resourceDirectories The first directory resolves relative paths.
|
||||
*
|
||||
* @throws \Exception If the path is not allowed
|
||||
*/
|
||||
public static function resolveHostPath(string|array $resourceDirectories, string $path, string $context = 'storage path'): string
|
||||
{
|
||||
$resourceDirectories = array_map(normalizeUnixPath(...), (array) $resourceDirectories);
|
||||
$path = trim($path);
|
||||
if ($path === '') {
|
||||
throw new \Exception("Invalid {$context}: the path is empty.");
|
||||
}
|
||||
if (str_starts_with($path, '~')) {
|
||||
throw new \Exception("Invalid {$context}: use an absolute path instead of ~.");
|
||||
}
|
||||
validateShellSafePath($path, $context);
|
||||
|
||||
$isAbsolute = str_starts_with($path, '/');
|
||||
$resolvedPath = normalizeUnixPath($isAbsolute ? $path : $resourceDirectories[0].'/'.$path);
|
||||
if (self::resourceDirectoryContaining($resolvedPath, $resourceDirectories) !== null) {
|
||||
return $resolvedPath;
|
||||
}
|
||||
|
||||
if (! $isAbsolute) {
|
||||
throw new \Exception("Invalid {$context}: a relative path must stay inside the resource directory.");
|
||||
}
|
||||
if (array_intersect(explode('/', $path), ['.', '..']) !== []) {
|
||||
throw new \Exception("Invalid {$context}: '.' and '..' segments are not allowed.");
|
||||
}
|
||||
if ($resolvedPath === '/') {
|
||||
throw new \Exception("Invalid {$context}: the root directory cannot be mounted.");
|
||||
}
|
||||
|
||||
return $resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \RuntimeException If Coolify must not use the path
|
||||
*/
|
||||
public static function assertHostPathOnServer(string $resourceDirectory, string $path, Server $server, bool $isDirectory): void
|
||||
{
|
||||
if (self::resourceDirectoryContaining($path, [normalizeUnixPath($resourceDirectory)]) !== null) {
|
||||
self::assertRemotePathIsConfined($resourceDirectory, $path, $server);
|
||||
} elseif (! $isDirectory) {
|
||||
self::assertRemotePathIsNotSymlink($path, $server);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \RuntimeException If the path is a symbolic link on the server
|
||||
*/
|
||||
public static function assertRemotePathIsNotSymlink(string $path, Server $server): void
|
||||
{
|
||||
$escapedPath = escapeshellarg($path);
|
||||
$result = instant_remote_process(["test -L {$escapedPath} && echo LINK || echo OK"], $server);
|
||||
|
||||
if (trim((string) $result) !== 'OK') {
|
||||
throw new \RuntimeException("{$path} is a symbolic link on the server. Coolify does not write a file through a symbolic link. Remove the link or use another path.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A path that cannot be resolved counts as outside.
|
||||
*/
|
||||
public function isOutsideResourceDirectory(): bool
|
||||
{
|
||||
$directories = $this->contentBaseDirectories();
|
||||
try {
|
||||
$path = normalizeUnixPath($this->resolvedFsPath($directories[0])->value());
|
||||
$directories = array_map(normalizeUnixPath(...), $directories);
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($path, '~') || self::resourceDirectoryContaining($path, $directories) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The resource directory is null when the host path is outside it.
|
||||
*
|
||||
* @return array{0: string, 1: string|null}
|
||||
*
|
||||
* @throws \Exception If the path is not allowed
|
||||
*/
|
||||
protected function hostPathAndResourceDirectory(): array
|
||||
{
|
||||
$directories = $this->contentBaseDirectories();
|
||||
$path = self::resolveHostPath($directories, $this->resolvedFsPath($directories[0])->value());
|
||||
|
||||
return [$path, self::resourceDirectoryContaining($path, array_map(normalizeUnixPath(...), $directories))];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $directories Normalized directories
|
||||
*/
|
||||
protected static function resourceDirectoryContaining(string $path, array $directories): ?string
|
||||
{
|
||||
foreach ($directories as $directory) {
|
||||
if ($path === $directory || str_starts_with($path, $directory.'/')) {
|
||||
return $directory;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host path of this mount. Like a relative bind source in Compose, a relative path is inside
|
||||
* the resource directory. Remote commands run in the home directory of the SSH user, so they
|
||||
|
||||
@@ -21,9 +21,7 @@ class ScheduledVolumeBackupExecution extends BaseModel
|
||||
'local_storage_deleted',
|
||||
's3_storage_deleted',
|
||||
's3_uploaded',
|
||||
'recovery_attempts',
|
||||
'recovery_last_attempt_at',
|
||||
'recovery_next_retry_at',
|
||||
'recovery_error',
|
||||
'recovery_needs_attention',
|
||||
];
|
||||
@@ -39,9 +37,7 @@ class ScheduledVolumeBackupExecution extends BaseModel
|
||||
'local_storage_deleted' => 'boolean',
|
||||
's3_storage_deleted' => 'boolean',
|
||||
's3_uploaded' => 'boolean',
|
||||
'recovery_attempts' => 'integer',
|
||||
'recovery_last_attempt_at' => 'datetime',
|
||||
'recovery_next_retry_at' => 'datetime',
|
||||
'recovery_needs_attention' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -236,6 +236,9 @@ class Server extends BaseModel
|
||||
$server->destinations()->each(function ($destination) {
|
||||
$destination->delete();
|
||||
});
|
||||
// Leftover active runner rows would block deleting the GitHub App.
|
||||
GithubRunnerExecution::deleteAndDeregister(GithubRunnerExecution::query()->where('server_id', $server->id));
|
||||
$server->githubRunnerConfig()->delete();
|
||||
$server->settings()->delete();
|
||||
$server->sslCertificates()->delete();
|
||||
$server->notificationThrottles()->delete();
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\VolumeBackup;
|
||||
|
||||
use App\Jobs\VolumeBackupRecoveryJob;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Notifications\CustomEmailNotification;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class RecoveryFailed extends CustomEmailNotification
|
||||
{
|
||||
public string $resourceName;
|
||||
|
||||
public string $target;
|
||||
|
||||
public string $reason;
|
||||
|
||||
public bool $containersStopped;
|
||||
|
||||
public ?string $url = null;
|
||||
|
||||
public function __construct(public ScheduledVolumeBackupExecution $execution)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$backup = $execution->scheduledVolumeBackup;
|
||||
$resource = $backup?->targetResource();
|
||||
$this->resourceName = $resource?->name ?? 'Unknown resource';
|
||||
$this->target = $backup ? $backup->targetType().' '.$backup->targetName() : 'Unknown storage';
|
||||
$this->reason = match ($execution->recovery_error) {
|
||||
VolumeBackupRecoveryJob::CATEGORY_S3_AUTH => 'S3 credentials were rejected',
|
||||
VolumeBackupRecoveryJob::CATEGORY_S3_BUCKET => 'S3 bucket is missing',
|
||||
VolumeBackupRecoveryJob::CATEGORY_REMOTE_COMMAND => 'A command on the server failed',
|
||||
default => 'Unknown error',
|
||||
};
|
||||
$this->containersStopped = (bool) $execution->stop_recovery_pending;
|
||||
|
||||
$linkable = $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase ? $resource->service : $resource;
|
||||
if ($linkable && method_exists($linkable, 'link')) {
|
||||
$this->url = $linkable->link();
|
||||
}
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return $notifiable->getEnabledChannels('backup_failure');
|
||||
}
|
||||
|
||||
public function summary(): string
|
||||
{
|
||||
$summary = "Recovery after the storage backup of {$this->target} ({$this->resourceName}) failed: {$this->reason}.";
|
||||
|
||||
return $this->containersStopped
|
||||
? $summary.' The containers stopped for the backup are still stopped, and new backups of this storage are skipped until you retry the recovery.'
|
||||
: $summary.' The partial S3 upload was not removed. Retry the recovery after fixing the S3 storage.';
|
||||
}
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$mail = new MailMessage;
|
||||
$mail->subject("Coolify: [ACTION REQUIRED] Storage backup recovery FAILED for {$this->resourceName}");
|
||||
$mail->view('emails.volume-backup-recovery-failed', [
|
||||
'summary' => $this->summary(),
|
||||
'url' => $this->url,
|
||||
]);
|
||||
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public function toDiscord(): DiscordMessage
|
||||
{
|
||||
$message = new DiscordMessage(
|
||||
title: ':cross_mark: Storage backup recovery failed',
|
||||
description: $this->summary(),
|
||||
color: DiscordMessage::errorColor(),
|
||||
isCritical: $this->containersStopped,
|
||||
);
|
||||
|
||||
if ($this->url) {
|
||||
$message->addField('Resource', '[Link]('.$this->url.')');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
public function toTelegram(): array
|
||||
{
|
||||
$data = ['message' => "Coolify: [ACTION REQUIRED] {$this->summary()}"];
|
||||
|
||||
if ($this->url) {
|
||||
$data['buttons'] = [['text' => 'Open resource in Coolify', 'url' => $this->url]];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function toPushover(): PushoverMessage
|
||||
{
|
||||
return new PushoverMessage(
|
||||
title: 'Storage backup recovery failed',
|
||||
level: 'error',
|
||||
message: "[ACTION REQUIRED] {$this->summary()}",
|
||||
buttons: $this->url ? [['text' => 'Open resource in Coolify', 'url' => $this->url]] : [],
|
||||
);
|
||||
}
|
||||
|
||||
public function toSlack(): SlackMessage
|
||||
{
|
||||
return new SlackMessage(
|
||||
title: 'Coolify: [ACTION REQUIRED] Storage backup recovery failed',
|
||||
description: $this->summary().($this->url ? "\n\n<{$this->url}|Open resource in Coolify>" : ''),
|
||||
color: SlackMessage::errorColor(),
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Storage backup recovery failed',
|
||||
'event' => 'volume_backup_recovery_failed',
|
||||
'resource_name' => $this->resourceName,
|
||||
'storage' => $this->target,
|
||||
'execution_uuid' => $this->execution->uuid,
|
||||
'reason' => $this->execution->recovery_error,
|
||||
'containers_stopped' => $this->containersStopped,
|
||||
'url' => $this->url,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -87,9 +87,10 @@ function validateDockerComposeForInjection(string $composeYaml, ?string $resourc
|
||||
if (is_array($serviceConfig) && isset($serviceConfig['networks']) && is_array($serviceConfig['networks'])) {
|
||||
foreach ($serviceConfig['networks'] as $networkKey => $networkDetails) {
|
||||
if (is_int($networkKey) && (is_string($networkDetails) || is_int($networkDetails))) {
|
||||
validateComposeNetworkName((string) $networkDetails, 'service network');
|
||||
validateComposeNetworkNameWithVariables((string) $networkDetails, 'service network', (string) $serviceName);
|
||||
} elseif (is_string($networkKey) || is_int($networkKey)) {
|
||||
validateComposeNetworkName((string) $networkKey, 'service network');
|
||||
// Compose does not interpolate keys, so a key must be a plain network name.
|
||||
validateComposeNetworkName((string) $networkKey, 'service network', (string) $serviceName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,40 +260,44 @@ function ensureComposeNetworkNameVariables(Application|Service $resource, iterab
|
||||
}
|
||||
|
||||
/**
|
||||
* A network `name:` may be such a variable: only Docker Compose reads this value and it never runs a
|
||||
* shell; Coolify's own network commands use the network keys. The default must still be a valid
|
||||
* network name, and nothing else is allowed around the variable.
|
||||
* A network `name:` may contain variables: only Docker Compose reads this value and it never runs a
|
||||
* shell; Coolify's own network commands use the network keys.
|
||||
*
|
||||
* @throws Exception If the value is not a valid network name or such a variable
|
||||
* @throws Exception If the value is not a valid network name with safe variables
|
||||
*/
|
||||
function validateComposeNetworkNameField(string $name): void
|
||||
{
|
||||
validateComposeNetworkNameWithVariables($name, 'network name field');
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows $VAR, ${VAR}, ${VAR:-default} and ${VAR-default} with safe defaults in a network value that Compose interpolates.
|
||||
*/
|
||||
function validateComposeNetworkNameWithVariables(string $name, string $context, ?string $serviceName = null): void
|
||||
{
|
||||
$variable = composeNetworkNameVariable($name);
|
||||
if ($variable !== null) {
|
||||
if ($variable['default'] !== null) {
|
||||
validateComposeNetworkName($variable['default'], 'network name field');
|
||||
}
|
||||
$isValid = $variable['default'] === null || ValidationPatterns::isValidDockerNetwork($variable['default']);
|
||||
} else {
|
||||
$hasSafeDefaults = true;
|
||||
$withoutVariables = preg_replace_callback(
|
||||
'/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$[A-Za-z_][A-Za-z0-9_]*/',
|
||||
function (array $matches) use (&$hasSafeDefaults): string {
|
||||
$default = $matches[2] ?? '';
|
||||
if ($default !== '' && preg_match('/\A[A-Za-z0-9_.-]+\z/', $default) !== 1) {
|
||||
$hasSafeDefaults = false;
|
||||
}
|
||||
|
||||
return;
|
||||
return 'x';
|
||||
},
|
||||
$name,
|
||||
);
|
||||
$isValid = $hasSafeDefaults && $withoutVariables !== null && ValidationPatterns::isValidDockerNetwork($withoutVariables);
|
||||
}
|
||||
|
||||
// Compose also resolves variables inside a longer name, for example ${COMPOSE_PROJECT_NAME}_default.
|
||||
// Each variable must be $VAR, ${VAR}, ${VAR:-default} or ${VAR-default} with a safe default; the name
|
||||
// is then checked with each variable replaced, so no shell syntax can remain.
|
||||
$withoutVariables = preg_replace_callback(
|
||||
'/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$[A-Za-z_][A-Za-z0-9_]*/',
|
||||
function (array $matches): string {
|
||||
$default = $matches[2] ?? '';
|
||||
if ($default !== '' && preg_match('/\A[A-Za-z0-9_.-]+\z/', $default) !== 1) {
|
||||
throw new Exception('Invalid Docker Compose network name field. Variable defaults may contain only alphanumeric characters, dots, hyphens, and underscores.');
|
||||
}
|
||||
|
||||
return 'x';
|
||||
},
|
||||
$name,
|
||||
);
|
||||
|
||||
validateComposeNetworkName($withoutVariables ?? $name, 'network name field');
|
||||
if (! $isValid) {
|
||||
throw invalidComposeNetworkNameException($name, $context, $serviceName, allowsVariables: true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,16 +305,23 @@ function validateComposeNetworkNameField(string $name): void
|
||||
*
|
||||
* @throws Exception If the network name is not a valid Docker network identifier
|
||||
*/
|
||||
function validateComposeNetworkName(string $networkName, string $context = 'network name'): void
|
||||
function validateComposeNetworkName(string $networkName, string $context = 'network name', ?string $serviceName = null): void
|
||||
{
|
||||
if ($networkName === '' || ! ValidationPatterns::isValidDockerNetwork($networkName)) {
|
||||
throw new Exception(
|
||||
'Invalid Docker Compose '.$context.
|
||||
'. Network names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.'
|
||||
);
|
||||
throw invalidComposeNetworkNameException($networkName, $context, $serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
function invalidComposeNetworkNameException(string $name, string $context, ?string $serviceName, bool $allowsVariables = false): Exception
|
||||
{
|
||||
$location = $serviceName === null ? '' : " in service {$serviceName}";
|
||||
|
||||
return new Exception(
|
||||
"Invalid Docker Compose {$context} \"{$name}\"{$location}. Network names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores"
|
||||
.($allowsVariables ? ', and can use variables such as ${NETWORK:-default}.' : '.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if a top-level Compose volume declaration is external: `external: true` or the old
|
||||
* `external: {name: x}` syntax. Docker Compose does not create or remove an external volume.
|
||||
|
||||
+1
-5
@@ -12,14 +12,12 @@ return new class extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('scheduled_volume_backup_executions', function (Blueprint $table) {
|
||||
$table->unsignedSmallInteger('recovery_attempts')->default(0);
|
||||
$table->timestampTz('recovery_last_attempt_at')->nullable();
|
||||
$table->timestampTz('recovery_next_retry_at')->nullable();
|
||||
$table->string('recovery_error', 32)->nullable();
|
||||
$table->boolean('recovery_needs_attention')->default(false);
|
||||
|
||||
$table->index(
|
||||
['stop_recovery_pending', 's3_cleanup_pending', 'recovery_needs_attention', 'recovery_next_retry_at'],
|
||||
['stop_recovery_pending', 's3_cleanup_pending', 'recovery_needs_attention'],
|
||||
'scheduled_volume_executions_recovery_index',
|
||||
);
|
||||
});
|
||||
@@ -33,9 +31,7 @@ return new class extends Migration
|
||||
Schema::table('scheduled_volume_backup_executions', function (Blueprint $table) {
|
||||
$table->dropIndex('scheduled_volume_executions_recovery_index');
|
||||
$table->dropColumn([
|
||||
'recovery_attempts',
|
||||
'recovery_last_attempt_at',
|
||||
'recovery_next_retry_at',
|
||||
'recovery_error',
|
||||
'recovery_needs_attention',
|
||||
]);
|
||||
|
||||
+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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<x-emails.layout>
|
||||
{{ $summary }}
|
||||
|
||||
@if ($url)
|
||||
Click [here]({{ $url }}) to open the resource.
|
||||
@endif
|
||||
</x-emails.layout>
|
||||
@@ -1,66 +0,0 @@
|
||||
@php
|
||||
$analyticsRoute = route('project.application.analytics', [
|
||||
'project_uuid' => $application->environment->project->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
@if (! $enabled)
|
||||
@if ($eligible && $serverUuid)
|
||||
<section class="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-[13px] font-semibold text-black dark:text-fg">Traffic analytics</h3>
|
||||
<p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
Enable Sentinel traffic analytics on this server to see requests, visitors, and
|
||||
geography for this application. Restarts the proxy + Sentinel.
|
||||
</p>
|
||||
</div>
|
||||
<a class="button shrink-0" href="{{ route('server.analytics', ['server_uuid' => $serverUuid]) }}" {{ wireNavigate() }}>
|
||||
Server settings
|
||||
<x-external-link />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@else
|
||||
<section class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-neutral-200 px-4 py-2.5 dark:border-white/[0.08]">
|
||||
<div>
|
||||
<h3 class="text-[12px]! leading-4! font-semibold! text-black dark:text-fg">Traffic (last 24h)</h3>
|
||||
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">Request activity for this application</p>
|
||||
</div>
|
||||
<a class="text-[12px] font-medium text-coollabs hover:underline dark:text-fg" href="{{ $analyticsRoute }}" {{ wireNavigate() }}>
|
||||
View full analytics →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (! $this->hasData())
|
||||
<p class="px-4 py-4 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
No traffic recorded in the last 24h yet.
|
||||
</p>
|
||||
@else
|
||||
<div class="grid grid-cols-2 gap-px bg-neutral-200 sm:grid-cols-4 dark:bg-white/[0.07]">
|
||||
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Requests</span>
|
||||
<span class="text-lg font-semibold text-black dark:text-fg">{{ number_format($overview['requests'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Unique visitors</span>
|
||||
<span class="text-lg font-semibold text-black dark:text-fg">{{ number_format($overview['uniqueVisitors'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Error rate</span>
|
||||
<span class="text-lg font-semibold text-black dark:text-fg">{{ $this->errorRate() }}%</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">p95 latency</span>
|
||||
<span class="text-lg font-semibold text-black dark:text-fg">{{ number_format($overview['latencyP95'] ?? 0, 1) }} ms</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
@@ -50,8 +50,8 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($fileStorage->is_host_file)
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm Host File Mount Removal?"
|
||||
buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$hostFileDeletionCheckboxes"
|
||||
:actions="['Only the mount configuration will be removed. The host file will not be deleted.']"
|
||||
buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$deletionCheckboxes"
|
||||
:actions="$deletionActions"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" />
|
||||
@@ -72,9 +72,7 @@
|
||||
</x-modal-input>
|
||||
@endif
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
|
||||
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
|
||||
'The selected directory and all its contents will be permanently deleted from the container.',
|
||||
]"
|
||||
isErrorButton submitAction="delete" :checkboxes="$deletionCheckboxes" :actions="$deletionActions"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" />
|
||||
@@ -92,7 +90,7 @@
|
||||
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
|
||||
server</x-forms.button>
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete"
|
||||
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']"
|
||||
isErrorButton submitAction="delete" :checkboxes="$deletionCheckboxes" :actions="$deletionActions"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" />
|
||||
|
||||
@@ -182,7 +182,12 @@
|
||||
x-data="{
|
||||
hostPath: @js($this->fileStorageHostPath()),
|
||||
filePath: @entangle('file_storage_path'),
|
||||
sourcePath: @entangle('file_storage_source'),
|
||||
previewPath() {
|
||||
const source = (this.sourcePath || '').trim();
|
||||
if (source !== '') {
|
||||
return source.startsWith('/') ? source : `${this.hostPath}/${source.replace(/^\.\//, '')}`;
|
||||
}
|
||||
const path = (this.filePath || '').trim();
|
||||
|
||||
return this.hostPath + (path === '' ? '/' : (path.startsWith('/') ? path : `/${path}`));
|
||||
@@ -202,6 +207,11 @@
|
||||
label="Destination Path" required
|
||||
x-on:input="filePath = $event.target.value"
|
||||
helper="File location inside the container" />
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/srv/config/app.conf" id="file_storage_source"
|
||||
label="Source Path"
|
||||
x-on:input="sourcePath = $event.target.value"
|
||||
helper="Optional. File location on the host: an absolute path anywhere on the host, or a path relative to the resource directory. Empty: Coolify keeps the file in the resource directory. Coolify never deletes a file outside the resource directory." />
|
||||
<x-forms.textarea canGate="update" :canResource="$resource" label="Content"
|
||||
id="file_storage_content"></x-forms.textarea>
|
||||
<div class="flex justify-end pt-2">
|
||||
@@ -326,7 +336,7 @@
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="{{ application_configuration_dir() }}/{{ $resource->uuid }}/etc/nginx"
|
||||
id="file_storage_directory_source" label="Source Directory"
|
||||
required helper="Directory on the host system." />
|
||||
required helper="Directory on the host: an absolute path anywhere on the host, or a path relative to the resource directory. Coolify creates it when it is missing, and never deletes a directory outside the resource directory." />
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/etc/nginx" id="file_storage_directory_destination"
|
||||
label="Destination Directory" required
|
||||
|
||||
@@ -27,10 +27,11 @@
|
||||
if (!el) { return; }
|
||||
|
||||
const palette = ['#3b82f6', '#f59e0b', '#ec4899', '#8b5cf6', '#10b981', '#14b8a6', '#6b7280'];
|
||||
const legend = () => ({ position: 'bottom', labels: { colors: textColor } });
|
||||
const escapeHtml = value => String(value).replace(/[&<>'"]/g, character => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"',
|
||||
})[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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,3 +356,24 @@ test('the Compose deployment validates the repository file before any command us
|
||||
->and($loadPosition)->toBeLessThan(strpos($body, 'base64 -d'))
|
||||
->and($body)->toContain('"stat -c \'%F\' ".escapeshellarg($realPathInGit)');
|
||||
});
|
||||
|
||||
test('loading a Compose file with a variable service network saves it', function () {
|
||||
$compose = "services:\n web:\n image: nginx\n networks:\n - \${NET:-proxy}\nnetworks:\n proxy:\n external: true\n";
|
||||
fakeRepositoryCompose($compose);
|
||||
|
||||
$this->application->loadComposeFile();
|
||||
|
||||
expect($this->application->fresh()->docker_compose_raw)->toBe(trim($compose));
|
||||
});
|
||||
|
||||
test('the deployment log names the service and network that failed validation', function () {
|
||||
fakeRepositoryCompose("services:\n web:\n image: nginx\n networks:\n - '\$(id)'\n");
|
||||
$logEntries = [];
|
||||
$job = composeDeploymentJob($this->application, 0, $logEntries);
|
||||
|
||||
expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'loadComposeFileForDeployment'))->invoke($job))
|
||||
->toThrow(DeploymentException::class);
|
||||
|
||||
expect(collect($logEntries)->pluck(0)->implode("\n"))
|
||||
->toContain('Invalid Docker Compose service network "$(id)" in service web.');
|
||||
});
|
||||
|
||||
@@ -181,6 +181,18 @@ it('keeps deployment logging available if value formatting fails', function () {
|
||||
->toBe(REDACTED);
|
||||
});
|
||||
|
||||
it('redacts shell-escaped remote secrets from command output', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'remote_secrets_cache' => ['DB_PASS' => "s3cr'et-value"],
|
||||
]);
|
||||
|
||||
$redacted = invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', "docker build --build-arg DB_PASS='s3cr'\\''et-value' --env 'DB_PASS=s3cr'\\''et-value' .");
|
||||
|
||||
expect($redacted)->not->toContain('s3cr')
|
||||
->toContain('--build-arg DB_PASS='.REDACTED);
|
||||
});
|
||||
|
||||
it('ignores empty and non-string remote secrets when redacting command output', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutDefer();
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
RateLimiter::clear('login');
|
||||
Once::flush();
|
||||
Log::spy();
|
||||
});
|
||||
|
||||
test('a failed login with a non-email identifier does not store the typed text', function () {
|
||||
$this->post('/login', [
|
||||
'email' => 'MySecretPassw0rd!',
|
||||
'password' => 'wrong-password',
|
||||
])->assertRedirect();
|
||||
|
||||
$failed = AuditEvent::query()->where('event', 'auth.user.login_failed')->sole();
|
||||
|
||||
expect(json_encode($failed->metadata))->not->toContain('MySecretPassw0rd!')
|
||||
->and($failed->metadata['attempted_email'])->toBe('[invalid]');
|
||||
});
|
||||
|
||||
test('a failed login with a valid email address records the attempted email', function () {
|
||||
$this->post('/login', [
|
||||
'email' => 'person@example.com',
|
||||
'password' => 'wrong-password',
|
||||
])->assertRedirect();
|
||||
|
||||
expect(AuditEvent::query()->where('event', 'auth.user.login_failed')->sole()->metadata['attempted_email'])
|
||||
->toBe('person@example.com');
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -235,6 +270,154 @@ describe('workflow_job webhook', function () {
|
||||
->and($execution->started_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('starts a new runner for the job whose runner another job took', function () {
|
||||
$stolen = runnerTestExecution($this->githubApp, [
|
||||
'trigger_workflow_job_id' => 5001,
|
||||
'workflow_job_id' => 5001,
|
||||
'job_name' => 'build',
|
||||
'status' => GithubRunnerStatus::Idle,
|
||||
'server_id' => $this->server->id,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
'provision_attempts' => 1,
|
||||
]);
|
||||
|
||||
foreach ([1, 2] as $delivery) {
|
||||
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
|
||||
'id' => 6001,
|
||||
'name' => 'pr-test',
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
])->assertOk();
|
||||
}
|
||||
|
||||
$stolen->refresh();
|
||||
$replacement = GithubRunnerExecution::query()->where('trigger_workflow_job_id', 5001)->sole();
|
||||
expect($stolen->status)->toBe(GithubRunnerStatus::Running)
|
||||
->and($stolen->trigger_workflow_job_id)->toBe(6001)
|
||||
->and($stolen->workflow_job_id)->toBe(6001)
|
||||
->and($replacement->id)->not->toBe($stolen->id)
|
||||
->and($replacement->status)->toBe(GithubRunnerStatus::Queued)
|
||||
->and($replacement->job_name)->toBe('build')
|
||||
->and($replacement->labels)->toBe(['self-hosted', 'coolify'])
|
||||
->and($replacement->provision_attempts)->toBe(1);
|
||||
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
|
||||
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $replacement->id);
|
||||
|
||||
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
|
||||
'id' => 6001,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
'conclusion' => 'success',
|
||||
])->assertOk();
|
||||
|
||||
expect($stolen->fresh()->status)->toBe(GithubRunnerStatus::Completed)
|
||||
->and($replacement->fresh()->status)->toBe(GithubRunnerStatus::Queued);
|
||||
});
|
||||
|
||||
it('starts a new runner when the completed hook of the other job arrives before its in_progress hook', function () {
|
||||
$stolen = runnerTestExecution($this->githubApp, [
|
||||
'trigger_workflow_job_id' => 5001,
|
||||
'status' => GithubRunnerStatus::Idle,
|
||||
'server_id' => $this->server->id,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
'provision_attempts' => 1,
|
||||
]);
|
||||
|
||||
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
|
||||
'id' => 6001,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
'conclusion' => 'success',
|
||||
])->assertOk();
|
||||
|
||||
expect($stolen->fresh()->status)->toBe(GithubRunnerStatus::Completed)
|
||||
->and($stolen->fresh()->trigger_workflow_job_id)->toBe(6001)
|
||||
->and(GithubRunnerExecution::query()->where('trigger_workflow_job_id', 5001)->sole()->status)->toBe(GithubRunnerStatus::Queued);
|
||||
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
|
||||
});
|
||||
|
||||
it('replaces a stale execution of the job that took the runner', function () {
|
||||
$stale = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 6001, 'status' => GithubRunnerStatus::TimedOut]);
|
||||
$stolen = runnerTestExecution($this->githubApp, [
|
||||
'trigger_workflow_job_id' => 5001,
|
||||
'status' => GithubRunnerStatus::Idle,
|
||||
'server_id' => $this->server->id,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
'provision_attempts' => 1,
|
||||
]);
|
||||
|
||||
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
|
||||
'id' => 6001,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
])->assertOk();
|
||||
|
||||
expect($stale->fresh())->toBeNull()
|
||||
->and($stolen->fresh()->trigger_workflow_job_id)->toBe(6001)
|
||||
->and(GithubRunnerExecution::query()->where('trigger_workflow_job_id', 5001)->sole()->status)->toBe(GithubRunnerStatus::Queued);
|
||||
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
|
||||
});
|
||||
|
||||
it('does not start a new runner when the runner took another job with its own pending runner', function () {
|
||||
$first = runnerTestExecution($this->githubApp, [
|
||||
'trigger_workflow_job_id' => 5001,
|
||||
'status' => GithubRunnerStatus::Idle,
|
||||
'server_id' => $this->server->id,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
]);
|
||||
$second = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 6001]);
|
||||
|
||||
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
|
||||
'id' => 6001,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
])->assertOk();
|
||||
|
||||
expect(GithubRunnerExecution::count())->toBe(2)
|
||||
->and($first->fresh()->status)->toBe(GithubRunnerStatus::Running)
|
||||
->and($first->fresh()->workflow_job_id)->toBe(6001)
|
||||
->and($second->fresh()->status)->toBe(GithubRunnerStatus::Queued);
|
||||
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
|
||||
});
|
||||
|
||||
it('does not start a new runner for a job whose runner was taken', function (Closure $setup, array $attributes) {
|
||||
$setup();
|
||||
$stolen = runnerTestExecution($this->githubApp, [
|
||||
'trigger_workflow_job_id' => 5001,
|
||||
'status' => GithubRunnerStatus::Idle,
|
||||
'server_id' => $this->server->id,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
'provision_attempts' => 1,
|
||||
...$attributes,
|
||||
]);
|
||||
|
||||
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
|
||||
'id' => 6001,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
])->assertOk();
|
||||
|
||||
expect(GithubRunnerExecution::count())->toBe(1)
|
||||
->and($stolen->fresh()->status)->toBe(GithubRunnerStatus::Running);
|
||||
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
|
||||
})->with([
|
||||
'runner limit of the job reached' => [fn () => null, ['provision_attempts' => ProvisionGithubRunnerJob::MAX_ATTEMPTS]],
|
||||
'configuration disabled' => [fn () => GithubRunnerConfig::query()->update(['is_enabled' => false]), []],
|
||||
'pull request job no longer allowed' => [fn () => null, ['is_pull_request' => true]],
|
||||
]);
|
||||
|
||||
it('marks the runner running without a new runner when it took the job it was started for', function () {
|
||||
$execution = runnerTestExecution($this->githubApp, [
|
||||
'trigger_workflow_job_id' => 5001,
|
||||
'status' => GithubRunnerStatus::Idle,
|
||||
'server_id' => $this->server->id,
|
||||
'runner_name' => 'coolify-runner-a',
|
||||
]);
|
||||
|
||||
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', ['runner_name' => 'coolify-runner-a'])->assertOk();
|
||||
|
||||
$execution->refresh();
|
||||
expect(GithubRunnerExecution::count())->toBe(1)
|
||||
->and($execution->status)->toBe(GithubRunnerStatus::Running)
|
||||
->and($execution->trigger_workflow_job_id)->toBe(5001)
|
||||
->and($execution->workflow_job_id)->toBe(5001);
|
||||
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
|
||||
});
|
||||
|
||||
it('completes the runner and queues its cleanup', function () {
|
||||
$execution = runnerTestExecution($this->githubApp, [
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
@@ -366,6 +549,58 @@ describe('provisioning', function () {
|
||||
expect($execution->fresh()->server_id)->toBe($server->id);
|
||||
});
|
||||
|
||||
it('places a pull request job only on a server whose configuration allows pull request jobs', function () {
|
||||
Queue::fake();
|
||||
fakeRunnerGithubApi($this->githubApp);
|
||||
Process::fake(['*' => Process::result(output: '29.8.0')]);
|
||||
$refusingServer = runnerTestServer($this->team);
|
||||
runnerTestConfig($refusingServer, $this->githubApp, ['allow_pull_requests' => false]);
|
||||
$allowingServer = runnerTestServer($this->team);
|
||||
$allowingConfig = runnerTestConfig($allowingServer, $this->githubApp, ['allow_pull_requests' => true]);
|
||||
runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $allowingServer->id, 'github_runner_config_id' => $allowingConfig->id]);
|
||||
$execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]);
|
||||
|
||||
(new ProvisionGithubRunnerJob($execution->id))->handle();
|
||||
|
||||
expect($execution->fresh()->server_id)->toBe($allowingServer->id);
|
||||
});
|
||||
|
||||
it('keeps a pull request job queued when no configuration with capacity allows pull request jobs', function () {
|
||||
Queue::fake();
|
||||
Process::fake();
|
||||
runnerTestConfig(runnerTestServer($this->team), $this->githubApp, ['allow_pull_requests' => false]);
|
||||
$execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]);
|
||||
|
||||
(new ProvisionGithubRunnerJob($execution->id))->handle();
|
||||
|
||||
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('stops provisioning when the server is deleted while GitHub registers the runner', function () {
|
||||
Queue::fake();
|
||||
Process::fake(['*' => Process::result(output: '29.8.0')]);
|
||||
$server = runnerTestServer($this->team);
|
||||
runnerTestConfig($server, $this->githubApp);
|
||||
$execution = runnerTestExecution($this->githubApp);
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
|
||||
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($server) {
|
||||
DeleteServer::run($server->id);
|
||||
|
||||
return Http::response(['runner' => ['id' => 42], 'encoded_jit_config' => 'SECRET-JIT-CONFIG'], 201);
|
||||
},
|
||||
]);
|
||||
|
||||
(new ProvisionGithubRunnerJob($execution->id))->handle();
|
||||
|
||||
expect(GithubRunnerExecution::find($execution->id))->toBeNull();
|
||||
Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, '/home/runner/run.sh'));
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42);
|
||||
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
|
||||
});
|
||||
|
||||
it('does not start runners on the Coolify host', function () {
|
||||
Queue::fake();
|
||||
Process::fake();
|
||||
@@ -624,6 +859,72 @@ describe('cleanup and reconciliation', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('server deletion', function () {
|
||||
beforeEach(function () {
|
||||
$this->server = runnerTestServer($this->team);
|
||||
$this->config = runnerTestConfig($this->server, $this->githubApp);
|
||||
});
|
||||
|
||||
it('deletes the runner configuration and executions of the server and lets the GitHub App be deleted', function () {
|
||||
Queue::fake();
|
||||
$running = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'runner_id' => 42]);
|
||||
$provisioning = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id]);
|
||||
$completed = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $this->server->id, 'runner_id' => 41]);
|
||||
$otherServer = runnerTestServer($this->team);
|
||||
$otherConfig = runnerTestConfig($otherServer, $this->githubApp, ['is_enabled' => false]);
|
||||
$other = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $otherServer->id]);
|
||||
$queued = runnerTestExecution($this->githubApp);
|
||||
|
||||
$this->server->delete();
|
||||
DeleteServer::run($this->server->id);
|
||||
|
||||
expect(Server::withTrashed()->find($this->server->id))->toBeNull()
|
||||
->and(GithubRunnerConfig::find($this->config->id))->toBeNull()
|
||||
->and(GithubRunnerExecution::whereKey([$running->id, $provisioning->id, $completed->id])->exists())->toBeFalse()
|
||||
->and($other->fresh())->not->toBeNull()
|
||||
->and($otherConfig->fresh())->not->toBeNull()
|
||||
->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued);
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, 1);
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42);
|
||||
|
||||
$this->githubApp->delete();
|
||||
expect(GithubApp::find($this->githubApp->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('removes the runner from GitHub and ignores GitHub errors', function (int $status) {
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
|
||||
'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, $status),
|
||||
]);
|
||||
|
||||
(new DeregisterGithubRunnerJob($this->githubApp->id, 42))->handle();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/orgs/acme/actions/runners/42'));
|
||||
})->with([204, 500]);
|
||||
|
||||
it('removes runners of servers deleted by older versions during reconciliation', function () {
|
||||
Queue::fake();
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
$orphan = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'runner_id' => 42]);
|
||||
$orphanWithoutRunner = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning]);
|
||||
$queued = runnerTestExecution($this->githubApp);
|
||||
$finished = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::TimedOut]);
|
||||
|
||||
(new ReconcileGithubRunnersJob)->handle();
|
||||
|
||||
expect(GithubRunnerExecution::whereKey([$orphan->id, $orphanWithoutRunner->id])->exists())->toBeFalse()
|
||||
->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued)
|
||||
->and($finished->fresh()->status)->toBe(GithubRunnerStatus::TimedOut);
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, 1);
|
||||
Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->runnerId === 42);
|
||||
|
||||
$this->config->update(['is_enabled' => false]);
|
||||
$this->githubApp->delete();
|
||||
expect(GithubApp::find($this->githubApp->id))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeRejectedJitConfig(): void
|
||||
{
|
||||
Http::fake([
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\IntegrationToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Services\DatabaseStartCommandExecutor;
|
||||
@@ -51,7 +52,7 @@ beforeEach(function () {
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{command: string, healthcheck: list<string>}
|
||||
* @return array{command: string, healthcheck: list<string>, environment: list<string>}
|
||||
*/
|
||||
function legacyPasswordQuotingStartedService(object $executor, string $uuid): array
|
||||
{
|
||||
@@ -59,7 +60,7 @@ function legacyPasswordQuotingStartedService(object $executor, string $uuid): ar
|
||||
if (preg_match("#^echo '([^']+)' \| base64 -d \| tee \S+/docker-compose\.yml#", $command, $matches)) {
|
||||
$service = Yaml::parse(base64_decode($matches[1]))['services'][$uuid];
|
||||
|
||||
return ['command' => $service['command'], 'healthcheck' => $service['healthcheck']['test']];
|
||||
return ['command' => $service['command'], 'healthcheck' => $service['healthcheck']['test'], 'environment' => $service['environment']];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,3 +156,111 @@ it('marks only databases that exist before the migration as legacy', function ()
|
||||
->and($newKeydb->fresh()->legacy_password_quoting)->toBeFalse()
|
||||
->and($newDragonfly->fresh()->legacy_password_quoting)->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps the exact v4.3.23 start command when Docker Compose split or cut the unquoted legacy password', function (string $create, string $start, string $passwordColumn, string $commandFormat, string $password) {
|
||||
$database = $create($this->environment->id, $this->destination, [$passwordColumn => $password]);
|
||||
DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]);
|
||||
|
||||
$start::run($database->fresh(), new Activity);
|
||||
|
||||
expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command'])
|
||||
->toBe(sprintf($commandFormat, $password));
|
||||
})->with('legacy-password-quoting-engines')->with([
|
||||
'semicolon' => 'ab;cd',
|
||||
'space' => 'a b',
|
||||
'tab' => "a\tb",
|
||||
'pipe' => 'x|y',
|
||||
'ampersand' => 'p&q',
|
||||
'redirects' => 'a<b>',
|
||||
]);
|
||||
|
||||
it('quotes a plain legacy password, which gives Docker Compose the same argument as v4.3.23', function (string $create, string $start, string $passwordColumn, string $commandFormat) {
|
||||
$database = $create($this->environment->id, $this->destination, [$passwordColumn => 'abc']);
|
||||
DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]);
|
||||
|
||||
$start::run($database->fresh(), new Activity);
|
||||
|
||||
expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command'])
|
||||
->toBe(sprintf($commandFormat, "'abc'"));
|
||||
})->with('legacy-password-quoting-engines');
|
||||
|
||||
it('quotes shell control characters in the password of a database created after the upgrade', function (string $create, string $start, string $passwordColumn, string $commandFormat, string $password) {
|
||||
$database = $create($this->environment->id, $this->destination, [$passwordColumn => $password]);
|
||||
|
||||
$start::run($database->fresh(), new Activity);
|
||||
|
||||
expect(legacyPasswordQuotingStartedService($this->executor, $database->uuid)['command'])
|
||||
->toBe(sprintf($commandFormat, escapeshellarg($password)));
|
||||
})->with('legacy-password-quoting-engines')->with([
|
||||
'semicolon' => 'ab;cd',
|
||||
'space' => 'a b',
|
||||
'pipe' => 'x|y',
|
||||
]);
|
||||
|
||||
it('runs with the stored password and syncs a plain REDIS_PASSWORD variable to it', function (string $create, string $start, string $passwordColumn, string $commandFormat) {
|
||||
$database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']);
|
||||
$env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'envpass']);
|
||||
|
||||
$start::run($database->fresh(), new Activity);
|
||||
|
||||
$service = legacyPasswordQuotingStartedService($this->executor, $database->uuid);
|
||||
expect($service['command'])->toBe(sprintf($commandFormat, "'storedpass'"))
|
||||
->and($service['healthcheck'][3])->toBe('storedpass')
|
||||
->and($service['environment'])->toContain('REDIS_PASSWORD=storedpass')
|
||||
->and($env->fresh()->value)->toBe('storedpass')
|
||||
->and($database->fresh()->{$passwordColumn})->toBe('storedpass');
|
||||
})->with('legacy-password-quoting-engines');
|
||||
|
||||
it('keeps the exact v4.3.23 command of a legacy database with a different plain REDIS_PASSWORD variable', function (string $create, string $start, string $passwordColumn, string $commandFormat) {
|
||||
$database = $create($this->environment->id, $this->destination, [$passwordColumn => 'stored;pass']);
|
||||
DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]);
|
||||
$database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'envpass']);
|
||||
|
||||
$start::run($database->fresh(), new Activity);
|
||||
|
||||
$service = legacyPasswordQuotingStartedService($this->executor, $database->uuid);
|
||||
expect($service['command'])->toBe(sprintf($commandFormat, 'stored;pass'))
|
||||
->and($service['healthcheck'][3])->toBe('stored;pass')
|
||||
->and($database->fresh()->legacy_password_quoting)->toBeTrue();
|
||||
})->with('legacy-password-quoting-engines');
|
||||
|
||||
it('runs with a remote secret REDIS_PASSWORD without storing it', function (string $create, string $start, string $passwordColumn, string $commandFormat) {
|
||||
Http::fake(['https://api.doppler.com/*' => Http::response(['REDIS_PASSWORD' => 'secretpass'])]);
|
||||
$token = IntegrationToken::query()->create([
|
||||
'team_id' => $this->environment->project->team_id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Doppler',
|
||||
'token' => 'the-secret-token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
$database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']);
|
||||
$database->secretManagerLink()->create(['integration_token_id' => $token->id]);
|
||||
$env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '{{vault.REDIS_PASSWORD}}']);
|
||||
|
||||
$start::run($database->fresh(), new Activity);
|
||||
|
||||
$service = legacyPasswordQuotingStartedService($this->executor, $database->uuid);
|
||||
expect($service['command'])->toBe(sprintf($commandFormat, "'secretpass'"))
|
||||
->and($service['healthcheck'][3])->toBe('secretpass')
|
||||
->and($env->fresh()->value)->toBe('{{vault.REDIS_PASSWORD}}')
|
||||
->and($database->fresh()->{$passwordColumn})->toBe('storedpass');
|
||||
})->with('legacy-password-quoting-engines');
|
||||
|
||||
it('copies a shared REDIS_PASSWORD variable into the stored password', function (string $create, string $start, string $passwordColumn, string $commandFormat) {
|
||||
SharedEnvironmentVariable::query()->create([
|
||||
'key' => 'REDIS_PASSWORD',
|
||||
'value' => 'sharedpass',
|
||||
'type' => 'team',
|
||||
'team_id' => $this->environment->project->team_id,
|
||||
]);
|
||||
$database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']);
|
||||
$env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '{{team.REDIS_PASSWORD}}']);
|
||||
|
||||
$start::run($database->fresh(), new Activity);
|
||||
|
||||
$service = legacyPasswordQuotingStartedService($this->executor, $database->uuid);
|
||||
expect($service['command'])->toBe(sprintf($commandFormat, "'sharedpass'"))
|
||||
->and($service['healthcheck'][3])->toBe('sharedpass')
|
||||
->and($env->fresh()->value)->toBe('{{team.REDIS_PASSWORD}}')
|
||||
->and($database->fresh()->{$passwordColumn})->toBe('sharedpass');
|
||||
})->with('legacy-password-quoting-engines');
|
||||
|
||||
@@ -182,3 +182,69 @@ it('saves a unique custom container name', function () {
|
||||
|
||||
expect($application->settings()->first()->custom_internal_name)->toBe('unique-name');
|
||||
});
|
||||
|
||||
it('rejects an invalid container name prefix on every save path', function (string $method, string $prefix) {
|
||||
$application = createApplicationForContainerNamingTest();
|
||||
$application->settings->update(['custom_container_name_prefix' => 'original-prefix']);
|
||||
$application = $application->fresh(['environment.project', 'settings', 'destination']);
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('customContainerNamePrefix', $prefix)
|
||||
->call($method)
|
||||
->assertDispatched('error')
|
||||
->assertNotDispatched('success');
|
||||
|
||||
expect($application->settings()->first()->custom_container_name_prefix)->toBe('original-prefix');
|
||||
})->with([
|
||||
'submit' => ['submit'],
|
||||
'instant save' => ['instantSave'],
|
||||
])->with([
|
||||
'shell characters' => ['Bad Name;id'],
|
||||
'uppercase' => ['MyApp'],
|
||||
'leading hyphen' => ['-api'],
|
||||
]);
|
||||
|
||||
it('rejects a container name prefix already used on the server on every save path', function (string $method) {
|
||||
$application = createApplicationForContainerNamingTest();
|
||||
Application::factory()->create([
|
||||
'environment_id' => $application->environment_id,
|
||||
'destination_id' => $application->destination_id,
|
||||
'destination_type' => $application->destination_type,
|
||||
])->settings->update(['custom_container_name_prefix' => 'shared-prefix']);
|
||||
$application = $application->fresh(['environment.project', 'settings', 'destination']);
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('customContainerNamePrefix', 'shared-prefix')
|
||||
->call($method)
|
||||
->assertDispatched('error', 'This container name prefix is already in use by another application on this Coolify instance.')
|
||||
->assertNotDispatched('success');
|
||||
|
||||
expect($application->settings()->first()->custom_container_name_prefix)->toBeNull();
|
||||
})->with(['submit', 'instantSave']);
|
||||
|
||||
it('saves a valid container name prefix on every save path', function (string $method) {
|
||||
$application = createApplicationForContainerNamingTest();
|
||||
$application = $application->fresh(['environment.project', 'settings', 'destination']);
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('customContainerNamePrefix', 'my-api-2')
|
||||
->call($method)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($application->settings()->first()->custom_container_name_prefix)->toBe('my-api-2');
|
||||
})->with(['submit', 'instantSave']);
|
||||
|
||||
it('keeps saving other settings when a stored prefix predates the format rule', function () {
|
||||
$application = createApplicationForContainerNamingTest();
|
||||
$application->settings->update(['custom_container_name_prefix' => 'Legacy_Prefix']);
|
||||
$application = $application->fresh(['environment.project', 'settings', 'destination']);
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('isGitLfsEnabled', true)
|
||||
->call('instantSave')
|
||||
->assertDispatched('success');
|
||||
|
||||
$settings = $application->settings()->first();
|
||||
expect($settings->is_git_lfs_enabled)->toBeTrue()
|
||||
->and($settings->custom_container_name_prefix)->toBe('Legacy_Prefix');
|
||||
});
|
||||
|
||||
@@ -112,6 +112,13 @@ describe('registry image guard', function () {
|
||||
callMultiServerDeploymentJob($job, 'ensureRegistryImageForMultipleServers');
|
||||
})->throwsNoExceptions();
|
||||
|
||||
test('allows docker compose applications, which build on each server', function () {
|
||||
$this->application->update(['build_pack' => 'dockercompose']);
|
||||
$job = makeMultiServerDeploymentJob(['application' => $this->application->fresh()]);
|
||||
|
||||
callMultiServerDeploymentJob($job, 'ensureRegistryImageForMultipleServers');
|
||||
})->throwsNoExceptions();
|
||||
|
||||
test('allows preview deployments, which run only on the main server', function () {
|
||||
$job = makeMultiServerDeploymentJob(['application' => $this->application, 'pull_request_id' => 7]);
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Storage::fake('ssh-keys');
|
||||
Storage::fake('ssh-mux');
|
||||
Process::fake(['*' => Process::result(output: 'NOK')]);
|
||||
});
|
||||
|
||||
test('the 9router template generates secrets that docker compose reads unchanged from the .env file', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
|
||||
]);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'docker_compose_raw' => file_get_contents(base_path('templates/compose/9router.yaml')),
|
||||
]);
|
||||
|
||||
$service->parse();
|
||||
|
||||
$secrets = $service->environment_variables()
|
||||
->where('key', 'like', 'SERVICE_PASSWORD%')
|
||||
->pluck('value', 'key');
|
||||
|
||||
// INITIAL_PASSWORD, JWT_SECRET and API_KEY_SECRET; the admin logs in with the shown password.
|
||||
expect($secrets)->toHaveCount(3);
|
||||
foreach ($secrets as $key => $value) {
|
||||
expect($value)->toMatch('/^[A-Za-z0-9]+$/', "{$key} contains characters that compose interpolates");
|
||||
}
|
||||
});
|
||||
@@ -96,6 +96,32 @@ it('asks to start a stopped proxy whose ports use variables and checks only conc
|
||||
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'sport = ') && str_contains($process->command, '${'));
|
||||
});
|
||||
|
||||
it('asks to start a stopped proxy whose ports all use variables without defaults', function () {
|
||||
$this->server->proxy->last_saved_proxy_configuration = "services:\n traefik:\n image: traefik:v3.6\n ports:\n - '\${HTTP_PORT}:80'\n - '\${HTTPS_PORT}:443'\n";
|
||||
$this->server->save();
|
||||
|
||||
expect(CheckProxy::run($this->server))->toBeTrue();
|
||||
|
||||
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'sport = '));
|
||||
});
|
||||
|
||||
it('does not ask to start a stopped proxy without published ports', function () {
|
||||
$this->server->proxy->last_saved_proxy_configuration = "services:\n traefik:\n image: traefik:v3.6\n ports:\n";
|
||||
$this->server->save();
|
||||
|
||||
expect(CheckProxy::run($this->server))->toBeFalse();
|
||||
});
|
||||
|
||||
it('saves and starts a proxy whose ports key is empty', function () {
|
||||
$configuration = "services:\n traefik:\n image: traefik:v3.6\n ports:\n";
|
||||
|
||||
SaveProxyConfiguration::run($this->server, $configuration);
|
||||
StartProxy::run($this->server->fresh(), async: false, force: true);
|
||||
|
||||
expect($this->server->fresh()->proxy->last_saved_proxy_configuration)->toBe($configuration);
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'docker compose -f /data/coolify/proxy/docker-compose.yml up -d --wait --remove-orphans'));
|
||||
});
|
||||
|
||||
it('configures traffic analytics for a proxy whose ports use variables', function () {
|
||||
Queue::fake();
|
||||
StartSentinel::partialMock()->shouldReceive('handle')->atLeast()->once();
|
||||
|
||||
@@ -29,6 +29,7 @@ use App\Notifications\Server\DockerCleanupFailed;
|
||||
use App\Services\ScheduledJobDeliveryService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -36,6 +37,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -359,7 +361,7 @@ it('does not query relationships when constructing scheduled task jobs', functio
|
||||
|
||||
expect(DB::getQueryLog())->toBeEmpty()
|
||||
->and($job->queue)->toBe(crons_queue())
|
||||
->and($job->timeout)->toBe(300);
|
||||
->and($job->timeout)->toBe(300 + ScheduledTaskJob::WORKER_TIMEOUT_MARGIN_SECONDS);
|
||||
});
|
||||
|
||||
it('reads current server settings in each queued manager run of the same worker', function () {
|
||||
@@ -803,6 +805,40 @@ it('leaves recently enqueued occurrences alone', function () {
|
||||
expect($occurrence->fresh()->enqueued_at->toDateTimeString())->toBe('2026-09-17 11:01:00');
|
||||
});
|
||||
|
||||
it('closes occurrences that an earlier release left enqueued without failed executions or notifications', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
|
||||
Queue::fake();
|
||||
Notification::fake();
|
||||
Event::fake([DockerCleanupDone::class]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
$task = createScheduledApplicationTask(createScheduledTaskApplication(), ['frequency' => 'daily']);
|
||||
$team = Team::find($task->team_id);
|
||||
$team->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true, 'docker_cleanup_failure_email_notifications' => true]);
|
||||
$server = $task->application->destination->server;
|
||||
$backup = createScheduledDatabaseBackup(createScheduledBackupDatabase(), ['frequency' => 'daily']);
|
||||
|
||||
$legacy = [
|
||||
createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 10, 0, 0, 0, 'UTC'), minutesAgo: 7 * 24 * 60),
|
||||
createStaleEnqueuedOccurrence("docker-cleanup:{$server->id}", 'docker-cleanup', $server->id, Carbon::create(2026, 9, 17, 9, 0, 0, 'UTC'), minutesAgo: 180),
|
||||
createStaleEnqueuedOccurrence("scheduled-backup:{$backup->id}", 'database-backup', $backup->id, Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC')),
|
||||
];
|
||||
// A recent job can still be waiting in the queue.
|
||||
$recent = createStaleEnqueuedOccurrence("scheduled-task:{$task->id}", 'scheduled-task', $task->id, Carbon::create(2026, 9, 17, 11, 55, 0, 'UTC'), minutesAgo: 5);
|
||||
|
||||
(require database_path('migrations/2026_10_05_064841_close_legacy_enqueued_scheduled_job_deliveries.php'))->up();
|
||||
(new ScheduledJobManager)->handle();
|
||||
|
||||
foreach ($legacy as $occurrence) {
|
||||
expect($occurrence->fresh()->status)->toBe('skipped');
|
||||
}
|
||||
expect($recent->fresh()->status)->toBe('enqueued')
|
||||
->and(ScheduledTaskExecution::query()->exists())->toBeFalse()
|
||||
->and(DockerCleanupExecution::query()->exists())->toBeFalse();
|
||||
Queue::assertNotPushed(DatabaseBackupJob::class);
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('dispatches only the schedules of its own type', function (string $type, array $expected) {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
Carbon::setTestNow(Carbon::create(2026, 9, 17, 1, 0, 0, 'UTC'));
|
||||
@@ -997,18 +1033,51 @@ it('does not run a scheduled task again when Redis hands it out after its worker
|
||||
expect(ScheduledTaskExecution::query()->where('scheduled_task_id', $task->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('marks only the unfinished execution as failed when a scheduled task job fails without its execution id', function () {
|
||||
it('fails only its own execution and notifies the team when the worker timeout stops a scheduled task job', function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Notification::fake();
|
||||
$task = createScheduledApplicationTask(createScheduledTaskApplication());
|
||||
$interrupted = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'running', 'started_at' => now()->subHours(2)]);
|
||||
$interrupted->forceFill(['created_at' => now()->subHours(2)])->save();
|
||||
$later = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'success', 'started_at' => now()->subHour(), 'finished_at' => now()->subMinutes(59)]);
|
||||
Carbon::setTestNow(Carbon::create(2026, 9, 17, 12, 0, 0, 'UTC'));
|
||||
$application = createScheduledTaskApplication();
|
||||
$task = createScheduledApplicationTask($application, ['timeout' => 300]);
|
||||
$team = Team::find($task->team_id);
|
||||
$team->emailNotificationSettings->update(['smtp_enabled' => true, 'scheduled_task_failure_email_notifications' => true]);
|
||||
$finished = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'success', 'started_at' => now()->subHour(), 'finished_at' => now()->subMinutes(59)]);
|
||||
$job = new ScheduledTaskJob($task);
|
||||
// At the worker timeout, failed() runs on a copy rebuilt from the queued payload.
|
||||
$queuedCopy = unserialize(serialize($job));
|
||||
$commands = collect();
|
||||
$atTimeout = null;
|
||||
Process::fake(function ($process) use ($application, $task, $queuedCopy, $team, $commands, &$atTimeout) {
|
||||
$commands->push($process->command);
|
||||
if (str_contains($process->command, 'docker ps')) {
|
||||
return Process::result(output: json_encode(['Names' => 'app-'.$application->uuid, 'Labels' => "coolify.applicationUuid={$application->uuid}", 'State' => 'running']));
|
||||
}
|
||||
if (str_contains($process->command, 'docker exec')) {
|
||||
Carbon::setTestNow(now()->addMinute());
|
||||
$newerRun = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'running', 'started_at' => now()]);
|
||||
$ownRun = ScheduledTaskExecution::query()->whereKeyNot([$newerRun->id])->where('status', 'running')->sole();
|
||||
|
||||
(new ScheduledTaskJob($task))->failed(new RuntimeException('worker killed'));
|
||||
$queuedCopy->failed(new TimeoutExceededException('App\Jobs\ScheduledTaskJob has timed out.'));
|
||||
|
||||
expect($interrupted->fresh()->status)->toBe('failed')
|
||||
->and($later->fresh()->status)->toBe('success');
|
||||
$atTimeout = [
|
||||
'own' => $ownRun->fresh()->status,
|
||||
'own_finished' => $ownRun->fresh()->finished_at !== null,
|
||||
'newer' => $newerRun->fresh()->status,
|
||||
'notifications' => Notification::sent($team, TaskFailed::class)->count(),
|
||||
];
|
||||
|
||||
throw new RuntimeException('The worker stopped the job.');
|
||||
}
|
||||
|
||||
return Process::result(output: '');
|
||||
});
|
||||
|
||||
expect(fn () => $job->handle())->toThrow(RuntimeException::class);
|
||||
|
||||
expect($atTimeout)->toBe(['own' => 'failed', 'own_finished' => true, 'newer' => 'running', 'notifications' => 1])
|
||||
->and($finished->fresh()->status)->toBe('success')
|
||||
->and($commands->first(fn (string $command) => str_contains($command, 'docker exec')))->toStartWith('timeout 300 ssh ')
|
||||
->and($job->timeout)->toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it('runs a fixed-time schedule once when daylight saving time repeats its local time', function () {
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Actions\Service\StopService;
|
||||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Events\DatabaseStatusChanged;
|
||||
use App\Exceptions\RemoteSecretException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Jobs\DatabaseStartJob;
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
|
||||
@@ -210,6 +211,28 @@ test('a service restart keeps the service running when remote secrets cannot be
|
||||
->toThrow(RuntimeException::class, 'Doppler is down');
|
||||
});
|
||||
|
||||
test('a literal service variable keeps reference text when no secret manager source is configured', function () {
|
||||
Http::fake();
|
||||
$service = remoteSecretLifecycleService();
|
||||
$service->secretManagerLink()->delete();
|
||||
$service->environment_variables()->create(['key' => 'TEMPLATE', 'value' => 'Hello {{vault.NAME}}', 'is_literal' => true]);
|
||||
|
||||
$service->ensureRemoteSecretsResolvable($service->environment_variables()->get());
|
||||
|
||||
expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain("TEMPLATE='Hello {{vault.NAME}}'");
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a service start stops before the service when a non-literal variable references a secret without a source', function () {
|
||||
StopService::shouldRun()->never();
|
||||
$service = remoteSecretLifecycleService();
|
||||
$service->secretManagerLink()->delete();
|
||||
$service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}']);
|
||||
|
||||
expect(fn () => StartService::run($service, stopBeforeStart: true))
|
||||
->toThrow(RemoteSecretException::class, 'no secret manager source is configured');
|
||||
});
|
||||
|
||||
test('a service restart fetches remote secrets only once', function () {
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => 'value'])]);
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Js;
|
||||
@@ -382,6 +383,35 @@ test('the edit modal value autocomplete offers the vault scope with lazy key fet
|
||||
expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']);
|
||||
});
|
||||
|
||||
test('the value autocomplete lists secret manager keys only for users who can update the resource', function (string $role, bool $allowed) {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DB_PASSWORD' => 'super-secret-value',
|
||||
]),
|
||||
]);
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
$env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->team->members()->attach($user->id, ['role' => $role]);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']);
|
||||
|
||||
if ($allowed) {
|
||||
expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
expect(fn () => $component->instance()->fetchSecretManagerKeys())->toThrow(AuthorizationException::class);
|
||||
Http::assertNothingSent();
|
||||
})->with([
|
||||
'member' => ['member', false],
|
||||
'admin' => ['admin', true],
|
||||
]);
|
||||
|
||||
test('the edit modal value autocomplete reports secret provider failures', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([], 503),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Livewire\Server\Proxy;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->proxyConfiguration = "services:\n traefik:\n image: traefik:v3.6\n environment:\n - CF_DNS_API_TOKEN=zzfix-cloudflare-secret\n ports:\n - '80:80'\n";
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => 'exited',
|
||||
'last_saved_proxy_configuration' => $this->proxyConfiguration,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
function actingAsProxyTeamUser(Team $team, string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => $role]);
|
||||
test()->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('does not load the proxy configuration for a team member', function () {
|
||||
actingAsProxyTeamUser($this->team, 'member');
|
||||
|
||||
$component = Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
->call('loadProxyConfiguration')
|
||||
->assertOk()
|
||||
->assertNotDispatched('error')
|
||||
->assertSet('proxySettings', null);
|
||||
|
||||
expect($component->html())->not->toContain('zzfix-cloudflare-secret');
|
||||
});
|
||||
|
||||
it('loads the proxy configuration for a team admin', function () {
|
||||
actingAsProxyTeamUser($this->team, 'admin');
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
->call('loadProxyConfiguration')
|
||||
->assertSet('proxySettings', $this->proxyConfiguration);
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Livewire\SettingsBackup;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
@@ -11,6 +12,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -161,3 +163,64 @@ test('configure backup creates only the missing schedule for an existing instanc
|
||||
->and($backup->frequency)->toBe('0 0 * * *')
|
||||
->and(ScheduledDatabaseBackup::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* A team database that is also named coolify-db, but is not the instance database (id 0).
|
||||
*/
|
||||
function settingsBackupTenantDatabaseNamedCoolifyDb(): StandalonePostgresql
|
||||
{
|
||||
$tenantTeam = Team::factory()->create();
|
||||
$tenantServer = Server::factory()->create(['team_id' => $tenantTeam->id]);
|
||||
$tenantDestination = StandaloneDocker::query()->where('server_id', $tenantServer->id)->firstOrFail();
|
||||
|
||||
$database = new StandalonePostgresql;
|
||||
$database->forceFill([
|
||||
'name' => 'coolify-db',
|
||||
'postgres_user' => 'tenant',
|
||||
'postgres_password' => 'tenant-secret',
|
||||
'postgres_db' => 'tenant',
|
||||
'status' => 'running',
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'destination_id' => $tenantDestination->id,
|
||||
]);
|
||||
$database->save();
|
||||
|
||||
return $database;
|
||||
}
|
||||
|
||||
test('instance backup settings ignore a team database named coolify-db', function () {
|
||||
settingsBackupInstanceDatabaseWithoutSchedule()->delete();
|
||||
$tenantDatabase = settingsBackupTenantDatabaseNamedCoolifyDb();
|
||||
|
||||
Livewire::test(SettingsBackup::class)
|
||||
->assertOk()
|
||||
->assertSet('database', null)
|
||||
->assertSee('Backup is not configured')
|
||||
->assertDontSee('tenant-secret');
|
||||
|
||||
expect($tenantDatabase->fresh()->scheduledBackups()->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('configure backup creates the instance database instead of using a team database named coolify-db', function () {
|
||||
settingsBackupInstanceDatabaseWithoutSchedule()->forceDelete();
|
||||
$tenantDatabase = settingsBackupTenantDatabaseNamedCoolifyDb();
|
||||
Storage::fake('ssh-keys');
|
||||
Storage::fake('ssh-mux');
|
||||
Server::query()->whereKey(0)->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => 0])->id]);
|
||||
Server::flushIdentityMap();
|
||||
Process::fake([
|
||||
'*docker inspect coolify-db*' => Process::result(output: json_encode([[
|
||||
'Config' => ['Env' => ['POSTGRES_USER=coolify', 'POSTGRES_PASSWORD=instance-secret', 'POSTGRES_DB=coolify']],
|
||||
]])),
|
||||
]);
|
||||
|
||||
Livewire::test(SettingsBackup::class)
|
||||
->call('addCoolifyDatabase')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('postgres_password', 'instance-secret');
|
||||
|
||||
$instanceDatabase = StandalonePostgresql::query()->findOrFail(0);
|
||||
expect($instanceDatabase->scheduledBackups()->sole()->team_id)->toBe(0)
|
||||
->and($tenantDatabase->fresh()->scheduledBackups()->exists())->toBeFalse()
|
||||
->and($tenantDatabase->fresh()->postgres_password)->toBe('tenant-secret');
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -219,6 +220,28 @@ it('forbids team members from mounting the volume into an application', function
|
||||
expect($this->application->persistentStorages()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a client-supplied volume name so the mount cannot become a host bind mount', function (string $volumeName) {
|
||||
expect(fn () => Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
|
||||
->set('applicationUuid', $this->application->uuid)
|
||||
->set('volumeName', $volumeName)
|
||||
->call('connect'))
|
||||
->toThrow(CannotUpdateLockedPropertyException::class);
|
||||
|
||||
expect($this->application->persistentStorages()->count())->toBe(0);
|
||||
})->with(['/', '../x', '/etc']);
|
||||
|
||||
it('does not mount a stored data volume whose name is not a docker volume name', function () {
|
||||
$this->sqlite->persistentStorages()->update(['name' => '/etc']);
|
||||
|
||||
Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
|
||||
->set('applicationUuid', $this->application->uuid)
|
||||
->call('connect')
|
||||
->assertHasErrors('volumeName')
|
||||
->assertNoRedirect();
|
||||
|
||||
expect($this->application->persistentStorages()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a docker volume shared with another resource when the application volumes are deleted', function () {
|
||||
Process::fake();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Support\DatabaseImport\DatabaseImportException;
|
||||
use App\Support\DatabaseImport\DatabaseImportSource;
|
||||
use App\Support\RemoteProcessCommand;
|
||||
use App\Support\ResourceStartActivity;
|
||||
@@ -88,6 +89,56 @@ test('plain and gzip server backups are copied into the database container uncha
|
||||
'custom archive' => ['/srv/backups/app.dmp', '5047444d5001'],
|
||||
]);
|
||||
|
||||
test('server backups without a known backup extension can be restored', function (string $path, string $magicHex) {
|
||||
$command = importServerBackup($this, $path, $magicHex);
|
||||
|
||||
expect($command)->toContain("docker cp '{$path}' '{$this->database->uuid}:/tmp/restore_");
|
||||
})->with([
|
||||
'no extension' => ['/srv/backups/pgdump', '5047444d5001'],
|
||||
'date suffix' => ['/srv/backups/app.sql.2026-10-01', '2d2d20506f73'],
|
||||
'sqlite' => ['/srv/backups/app.sqlite', '53514c697465'],
|
||||
]);
|
||||
|
||||
test('server backup paths with shell characters are still rejected', function (string $path) {
|
||||
expect(fn () => importServerBackup($this, $path, '2d2d20506f73'))
|
||||
->toThrow(DatabaseImportException::class, 'The server path is invalid.');
|
||||
})->with([
|
||||
'relative' => ['srv/backups/pgdump'],
|
||||
'traversal' => ['/srv/backups/../../etc/shadow'],
|
||||
'command substitution' => ['/srv/backups/$(id)'],
|
||||
'semicolon' => ['/srv/backups/a;id'],
|
||||
'quote' => ["/srv/backups/a'b"],
|
||||
]);
|
||||
|
||||
test('s3 backups still need a known backup extension', function (string $path, bool $allowed) {
|
||||
$storage = S3Storage::create([
|
||||
'name' => 'Import S3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key',
|
||||
'secret' => 'secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://8.8.8.8',
|
||||
'is_usable' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$disk = Mockery::mock(FilesystemAdapter::class);
|
||||
$disk->shouldReceive('exists')->andReturn(false);
|
||||
$filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial();
|
||||
$filesystem->shouldReceive('build')->andReturn($disk);
|
||||
Storage::swap($filesystem);
|
||||
Process::fake();
|
||||
|
||||
expect(fn () => app(StartDatabaseImport::class)->handle(
|
||||
$this->database,
|
||||
new DatabaseImportSource('s3', path: $path, s3StorageUuid: $storage->uuid),
|
||||
$this->team->id,
|
||||
))->toThrow(DatabaseImportException::class, $allowed ? 'The S3 backup was not found' : 'The S3 path is invalid.');
|
||||
})->with([
|
||||
'no extension' => ['backups/pgdump', false],
|
||||
'sqlite' => ['backups/app.sqlite', true],
|
||||
'gzip sqlite3' => ['backups/app.sqlite3.gz', true],
|
||||
]);
|
||||
|
||||
test('bz2, xz, and zip server backups are prepared in the helper image', function (string $path, string $magicHex) {
|
||||
$command = importServerBackup($this, $path, $magicHex);
|
||||
|
||||
|
||||
@@ -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,451 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Livewire\Project\Service\FileStorage;
|
||||
use App\Livewire\Project\Service\Storage;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
config([
|
||||
'app.maintenance.store' => 'array',
|
||||
'cache.default' => 'array',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||
$this->member = User::factory()->create();
|
||||
$this->member->teams()->attach($this->team, ['role' => 'member']);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
storageHostPathFakeServer();
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
/**
|
||||
* `test -L` prints $symlink and `test -f`/`test -d` print $exists.
|
||||
*/
|
||||
function storageHostPathFakeServer(string $symlink = 'OK', string $exists = 'NOK'): void
|
||||
{
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains($process->command, 'test -L') => $symlink,
|
||||
str_contains($process->command, 'readlink -f') => 'OK',
|
||||
str_contains($process->command, 'test -') => $exists,
|
||||
default => '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $abilities
|
||||
*/
|
||||
function storageHostPathToken(User $user, array $abilities = ['*']): string
|
||||
{
|
||||
// A token request must not use the session user of the Livewire tests.
|
||||
auth()->forgetGuards();
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'host-path-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => test()->team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: array<string, string>, 2: mixed}
|
||||
*/
|
||||
function storageHostPathApiTarget(string $resourceType): array
|
||||
{
|
||||
if ($resourceType === 'application') {
|
||||
return ['/api/v1/applications/'.test()->application->uuid.'/storages', [], test()->application];
|
||||
}
|
||||
if ($resourceType === 'database') {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'host-path-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
return ["/api/v1/databases/{$database->uuid}/storages", [], $database];
|
||||
}
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => test()->environment->id,
|
||||
'server_id' => test()->server->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n",
|
||||
]);
|
||||
$serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]);
|
||||
|
||||
return ["/api/v1/services/{$service->uuid}/storages", ['resource_uuid' => $serviceApplication->uuid], $serviceApplication];
|
||||
}
|
||||
|
||||
function storageHostPathFileVolume(string $fsPath, ?string $content, bool $isDirectory = false): LocalFileVolume
|
||||
{
|
||||
return LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => '/mnt/'.md5($fsPath),
|
||||
'content' => $content,
|
||||
'is_directory' => $isDirectory,
|
||||
'resource_id' => test()->application->id,
|
||||
'resource_type' => test()->application->getMorphClass(),
|
||||
])->fresh();
|
||||
}
|
||||
|
||||
function storageHostPathAssertNoDelete(): void
|
||||
{
|
||||
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -') || str_contains($process->command, 'rmdir'));
|
||||
}
|
||||
|
||||
test('an admin creates a directory mount anywhere on the host in the UI', function () {
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('file_storage_directory_source', '/srv/zz')
|
||||
->set('file_storage_directory_destination', '/data')
|
||||
->call('submitFileStorageDirectory')
|
||||
->assertDispatched('success');
|
||||
|
||||
$volume = LocalFileVolume::query()->sole();
|
||||
expect($volume->fs_path)->toBe('/srv/zz')
|
||||
->and($volume->is_directory)->toBeTrue();
|
||||
|
||||
$volume->saveStorageOnServer();
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/srv/zz'"));
|
||||
});
|
||||
|
||||
test('an admin creates a file mount with content anywhere on the host in the UI', function () {
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('file_storage_source', '/etc/zz/app.conf')
|
||||
->set('file_storage_path', '/etc/app.conf')
|
||||
->set('file_storage_content', 'listen 8080;')
|
||||
->call('submitFileStorage')
|
||||
->assertDispatched('success');
|
||||
|
||||
$volume = LocalFileVolume::query()->sole();
|
||||
expect($volume->fs_path)->toBe('/etc/zz/app.conf')
|
||||
->and($volume->mount_path)->toBe('/etc/app.conf');
|
||||
|
||||
$volume->saveStorageOnServer();
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/etc/zz'"));
|
||||
Process::assertRan(fn ($process) => preg_match("#base64 -d \\| (sudo )?tee '/etc/zz/app.conf' > /dev/null#", $process->command) === 1);
|
||||
expect($volume->contentPathOnServer())->toBe('/etc/zz/app.conf');
|
||||
});
|
||||
|
||||
test('an admin creates directory and file mounts anywhere on the host through the API', function (string $resourceType) {
|
||||
[$url, $payload, $resource] = storageHostPathApiTarget($resourceType);
|
||||
$token = storageHostPathToken($this->admin);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson($url, [...$payload, ...[
|
||||
'type' => 'file',
|
||||
'is_directory' => true,
|
||||
'fs_path' => '/srv/zz',
|
||||
'mount_path' => '/data',
|
||||
]])->assertCreated()->assertJsonPath('fs_path', '/srv/zz');
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])->postJson($url, [...$payload, ...[
|
||||
'type' => 'file',
|
||||
'fs_path' => '/etc/zz/app.conf',
|
||||
'mount_path' => '/etc/app.conf',
|
||||
'content' => 'listen 8080;',
|
||||
]])->assertCreated()->assertJsonPath('fs_path', '/etc/zz/app.conf');
|
||||
|
||||
expect($resource->fileStorages()->orderBy('id')->pluck('fs_path')->all())->toBe(['/srv/zz', '/etc/zz/app.conf']);
|
||||
})->with(['application', 'database', 'service']);
|
||||
|
||||
test('members cannot create host path mounts', function () {
|
||||
$this->actingAs($this->member);
|
||||
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('file_storage_directory_source', '/srv/zz')
|
||||
->set('file_storage_directory_destination', '/data')
|
||||
->call('submitFileStorageDirectory')
|
||||
->assertDispatched('error');
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->member)])
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'fs_path' => '/etc/zz/app.conf',
|
||||
'mount_path' => '/etc/app.conf',
|
||||
'content' => 'listen 8080;',
|
||||
])->assertForbidden();
|
||||
|
||||
expect(LocalFileVolume::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('host paths with traversal, home or shell characters are rejected', function (string $path) {
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('file_storage_directory_source', $path)
|
||||
->set('file_storage_directory_destination', '/data')
|
||||
->call('submitFileStorageDirectory')
|
||||
->assertDispatched('error');
|
||||
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('file_storage_source', $path)
|
||||
->set('file_storage_path', '/etc/app.conf')
|
||||
->set('file_storage_content', 'listen 8080;')
|
||||
->call('submitFileStorage')
|
||||
->assertDispatched('error');
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)])
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'is_directory' => true,
|
||||
'fs_path' => $path,
|
||||
'mount_path' => '/data',
|
||||
])->assertUnprocessable();
|
||||
|
||||
expect(LocalFileVolume::query()->count())->toBe(0);
|
||||
})->with([
|
||||
'parent segment' => '/srv/../etc/zz',
|
||||
'relative escape' => '../../../etc/zz',
|
||||
'home directory' => '~/zz',
|
||||
'command separator' => '/srv/zz;id',
|
||||
'command substitution' => '/srv/$(id)',
|
||||
]);
|
||||
|
||||
test('a symbolic link at a file path outside the resource directory is refused', function () {
|
||||
storageHostPathFakeServer(symlink: 'LINK');
|
||||
$volume = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;');
|
||||
|
||||
expect(fn () => $volume->saveStorageOnServer())
|
||||
->toThrow(RuntimeException::class, '/etc/zz/app.conf is a symbolic link on the server.');
|
||||
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'base64 -d'));
|
||||
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'touch '));
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)])
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'fs_path' => '/etc/zz/other.conf',
|
||||
'mount_path' => '/etc/other.conf',
|
||||
'content' => 'listen 8080;',
|
||||
])->assertUnprocessable();
|
||||
|
||||
expect(LocalFileVolume::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('deleting a mount outside the resource directory never deletes on the host', function () {
|
||||
storageHostPathFakeServer(exists: 'OK');
|
||||
$directory = storageHostPathFileVolume('/srv/zz', null, isDirectory: true);
|
||||
$file = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;');
|
||||
|
||||
$directory->deleteStorageOnServer();
|
||||
$file->deleteStorageOnServer();
|
||||
storageHostPathAssertNoDelete();
|
||||
|
||||
Livewire::test(FileStorage::class, ['fileStorage' => $file->fresh()])
|
||||
->set('permanently_delete', true)
|
||||
->call('delete', 'password')
|
||||
->assertDispatched('success', 'File deleted.');
|
||||
|
||||
storageHostPathAssertNoDelete();
|
||||
expect(LocalFileVolume::query()->pluck('fs_path')->all())->toBe(['/srv/zz']);
|
||||
});
|
||||
|
||||
test('deleting a mount inside the resource directory still deletes it on the host', function () {
|
||||
storageHostPathFakeServer(exists: 'OK');
|
||||
$path = $this->application->workdir().'/data/zz';
|
||||
|
||||
storageHostPathFileVolume($path, null, isDirectory: true)->deleteStorageOnServer();
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$path}'"));
|
||||
});
|
||||
|
||||
test('the UI deletes a mount inside the resource directory on the host only when the user keeps the option checked', function (bool $isDirectory, array $selectedActions, bool $deletesOnHost, string $message) {
|
||||
storageHostPathFakeServer(exists: 'OK');
|
||||
$path = $this->application->workdir().'/data/zz';
|
||||
$volume = storageHostPathFileVolume($path, $isDirectory ? null : 'listen 8080;', isDirectory: $isDirectory);
|
||||
|
||||
Livewire::test(FileStorage::class, ['fileStorage' => $volume])
|
||||
->call('delete', 'password', $selectedActions)
|
||||
->assertDispatched('success', $message);
|
||||
|
||||
expect($volume->fresh())->toBeNull();
|
||||
if ($deletesOnHost) {
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$path}'"));
|
||||
} else {
|
||||
storageHostPathAssertNoDelete();
|
||||
}
|
||||
})->with([
|
||||
'directory, option checked' => [true, ['permanently_delete'], true, 'Directory deleted from the server.'],
|
||||
'directory, option unchecked' => [true, [], false, 'Directory deleted.'],
|
||||
'file, option checked' => [false, ['permanently_delete'], true, 'File deleted from the server.'],
|
||||
'file, option unchecked' => [false, [], false, 'File deleted.'],
|
||||
]);
|
||||
|
||||
test('the delete dialog offers deletion on the host only for mounts inside the resource directory', function () {
|
||||
$inside = storageHostPathFileVolume($this->application->workdir().'/data/zz', 'listen 8080;');
|
||||
$outside = storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;');
|
||||
$outsideDirectory = storageHostPathFileVolume('/srv/zz', null, isDirectory: true);
|
||||
|
||||
$insideDialog = Livewire::test(FileStorage::class, ['fileStorage' => $inside]);
|
||||
expect($insideDialog->viewData('deletionCheckboxes'))->toHaveCount(1)
|
||||
->and($insideDialog->viewData('deletionCheckboxes')[0]['id'])->toBe('permanently_delete');
|
||||
|
||||
foreach ([$outside, $outsideDirectory] as $volume) {
|
||||
$dialog = Livewire::test(FileStorage::class, ['fileStorage' => $volume]);
|
||||
expect($dialog->viewData('deletionCheckboxes'))->toBe([])
|
||||
->and(implode(' ', $dialog->viewData('deletionActions')))->toContain('is not deleted on the server');
|
||||
}
|
||||
});
|
||||
|
||||
test('a file outside the resource directory never replaces a directory on the host', function () {
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains($process->command, 'test -L') => 'OK',
|
||||
str_contains($process->command, 'test -d') => 'OK',
|
||||
str_contains($process->command, 'test -f') => 'NOK',
|
||||
str_contains($process->command, 'sh -c') => '1:empty-directory',
|
||||
default => '',
|
||||
}));
|
||||
|
||||
expect(fn () => storageHostPathFileVolume('/etc/zz/app.conf', 'listen 8080;')->saveStorageOnServer())
|
||||
->toThrow(Exception::class, 'is a directory on the server');
|
||||
storageHostPathAssertNoDelete();
|
||||
});
|
||||
|
||||
test('relative host paths stay inside the resource directory', function () {
|
||||
$workdir = $this->application->workdir();
|
||||
|
||||
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||
->set('file_storage_directory_source', 'data/zz')
|
||||
->set('file_storage_directory_destination', '/data')
|
||||
->call('submitFileStorageDirectory')
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)])
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'fs_path' => './config/app.conf',
|
||||
'mount_path' => '/etc/app.conf',
|
||||
'content' => 'listen 8080;',
|
||||
])->assertCreated();
|
||||
|
||||
expect(LocalFileVolume::query()->orderBy('id')->pluck('fs_path')->all())
|
||||
->toBe([$workdir.'/data/zz', $workdir.'/config/app.conf']);
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'readlink -f')
|
||||
&& str_contains($process->command, "'{$workdir}' '{$workdir}/data/zz'"));
|
||||
});
|
||||
|
||||
test('a token without deploy cannot create a mount outside the resource directory', function (string $resourceType, array $mount) {
|
||||
[$url, $payload] = storageHostPathApiTarget($resourceType);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])])
|
||||
->postJson($url, [...$payload, 'type' => 'file', ...$mount])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.');
|
||||
|
||||
expect(LocalFileVolume::query()->count())->toBe(0);
|
||||
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
||||
Process::assertNothingRan();
|
||||
})->with(['application', 'database', 'service'])->with([
|
||||
'directory' => [['is_directory' => true, 'fs_path' => '/root/.ssh', 'mount_path' => '/data']],
|
||||
'file with content' => [['fs_path' => '/root/.ssh/authorized_keys', 'mount_path' => '/etc/keys', 'content' => 'ssh-ed25519 AAAA']],
|
||||
'empty file' => [['fs_path' => '/etc/zz/app.conf', 'mount_path' => '/etc/app.conf']],
|
||||
]);
|
||||
|
||||
test('a deploy or root token creates a mount outside the resource directory', function (string $resourceType, array $abilities) {
|
||||
[$url, $payload, $resource] = storageHostPathApiTarget($resourceType);
|
||||
$token = storageHostPathToken($this->admin, $abilities);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => '/srv/zz', 'mount_path' => '/data'])
|
||||
->assertCreated();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => '/etc/zz/app.conf', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;'])
|
||||
->assertCreated();
|
||||
|
||||
expect($resource->fileStorages()->orderBy('id')->pluck('fs_path')->all())->toBe(['/srv/zz', '/etc/zz/app.conf']);
|
||||
})->with(['application', 'database', 'service'])->with([
|
||||
'deploy' => [['read', 'write', 'deploy']],
|
||||
'root' => [['root']],
|
||||
]);
|
||||
|
||||
test('a write token creates mounts inside the resource directory', function (string $resourceType) {
|
||||
[$url, $payload, $resource] = storageHostPathApiTarget($resourceType);
|
||||
$token = storageHostPathToken($this->admin, ['read', 'write']);
|
||||
$workdir = $resource instanceof ServiceApplication ? $resource->service->workdir() : $resource->workdir();
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => 'data/zz', 'mount_path' => '/data'])
|
||||
->assertCreated();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => $workdir.'/config/app.conf', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;'])
|
||||
->assertCreated();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/other.conf', 'content' => 'listen 8081;'])
|
||||
->assertCreated();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$token])
|
||||
->postJson($url, [...$payload, 'type' => 'file', 'is_host_file' => true, 'fs_path' => '/etc/hosts', 'mount_path' => '/etc/hosts'])
|
||||
->assertCreated();
|
||||
|
||||
expect($resource->fileStorages()->count())->toBe(4);
|
||||
})->with(['application', 'database', 'service']);
|
||||
|
||||
test('changing the content of a mount outside the resource directory needs deploy', function (string $resourceType) {
|
||||
[$url, , $resource] = storageHostPathApiTarget($resourceType);
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => '/etc/zz/app.conf',
|
||||
'mount_path' => '/etc/app.conf',
|
||||
'content' => 'listen 8080;',
|
||||
'resource_id' => $resource->id,
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])])
|
||||
->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'content' => 'ssh-ed25519 AAAA'])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.');
|
||||
expect($storage->fresh()->content)->toBe('listen 8080;');
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])])
|
||||
->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'mount_path' => '/etc/app2.conf', 'content' => 'listen 8080;'])
|
||||
->assertOk();
|
||||
expect($storage->fresh()->mount_path)->toBe('/etc/app2.conf');
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write', 'deploy'])])
|
||||
->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'content' => 'listen 9090;'])
|
||||
->assertOk();
|
||||
expect($storage->fresh()->content)->toBe('listen 9090;');
|
||||
})->with(['application', 'database', 'service']);
|
||||
@@ -178,6 +178,9 @@ it('still offers a newer Traefik branch than the configured image', function ()
|
||||
|
||||
it('clears the stale minor warning after the configured branch is applied', function () {
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
$this->actingAs($user);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'proxy' => [
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\TrafficOverview;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class FakeAppOverviewTrafficClient extends SentinelTrafficClient
|
||||
{
|
||||
public array $responses = [];
|
||||
|
||||
protected function raw(string $url): string
|
||||
{
|
||||
foreach ($this->responses as $needle => $response) {
|
||||
if (str_contains($url, $needle)) {
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// Servers are memoized via once()/cache; clear both so DB-id reuse across tests doesn't bleed a stale enabled server.
|
||||
Cache::flush();
|
||||
Server::flushIdentityMap();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
function makeAppOnServer(bool $analyticsEnabled): Application
|
||||
{
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => test()->team->id,
|
||||
'private_key_id' => test()->privateKey->id,
|
||||
]);
|
||||
$server->settings->is_traffic_analytics_enabled = $analyticsEnabled;
|
||||
$server->settings->save();
|
||||
|
||||
$project = Project::factory()->create(['team_id' => test()->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
|
||||
|
||||
return Application::factory()->create([
|
||||
'name' => 'Widget App',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
}
|
||||
|
||||
it('shows last-24h KPIs and a link to full analytics when enabled with data', function () {
|
||||
$application = makeAppOnServer(true);
|
||||
|
||||
app()->bind(SentinelTrafficClient::class, function ($app, $params) {
|
||||
$client = new FakeAppOverviewTrafficClient($params['server']);
|
||||
$client->responses = [
|
||||
'/traffic/overview' => json_encode([
|
||||
'requests' => 4200,
|
||||
'bytes_in' => 5000,
|
||||
'bytes_out' => 25000,
|
||||
'status' => ['s2xx' => 4000, 's3xx' => 100, 's4xx' => 80, 's5xx' => 20],
|
||||
'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
|
||||
'unique_visitors' => 1234,
|
||||
]),
|
||||
];
|
||||
|
||||
return $client;
|
||||
});
|
||||
|
||||
$component = loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application]));
|
||||
$component->assertOk()
|
||||
->assertSet('enabled', true)
|
||||
->assertSee('Traffic (last 24h)')
|
||||
->assertSee('4,200')
|
||||
->assertSee('1,234')
|
||||
->assertSee('View full analytics');
|
||||
});
|
||||
|
||||
it('shows the muted no-data note when enabled but no traffic recorded', function () {
|
||||
$application = makeAppOnServer(true);
|
||||
|
||||
app()->bind(SentinelTrafficClient::class, function ($app, $params) {
|
||||
$client = new FakeAppOverviewTrafficClient($params['server']);
|
||||
$client->responses = ['/traffic/overview' => json_encode(['requests' => 0])];
|
||||
|
||||
return $client;
|
||||
});
|
||||
|
||||
loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application]))
|
||||
->assertOk()
|
||||
->assertSee('No traffic recorded in the last 24h yet');
|
||||
});
|
||||
|
||||
it('shows the enable nudge when analytics is disabled on an eligible server', function () {
|
||||
$application = makeAppOnServer(false);
|
||||
|
||||
loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application]))
|
||||
->assertOk()
|
||||
->assertSet('enabled', false)
|
||||
->assertSee('Traffic analytics')
|
||||
->assertSee('Server settings')
|
||||
->assertDontSee('Traffic (last 24h)');
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use App\Livewire\Analytics as GlobalAnalytics;
|
||||
use App\Livewire\Project\Application\Analytics as ApplicationAnalytics;
|
||||
use App\Livewire\Project\Application\TrafficOverview;
|
||||
use App\Livewire\Project\Service\Analytics as ServiceAnalytics;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
@@ -369,31 +368,11 @@ it('falls back to the per-key merge with approximate badges when Sentinel lacks
|
||||
|
||||
// The absence is cached: a refresh within the ttl goes straight to the per-key path.
|
||||
$component->call('loadData')->assertSet('uniquesApproximate', true);
|
||||
loadLazy(Livewire::test(TrafficOverview::class, ['application' => $this->application]))->assertOk();
|
||||
loadLazy(Livewire::test(ApplicationAnalytics::class, ['application' => $this->application]))->assertOk();
|
||||
|
||||
expect(requestedResourceScopeUrls('/resource/'))->toHaveCount($probes);
|
||||
});
|
||||
|
||||
it('shows the resource overview in the application traffic card', function () {
|
||||
bindResourceScopeFake(resourceScopeResponses($this->application->uuid, resourceScopeOverview(4200, 33)));
|
||||
|
||||
$component = loadLazy(Livewire::test(TrafficOverview::class, ['application' => $this->application]))
|
||||
->assertOk()
|
||||
->assertSee('4,200');
|
||||
|
||||
expect($component->instance()->overview['uniqueVisitors'])->toBe(33)
|
||||
->and(requestedResourceScopeUrls('/resource/'))->toHaveCount(1)
|
||||
->and(requestedResourceScopeUrls('/traffic/dashboard'))->toBeEmpty();
|
||||
});
|
||||
|
||||
it('sums the keys in the application traffic card when Sentinel lacks the resource routes', function () {
|
||||
bindResourceScopeFake(perKeyFallbackResponses($this->application->uuid, $this->apiKey, $this->webKey));
|
||||
|
||||
loadLazy(Livewire::test(TrafficOverview::class, ['application' => $this->application]))
|
||||
->assertOk()
|
||||
->assertSee('150');
|
||||
});
|
||||
|
||||
it('uses the resource scope when the global analytics filter selects a resource', function () {
|
||||
bindResourceScopeFake([
|
||||
...resourceScopeResponses($this->application->uuid, resourceScopeOverview(150, 12)),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user