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

This commit is contained in:
github-actions[bot]
2026-10-05 19:47:56 +00:00
66 changed files with 2981 additions and 270 deletions
+6 -12
View File
@@ -250,22 +250,16 @@ class StartDragonfly
{
$environment_variables = collect();
$this->resolvedRedisPassword = (string) $this->database->dragonfly_password;
$passwordVariable = $this->database->serverPasswordEnvironmentVariable();
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 = $usesSecretManager;
// A REDIS_PASSWORD variable overrides the stored password, which stays unchanged.
if ($passwordVariable?->is($env) && $rawValue !== '') {
// Credentials below are placed directly in the compose file (healthcheck, command).
$this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue);
$this->redisPasswordFromSecretManager = $this->database->environmentVariableUsesSecretManager($env);
}
}
+6 -12
View File
@@ -250,22 +250,16 @@ class StartKeydb
{
$environment_variables = collect();
$this->resolvedRedisPassword = (string) $this->database->keydb_password;
$passwordVariable = $this->database->serverPasswordEnvironmentVariable();
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 = $usesSecretManager;
// A REDIS_PASSWORD variable overrides the stored password, which stays unchanged.
if ($passwordVariable?->is($env) && $rawValue !== '') {
// Credentials below are placed directly in the compose file (healthcheck, command).
$this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue);
$this->redisPasswordFromSecretManager = $this->database->environmentVariableUsesSecretManager($env);
}
}
@@ -39,7 +39,7 @@ class DeployServiceApplication
if ($pullLatestImages) {
$commands->push('echo Pulling image for service.');
$commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName}");
$commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName} < /dev/null");
}
if ($service->networks()->count() > 0) {
@@ -51,7 +51,8 @@ class DeployServiceApplication
if ($forceRebuild) {
$upCommand .= ' --build';
}
$upCommand .= " {$safeComposeServiceName}";
// No stdin for Compose prompts: they would read the next script lines (see StartService).
$upCommand .= " {$safeComposeServiceName} < /dev/null";
$commands->push('echo Starting service container.');
$commands->push($upCommand);
+6 -2
View File
@@ -42,16 +42,20 @@ class StartService
$commands[] = "touch {$workdir}/.env";
$commands = array_merge($commands, EnsureContentFilesOnServer::echoCommands($this->contentFileStorages($service), $service->server));
$commands = array_merge($commands, self::composeVolumeWarningCommands($service));
// The script reaches the server on stdin. Compose prompts (for example "Volume ... exists but
// doesn't match configuration. Recreate?") would read the next script lines as the answer.
// Give Compose no stdin so it uses the default answer (keep the volume). Never pass --yes:
// it recreates the volume and deletes its data.
if ($pullLatestImages) {
$commands[] = "echo 'Pulling images.'";
$commands[] = "docker compose --project-directory {$workdir} pull";
$commands[] = "docker compose --project-directory {$workdir} pull < /dev/null";
}
if ($service->networks()->count() > 0) {
$commands[] = "echo 'Creating Docker network.'";
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
}
$commands[] = 'echo Starting service.';
$commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build";
$commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build < /dev/null";
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
if (data_get($service, 'connect_to_docker_network')) {
$compose = data_get($service, 'docker_compose', []);
@@ -91,7 +91,7 @@ class ApplicationPreviewsController extends Controller
$this->authorize('view', $application);
$pullRequestIdRaw = $request->route('pull_request_id');
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0 || (int) $pullRequestIdRaw > 2147483647) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
@@ -116,7 +116,7 @@ class ApplicationPreviewsController extends Controller
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(
required: ['pull_request_id'],
properties: [
new OA\Property(property: 'pull_request_id', type: 'integer', minimum: 1, example: 42),
new OA\Property(property: 'pull_request_id', type: 'integer', minimum: 1, maximum: 2147483647, example: 42),
new OA\Property(property: 'pull_request_html_url', type: 'string', nullable: true, example: 'https://github.com/org/repo/pull/42'),
new OA\Property(property: 'git_type', type: 'string', nullable: true, enum: self::GIT_TYPES, description: 'Git provider that hosts the pull request. Sets the ref Coolify fetches (pull/{id}/head or merge-requests/{id}/head).'),
new OA\Property(property: 'commit', type: 'string', nullable: true, description: 'Commit SHA to deploy. Required for Bitbucket.'),
@@ -186,7 +186,7 @@ class ApplicationPreviewsController extends Controller
}
$rules = [
'pull_request_id' => 'required|integer|min:1',
'pull_request_id' => 'required|integer|min:1|max:2147483647',
'pull_request_html_url' => 'nullable|url|max:2048',
'force' => 'boolean',
'instant_deploy' => 'boolean',
@@ -184,6 +184,26 @@ class ApplicationsController extends Controller
], 422);
}
/**
* Container names honor custom_internal_name only with consistent container naming, so reject a name that would be ignored.
*/
private function customInternalNameValidationResponse(array $settings, ?Application $application = null): ?JsonResponse
{
if (! filled($settings['custom_internal_name'] ?? null)) {
return null;
}
$consistentNaming = $settings['is_consistent_container_name_enabled']
?? (bool) $application?->settings?->is_consistent_container_name_enabled;
if ($consistentNaming) {
return null;
}
return response()->json([
'message' => 'Validation failed.',
'errors' => ['custom_internal_name' => ['Set is_consistent_container_name_enabled to true to use custom_internal_name. Coolify ignores the custom internal name while consistent container naming is turned off.']],
], 422);
}
private function applyApplicationSettings(Application $application, array $settings): void
{
if ($settings === []) {
@@ -420,7 +440,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
@@ -619,7 +639,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
@@ -818,7 +838,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
@@ -988,7 +1008,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
@@ -1154,7 +1174,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
@@ -1414,6 +1434,9 @@ class ApplicationsController extends Controller
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $destination->server)) {
return $prefixValidation;
}
if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) {
return $internalNameValidation;
}
if ($type === 'public') {
$validationRules = [
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
@@ -2621,7 +2644,7 @@ class ApplicationsController extends Controller
$pullRequestId = null;
$pullRequestIdRaw = $request->route('pull_request_id');
if ($pullRequestIdRaw !== null) {
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0 || (int) $pullRequestIdRaw > 2147483647) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
$pullRequestId = (int) $pullRequestIdRaw;
@@ -2728,7 +2751,7 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$pullRequestIdRaw = $request->route('pull_request_id');
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0 || (int) $pullRequestIdRaw > 2147483647) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
@@ -3155,7 +3178,7 @@ class ApplicationsController extends Controller
'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'],
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'],
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
@@ -3341,6 +3364,9 @@ class ApplicationsController extends Controller
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) {
return $prefixValidation;
}
if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings, $application)) {
return $internalNameValidation;
}
$requestedBuildPack = $request->input('build_pack', $application->build_pack);
if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') {
return response()->json([
@@ -5673,7 +5699,7 @@ class ApplicationsController extends Controller
$this->authorize('delete', $application);
$pullRequestIdRaw = $request->route('pull_request_id');
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0 || (int) $pullRequestIdRaw > 2147483647) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
$pullRequestId = (int) $pullRequestIdRaw;
@@ -8,9 +8,80 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
class AuditEventsController extends Controller
{
#[OA\Get(
summary: 'List',
description: 'List audit events of the team. Only team admins and owners can view audit events. Actor e-mail, token, metadata, changes, IP address, and user agent are returned only for tokens with the read:sensitive or root permission.',
path: '/audit-events',
operationId: 'list-audit-events',
security: [
['bearerAuth' => []],
],
tags: ['Audit Events'],
parameters: [
new OA\Parameter(name: 'per_page', in: 'query', description: 'Items per page.', required: false, schema: new OA\Schema(type: 'integer', default: 25, minimum: 1, maximum: 100)),
new OA\Parameter(name: 'page', in: 'query', description: 'Page number.', required: false, schema: new OA\Schema(type: 'integer', default: 1, minimum: 1)),
new OA\Parameter(name: 'search', in: 'query', description: 'Search text.', required: false, schema: new OA\Schema(type: 'string', maxLength: 255)),
new OA\Parameter(name: 'action', in: 'query', description: 'Filter by action. Use all for every action.', required: false, schema: new OA\Schema(type: 'string', maxLength: 255)),
new OA\Parameter(name: 'source', in: 'query', description: 'Filter by source.', required: false, schema: new OA\Schema(type: 'string', enum: ['all', 'ui', 'api', 'mcp', 'webhook', 'system', 'scheduler'])),
],
responses: [
new OA\Response(
response: 200,
description: 'A page of audit events, newest first.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'current_page' => ['type' => 'integer'],
'data' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'team_id' => ['type' => 'integer', 'nullable' => true],
'event' => ['type' => 'string'],
'source' => ['type' => 'string'],
'action' => ['type' => 'string'],
'level' => ['type' => 'string'],
'actor_type' => ['type' => 'string', 'nullable' => true],
'actor_id' => ['type' => 'integer', 'nullable' => true],
'actor_name' => ['type' => 'string', 'nullable' => true],
'resource_type' => ['type' => 'string', 'nullable' => true],
'resource_uuid' => ['type' => 'string', 'nullable' => true],
'resource_name' => ['type' => 'string', 'nullable' => true],
'description' => ['type' => 'string', 'nullable' => true],
'created_at' => ['type' => 'string', 'format' => 'date-time'],
],
],
],
'per_page' => ['type' => 'integer'],
'last_page' => ['type' => 'integer'],
'total' => ['type' => 'integer'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 403,
description: 'Only team admins and owners can view audit logs.',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
@@ -82,6 +82,12 @@ trait HandlesDatabaseImportsApi
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
// A server import reads any absolute path on the host, so it needs the same permission as host mounts outside the resource directory.
if ($payload['source'] === 'server' && ! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) {
return response()->json([
'message' => 'Missing required permissions: deploy. An import from a server path needs a token with the deploy permission.',
], 403);
}
try {
$source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false), (bool) ($payload['replace_existing'] ?? false), (bool) ($payload['keep_owners'] ?? false), isset($payload['sqlite_database']) ? (string) $payload['sqlite_database'] : null, (bool) ($payload['restore_mysql_users'] ?? false));
@@ -315,8 +315,8 @@ class DeployController extends Controller
new OA\Parameter(name: 'tag', in: 'query', description: 'Tag name(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')),
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer', maximum: 2147483647)),
new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer', maximum: 2147483647)),
new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')),
],
@@ -372,6 +372,9 @@ class DeployController extends Controller
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
if ($pr > 2147483647) {
return response()->json(['message' => 'pull_request_id must be between 1 and 2147483647.'], 400);
}
if ($uuids && $tags) {
return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400);
}
@@ -253,12 +253,15 @@ class SecurityController extends Controller
#[OA\Patch(
summary: 'Update',
description: 'Update a private key.',
path: '/security/keys',
path: '/security/keys/{uuid}',
operationId: 'update-private-key',
security: [
['bearerAuth' => []],
],
tags: ['Private Keys'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Private Key UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: [
@@ -300,6 +303,10 @@ class SecurityController extends Controller
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
description: 'Private Key not found.',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
+1 -1
View File
@@ -5667,7 +5667,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
try {
queue_next_deployment($this->application);
queue_next_deployment($this->application, $this->application_deployment_queue->server_id);
} catch (Throwable $e) {
$this->logStatusTransitionSideEffectFailure('Starting the next queued deployment failed', $e);
}
@@ -18,6 +18,14 @@ class CheckMissingDatabaseBackupsJob implements ShouldBeEncrypted, ShouldBeUniqu
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 1800;
/**
* Releases the unique lock of a killed worker after one run (the timeout), so the next hourly run is not
* blocked until the queue retry_after.
*/
public int $uniqueFor = 1800;
public function handle(): void
{
ScheduledDatabaseBackup::query()
+6
View File
@@ -23,6 +23,12 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
public $timeout = 60;
/**
* Releases the unique lock of a killed worker after one run (the timeout), so the next run every one or
* two minutes is not blocked until the queue retry_after.
*/
public int $uniqueFor = 60;
public function __construct() {}
public function middleware(): array
@@ -30,6 +30,12 @@ class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBe
public $timeout = 600; // 10 minutes max
/**
* Releases the unique lock of a killed worker after one run (the timeout), instead of after the queue
* retry_after, so the next daily run is not blocked.
*/
public int $uniqueFor = 600;
public function __construct() {}
public function middleware(): array
+9
View File
@@ -323,6 +323,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip";
}
foreach ($databasesToBackup as $database) {
// Reset per-database state, so a failure before this database's execution exists
// does not mark the previous database's execution failed or delete its backup file.
$this->backup_log = null;
$this->backup_location = null;
$this->backup_output = null;
$this->error_output = null;
// Generate unique UUID for each database backup execution
$attempts = 0;
do {
@@ -457,6 +464,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'size' => $size,
'filename' => null,
's3_uploaded' => null,
'finished_at' => Carbon::now()->toImmutable(),
]);
}
try {
@@ -505,6 +513,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'size' => $size,
's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null,
'local_storage_deleted' => $localStorageDeleted,
'finished_at' => Carbon::now()->toImmutable(),
]);
// Send appropriate notification (wrapped in try-catch so notification
+61 -11
View File
@@ -16,6 +16,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -27,24 +28,52 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
public ?string $usageBefore = null;
/**
* A manual run waits for a running cleanup (released and tried again), so its exceptions must not
* count as reasons to try again.
*/
public $maxExceptions = 1;
/**
* Seconds a manual run waits before it tries again while another cleanup holds the server lock.
*/
public const MANUAL_RELEASE_DELAY = 60;
public ?DockerCleanupExecution $execution_log = null;
/**
* Shares the per-server lock with queued CleanupDocker runs, so two cleanups never run on
* one server at once. A scheduled run that finds the lock held marks its occurrence
* skipped (not missed); a manual run is dropped as before. The lock outlives the job
* skipped (not missed); a stop-triggered run is dropped. A manual run is released and tried
* again until the running cleanup finishes (see retryUntil()). The lock outlives the job
* timeout, so a new run cannot start while a timed-out run is still finishing.
*/
public function middleware(): array
{
$withoutOverlapping = ScheduledJobDeliveryService::withoutOverlapping(CleanupDocker::overlapLockKey($this->server), $this->occurrenceUuid)
->shared()
->expireAfter(CleanupDocker::overlapLockExpiresAfter());
return [
ScheduledJobDeliveryService::withoutOverlapping(CleanupDocker::overlapLockKey($this->server), $this->occurrenceUuid)
->shared()
->expireAfter(CleanupDocker::overlapLockExpiresAfter())
->dontRelease(),
$this->manualCleanup
? $withoutOverlapping->releaseAfter(self::MANUAL_RELEASE_DELAY)
: $withoutOverlapping->dontRelease(),
];
}
/**
* A manual run may wait for one full cleanup that holds the lock. Fixed at dispatch, so a manual run of a
* killed worker, which comes back after retry_after, fails instead of running late. Other runs try once.
*/
public function retryUntil(): ?\DateTimeInterface
{
if (! $this->manualCleanup) {
return null;
}
return now()->addSeconds(CleanupDocker::overlapLockExpiresAfter() + self::MANUAL_RELEASE_DELAY * 2);
}
public function __construct(
public Server $server,
public bool $manualCleanup = false,
@@ -67,6 +96,7 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
$this->execution_log = DockerCleanupExecution::create([
'server_id' => $this->server->id,
]);
$this->rememberExecution();
if (! $this->server->isFunctional()) {
$this->execution_log->update([
@@ -176,21 +206,41 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
'finished_at' => Carbon::now()->toImmutable(),
]);
}
if (! $failed && $this->executionCacheKey()) {
Cache::forget($this->executionCacheKey());
}
}
}
/**
* failed() runs on a fresh job copy built from the queue payload (for example after a worker timeout),
* so the execution this run created is kept under the queue job uuid instead of on the job.
*/
private function rememberExecution(): void
{
if ($this->executionCacheKey()) {
Cache::put($this->executionCacheKey(), $this->execution_log->id, now()->addSeconds((int) config('queue.connections.redis.retry_after', 86400) + 3600));
}
}
private function executionCacheKey(): ?string
{
$jobUuid = $this->job?->uuid();
return $jobUuid ? 'docker-cleanup-execution:'.$jobUuid : null;
}
public function failed(?\Throwable $exception): void
{
if ($this->occurrenceUuid) {
app(ScheduledJobDeliveryService::class)->fail($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid);
}
$execution = DockerCleanupExecution::query()
->where('server_id', $this->server->id)
->where('status', 'running')
->whereNull('finished_at')
->latest('id')
->first();
// Only this run's own execution. A run that never started (e.g. a manual run whose wait for the
// server lock ran out) created none and must not fail another running cleanup.
$executionId = $this->executionCacheKey() ? Cache::pull($this->executionCacheKey()) : null;
$execution = $executionId ? DockerCleanupExecution::query()->find($executionId) : null;
if (! $execution) {
return;
+5 -1
View File
@@ -10,6 +10,7 @@ use App\Services\GithubRunner\GithubRunnerContainer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
@@ -42,8 +43,11 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue
public function handle(): void
{
// Disabling keeps running jobs, so a disabled server is reconciled until its last runner is done.
Server::query()
->whereHas('githubRunnerConfig')
->where(fn (Builder $query) => $query
->whereHas('githubRunnerConfig', fn (Builder $config) => $config->where('is_enabled', true))
->orWhereHas('githubRunnerExecutions', fn (Builder $execution) => $execution->whereIn('status', GithubRunnerStatus::occupying())))
->with('settings')
->get()
->filter(fn (Server $server) => $server->isFunctional())
@@ -26,6 +26,11 @@ class RevalidateUnusableS3StoragesJob implements ShouldBeEncrypted, ShouldBeUniq
public $timeout = 1800;
/**
* Releases the unique lock of a killed worker after one run, instead of after the queue retry_after.
*/
public int $uniqueFor = 1800;
/**
* Stay well below $timeout, because a single unreachable endpoint can take a while to fail.
*/
+9 -1
View File
@@ -64,15 +64,23 @@ class ScheduledJobManager implements ShouldQueue
private int $skippedCount = 0;
/**
* One key of TYPES, or null for all types.
*
* Declared with a default instead of promoted, so jobs queued by older versions without this property still run.
*/
public ?string $type = null;
/**
* @param string|null $type One key of TYPES, or null for all types.
*/
public function __construct(public ?string $type = null)
public function __construct(?string $type = null)
{
if ($type !== null && ! array_key_exists($type, self::TYPES)) {
throw new \InvalidArgumentException("Unknown scheduled job type [{$type}].");
}
$this->type = $type;
$this->onQueue(crons_queue());
}
+4
View File
@@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Models\NotificationThrottle;
use App\Models\Server;
use App\Notifications\Server\HighDiskUsage;
use Illuminate\Bus\Queueable;
@@ -59,6 +60,9 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
if ($this->percentage > $serverDiskUsageNotificationThreshold) {
$team->notify(new HighDiskUsage($this->server, $this->percentage, $serverDiskUsageNotificationThreshold));
} else {
// Usage recovered: the next spike should alert again instead of waiting for the interval.
NotificationThrottle::release($this->server, HighDiskUsage::class);
}
} catch (\Throwable $e) {
return handleError($e);
+5 -2
View File
@@ -276,10 +276,13 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou
$execution->update(['stop_container_ids' => $containers]);
$remainingFile = $stateFile.'.remaining';
// A container that no longer exists (for example after a redeploy) has nothing to restart; only
// other inspect or start failures keep the container for another recovery attempt.
$script = 'status=0; : > '.escapeshellarg($remainingFile).'; '
.'if [ -f '.escapeshellarg($stateFile).' ]; then while IFS= read -r container; do '
.'[ -z "$container" ] && continue; running=$(docker inspect --format \'{{.State.Running}}\' "$container" 2>/dev/null) '
.'|| { echo "$container" >> '.escapeshellarg($remainingFile).'; status=1; continue; }; '
.'[ -z "$container" ] && continue; running=$(docker inspect --format \'{{.State.Running}}\' "$container" 2>&1) '
.'|| { case "$running" in *"o such object"*|*"o such container"*) continue ;; esac; '
.'echo "$container" >> '.escapeshellarg($remainingFile).'; status=1; continue; }; '
.'if [ "$running" != true ] && ! docker start "$container" >/dev/null; then echo "$container" >> '
.escapeshellarg($remainingFile).'; status=1; fi; done < '.escapeshellarg($stateFile).'; fi; '
.'if [ -s '.escapeshellarg($remainingFile).' ]; then mv '.escapeshellarg($remainingFile).' '.escapeshellarg($stateFile)
+14 -5
View File
@@ -216,7 +216,16 @@ class ImportForm extends Component
return null;
}
return Server::ownedByCurrentTeam()->find($this->serverId);
return Server::query()->where('team_id', $this->resourceTeamId())->find($this->serverId);
}
/**
* Team of the loaded database. The session team can differ: a user can switch teams in
* another tab while this form stays open.
*/
private function resourceTeamId(): ?int
{
return $this->resource?->team()?->id;
}
protected $listeners = [
@@ -467,7 +476,7 @@ class ImportForm extends Component
$source = Storage::exists("upload/{$this->resourceUuid}/restore")
? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase, restoreMysqlUsers: $this->restoreMysqlUsers)
: new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase, restoreMysqlUsers: $this->restoreMysqlUsers);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$activity = StartDatabaseImport::run($this->resource, $source, (int) $this->resourceTeamId());
$this->activityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
@@ -497,7 +506,7 @@ class ImportForm extends Component
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
$this->availableS3Storages = S3Storage::ownedByCurrentTeamAPI((int) $this->resourceTeamId(), ['id', 'name', 'description'])
->where('is_usable', true)
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
@@ -551,7 +560,7 @@ class ImportForm extends Component
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$s3Storage = S3Storage::ownedByCurrentTeamAPI((int) $this->resourceTeamId())->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
@@ -629,7 +638,7 @@ class ImportForm extends Component
try {
$this->importRunning = true;
$source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase, restoreMysqlUsers: $this->restoreMysqlUsers);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$activity = StartDatabaseImport::run($this->resource, $source, (int) $this->resourceTeamId());
$this->activityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
@@ -184,18 +184,20 @@ class ConnectApplication extends Component
}
/**
* Applications owned by the current team that run on the same server as the database.
* Applications of the database's team that run on the same server as the database. The session
* team can differ: a user can switch teams in another tab while this component is still open.
*
* @return Collection<int, Application>
*/
private function connectableApplications(): Collection
{
$serverId = $this->database->destination?->server_id;
if ($serverId === null) {
$teamId = $this->database->team()?->id;
if ($serverId === null || $teamId === null) {
return collect();
}
return Application::ownedByCurrentTeam()
return Application::ownedByCurrentTeamAPI($teamId)
->with(['destination', 'environment.project'])
->get()
->filter(fn (Application $application) => $application->destination?->server_id === $serverId)
+1
View File
@@ -49,6 +49,7 @@ class Domains extends Component
public ?int $pendingRedirectServiceApplicationId = null;
/** @var array<int, array<string, mixed>> */
#[Locked]
public array $domainRows = [];
public ?int $newServiceApplicationId = null;
@@ -8,6 +8,7 @@ use App\Support\ValidationPatterns;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
/**
@@ -124,7 +125,8 @@ class SecretManagerLinks extends Component
$this->settings = [];
}
$settings = array_filter($this->settings, fn ($value) => filled($value));
$validated = $this->validate($this->tokenChangeRules());
$settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value));
$this->resource->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
@@ -138,11 +140,28 @@ class SecretManagerLinks extends Component
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.');
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* The settings rules of the selected provider, with the fields still optional: a token is
* saved before the user fills in its settings.
*
* @return array<string, array<int, string>>
*/
private function tokenChangeRules(): array
{
return collect($this->rules())
->map(fn (array $rules, string $field): array => str_starts_with($field, 'settings.')
? array_map(fn (string $rule): string => $rule === 'required' ? 'nullable' : $rule, $rules)
: $rules)
->all();
}
/**
* Auto-save of the provider-specific settings fields (called on blur).
*/
+5
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Server;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
class DockerImages extends Component
@@ -14,6 +15,10 @@ class DockerImages extends Component
public array $parameters = [];
/**
* Images read from the server. Deletion checks container usage against this list.
*/
#[Locked]
public array $images = [];
public ?array $usage = null;
+3 -1
View File
@@ -22,7 +22,9 @@ class AuditLog extends Component
public function boot(): void
{
abort_unless(auth()->user()->isAdminOfTeam(currentTeam()->id), 403);
$team = currentTeam();
abort_unless($team && auth()->user()->isAdminOfTeam($team->id), 403);
}
public function updatedSearch(): void
+3
View File
@@ -42,6 +42,9 @@ class Deploy extends Tool
$force = filter_var($request->get('force'), FILTER_VALIDATE_BOOLEAN);
$pullRequestId = (int) ($request->get('pull_request_id') ?? 0);
if ($pullRequestId < 0 || $pullRequestId > 2147483647) {
return $this->mcpError($request, 'pull_request_id must be between 0 and 2147483647.', ['resource_uuid' => $uuid]);
}
$deploymentUuid = new_public_id();
$result = queue_application_deployment(
+4 -4
View File
@@ -227,8 +227,9 @@ class LocalPersistentVolume extends BaseModel
/**
* The external Compose volume that this storage entry still replaces, or null. Before Coolify
* used external volumes as written, the parsers gave them a generated name, for example
* "{uuid}_{volume}" or "{uuid}_{volume}-pr-{id}". While this storage entry exists, the parsers
* keep that name so that the resource keeps its data (see useComposeExternalVolumeAsWritten()).
* "{uuid}_{volume}". While this storage entry exists, the parsers keep that name so that the
* resource keeps its data (see useComposeExternalVolumeAsWritten()). A preview volume
* ("{uuid}_{volume}-pr-{id}") replaces nothing: a preview never uses the external volume.
*/
public function replacedExternalComposeVolume(): ?string
{
@@ -256,8 +257,7 @@ class LocalPersistentVolume extends BaseModel
$resource instanceof Application => $resource->uuid.'_'.Str::slug($key, '-'),
default => data_get($resource, 'service.uuid').'_'.Str::slug($key, '-'),
};
$isLegacyName = $legacyName !== $key && $this->name === $legacyName;
if ($isLegacyName || preg_match('/^'.preg_quote($legacyName, '/').'-pr-\d+$/', $this->name) === 1) {
if ($legacyName !== $key && $this->name === $legacyName) {
return $key;
}
}
+43 -2
View File
@@ -312,7 +312,7 @@ class StandaloneDragonfly extends BaseModel
get: function () {
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$port = $this->enable_ssl ? 6380 : 6379;
$encodedPass = rawurlencode($this->dragonfly_password);
$encodedPass = rawurlencode($this->connectionPassword());
$url = "{$scheme}://:{$encodedPass}@{$this->uuid}:{$port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
@@ -334,7 +334,7 @@ class StandaloneDragonfly extends BaseModel
return null;
}
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$encodedPass = rawurlencode($this->dragonfly_password);
$encodedPass = rawurlencode($this->connectionPassword());
$url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
@@ -369,6 +369,47 @@ class StandaloneDragonfly extends BaseModel
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
/**
* The REDIS_PASSWORD variable that sets the server password instead of the stored password.
* Databases created before this release (legacy_password_quoting) keep their v4.3.23 server
* password, the stored one, unless the variable reads a remote secret.
*/
public function serverPasswordEnvironmentVariable(): ?EnvironmentVariable
{
$environmentVariable = $this->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->first();
if (! $environmentVariable) {
return null;
}
if ($this->legacy_password_quoting && ! $this->environmentVariableUsesSecretManager($environmentVariable)) {
return null;
}
return $environmentVariable;
}
/**
* The server password for connection URLs. A remote secret is not fetched here, so the URL
* shows its reference instead of a stale or wrong password.
*/
public function connectionPassword(): string
{
$environmentVariable = $this->serverPasswordEnvironmentVariable();
if ($environmentVariable) {
$password = $this->environmentVariableUsesSecretManager($environmentVariable)
? (string) $environmentVariable->value
: (string) $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
if ($password !== '') {
return $password;
}
}
return (string) $this->dragonfly_password;
}
public function persistentStorages()
{
return $this->morphMany(LocalPersistentVolume::class, 'resource');
+43 -2
View File
@@ -313,7 +313,7 @@ class StandaloneKeydb extends BaseModel
get: function () {
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$port = $this->enable_ssl ? 6380 : 6379;
$encodedPass = rawurlencode($this->keydb_password);
$encodedPass = rawurlencode($this->connectionPassword());
$url = "{$scheme}://:{$encodedPass}@{$this->uuid}:{$port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
@@ -335,7 +335,7 @@ class StandaloneKeydb extends BaseModel
return null;
}
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$encodedPass = rawurlencode($this->keydb_password);
$encodedPass = rawurlencode($this->connectionPassword());
$url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
@@ -370,6 +370,47 @@ class StandaloneKeydb extends BaseModel
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
/**
* The REDIS_PASSWORD variable that sets the server password instead of the stored password.
* Databases created before this release (legacy_password_quoting) keep their v4.3.23 server
* password, the stored one, unless the variable reads a remote secret.
*/
public function serverPasswordEnvironmentVariable(): ?EnvironmentVariable
{
$environmentVariable = $this->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->first();
if (! $environmentVariable) {
return null;
}
if ($this->legacy_password_quoting && ! $this->environmentVariableUsesSecretManager($environmentVariable)) {
return null;
}
return $environmentVariable;
}
/**
* The server password for connection URLs. A remote secret is not fetched here, so the URL
* shows its reference instead of a stale or wrong password.
*/
public function connectionPassword(): string
{
$environmentVariable = $this->serverPasswordEnvironmentVariable();
if ($environmentVariable) {
$password = $this->environmentVariableUsesSecretManager($environmentVariable)
? (string) $environmentVariable->value
: (string) $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
if ($password !== '') {
return $password;
}
}
return (string) $this->keydb_password;
}
public function persistentStorages()
{
return $this->morphMany(LocalPersistentVolume::class, 'resource');
+9 -3
View File
@@ -7,6 +7,9 @@ use App\Models\User;
class IntegrationTokenPolicy
{
/**
* Listing and creating tokens happen on the session team's security pages.
*/
public function viewAny(User $user): bool
{
return $user->isAdmin();
@@ -17,18 +20,21 @@ class IntegrationTokenPolicy
return $user->isAdmin();
}
/**
* A loaded token is checked against its own team: the session team can differ.
*/
public function view(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
return $user->isAdminOfTeam((int) $integrationToken->team_id);
}
public function update(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
return $user->isAdminOfTeam((int) $integrationToken->team_id);
}
public function delete(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
return $user->isAdminOfTeam((int) $integrationToken->team_id);
}
}
+25 -6
View File
@@ -116,9 +116,15 @@ function queue_application_deployment(Application $application, string $deployme
}
if ($admission['started']) {
ApplicationDeploymentJob::dispatch(
application_deployment_queue_id: $deployment->id,
);
try {
ApplicationDeploymentJob::dispatch(
application_deployment_queue_id: $deployment->id,
);
} catch (Throwable $exception) {
fail_undispatchable_deployment($deployment, $exception);
throw $exception;
}
}
return [
@@ -136,10 +142,23 @@ function force_start_deployment(ApplicationDeploymentQueue $deployment): bool
{
return start_queued_deployment($deployment, force: true);
}
function queue_next_deployment(Application $application)
/**
* Start the queued deployments that can run now on the application's primary server, on the
* server of the deployment that just ended (an additional server), and on every other server
* with a queued deployment of this application, because those waited for this one to end.
*/
function queue_next_deployment(Application $application, ?int $finished_deployment_server_id = null)
{
$server_id = $application->destination->server_id;
$queued_deployments = ApplicationDeploymentQueue::where('server_id', $server_id)
$application_queued_server_ids = ApplicationDeploymentQueue::where('application_id', $application->id)
->where('status', ApplicationDeploymentStatus::QUEUED)
->distinct()
->pluck('server_id');
$server_ids = collect([$application->destination->server_id, $finished_deployment_server_id])
->merge($application_queued_server_ids)
->filter(fn ($server_id) => $server_id !== null)
->unique()
->values();
$queued_deployments = ApplicationDeploymentQueue::whereIn('server_id', $server_ids)
->where('status', ApplicationDeploymentStatus::QUEUED)
->get()
->sortBy('created_at');
+29 -12
View File
@@ -342,8 +342,9 @@ function isComposeExternalVolume(mixed $declaration): bool
/**
* Returns the top-level declaration of a volume source when the Compose file declares it as
* external, and null for all other sources. The parsers use an external volume as written: no
* "{uuid}_" prefix, no preview suffix and no LocalPersistentVolume row, so that Coolify never
* removes a volume that it does not own.
* "{uuid}_" prefix and no LocalPersistentVolume row, so that Coolify never removes a volume that it
* does not own. A preview deployment never uses an external volume; it gets its own volume
* "{uuid}_{volume}-pr-{id}" like any other named volume.
*
* @param iterable<array-key, mixed> $topLevelVolumes
* @return array<string, mixed>|null
@@ -364,9 +365,11 @@ function composeExternalVolumeDeclaration(iterable $topLevelVolumes, string $sou
/**
* Tells if a parser uses the volume source as written because it is an external volume.
*
* Only for production deployments: a preview deployment always gets its own volume.
*
* Before Coolify used external volumes as written, the parsers renamed them like all other
* volumes (the old name, for example "{uuid}_{volume}" or "{uuid}_{volume}-pr-{id}"), so the
* resource wrote its data into the renamed volume. When the owner still has the storage entry
* volumes (the old name, for example "{uuid}_{volume}"), so the resource wrote its data into the
* renamed volume. When the owner still has the storage entry
* with the old name, the parser must keep the old name, or the resource loses its data. Then
* this function records a warning on the resource and returns false. When the user deletes that
* storage entry, the next parse uses the external volume.
@@ -431,7 +434,8 @@ function legacyApplicationRenamedVolumeDeclaration(string $name): array
* Records a warning when a legacy Compose application (parser version 1 or 2) does not use an
* external volume as written. These parsers keep the old volume name (see
* legacyApplicationComposeVolumeName()), so the resource keeps its data. The parser version 1 keeps
* the name of a production volume, so it uses the external volume and gets no warning.
* the name of a production volume, so it uses the external volume and gets no warning. A preview
* always uses its own volume, so it gets no warning either.
*
* The volume names are not validated here, so that the legacy parsers keep their old behavior. The
* warning shows the Docker volume name only when it is a literal, valid Docker volume name.
@@ -440,6 +444,9 @@ function legacyApplicationRenamedVolumeDeclaration(string $name): array
*/
function warnLegacyApplicationComposeExternalVolume(Application $resource, iterable $topLevelVolumes, string $source, int $pull_request_id): void
{
if ($pull_request_id !== 0) {
return;
}
$declaration = collect($topLevelVolumes)->get($source);
if (! isComposeExternalVolume($declaration)) {
return;
@@ -1331,6 +1338,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
// Parse the rest of the services
$previewOwnExternalVolumes = collect([]);
foreach ($services as $serviceName => $service) {
$image = data_get_str($service, 'image');
$restart = data_get_str($service, 'restart', RESTART_MODE);
@@ -1481,16 +1489,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
} elseif ($type->value() === 'volume') {
$legacyName = "{$uuid}_".Str::slug($source, '-');
if ($isPullRequest) {
$legacyName = addPreviewDeploymentSuffix($legacyName, $pull_request_id);
}
if (useComposeExternalVolumeAsWritten($resource, $originalResource, $topLevel->get('volumes'), $source->value(), $legacyName)) {
// Previews share the external volume. It gets no row, so Coolify never removes it.
// A preview never mounts an external volume: two deployments that write into the same
// data directory (for example two databases) can corrupt it. The preview gets its own
// volume with the name that older Coolify versions gave it, so it keeps its data, and
// the preview cleanup removes it.
$isPreviewOwnExternalVolume = $isPullRequest
&& composeExternalVolumeDeclaration($topLevel->get('volumes'), $source->value()) !== null;
if (! $isPullRequest && useComposeExternalVolumeAsWritten($resource, $originalResource, $topLevel->get('volumes'), $source->value(), "{$uuid}_".Str::slug($source, '-'))) {
// The external volume gets no row, so Coolify never removes it.
$volumesParsed->put($index, $volume);
continue;
}
if ($isPreviewOwnExternalVolume) {
$previewOwnExternalVolumes->put($source->value(), true);
}
$declaration = $topLevel->get('volumes')->get($source->value());
if (in_array(data_get($declaration, 'driver_opts.type'), ['cifs', 'nfs'], true)) {
// Network volumes are used as written.
@@ -1529,7 +1542,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resource_type' => get_class($originalResource),
]
);
$topLevel->get('volumes')->put($name, composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume, $isPullRequest));
$topLevel->get('volumes')->put($name, $isPreviewOwnExternalVolume
? ['name' => $name]
: composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume, $isPullRequest));
}
dispatch(new ServerFilesFromServerJob($originalResource));
$volumesParsed->put($index, $volume);
@@ -2134,6 +2149,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$parsedServices->put($serviceName, $payload);
}
// The preview mounts its own volumes instead of these external volumes (see above), so it does not declare them.
$topLevel->put('volumes', $topLevel->get('volumes')->except($previewOwnExternalVolumes->keys()->all()));
$topLevel->put('services', $parsedServices);
$customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
+2 -2
View File
@@ -15,8 +15,8 @@ return [
// devHostDockerPath() uses them only for that server. The defaults are the legacy docker-compose.dev.yml names.
'dev_data_volume' => env('DEV_COOLIFY_DATA_VOLUME', 'coolify_dev_coolify_data'),
'dev_backups_volume' => env('DEV_COOLIFY_BACKUPS_VOLUME', 'coolify_dev_backups_data'),
'registry_url' => env('REGISTRY_URL', 'ghcr.io'),
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'registry_url' => env('REGISTRY_URL', 'docker.io'),
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-helper'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
+265 -72
View File
@@ -86,6 +86,7 @@
"properties": {
"pull_request_id": {
"type": "integer",
"maximum": 2147483647,
"minimum": 1,
"example": 42
},
@@ -949,7 +950,7 @@
"custom_internal_name": {
"type": "string",
"nullable": true,
"description": "Custom internal container name."
"description": "Custom internal container name. Requires is_consistent_container_name_enabled to be true."
},
"custom_container_name_prefix": {
"type": "string",
@@ -1570,7 +1571,7 @@
"custom_internal_name": {
"type": "string",
"nullable": true,
"description": "Custom internal container name."
"description": "Custom internal container name. Requires is_consistent_container_name_enabled to be true."
},
"custom_container_name_prefix": {
"type": "string",
@@ -2191,7 +2192,7 @@
"custom_internal_name": {
"type": "string",
"nullable": true,
"description": "Custom internal container name."
"description": "Custom internal container name. Requires is_consistent_container_name_enabled to be true."
},
"custom_container_name_prefix": {
"type": "string",
@@ -2707,7 +2708,7 @@
"custom_internal_name": {
"type": "string",
"nullable": true,
"description": "Custom internal container name."
"description": "Custom internal container name. Requires is_consistent_container_name_enabled to be true."
},
"custom_container_name_prefix": {
"type": "string",
@@ -3203,7 +3204,7 @@
"custom_internal_name": {
"type": "string",
"nullable": true,
"description": "Custom internal container name."
"description": "Custom internal container name. Requires is_consistent_container_name_enabled to be true."
},
"custom_container_name_prefix": {
"type": "string",
@@ -3970,7 +3971,7 @@
"custom_internal_name": {
"type": "string",
"nullable": true,
"description": "Custom internal container name."
"description": "Custom internal container name. Requires is_consistent_container_name_enabled to be true."
},
"custom_container_name_prefix": {
"type": "string",
@@ -5907,6 +5908,178 @@
]
}
},
"\/audit-events": {
"get": {
"tags": [
"Audit Events"
],
"summary": "List",
"description": "List audit events of the team. Only team admins and owners can view audit events. Actor e-mail, token, metadata, changes, IP address, and user agent are returned only for tokens with the read:sensitive or root permission.",
"operationId": "list-audit-events",
"parameters": [
{
"name": "per_page",
"in": "query",
"description": "Items per page.",
"required": false,
"schema": {
"type": "integer",
"default": 25,
"maximum": 100,
"minimum": 1
}
},
{
"name": "page",
"in": "query",
"description": "Page number.",
"required": false,
"schema": {
"type": "integer",
"default": 1,
"minimum": 1
}
},
{
"name": "search",
"in": "query",
"description": "Search text.",
"required": false,
"schema": {
"type": "string",
"maxLength": 255
}
},
{
"name": "action",
"in": "query",
"description": "Filter by action. Use all for every action.",
"required": false,
"schema": {
"type": "string",
"maxLength": 255
}
},
{
"name": "source",
"in": "query",
"description": "Filter by source.",
"required": false,
"schema": {
"type": "string",
"enum": [
"all",
"ui",
"api",
"mcp",
"webhook",
"system",
"scheduler"
]
}
}
],
"responses": {
"200": {
"description": "A page of audit events, newest first.",
"content": {
"application\/json": {
"schema": {
"properties": {
"current_page": {
"type": "integer"
},
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"team_id": {
"type": "integer",
"nullable": true
},
"event": {
"type": "string"
},
"source": {
"type": "string"
},
"action": {
"type": "string"
},
"level": {
"type": "string"
},
"actor_type": {
"type": "string",
"nullable": true
},
"actor_id": {
"type": "integer",
"nullable": true
},
"actor_name": {
"type": "string",
"nullable": true
},
"resource_type": {
"type": "string",
"nullable": true
},
"resource_uuid": {
"type": "string",
"nullable": true
},
"resource_name": {
"type": "string",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time"
}
}
}
},
"per_page": {
"type": "integer"
},
"last_page": {
"type": "integer"
},
"total": {
"type": "integer"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Only team admins and owners can view audit logs."
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/cloud-init-scripts": {
"get": {
"tags": [
@@ -10450,7 +10623,8 @@
"in": "query",
"description": "Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.",
"schema": {
"type": "integer"
"type": "integer",
"maximum": 2147483647
}
},
{
@@ -10458,7 +10632,8 @@
"in": "query",
"description": "Preview deployment identifier. Alias of pr.",
"schema": {
"type": "integer"
"type": "integer",
"maximum": 2147483647
}
},
{
@@ -15404,70 +15579,6 @@
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Private Keys"
],
"summary": "Update",
"description": "Update a private key.",
"operationId": "update-private-key",
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"private_key"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"private_key": {
"type": "string"
}
},
"type": "object",
"additionalProperties": false
}
}
}
},
"responses": {
"201": {
"description": "The updated private key's UUID.",
"content": {
"application\/json": {
"schema": {
"properties": {
"uuid": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/security\/keys\/{uuid}": {
@@ -15582,6 +15693,84 @@
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Private Keys"
],
"summary": "Update",
"description": "Update a private key.",
"operationId": "update-private-key",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "Private Key UUID",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"private_key"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"private_key": {
"type": "string"
}
},
"type": "object",
"additionalProperties": false
}
}
}
},
"responses": {
"201": {
"description": "The updated private key's UUID.",
"content": {
"application\/json": {
"schema": {
"properties": {
"uuid": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"description": "Private Key not found."
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/servers\/{uuid}\/cloudflare-tunnel": {
@@ -24805,6 +24994,10 @@
"name": "Secret Managers",
"description": "Secret Managers"
},
{
"name": "Audit Events",
"description": "Audit Events"
},
{
"name": "Cloud-init Scripts",
"description": "Cloud-init Scripts"
+142 -46
View File
@@ -62,6 +62,7 @@ paths:
properties:
pull_request_id:
type: integer
maximum: 2147483647
minimum: 1
example: 42
pull_request_html_url:
@@ -622,7 +623,7 @@ paths:
custom_internal_name:
type: string
nullable: true
description: 'Custom internal container name.'
description: 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'
custom_container_name_prefix:
type: string
nullable: true
@@ -1031,7 +1032,7 @@ paths:
custom_internal_name:
type: string
nullable: true
description: 'Custom internal container name.'
description: 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'
custom_container_name_prefix:
type: string
nullable: true
@@ -1440,7 +1441,7 @@ paths:
custom_internal_name:
type: string
nullable: true
description: 'Custom internal container name.'
description: 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'
custom_container_name_prefix:
type: string
nullable: true
@@ -1790,7 +1791,7 @@ paths:
custom_internal_name:
type: string
nullable: true
description: 'Custom internal container name.'
description: 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'
custom_container_name_prefix:
type: string
nullable: true
@@ -2126,7 +2127,7 @@ paths:
custom_internal_name:
type: string
nullable: true
description: 'Custom internal container name.'
description: 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'
custom_container_name_prefix:
type: string
nullable: true
@@ -2635,7 +2636,7 @@ paths:
custom_internal_name:
type: string
nullable: true
description: 'Custom internal container name.'
description: 'Custom internal container name. Requires is_consistent_container_name_enabled to be true.'
custom_container_name_prefix:
type: string
nullable: true
@@ -3840,6 +3841,86 @@ paths:
security:
-
bearerAuth: []
/audit-events:
get:
tags:
- 'Audit Events'
summary: List
description: 'List audit events of the team. Only team admins and owners can view audit events. Actor e-mail, token, metadata, changes, IP address, and user agent are returned only for tokens with the read:sensitive or root permission.'
operationId: list-audit-events
parameters:
-
name: per_page
in: query
description: 'Items per page.'
required: false
schema:
type: integer
default: 25
maximum: 100
minimum: 1
-
name: page
in: query
description: 'Page number.'
required: false
schema:
type: integer
default: 1
minimum: 1
-
name: search
in: query
description: 'Search text.'
required: false
schema:
type: string
maxLength: 255
-
name: action
in: query
description: 'Filter by action. Use all for every action.'
required: false
schema:
type: string
maxLength: 255
-
name: source
in: query
description: 'Filter by source.'
required: false
schema:
type: string
enum:
- all
- ui
- api
- mcp
- webhook
- system
- scheduler
responses:
'200':
description: 'A page of audit events, newest first.'
content:
application/json:
schema:
properties:
current_page: { type: integer }
data: { type: array, items: { type: object, properties: { id: { type: integer }, team_id: { type: integer, nullable: true }, event: { type: string }, source: { type: string }, action: { type: string }, level: { type: string }, actor_type: { type: string, nullable: true }, actor_id: { type: integer, nullable: true }, actor_name: { type: string, nullable: true }, resource_type: { type: string, nullable: true }, resource_uuid: { type: string, nullable: true }, resource_name: { type: string, nullable: true }, description: { type: string, nullable: true }, created_at: { type: string, format: date-time } } } }
per_page: { type: integer }
last_page: { type: integer }
total: { type: integer }
type: object
'401':
$ref: '#/components/responses/401'
'403':
description: 'Only team admins and owners can view audit logs.'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/cloud-init-scripts:
get:
tags:
@@ -6856,12 +6937,14 @@ paths:
description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.'
schema:
type: integer
maximum: 2147483647
-
name: pull_request_id
in: query
description: 'Preview deployment identifier. Alias of pr.'
schema:
type: integer
maximum: 2147483647
-
name: docker_tag
in: query
@@ -9894,46 +9977,6 @@ paths:
security:
-
bearerAuth: []
patch:
tags:
- 'Private Keys'
summary: Update
description: 'Update a private key.'
operationId: update-private-key
requestBody:
required: true
content:
application/json:
schema:
required:
- private_key
properties:
name:
type: string
description:
type: string
private_key:
type: string
type: object
additionalProperties: false
responses:
'201':
description: "The updated private key's UUID."
content:
application/json:
schema:
properties:
uuid: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/security/keys/{uuid}':
get:
tags:
@@ -10005,6 +10048,56 @@ paths:
security:
-
bearerAuth: []
patch:
tags:
- 'Private Keys'
summary: Update
description: 'Update a private key.'
operationId: update-private-key
parameters:
-
name: uuid
in: path
description: 'Private Key UUID'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- private_key
properties:
name:
type: string
description:
type: string
private_key:
type: string
type: object
additionalProperties: false
responses:
'201':
description: "The updated private key's UUID."
content:
application/json:
schema:
properties:
uuid: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
description: 'Private Key not found.'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/servers/{uuid}/cloudflare-tunnel':
get:
tags:
@@ -15931,6 +16024,9 @@ tags:
-
name: 'Secret Managers'
description: 'Secret Managers'
-
name: 'Audit Events'
description: 'Audit Events'
-
name: 'Cloud-init Scripts'
description: 'Cloud-init Scripts'
@@ -0,0 +1,80 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$team = Team::factory()->create();
$primaryServer = Server::factory()->create(['team_id' => $team->id]);
$this->additionalServer = Server::factory()->create(['team_id' => $team->id]);
$this->additionalServer->settings->update(['concurrent_builds' => 1]);
$primaryDestination = StandaloneDocker::where('server_id', $primaryServer->id)->firstOrFail();
$this->additionalDestination = StandaloneDocker::where('server_id', $this->additionalServer->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = $project->environments()->first()
?? Environment::factory()->create(['project_id' => $project->id]);
$makeApplication = function (Server $deploymentServer, StandaloneDocker $deploymentDestination) use ($environment): Application {
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $deploymentDestination->id,
'destination_type' => $deploymentDestination->getMorphClass(),
]);
$application->settings->update(['is_consistent_container_name_enabled' => true]);
return $application;
};
$this->multiServerApplication = $makeApplication($primaryServer, $primaryDestination);
$this->multiServerApplication->additional_servers()->attach($this->additionalServer->id, [
'standalone_docker_id' => $this->additionalDestination->id,
]);
$this->otherApplication = $makeApplication($this->additionalServer, $this->additionalDestination);
Process::fake(['*' => Process::result(output: '')]);
Queue::fake();
Notification::fake();
});
test('a finished additional-server deployment starts the next deployment queued on that server', function () {
$childDeployment = ApplicationDeploymentQueue::create([
'application_id' => $this->multiServerApplication->id,
'deployment_uuid' => 'prr-child-deployment',
'status' => 'in_progress',
'server_id' => $this->additionalServer->id,
'destination_id' => $this->additionalDestination->id,
'commit' => 'HEAD',
'pull_request_id' => 0,
'parent_deployment_uuid' => 'prr-parent-deployment',
]);
$waitingDeployment = ApplicationDeploymentQueue::create([
'application_id' => $this->otherApplication->id,
'deployment_uuid' => 'prr-waiting-deployment',
'status' => 'queued',
'server_id' => $this->additionalServer->id,
'destination_id' => $this->additionalDestination->id,
'commit' => 'HEAD',
'pull_request_id' => 0,
]);
(new ApplicationDeploymentJob($childDeployment->id))->failed(new RuntimeException('build failed'));
expect($childDeployment->fresh()->status)->toBe('failed')
->and($waitingDeployment->fresh()->status)->toBe('in_progress');
Queue::assertPushed(ApplicationDeploymentJob::class, fn (ApplicationDeploymentJob $job) => $job->application_deployment_queue_id === $waitingDeployment->id);
});
@@ -0,0 +1,126 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
config(['app.maintenance.store' => 'array']);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = $this->user->createToken('custom-internal-name-test', ['*'])->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function customInternalNameCreatePayload(array $overrides): array
{
return array_merge([
'project_uuid' => test()->project->uuid,
'environment_uuid' => test()->environment->uuid,
'server_uuid' => test()->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/custom-internal-name',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'autogenerate_domain' => false,
], $overrides);
}
const CUSTOM_INTERNAL_NAME_NEEDS_CONSISTENT_NAMING = 'Set is_consistent_container_name_enabled to true to use custom_internal_name. Coolify ignores the custom internal name while consistent container naming is turned off.';
test('updating only custom_internal_name is rejected while consistent container naming is off', function () {
$this->withToken($this->bearerToken)
->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_internal_name' => 'my-app'])
->assertUnprocessable()
->assertJsonPath('errors.custom_internal_name.0', CUSTOM_INTERNAL_NAME_NEEDS_CONSISTENT_NAMING);
expect($this->application->fresh()->settings->custom_internal_name)->toBeNull();
});
test('updating custom_internal_name while turning consistent container naming off is rejected', function () {
$this->application->settings->update(['is_consistent_container_name_enabled' => true]);
$this->withToken($this->bearerToken)
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'custom_internal_name' => 'my-app',
'is_consistent_container_name_enabled' => false,
])
->assertUnprocessable()
->assertJsonPath('errors.custom_internal_name.0', CUSTOM_INTERNAL_NAME_NEEDS_CONSISTENT_NAMING);
});
test('updating custom_internal_name together with consistent container naming succeeds', function () {
$this->withToken($this->bearerToken)
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'custom_internal_name' => 'my-app',
'is_consistent_container_name_enabled' => true,
])
->assertOk();
$settings = $this->application->fresh()->settings;
expect($settings->custom_internal_name)->toBe('my-app')
->and($settings->is_consistent_container_name_enabled)->toBeTrue();
});
test('updating only custom_internal_name succeeds when consistent container naming is already on', function () {
$this->application->settings->update(['is_consistent_container_name_enabled' => true]);
$this->withToken($this->bearerToken)
->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_internal_name' => 'my-app'])
->assertOk();
expect($this->application->fresh()->settings->custom_internal_name)->toBe('my-app');
});
test('clearing custom_internal_name does not need consistent container naming', function (?string $value) {
$this->withToken($this->bearerToken)
->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_internal_name' => $value])
->assertOk();
})->with(['null' => [null], 'empty string' => ['']]);
test('creating an application with custom_internal_name requires consistent container naming', function () {
Queue::fake();
$this->withToken($this->bearerToken)
->postJson('/api/v1/applications/public', customInternalNameCreatePayload(['custom_internal_name' => 'my-app']))
->assertUnprocessable()
->assertJsonPath('errors.custom_internal_name.0', CUSTOM_INTERNAL_NAME_NEEDS_CONSISTENT_NAMING);
expect(Application::query()->count())->toBe(1);
});
test('creating an application with custom_internal_name and consistent container naming succeeds', function () {
Queue::fake();
$response = $this->withToken($this->bearerToken)
->postJson('/api/v1/applications/public', customInternalNameCreatePayload([
'custom_internal_name' => 'my-app',
'is_consistent_container_name_enabled' => true,
]))
->assertCreated();
expect(Application::query()->where('uuid', $response->json('uuid'))->sole()->settings->custom_internal_name)->toBe('my-app');
});
@@ -380,6 +380,7 @@ test('PATCH /api/v1/applications/{uuid} accepts Docker-compatible custom interna
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'custom_internal_name' => $name,
'is_consistent_container_name_enabled' => true,
])
->assertOk();
+1 -1
View File
@@ -26,7 +26,7 @@ beforeEach(function () {
$this->user = User::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['write', 'read'], 'team_id' => $this->team->id]);
$this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['write', 'read', 'deploy'], 'team_id' => $this->team->id]);
$this->headers = ['Authorization' => 'Bearer '.$this->token->id.'|secret'];
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::firstOrCreate(['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']);
@@ -0,0 +1,89 @@
<?php
use App\Actions\Database\StartDatabaseImport;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::firstOrCreate(['server_id' => $server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]);
$service = Service::factory()->create(['environment_id' => $environment->id, 'server_id' => $server->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), 'docker_compose_raw' => "services:\n postgres:\n image: postgres:17\n"]);
$serviceDatabase = ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'name' => 'postgres', 'service_id' => $service->id, 'image' => 'postgres:17']);
$this->importPaths = [
'standalone' => "/api/v1/databases/{$database->uuid}/imports",
'service' => "/api/v1/services/{$service->uuid}/databases/{$serviceDatabase->uuid}/imports",
];
});
function serverSourceImportToken(User $user, Team $team, array $abilities): string
{
$token = $user->createToken('server-source-import', $abilities);
$token->accessToken->forceFill(['team_id' => $team->id])->save();
return $token->plainTextToken;
}
function expectServerSourceImportStarts(): void
{
$activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]);
$action = Mockery::mock(StartDatabaseImport::class);
$action->shouldReceive('handle')->once()->andReturn($activity);
app()->instance(StartDatabaseImport::class, $action);
}
dataset('server source import variants', ['standalone', 'service']);
test('a write token cannot import from a server path', function (string $variant) {
$action = Mockery::mock(StartDatabaseImport::class);
$action->shouldNotReceive('handle');
app()->instance(StartDatabaseImport::class, $action);
$this->withToken(serverSourceImportToken($this->user, $this->team, ['read', 'write']))
->postJson($this->importPaths[$variant], ['source' => 'server', 'path' => '/etc/hostname'])
->assertForbidden()
->assertJsonPath('message', 'Missing required permissions: deploy. An import from a server path needs a token with the deploy permission.');
})->with('server source import variants');
test('a write token with deploy or a root token can import from a server path', function (string $variant, array $abilities) {
expectServerSourceImportStarts();
$this->withToken(serverSourceImportToken($this->user, $this->team, $abilities))
->postJson($this->importPaths[$variant], ['source' => 'server', 'path' => '/tmp/backup.sql'])
->assertAccepted();
})->with('server source import variants')->with([
'write and deploy' => [['read', 'write', 'deploy']],
'root' => [['root']],
]);
test('a write token can still import from an upload without deploy', function () {
expectServerSourceImportStarts();
$this->withToken(serverSourceImportToken($this->user, $this->team, ['read', 'write']))
->postJson($this->importPaths['standalone'], ['source' => 'upload', 'upload_id' => (string) Str::uuid()])
->assertAccepted();
});
@@ -0,0 +1,92 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake();
Server::flushIdentityMap();
config()->set('app.maintenance.store', 'array');
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = $this->user->createToken('preview-range', ['*'])->plainTextToken;
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->application = Application::factory()->create([
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'nginx',
'docker_registry_image_tag' => 'alpine',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
test('creating a preview rejects a pull request id above the database integer range', function () {
$this->withToken($this->bearerToken)
->postJson("/api/v1/applications/{$this->application->uuid}/previews", [
'pull_request_id' => 2147483648,
'docker_tag' => 'pr-1',
'instant_deploy' => false,
])
->assertUnprocessable()
->assertJsonValidationErrors('pull_request_id');
expect($this->application->previews()->count())->toBe(0);
});
test('creating a preview accepts the largest database integer pull request id', function () {
$this->withToken($this->bearerToken)
->postJson("/api/v1/applications/{$this->application->uuid}/previews", [
'pull_request_id' => 2147483647,
'docker_tag' => 'pr-1',
'instant_deploy' => false,
])
->assertCreated()
->assertJsonPath('preview.pull_request_id', 2147483647);
});
test('deploy rejects a pull request id above the database integer range', function (string $field) {
$this->withToken($this->bearerToken)
->postJson('/api/v1/deploy', ['uuid' => $this->application->uuid, $field => '2147483648'])
->assertStatus(400)
->assertJsonPath('message', 'pull_request_id must be between 1 and 2147483647.');
Bus::assertNothingDispatched();
})->with(['pr', 'pull_request_id']);
test('deploy passes the largest database integer pull request id to the preview lookup', function () {
$this->withToken($this->bearerToken)
->postJson('/api/v1/deploy', ['uuid' => $this->application->uuid, 'pr' => 2147483647])
->assertOk()
->assertJsonPath('deployments.0.message', 'Pull request 2147483647 not found for this resource.');
});
test('preview routes reject a pull request id above the database integer range', function (string $method, string $suffix) {
$this->withToken($this->bearerToken)
->json($method, "/api/v1/applications/{$this->application->uuid}/previews/2147483648{$suffix}")
->assertUnprocessable()
->assertJsonPath('message', 'Invalid pull_request_id.');
})->with([
'show' => ['GET', ''],
'update' => ['PATCH', ''],
'delete' => ['DELETE', ''],
'logs' => ['GET', '/logs'],
]);
@@ -131,7 +131,8 @@ it('removes only the network and volumes that Coolify generated for the preview'
expect($resourceCommands)
->toContain("sudo docker volume rm -f '{$this->uuid}_app-data-pr-42'")
->not->toContain('shared-data-pr-42')
// A preview gets its own volume instead of the external one.
->toContain("sudo docker volume rm -f '{$this->uuid}_shared-data-pr-42'")
->toContain("sudo docker network disconnect '{$this->previewNetwork}' coolify-proxy")
->toContain("sudo docker network rm '{$this->previewNetwork}'")
->not->toContain("'app-data'")
+77 -18
View File
@@ -304,18 +304,34 @@ describe('applicationParser', function () {
->and(persistentVolumeNames())->toBe([]);
});
it('shares the external volume with a preview deployment', function () {
it('gives a preview deployment its own volume instead of the external volume', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE);
$uuid = $application->uuid;
$preview = externalVolumePreview($application);
$compose = applicationParser($application, 42, $preview->id)->toArray();
expect($compose['services']['web-pr-42']['volumes'])->toContain('shared-data:/data:ro')
expect($compose['services']['web-pr-42']['volumes'])->toContain("{$uuid}_shared-data-pr-42:/data:ro")
->toContain("{$uuid}_app-data-pr-42:/app")
->and($compose['volumes']['shared-data'])->toBe(['external' => true])
->and($compose['volumes'])->not->toHaveKey("{$uuid}_shared-data-pr-42")
->and(persistentVolumeNames())->toBe(["{$uuid}_app-data-pr-42"]);
->not->toContain('shared-data:/data:ro')
->and($compose['volumes']["{$uuid}_shared-data-pr-42"])->toBe(['name' => "{$uuid}_shared-data-pr-42"])
->and($compose['volumes'])->not->toHaveKey('shared-data')
->and(persistentVolumeNames())->toBe(["{$uuid}_app-data-pr-42", "{$uuid}_shared-data-pr-42"])
->and($application->composeVolumeWarnings())->toBe([]);
});
it('gives a preview deployment its own volume instead of a named external volume in long syntax', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_LONG_COMPOSE);
$uuid = $application->uuid;
$preview = externalVolumePreview($application);
$compose = applicationParser($application, 42, $preview->id);
$sources = collect($compose->toArray()['services']['web-pr-42']['volumes'])->pluck('source')->all();
expect($sources)->toBe(["{$uuid}_shared-data-pr-42", "{$uuid}_app-data-pr-42"])
->and($compose->toArray()['volumes']["{$uuid}_shared-data-pr-42"])->toBe(['name' => "{$uuid}_shared-data-pr-42"])
->and(Yaml::dump(convertToArray($compose), 10, 2))->not->toContain('existing-shared-volume')
->and($application->composeVolumeWarnings())->toBe([]);
});
it('does not show a warning when the resource has no old storage entry', function () {
@@ -354,21 +370,22 @@ describe('applicationParser', function () {
->and($application->composeVolumeWarnings())->toBe([legacyExternalVolumeWarning("{$uuid}_shared-data", 'existing-shared-volume')]);
});
it('keeps the old prefixed preview volume when the resource has its old storage entry', function () {
it('reuses the preview volume of an older Coolify version without a warning', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE);
$uuid = $application->uuid;
$preview = externalVolumePreview($application);
legacyExternalVolumeRow($application, "{$uuid}_shared-data-pr-42");
$oldVolume = legacyExternalVolumeRow($application, "{$uuid}_shared-data-pr-42");
$compose = applicationParser($application, 42, $preview->id)->toArray();
expect($compose['services']['web-pr-42']['volumes'])->toContain("{$uuid}_shared-data-pr-42:/data:ro")
->and($compose['volumes']["{$uuid}_shared-data-pr-42"])->toBe(['name' => "{$uuid}_shared-data-pr-42"])
->and(LocalPersistentVolume::find($oldVolume->id)?->name)->toBe("{$uuid}_shared-data-pr-42")
->and(persistentVolumeNames())->toBe(["{$uuid}_app-data-pr-42", "{$uuid}_shared-data-pr-42"])
->and($application->composeVolumeWarnings())->toBe([legacyExternalVolumeWarning("{$uuid}_shared-data-pr-42")]);
->and($application->composeVolumeWarnings())->toBe([]);
});
it('uses the external volume in a preview when only the production volume has an old storage entry', function () {
it('gives a preview its own volume when only the production volume has an old storage entry', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE);
$uuid = $application->uuid;
$preview = externalVolumePreview($application);
@@ -376,7 +393,9 @@ describe('applicationParser', function () {
$compose = applicationParser($application, 42, $preview->id)->toArray();
expect($compose['services']['web-pr-42']['volumes'])->toContain('shared-data:/data:ro')
expect($compose['services']['web-pr-42']['volumes'])->toContain("{$uuid}_shared-data-pr-42:/data:ro")
->not->toContain('shared-data:/data:ro')
->not->toContain("{$uuid}_shared-data:/data:ro")
->and($application->composeVolumeWarnings())->toBe([]);
});
@@ -532,7 +551,7 @@ describe('legacy parsers', function () {
->and($service->composeVolumeWarnings())->toBe([]);
});
it('keeps the old volume names of a legacy application and shows a warning when the name is not the external name', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedVolume, ?string $dockerVolume) {
it('keeps the old volume names of a legacy application and shows a warning when production does not use the external name', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedVolume, ?string $dockerVolume) {
// Legacy Compose applications (parser versions 1 and 2) never stored their volumes, so Coolify
// cannot tell which external volume already holds data: they always keep the old names.
$application = externalVolumeApplication($compose, $parsingVersion);
@@ -548,7 +567,8 @@ describe('legacy parsers', function () {
->and(array_keys($parsed['volumes'] ?? []))->toContain($expectedName)
->and($application->composeVolumeWarnings())->toBe(
// Parser version 1 keeps the name of a production volume, so it uses the external volume.
$expectedName === 'shared-data' ? [] : [legacyParserExternalVolumeWarning($expectedName, $dockerVolume)]
// A preview always uses its own volume, so it gets no warning.
$pullRequestId !== 0 || $expectedName === 'shared-data' ? [] : [legacyParserExternalVolumeWarning($expectedName, $dockerVolume)]
);
})->with([
'v1 short' => ['1', 0, EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data:/data:ro', null],
@@ -572,8 +592,20 @@ describe('legacy parsers', function () {
->and($logEntries)->toBe([['Warning: '.legacyParserExternalVolumeWarning($keptName), 'stderr']]);
})->with([
'v2 production' => ['2', 0, '{uuid}-shared-data'],
'v2 preview' => ['2', 42, '{uuid}-shared-data-pr-42'],
'v1 preview' => ['1', 42, 'shared-data-pr-42'],
]);
it('writes no warning to the deployment log of a legacy application preview, which always uses its own volume', function (string $parsingVersion, string $keptName) {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, $parsingVersion);
$keptName = str_replace('{uuid}', $application->uuid, $keptName);
$preview = externalVolumePreview($application);
[$composeFile, $logEntries] = parseExternalVolumeDeployment($application, 42, $preview);
expect(data_get($composeFile, 'services.web-pr-42.volumes'))->toContain("{$keptName}:/data:ro")
->and($logEntries)->toBe([]);
})->with([
'v2 preview' => ['2', '{uuid}-shared-data-pr-42'],
'v1 preview' => ['1', 'shared-data-pr-42'],
]);
it('writes no warning to the deployment log for a production deployment of a parser version 1 application', function () {
@@ -842,8 +874,8 @@ describe('delete', function () {
expect(LocalPersistentVolume::find($volume->id))->not->toBeNull();
});
it('does not remove the external volume when a preview is deleted', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE);
it('removes the own volume of the preview, but not the external volume, when a preview is deleted', function (string $compose, string $externalVolume) {
$application = externalVolumeApplication($compose);
$uuid = $application->uuid;
$preview = externalVolumePreview($application);
$commands = [];
@@ -853,7 +885,23 @@ describe('delete', function () {
$volumeCommands = collect($commands)->filter(fn (string $command): bool => str_contains($command, 'docker volume'))->implode("\n");
expect($volumeCommands)->toContain("docker volume rm -f '{$uuid}_app-data-pr-42'")
->not->toContain('shared-data');
->toContain("docker volume rm -f '{$uuid}_shared-data-pr-42'")
->not->toContain("'{$externalVolume}'");
})->with([
'short syntax' => [EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data'],
'named external volume' => [EXTERNAL_VOLUME_LONG_COMPOSE, 'existing-shared-volume'],
]);
it('does not mark the own volume of a preview as an entry that replaces the external volume', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE);
$uuid = $application->uuid;
$preview = externalVolumePreview($application);
$productionEntry = legacyExternalVolumeRow($application, "{$uuid}_shared-data");
applicationParser($application, 42, $preview->id);
$previewEntry = LocalPersistentVolume::query()->where('name', "{$uuid}_shared-data-pr-42")->firstOrFail();
expect($previewEntry->replacedExternalComposeVolume())->toBeNull()
->and($productionEntry->replacedExternalComposeVolume())->toBe('shared-data');
});
it('removes the old prefixed preview volume, but not the external volume, when a preview is deleted', function () {
@@ -886,9 +934,20 @@ describe('warnings', function () {
->and($logEntries)->toBe([['Warning: '.legacyExternalVolumeWarning(str_replace('{uuid}', $uuid, $oldName)), 'stderr']]);
})->with([
'production' => [0, '{uuid}_shared-data', '{uuid}_shared-data:/data:ro'],
'preview' => [42, '{uuid}_shared-data-pr-42', '{uuid}_shared-data-pr-42:/data:ro'],
]);
it('writes no warning to the deployment log of a preview, which always uses its own volume', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE);
$uuid = $application->uuid;
$preview = externalVolumePreview($application);
legacyExternalVolumeRow($application, "{$uuid}_shared-data-pr-42");
[$composeFile, $logEntries] = parseExternalVolumeDeployment($application, 42, $preview);
expect(data_get($composeFile, 'services.web-pr-42.volumes'))->toContain("{$uuid}_shared-data-pr-42:/data:ro")
->and($logEntries)->toBe([]);
});
it('writes no warning to the deployment log when the external volume is used as written', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE);
@@ -0,0 +1,115 @@
<?php
use App\Events\BackupCreated;
use App\Jobs\DatabaseBackupJob;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
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');
Event::fake([BackupCreated::class]);
Notification::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
});
afterEach(function () {
ScheduledDatabaseBackupExecution::flushEventListeners();
});
it('keeps the successful execution of an earlier database when creating the next execution fails', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
$database = create_standalone_postgresql($environment->id, $destination, ['status' => 'running:healthy']);
$backup = new ScheduledDatabaseBackup;
$backup->forceFill([
'enabled' => true,
'save_s3' => false,
'frequency' => 'daily',
'databases_to_backup' => 'first,second',
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'team_id' => $team->id,
])->save();
$commands = collect();
Process::fake(function ($process) use ($commands) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
$commands->push($command);
return Process::result(output: str_contains($command, 'du -b') ? '128' : '');
});
ScheduledDatabaseBackupExecution::creating(function (ScheduledDatabaseBackupExecution $execution) {
if ($execution->database_name === 'second') {
throw new RuntimeException('database is unavailable');
}
});
(new DatabaseBackupJob($backup->fresh()))->handle();
$first = ScheduledDatabaseBackupExecution::query()->sole();
expect($first->database_name)->toBe('first')
->and($first->status)->toBe('success')
->and($first->filename)->not->toBeNull()
->and($commands->contains(fn (string $command) => str_contains($command, 'rm ') && str_contains($command, $first->filename)))->toBeFalse();
});
it('finishes the execution of every database in a run with several databases', function () {
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
$database = create_standalone_postgresql($environment->id, $destination, ['status' => 'running:healthy']);
$backup = new ScheduledDatabaseBackup;
$backup->forceFill([
'enabled' => true,
'save_s3' => false,
'frequency' => 'daily',
'databases_to_backup' => 'first,second,third',
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'team_id' => $team->id,
])->save();
Process::fake(function ($process) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
if (str_contains($command, 'pg_dump') && str_contains($command, 'second')) {
return Process::result(errorOutput: 'pg_dump: database "second" does not exist', exitCode: 1);
}
return Process::result(output: str_contains($command, 'du -b') ? '128' : '');
});
(new DatabaseBackupJob($backup->fresh()))->handle();
$executions = ScheduledDatabaseBackupExecution::query()->orderBy('id')->get();
expect($executions->map->only(['database_name', 'status'])->all())->toBe([
['database_name' => 'first', 'status' => 'success'],
['database_name' => 'second', 'status' => 'failed'],
['database_name' => 'third', 'status' => 'success'],
])
->and($executions->whereNull('finished_at')->pluck('database_name')->all())->toBe([]);
});
@@ -89,7 +89,7 @@ describe('locked properties', function () {
});
describe('server method uses team scoping', function () {
test('server computed property calls ownedByCurrentTeam', function () {
test('server computed property is scoped to the database team', function () {
$method = new ReflectionMethod(ImportForm::class, 'server');
// Extract the server method body
@@ -98,7 +98,7 @@ describe('server method uses team scoping', function () {
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
$methodBody = implode('', $lines);
expect($methodBody)->toContain('ownedByCurrentTeam');
expect($methodBody)->toContain('where(\'team_id\', $this->resourceTeamId())');
expect($methodBody)->not->toContain('Server::find($this->serverId)');
});
});
@@ -0,0 +1,153 @@
<?php
use App\Actions\Database\StartDatabaseImport;
use App\Livewire\Project\Database\ImportForm;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate([
'id' => 0,
'disable_two_step_confirmation' => true,
]);
$this->resourceTeam = Team::factory()->create();
$this->otherTeam = Team::factory()->create();
$this->user = User::factory()->create();
$this->resourceTeam->members()->attach($this->user, ['role' => 'admin']);
$this->otherTeam->members()->attach($this->user, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->resourceTeam]);
$this->server = Server::factory()->create(['team_id' => $this->resourceTeam->id]);
$destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'docker']
);
$project = Project::factory()->create(['team_id' => $this->resourceTeam->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'db',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'db',
'image' => 'postgres:17',
'status' => 'running',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$s3Attributes = ['region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'backups', 'endpoint' => 'https://s3.example.com', 'is_usable' => true];
$this->resourceTeamStorage = S3Storage::create([...$s3Attributes, 'team_id' => $this->resourceTeam->id, 'name' => 'resource-team-s3']);
$this->otherTeamStorage = S3Storage::create([...$s3Attributes, 'team_id' => $this->otherTeam->id, 'name' => 'other-team-s3']);
});
class ImportFormResourceTeamTestComponent extends ImportForm
{
public function mount($database = null, $server = null): void
{
$this->resourceId = $database->id;
$this->resourceType = $database::class;
$this->serverId = $server->id;
$this->container = $database->uuid;
$this->resourceUuid = $database->uuid;
$this->resourceStatus = $database->status ?? '';
$this->resourceDbType = $database->type();
$this->loadAvailableS3Storages();
}
public function render()
{
return view('livewire.project.database.import-form');
}
}
function importFormAfterTeamSwitch(): Testable
{
$component = Livewire::test(ImportFormResourceTeamTestComponent::class, [
'database' => test()->database,
'server' => test()->server,
]);
session(['currentTeam' => test()->otherTeam]);
return $component;
}
function fakeStartedDatabaseImport(): Activity
{
return Activity::create([
'log_name' => 'default',
'description' => 'queued',
'properties' => ['status' => 'queued'],
]);
}
test('a file import started after a session team switch runs for the database team', function () {
StartDatabaseImport::shouldRun()->once()
->withArgs(fn ($resource, $source, int $teamId): bool => $teamId === $this->resourceTeam->id)
->andReturn(fakeStartedDatabaseImport());
importFormAfterTeamSwitch()
->set('filename', 'backup.dump')
->call('runImport')
->assertNotDispatched('error')
->assertDispatched('databaserestore');
});
test('an S3 restore started after a session team switch runs for the database team', function () {
StartDatabaseImport::shouldRun()->once()
->withArgs(fn ($resource, $source, int $teamId): bool => $teamId === $this->resourceTeam->id
&& $source->s3StorageUuid === (string) $this->resourceTeamStorage->id)
->andReturn(fakeStartedDatabaseImport());
importFormAfterTeamSwitch()
->set('s3StorageId', $this->resourceTeamStorage->id)
->set('s3Path', '/backups/backup.dump')
->set('s3FileSize', 1024)
->call('restoreFromS3')
->assertNotDispatched('error')
->assertDispatched('databaserestore');
});
test('the S3 storage list contains only storages of the database team', function () {
importFormAfterTeamSwitch()
->call('loadAvailableS3Storages')
->assertSet('availableS3Storages', fn (array $storages): bool => collect($storages)->pluck('id')->all() === [$this->resourceTeamStorage->id]);
});
test('an S3 storage of another team cannot be checked for the database', function () {
importFormAfterTeamSwitch()
->set('s3StorageId', $this->otherTeamStorage->id)
->set('s3Path', '/backups/backup.dump')
->call('checkS3File')
->assertNotFound();
});
test('a member of the database team cannot start an import after switching to a team they own', function () {
$this->resourceTeam->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->user->refresh();
StartDatabaseImport::shouldRun()->never();
importFormAfterTeamSwitch()
->set('filename', 'backup.dump')
->call('runImport')
->assertForbidden();
});
+137 -23
View File
@@ -1,6 +1,7 @@
<?php
use App\Actions\Server\CleanupDocker;
use App\Events\DockerCleanupDone;
use App\Jobs\DockerCleanupJob;
use App\Models\Application;
use App\Models\DockerCleanupExecution;
@@ -16,13 +17,20 @@ use App\Models\User;
use App\Notifications\Server\DockerCleanupFailed;
use App\Services\ScheduledJobDeliveryService;
use App\Support\Actions\UniqueUntilProcessingJobDecorator;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Jobs\FakeJob;
use Illuminate\Queue\MaxAttemptsExceededException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Decorators\JobDecorator;
@@ -128,26 +136,63 @@ it('creates a failed execution record when server is force disabled', function (
->and($execution->message)->toContain('not functional');
});
it('finishes the latest running execution when the job fails after a timeout', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
function dockerCleanupQueueJob(string $uuid): FakeJob
{
return new class($uuid) extends FakeJob
{
public function __construct(private string $jobUuid) {}
$olderExecution = DockerCleanupExecution::create([
'server_id' => $server->id,
]);
$timedOutExecution = DockerCleanupExecution::create([
'server_id' => $server->id,
]);
public function getRawBody()
{
return json_encode(['uuid' => $this->jobUuid]);
}
};
}
$job = new DockerCleanupJob($server);
$job->failed(new RuntimeException('Docker cleanup job has timed out.'));
it('finishes its own execution when the job fails after a timeout, not a newer running cleanup', function () {
Notification::fake();
Event::fake([DockerCleanupDone::class]);
$server = dockerCleanupReachableServer();
$queueJob = dockerCleanupQueueJob('prr-fix-timed-out-cleanup');
$statesWhenTimedOut = null;
dockerCleanupFakeProcesses(function () use ($server, $queueJob, &$statesWhenTimedOut) {
if ($statesWhenTimedOut !== null) {
return;
}
$ownExecution = DockerCleanupExecution::query()->sole();
$scheduledExecution = DockerCleanupExecution::create(['server_id' => $server->id]);
expect($timedOutExecution->refresh()->status)->toBe('failed')
->and($timedOutExecution->message)->toBe('Docker cleanup job has timed out.')
->and($timedOutExecution->finished_at)->not->toBeNull()
->and($olderExecution->refresh()->status)->toBe('running')
->and($olderExecution->finished_at)->toBeNull();
// The worker timeout calls failed() on a fresh job copy built from the same queue payload.
(new DockerCleanupJob($server, true))->setJob($queueJob)->failed(new RuntimeException('Docker cleanup job has timed out.'));
$statesWhenTimedOut = [
'own' => $ownExecution->fresh()->only(['status', 'message']),
'ownFinished' => $ownExecution->fresh()->finished_at !== null,
'scheduled' => $scheduledExecution->fresh()->only(['status', 'finished_at']),
];
});
(new DockerCleanupJob($server, true))->setJob($queueJob)->handle();
expect($statesWhenTimedOut['own'])->toBe(['status' => 'failed', 'message' => 'Docker cleanup job has timed out.'])
->and($statesWhenTimedOut['ownFinished'])->toBeTrue()
->and($statesWhenTimedOut['scheduled'])->toBe(['status' => 'running', 'finished_at' => null]);
});
it('leaves a running scheduled cleanup untouched when a manual cleanup fails before it started', function () {
Notification::fake();
Event::fake([DockerCleanupDone::class]);
$server = dockerCleanupReachableServer();
$scheduledExecution = DockerCleanupExecution::create(['server_id' => $server->id]);
(new DockerCleanupJob($server, true))
->setJob(dockerCleanupQueueJob('prr-fix-waiting-manual-cleanup'))
->failed(new MaxAttemptsExceededException('App\Jobs\DockerCleanupJob has been attempted too many times.'));
expect($scheduledExecution->fresh()->status)->toBe('running')
->and($scheduledExecution->fresh()->finished_at)->toBeNull();
Notification::assertNothingSent();
Event::assertNotDispatched(DockerCleanupDone::class);
});
it('skips a scheduled cleanup occurrence while another cleanup holds the server lock, so it is not reported missed later', function () {
@@ -183,18 +228,87 @@ it('skips a scheduled cleanup occurrence while another cleanup holds the server
Notification::assertNotSentTo($server->team, DockerCleanupFailed::class);
});
it('drops a manual cleanup while another cleanup holds the server lock without touching occurrences', function () {
function dockerCleanupDatabaseQueue(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
config(['queue.default' => 'database']);
}
function dockerCleanupWorkOnce(): void
{
Artisan::call('queue:work', ['connection' => 'database', '--once' => true, '--queue' => maintenance_queue(), '--sleep' => 0]);
}
it('runs a manual cleanup after another cleanup releases the server lock instead of dropping it', function () {
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'));
Notification::fake();
Event::fake([DockerCleanupDone::class]);
dockerCleanupDatabaseQueue();
$server = dockerCleanupReachableServer();
$job = new DockerCleanupJob($server, true);
$runningCleanupLock = Cache::lock($job->middleware()[0]->getLockKey($job), 3600);
$runningCleanupLock = Cache::lock('laravel-queue-overlap:docker-cleanup-'.$server->uuid, 3600);
$runningCleanupLock->get();
$commands = dockerCleanupFakeProcesses();
dispatch_sync($job);
DockerCleanupJob::dispatch($server, true);
dockerCleanupWorkOnce();
expect(DB::table('jobs')->count())->toBe(1)
->and(DB::table('jobs')->value('available_at'))->toBe(now()->addSeconds(DockerCleanupJob::MANUAL_RELEASE_DELAY)->getTimestamp())
->and(DockerCleanupExecution::query()->exists())->toBeFalse()
->and($commands)->toBeEmpty();
$runningCleanupLock->release();
Carbon::setTestNow(now()->addSeconds(DockerCleanupJob::MANUAL_RELEASE_DELAY + 1));
dockerCleanupWorkOnce();
expect(DB::table('jobs')->count())->toBe(0)
->and(DB::table('failed_jobs')->count())->toBe(0)
->and(DockerCleanupExecution::query()->sole()->status)->toBe('success')
->and(ScheduledJobDelivery::query()->exists())->toBeFalse();
});
it('fails a waiting manual cleanup instead of running it after its wait window', function () {
Carbon::setTestNow(Carbon::create(2026, 9, 17, 0, 0, 0, 'UTC'));
dockerCleanupDatabaseQueue();
$server = dockerCleanupReachableServer();
$runningCleanupLock = Cache::lock('laravel-queue-overlap:docker-cleanup-'.$server->uuid, 3600);
$runningCleanupLock->get();
$commands = dockerCleanupFakeProcesses();
DockerCleanupJob::dispatch($server, true);
dockerCleanupWorkOnce();
$runningCleanupLock->release();
Carbon::setTestNow(now()->addDay());
dockerCleanupWorkOnce();
expect(DB::table('jobs')->count())->toBe(0)
->and(DB::table('failed_jobs')->count())->toBe(1)
->and(DockerCleanupExecution::query()->exists())->toBeFalse()
->and($commands)->toBeEmpty();
});
it('drops a stop-triggered cleanup and a scheduled run while another cleanup holds the server lock', function () {
dockerCleanupDatabaseQueue();
$server = dockerCleanupReachableServer();
$runningCleanupLock = Cache::lock('laravel-queue-overlap:docker-cleanup-'.$server->uuid, 3600);
$runningCleanupLock->get();
$commands = dockerCleanupFakeProcesses();
DockerCleanupJob::dispatch($server);
dockerCleanupWorkOnce();
$runningCleanupLock->release();
expect(DockerCleanupExecution::query()->exists())->toBeFalse()
->and(ScheduledJobDelivery::query()->exists())->toBeFalse()
expect(DB::table('jobs')->count())->toBe(0)
->and(DB::table('failed_jobs')->count())->toBe(0)
->and(DockerCleanupExecution::query()->exists())->toBeFalse()
->and($commands)->toBeEmpty();
});
@@ -0,0 +1,53 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = $project->environments()->first()
?? Environment::factory()->create(['project_id' => $project->id]);
$this->application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_registry_image_name' => 'bad name;x',
]);
Queue::fake();
Notification::fake();
});
test('a deployment whose job cannot be dispatched fails and does not block the next deployment', function () {
expect(fn () => queue_application_deployment($this->application, 'prr-undispatchable'))
->toThrow(DeploymentException::class, 'Docker registry image name contains invalid characters.');
$failedDeployment = ApplicationDeploymentQueue::where('deployment_uuid', 'prr-undispatchable')->firstOrFail();
expect($failedDeployment->status)->toBe('failed')
->and($failedDeployment->logs)->toContain('Deployment could not be started');
$this->application->update(['docker_registry_image_name' => 'ghcr.io/coollabs/app']);
queue_application_deployment($this->application->fresh(), 'prr-next-deployment');
expect(ApplicationDeploymentQueue::where('deployment_uuid', 'prr-next-deployment')->value('status'))->toBe('in_progress');
Queue::assertPushed(ApplicationDeploymentJob::class, 1);
});
@@ -0,0 +1,36 @@
<?php
use App\Models\IntegrationToken;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tokenTeam = Team::factory()->create();
$this->otherTeam = Team::factory()->create();
$this->token = IntegrationToken::factory()->create(['team_id' => $this->tokenTeam->id]);
$this->user = User::factory()->create();
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->otherTeam]);
});
it('lets an admin of the token team manage the token while the session team is another team', function (string $ability) {
$this->tokenTeam->members()->attach($this->user->id, ['role' => 'admin']);
expect($this->user->fresh()->can($ability, $this->token))->toBeTrue();
})->with(['view', 'update', 'delete']);
it('denies a member of the token team who owns the session team', function (string $ability) {
$this->tokenTeam->members()->attach($this->user->id, ['role' => 'member']);
expect($this->user->fresh()->can($ability, $this->token))->toBeFalse();
})->with(['view', 'update', 'delete']);
it('denies a user outside the token team even when the session points to the token team', function (string $ability) {
session(['currentTeam' => $this->tokenTeam]);
expect($this->user->fresh()->can($ability, $this->token))->toBeFalse();
})->with(['view', 'update', 'delete']);
@@ -197,30 +197,58 @@ it('quotes shell control characters in the password of a database created after
'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) {
it('runs with a plain REDIS_PASSWORD variable instead of the stored password without changing either', 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, "'envpass'"))
->and($service['healthcheck'][3])->toBe('envpass')
->and($service['environment'])->toContain('REDIS_PASSWORD=envpass')
->and($env->fresh()->value)->toBe('envpass')
->and($database->fresh()->{$passwordColumn})->toBe('storedpass');
})->with('legacy-password-quoting-engines');
it('runs with the stored password again after the REDIS_PASSWORD variable is removed', 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);
$env->delete();
$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');
->and($service['environment'])->toContain('REDIS_PASSWORD=storedpass');
})->with('legacy-password-quoting-engines');
it('runs with the stored password when the REDIS_PASSWORD variable is empty', function (string $create, string $start, string $passwordColumn, string $commandFormat) {
$database = $create($this->environment->id, $this->destination, [$passwordColumn => 'storedpass']);
$database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '']);
$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');
})->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']);
$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, 'stored;pass'))
->and($service['healthcheck'][3])->toBe('stored;pass')
->and($service['environment'])->toContain('REDIS_PASSWORD=envpass')
->and($env->fresh()->value)->toBe('envpass')
->and($database->fresh()->{$passwordColumn})->toBe('stored;pass')
->and($database->fresh()->legacy_password_quoting)->toBeTrue();
})->with('legacy-password-quoting-engines');
@@ -246,7 +274,7 @@ it('runs with a remote secret REDIS_PASSWORD without storing it', function (stri
->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) {
it('runs with a shared REDIS_PASSWORD variable without storing it', function (string $create, string $start, string $passwordColumn, string $commandFormat) {
SharedEnvironmentVariable::query()->create([
'key' => 'REDIS_PASSWORD',
'value' => 'sharedpass',
@@ -262,5 +290,67 @@ it('copies a shared REDIS_PASSWORD variable into the stored password', function
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');
->and($database->fresh()->{$passwordColumn})->toBe('storedpass');
})->with('legacy-password-quoting-engines');
it('keeps the stored password of a legacy database with a shared REDIS_PASSWORD variable', 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 => 'stored;pass']);
DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]);
$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, 'stored;pass'))
->and($service['healthcheck'][3])->toBe('stored;pass')
->and($database->fresh()->{$passwordColumn})->toBe('stored;pass')
->and($database->fresh()->legacy_password_quoting)->toBeTrue();
})->with('legacy-password-quoting-engines');
it('shows the password the server uses in the connection URLs', function (string $create, string $start, string $passwordColumn, string $commandFormat) {
SharedEnvironmentVariable::query()->create([
'key' => 'REDIS_PASSWORD',
'value' => 'shared/pass',
'type' => 'team',
'team_id' => $this->environment->project->team_id,
]);
$database = $create($this->environment->id, $this->destination, [$passwordColumn => 'stored/pass', 'is_public' => true, 'public_port' => 16379]);
expect($database->fresh()->internal_db_url)->toContain(':stored%2Fpass@')
->and($database->fresh()->external_db_url)->toContain(':stored%2Fpass@');
$env = $database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => 'env/pass']);
expect($database->fresh()->internal_db_url)->toContain(':env%2Fpass@')
->and($database->fresh()->external_db_url)->toContain(':env%2Fpass@');
$env->update(['value' => '{{team.REDIS_PASSWORD}}']);
expect($database->fresh()->internal_db_url)->toContain(':shared%2Fpass@')
->and($database->fresh()->external_db_url)->toContain(':shared%2Fpass@');
DB::table($database->getTable())->where('id', $database->id)->update(['legacy_password_quoting' => true]);
expect($database->fresh()->internal_db_url)->toContain(':stored%2Fpass@')
->and($database->fresh()->external_db_url)->toContain(':stored%2Fpass@');
})->with('legacy-password-quoting-engines');
it('shows the unresolved remote secret reference in the connection URL without fetching it', function (string $create, string $start, string $passwordColumn, string $commandFormat) {
Http::fake();
$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]);
$database->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '{{vault.REDIS_PASSWORD}}']);
expect($database->fresh()->internal_db_url)->toContain(':'.rawurlencode('{{vault.REDIS_PASSWORD}}').'@')
->not->toContain('storedpass');
Http::assertNothingSent();
})->with('legacy-password-quoting-engines');
@@ -0,0 +1,78 @@
<?php
use App\Livewire\Project\Service\Domains;
use App\Livewire\Server\DockerImages;
use App\Models\Environment;
use App\Models\InstanceSettings;
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\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0, 'is_dns_validation_enabled' => false]));
$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]);
$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->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
});
it('rejects a client update of the service domain rows', function () {
$destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
));
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]);
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $environment->id,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'service_id' => $service->id,
'name' => 'web',
'image' => 'nginx:alpine',
'fqdn' => 'https://web.example.com',
]);
Livewire::test(Domains::class, ['service' => $service->fresh(['applications', 'server'])])
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->all() === ['https://web.example.com'])
->set('domainRows', [['url' => 'https://attacker.example.com']]);
})->throws(CannotUpdateLockedPropertyException::class);
it('rejects a client update of the docker image list', function () {
$imageId = 'sha256:'.str_repeat('a', 64);
Process::fake([
'*docker image ls*' => Process::result(output: json_encode(['Repository' => 'nginx', 'Tag' => '1.27', 'ID' => $imageId, 'Size' => '52.5MB', 'CreatedSince' => '3 weeks ago'])),
'*docker ps -a*' => Process::result(output: "{$imageId}#/web\n"),
'*docker system df*' => Process::result(output: ''),
'*docker image rm*' => Process::result(output: 'Deleted'),
]);
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
->call('load')
->assertSet('images.0.containers', ['web'])
->set('images.0.containers', []);
})->throws(CannotUpdateLockedPropertyException::class);
@@ -0,0 +1,77 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
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\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'app.maintenance.driver' => 'file',
]);
Queue::fake();
InstanceSettings::query()->delete();
$settings = new InstanceSettings(['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]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = $project->environments()->first()
?? Environment::factory()->create(['project_id' => $project->id]);
$this->application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
function mcpDeployPullRequestCall(array $arguments)
{
auth()->forgetGuards();
$token = test()->user->createToken('mcp-deploy-pr', ['read', 'deploy'])->plainTextToken;
return test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => ['name' => 'deploy', 'arguments' => (object) $arguments],
]);
}
test('deploy tool rejects a pull request id above the database integer range', function (int $pullRequestId) {
$response = mcpDeployPullRequestCall([
'uuid' => $this->application->uuid,
'pull_request_id' => $pullRequestId,
]);
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue()
->and($response->json('result.content.0.text'))->toContain('pull_request_id must be between 0 and 2147483647')
->and(ApplicationDeploymentQueue::query()->count())->toBe(0);
})->with([
'above int4' => 3000000000,
'negative' => -1,
]);
@@ -0,0 +1,87 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$team = Team::factory()->create();
$this->primaryServer = Server::factory()->create(['team_id' => $team->id]);
$this->additionalServer = Server::factory()->create(['team_id' => $team->id]);
$this->primaryServer->settings->update(['concurrent_builds' => 1]);
$this->additionalServer->settings->update(['concurrent_builds' => 1]);
$this->primaryDestination = StandaloneDocker::where('server_id', $this->primaryServer->id)->firstOrFail();
$this->additionalDestination = StandaloneDocker::where('server_id', $this->additionalServer->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = $project->environments()->first()
?? Environment::factory()->create(['project_id' => $project->id]);
$makeApplication = function (StandaloneDocker $destination) use ($environment): Application {
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$application->settings->update(['is_consistent_container_name_enabled' => true]);
return $application;
};
$this->application = $makeApplication($this->primaryDestination);
$this->application->additional_servers()->attach($this->additionalServer->id, [
'standalone_docker_id' => $this->additionalDestination->id,
]);
$this->otherApplication = $makeApplication($this->additionalDestination);
$this->makeDeployment = fn (Application $application, Server $server, StandaloneDocker $destination, string $status, string $uuid): ApplicationDeploymentQueue => ApplicationDeploymentQueue::create([
'application_id' => $application->id,
'deployment_uuid' => $uuid,
'status' => $status,
'server_id' => $server->id,
'destination_id' => $destination->id,
'commit' => 'HEAD',
'pull_request_id' => 0,
'only_this_server' => true,
]);
Process::fake(['*' => Process::result(output: '')]);
Queue::fake();
Notification::fake();
});
test('a finished primary-server deployment starts the deployment of the same application queued on an additional server', function () {
$primaryDeployment = ($this->makeDeployment)($this->application, $this->primaryServer, $this->primaryDestination, 'in_progress', 'prr-primary-deployment');
$additionalDeployment = ($this->makeDeployment)($this->application, $this->additionalServer, $this->additionalDestination, 'queued', 'prr-additional-deployment');
(new ApplicationDeploymentJob($primaryDeployment->id))->failed(new RuntimeException('build failed'));
expect($primaryDeployment->fresh()->status)->toBe('failed')
->and($additionalDeployment->fresh()->status)->toBe('in_progress');
Queue::assertPushed(ApplicationDeploymentJob::class, 1);
Queue::assertPushed(ApplicationDeploymentJob::class, fn (ApplicationDeploymentJob $job) => $job->application_deployment_queue_id === $additionalDeployment->id);
});
test('a finished primary-server deployment keeps the concurrent build limit of the additional server', function () {
$primaryDeployment = ($this->makeDeployment)($this->application, $this->primaryServer, $this->primaryDestination, 'in_progress', 'prr-primary-deployment');
($this->makeDeployment)($this->otherApplication, $this->additionalServer, $this->additionalDestination, 'in_progress', 'prr-busy-deployment');
$additionalDeployment = ($this->makeDeployment)($this->application, $this->additionalServer, $this->additionalDestination, 'queued', 'prr-additional-deployment');
(new ApplicationDeploymentJob($primaryDeployment->id))->failed(new RuntimeException('build failed'));
expect($additionalDeployment->fresh()->status)->toBe('queued');
Queue::assertNotPushed(ApplicationDeploymentJob::class);
});
@@ -0,0 +1,116 @@
<?php
use App\Enums\GithubRunnerStatus;
use App\Jobs\ReconcileGithubRunnersJob;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Server::flushIdentityMap();
Queue::fake();
Process::fake(['*' => Process::result(output: '')]);
$this->team = Team::factory()->create();
$rsaKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($rsaKey, $pemKey);
$privateKey = PrivateKey::create(['name' => 'Runner key', 'private_key' => $pemKey, 'team_id' => $this->team->id]);
$this->githubApp = GithubApp::create([
'name' => 'reconcile-disabled-app',
'organization' => 'acme',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 2222,
'installation_id' => 2223,
'webhook_secret' => 'secret',
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'runner_group_id' => 7,
]);
});
afterEach(function () {
Server::flushIdentityMap();
});
function reconcileDisabledTestServer(Team $team, GithubApp $githubApp, bool $enabled): Server
{
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'server_role' => 'build',
'is_build_server' => true,
'force_disabled' => false,
]);
GithubRunnerConfig::create([
'server_id' => $server->id,
'github_app_id' => $githubApp->id,
'labels' => ['coolify'],
'max_runners' => 2,
'docker_mode' => 'dind',
'is_enabled' => $enabled,
]);
return $server->fresh();
}
function reconcileDisabledTestCommandFor(Server $server): Closure
{
return fn (PendingProcess $process) => str_contains($process->command, $server->ip);
}
it('reconciles a server with enabled runners', function () {
$server = reconcileDisabledTestServer($this->team, $this->githubApp, enabled: true);
(new ReconcileGithubRunnersJob)->handle();
Process::assertRan(reconcileDisabledTestCommandFor($server));
});
it('does not connect to a server whose runners are disabled and have no active runners left', function () {
$server = reconcileDisabledTestServer($this->team, $this->githubApp, enabled: false);
GithubRunnerExecution::create([
'github_app_id' => $this->githubApp->id,
'server_id' => $server->id,
'trigger_workflow_job_id' => 3001,
'labels' => ['self-hosted', 'coolify'],
'status' => GithubRunnerStatus::Completed,
]);
(new ReconcileGithubRunnersJob)->handle();
Process::assertDidntRun(reconcileDisabledTestCommandFor($server));
});
it('keeps reconciling a disabled server until its running jobs finish', function () {
$server = reconcileDisabledTestServer($this->team, $this->githubApp, enabled: false);
$running = GithubRunnerExecution::create([
'github_app_id' => $this->githubApp->id,
'server_id' => $server->id,
'github_runner_config_id' => $server->githubRunnerConfig->id,
'trigger_workflow_job_id' => 3002,
'labels' => ['self-hosted', 'coolify'],
'status' => GithubRunnerStatus::Running,
'started_at' => now(),
]);
(new ReconcileGithubRunnersJob)->handle();
Process::assertRan(reconcileDisabledTestCommandFor($server));
expect($running->fresh()->status)->toBe(GithubRunnerStatus::Failed);
});
@@ -0,0 +1,25 @@
<?php
use App\Jobs\RevalidateUnusableS3StoragesJob;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
config()->set('cache.default', 'array');
Queue::fake();
});
it('does not queue a second revalidation while the unique lock is held', function () {
RevalidateUnusableS3StoragesJob::dispatch();
$this->travel(29)->minutes();
RevalidateUnusableS3StoragesJob::dispatch();
Queue::assertPushed(RevalidateUnusableS3StoragesJob::class, 1);
});
it('queues the revalidation again after a lock left by a killed worker expires', function () {
RevalidateUnusableS3StoragesJob::dispatch();
$this->travel(31)->minutes();
RevalidateUnusableS3StoragesJob::dispatch();
Queue::assertPushed(RevalidateUnusableS3StoragesJob::class, 2);
});
@@ -0,0 +1,33 @@
<?php
use App\Jobs\CheckMissingDatabaseBackupsJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
config()->set('cache.default', 'array');
Queue::fake();
});
dataset('scheduled instance jobs', [
'orphaned preview containers cleanup' => [CleanupOrphanedPreviewContainersJob::class, 600],
'instance cleanup' => [CleanupInstanceStuffsJob::class, 60],
'missing database backups check' => [CheckMissingDatabaseBackupsJob::class, 1800],
]);
it('does not queue the job again while its unique lock is held', function (string $jobClass, int $lockSeconds) {
$jobClass::dispatch();
$this->travel($lockSeconds - 1)->seconds();
$jobClass::dispatch();
Queue::assertPushed($jobClass, 1);
})->with('scheduled instance jobs');
it('queues the job again after a unique lock left by a killed worker expires', function (string $jobClass, int $lockSeconds) {
$jobClass::dispatch();
$this->travel($lockSeconds + 1)->seconds();
$jobClass::dispatch();
Queue::assertPushed($jobClass, 2);
})->with('scheduled instance jobs');
@@ -0,0 +1,93 @@
<?php
use App\Livewire\Project\Shared\SecretManagerLinks;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\Project;
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();
$this->withoutDefer();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = $server->standaloneDockers()->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(),
]);
$vaultToken = fn (string $name) => IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'vault',
'name' => $name,
'token' => 'hvs.token',
'capabilities' => ['secrets'],
'metadata' => ['base_url' => 'https://vault.internal:8200'],
]);
$this->currentToken = $vaultToken('Vault current');
$this->nextToken = $vaultToken('Vault next');
$this->application->secretManagerLink()->create([
'integration_token_id' => $this->currentToken->id,
'settings' => ['mount' => 'secret', 'path' => 'my-app/production'],
]);
});
test('switching to a token of the same provider rejects invalid settings and keeps the saved source', function () {
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
->set([
'settings' => ['mount' => ['nested' => 'value'], 'path' => 'my-app/production'],
'integration_token_uuid' => $this->nextToken->uuid,
])
->assertHasErrors(['settings.mount']);
$link = $this->application->secretManagerLink()->firstOrFail();
expect($link->integration_token_id)->toBe($this->currentToken->id)
->and($link->settings)->toBe(['mount' => 'secret', 'path' => 'my-app/production']);
});
test('switching to a token of the same provider saves only the provider settings fields', function () {
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
->set([
'settings' => ['mount' => 'kv', 'path' => 'my-app/staging', 'unexpected' => 'value'],
'integration_token_uuid' => $this->nextToken->uuid,
])
->assertHasNoErrors()
->assertDispatched('success');
$link = $this->application->secretManagerLink()->firstOrFail();
expect($link->integration_token_id)->toBe($this->nextToken->id)
->and($link->settings)->toBe(['mount' => 'kv', 'path' => 'my-app/staging']);
});
test('switching to a token of the same provider keeps working while the settings are still empty', function () {
$this->application->secretManagerLink()->update(['settings' => null]);
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
->set('integration_token_uuid', $this->nextToken->uuid)
->assertHasNoErrors()
->assertDispatched('success');
$link = $this->application->secretManagerLink()->firstOrFail();
expect($link->integration_token_id)->toBe($this->nextToken->id)
->and($link->settings)->toBeNull();
});
@@ -67,6 +67,25 @@ it('sends one high disk usage notification per configured interval', function ()
Notification::assertSentToTimes($server->team, HighDiskUsage::class, 2);
});
it('notifies again on a new spike after disk usage recovered within the interval', function () {
$server = diskUsageIntervalServer(intervalHours: 24);
runDiskUsageCheck($server, 93);
Notification::assertSentToTimes($server->team, HighDiskUsage::class, 1);
Carbon::setTestNow(now()->addMinutes(10));
runDiskUsageCheck($server, 95);
Notification::assertSentToTimes($server->team, HighDiskUsage::class, 1);
Carbon::setTestNow(now()->addHour());
runDiskUsageCheck($server, 40);
expect(NotificationThrottle::wasSent($server, HighDiskUsage::class))->toBeFalse();
Carbon::setTestNow(now()->addHour());
runDiskUsageCheck($server, 92);
Notification::assertSentToTimes($server->team, HighDiskUsage::class, 2);
});
it('defaults to one notification per 24 hours', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
+140
View File
@@ -0,0 +1,140 @@
<?php
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\StartService;
use App\Models\Environment;
use App\Models\InstanceSettings;
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 App\Support\RemoteProcessCommand;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Process\Process as SymfonyProcess;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Bus::fake();
Process::fake();
InstanceSettings::forceCreate(['id' => 0]);
config([
'app.maintenance.store' => 'array',
'constants.ssh.mux_enabled' => false,
]);
$this->stubDirectory = sys_get_temp_dir().'/service-compose-stdin-'.bin2hex(random_bytes(6));
});
afterEach(function () {
File::deleteDirectory($this->stubDirectory);
});
function serviceComposeStdinService(string $sshUser): Service
{
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'user' => $sshUser,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'connect_to_docker_network' => true,
'docker_compose_raw' => "services:\n app:\n image: busybox:latest\n volumes:\n - data:/data\nvolumes:\n data:\n labels:\n example: changed\n",
]);
}
/**
* Runs the generated script the way SSH does (`bash -se` with the script on stdin).
* Process is faked for the SSH calls of the action, so this uses Symfony Process directly.
* The `docker compose up`/`pull` stub reads one line from stdin, like the Compose prompt
* "Volume ... exists but doesn't match configuration in compose file. Recreate?".
*
* @return list<string> the commands that ran, and the lines the prompt consumed
*/
function runServiceScriptLikeSsh(string $script, string $stubDirectory): array
{
File::ensureDirectoryExists($stubDirectory);
$log = "{$stubDirectory}/calls.log";
File::put("{$stubDirectory}/docker", <<<'SH'
#!/bin/sh
echo "docker $*" >> "$STUB_LOG"
case " $* " in
*" compose "*" up "*|*" compose "*" pull"*)
if IFS= read -r answer; then echo "prompt read: $answer" >> "$STUB_LOG"; fi
;;
esac
exit 0
SH);
File::put("{$stubDirectory}/touch", "#!/bin/sh\nexit 0\n");
File::put("{$stubDirectory}/sudo", "#!/bin/sh\nexec \"\$@\"\n");
foreach (['docker', 'touch', 'sudo'] as $stub) {
chmod("{$stubDirectory}/{$stub}", 0755);
}
$process = new SymfonyProcess(['bash', '-se'], env: ['PATH' => $stubDirectory.':'.getenv('PATH'), 'STUB_LOG' => $log], input: $script."\n", timeout: 30);
$process->run();
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput());
return File::exists($log) ? explode("\n", trim(File::get($log))) : [];
}
function serviceComposeCommandLines(Activity $activity): array
{
return array_values(array_filter(
explode("\n", RemoteProcessCommand::read($activity)),
fn (string $line) => str_contains($line, 'docker compose ') && (str_contains($line, ' up -d') || str_contains($line, ' pull')),
));
}
it('runs the proxy network connect after a service compose up that reads stdin', function (string $sshUser) {
$service = serviceComposeStdinService($sshUser);
$activity = StartService::run($service, pullLatestImages: true);
$calls = runServiceScriptLikeSsh(RemoteProcessCommand::read($activity), $this->stubDirectory);
expect($calls)
->toContain("docker network connect {$service->uuid} coolify-proxy")
->and(collect($calls)->filter(fn (string $call) => str_starts_with($call, 'prompt read:'))->all())->toBe([]);
})->with(['root' => 'root', 'non-root' => 'deploy']);
it('runs the proxy network connect after a single service application compose up that reads stdin', function (string $sshUser) {
$service = serviceComposeStdinService($sshUser);
$service->parse();
$activity = DeployServiceApplication::run($service->applications()->firstOrFail(), pullLatestImages: true);
$calls = runServiceScriptLikeSsh(RemoteProcessCommand::read($activity), $this->stubDirectory);
expect($calls)
->toContain("docker network connect {$service->uuid} coolify-proxy")
->and(collect($calls)->filter(fn (string $call) => str_starts_with($call, 'prompt read:'))->all())->toBe([]);
})->with(['root' => 'root', 'non-root' => 'deploy']);
it('gives service compose up and pull no stdin and never answers the recreate prompt', function () {
$service = serviceComposeStdinService('deploy');
$lines = serviceComposeCommandLines(StartService::run($service, pullLatestImages: true));
expect($lines)->toHaveCount(2);
foreach ($lines as $line) {
expect($line)->toStartWith('sudo docker compose ')
->toEndWith(' < /dev/null')
->not->toContain(' --yes')
->not->toContain(' -y ');
}
});
@@ -0,0 +1,110 @@
<?php
use App\Livewire\Project\Database\Sqlite\ConnectApplication;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneSqlite;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->resourceTeam = Team::factory()->create();
$this->otherTeam = Team::factory()->create();
$this->user = User::factory()->create();
$this->resourceTeam->members()->attach($this->user->id, ['role' => 'admin']);
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->resourceTeam]);
$server = Server::factory()->create(['team_id' => $this->resourceTeam->id]);
$this->destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
['server_id' => $server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
));
$this->sqlite = StandaloneSqlite::create([
'name' => 'app-sqlite',
'environment_id' => sqliteResourceTeamEnvironment($this->resourceTeam)->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->resourceTeamApplication = sqliteResourceTeamApplication(sqliteResourceTeamEnvironment($this->resourceTeam), $this->destination, 'Resource Team App');
$this->otherTeamApplication = sqliteResourceTeamApplication(sqliteResourceTeamEnvironment($this->otherTeam), $this->destination, 'Other Team App');
});
function sqliteResourceTeamEnvironment(Team $team): Environment
{
return Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
}
function sqliteResourceTeamApplication(Environment $environment, StandaloneDocker $destination, string $name): Application
{
return Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => $name,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'nginx',
]);
}
it('offers only applications of the database team when the session team is another team', function () {
session(['currentTeam' => $this->otherTeam]);
Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
->assertSet('applicationOptions', fn (array $options): bool => collect($options)->pluck('value')->all() === [$this->resourceTeamApplication->uuid]);
});
it('connects an application of the database team after a session team switch', function () {
$component = Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]);
session(['currentTeam' => $this->otherTeam]);
$component->set('applicationUuid', $this->resourceTeamApplication->uuid)
->call('connect')
->assertNotDispatched('error')
->assertRedirect();
expect($this->resourceTeamApplication->persistentStorages()->where('standalone_sqlite_id', $this->sqlite->id)->exists())->toBeTrue();
});
it('does not connect an application of another team after a session team switch', function () {
$component = Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]);
session(['currentTeam' => $this->otherTeam]);
$component->set('applicationUuid', $this->otherTeamApplication->uuid)
->call('connect')
->assertDispatched('error')
->assertNoRedirect();
expect($this->otherTeamApplication->persistentStorages()->count())->toBe(0);
});
it('forbids a member of the database team from connecting after switching to a team they own', function () {
$this->resourceTeam->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->user->refresh();
$component = Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]);
session(['currentTeam' => $this->otherTeam]);
$component->set('applicationUuid', $this->resourceTeamApplication->uuid)
->call('connect')
->assertForbidden();
expect($this->resourceTeamApplication->persistentStorages()->count())->toBe(0);
});
@@ -0,0 +1,48 @@
<?php
use App\Livewire\Team\AuditLog;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->secondTeam = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'admin']);
$this->secondTeam->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('the audit log is forbidden when the session has no team', function () {
session()->forget('currentTeam');
Livewire::test(AuditLog::class)->assertForbidden();
});
test('an open audit log is forbidden after the session team is removed from the user', function () {
$component = Livewire::test(AuditLog::class)->assertOk();
$this->team->members()->detach($this->user->id);
auth()->setUser($this->user->fresh());
$component->set('search', 'deployment')->assertForbidden();
});
test('team admins can still open the audit log', function () {
Livewire::test(AuditLog::class)->assertOk();
});
test('team members cannot open the audit log', function () {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
auth()->setUser($this->user->fresh());
Livewire::test(AuditLog::class)->assertForbidden();
});
@@ -0,0 +1,171 @@
<?php
use App\Jobs\ScheduledJobManager;
use App\Jobs\VolumeBackupJob;
use App\Jobs\VolumeBackupRecoveryJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Process as SymfonyProcess;
uses(RefreshDatabase::class);
const MISSING_CONTAINER_GONE = 'aaaaaaaaaaaa';
const MISSING_CONTAINER_BROKEN = 'bbbbbbbbbbbb';
const MISSING_CONTAINER_STOPPED = 'cccccccccccc';
beforeEach(function () {
Server::flushIdentityMap();
Notification::fake();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
config(['broadcasting.default' => 'null', 'constants.ssh.mux_enabled' => false, 'constants.ssh.max_retries' => 1]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->fakeDockerDir = sys_get_temp_dir().'/prr-fix-fake-docker-'.uniqid();
File::ensureDirectoryExists($this->fakeDockerDir);
$this->dockerLog = $this->fakeDockerDir.'/calls.log';
File::put($this->fakeDockerDir.'/docker', <<<'SH'
#!/bin/sh
echo "$*" >> "$(dirname "$0")/calls.log"
case "$1" in
inspect)
case "$4" in
aaaaaaaaaaaa) echo "Error: no such object: $4" >&2; exit 1 ;;
*) echo false ;;
esac ;;
start)
case "$2" in
bbbbbbbbbbbb) echo "Error response from daemon: cannot start" >&2; exit 1 ;;
esac ;;
esac
SH);
chmod($this->fakeDockerDir.'/docker', 0755);
});
afterEach(function () {
File::deleteDirectory($this->fakeDockerDir);
foreach (ScheduledVolumeBackupExecution::query()->get() as $execution) {
File::delete([VolumeBackupRecoveryJob::stateFile($execution), VolumeBackupRecoveryJob::stateFile($execution).'.remaining']);
}
Carbon::setTestNow();
});
/**
* @return array{0: Server, 1: ScheduledVolumeBackup}
*/
function missingContainerRecoveryBackup(string $user): array
{
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
'user' => $user,
]);
$server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$volume = LocalPersistentVolume::create([
'name' => 'app-data',
'mount_path' => '/data',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$backup = $volume->scheduledBackups()->create([
'team_id' => $team->id,
'frequency' => '* * * * *',
'enabled' => true,
'save_s3' => false,
'stop_during_backup' => true,
]);
return [Server::find($server->id), $backup];
}
/**
* Runs the remote container recovery script with a local sh and a fake docker binary, as the server would.
*/
function fakeMissingContainerRemote(string $fakeDockerDir): void
{
Process::fake(function ($process) use ($fakeDockerDir) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
$lines = explode(PHP_EOL, $command);
$remote = implode(PHP_EOL, array_slice($lines, 1, -1));
if (str_starts_with($remote, 'sudo ')) {
$remote = substr($remote, strlen('sudo '));
}
if (! str_starts_with($remote, 'sh -c ') && ! str_starts_with($remote, 'cat ')) {
return Process::result(output: '');
}
$result = new SymfonyProcess(['sh', '-c', $remote], null, ['PATH' => $fakeDockerDir.':'.getenv('PATH')]);
$result->run();
return Process::result(output: $result->getOutput(), errorOutput: $result->getErrorOutput(), exitCode: $result->getExitCode());
});
}
it('treats stopped containers that no longer exist as recovered and does not block the schedule', function (string $user) {
Carbon::setTestNow('2026-10-03 12:00:00');
config(['constants.coolify.self_hosted' => true]);
[$server, $backup] = missingContainerRecoveryBackup($user);
$execution = ScheduledVolumeBackupExecution::create([
'scheduled_volume_backup_id' => $backup->id,
'status' => 'failed',
'stop_recovery_pending' => true,
]);
File::put(VolumeBackupRecoveryJob::stateFile($execution), MISSING_CONTAINER_GONE."\n".MISSING_CONTAINER_STOPPED."\n");
fakeMissingContainerRemote($this->fakeDockerDir);
VolumeBackupRecoveryJob::recoverWithBounds($execution);
$execution->refresh();
expect($execution->stop_recovery_pending)->toBeFalse()
->and($execution->recovery_needs_attention)->toBeFalse()
->and(File::exists(VolumeBackupRecoveryJob::stateFile($execution)))->toBeFalse()
->and(File::get($this->dockerLog))->toContain('start '.MISSING_CONTAINER_STOPPED);
Queue::fake();
Cache::forget("scheduled-volume-backup:{$backup->id}");
(new ScheduledJobManager)->handle();
Queue::assertPushed(VolumeBackupJob::class, fn (VolumeBackupJob $job) => $job->backup->is($backup));
})->with(['root user' => ['root'], 'non-root user' => ['ubuntu']]);
it('still needs attention when an existing container fails to start', function (string $user) {
[, $backup] = missingContainerRecoveryBackup($user);
$execution = ScheduledVolumeBackupExecution::create([
'scheduled_volume_backup_id' => $backup->id,
'status' => 'failed',
'stop_recovery_pending' => true,
]);
File::put(VolumeBackupRecoveryJob::stateFile($execution), MISSING_CONTAINER_GONE."\n".MISSING_CONTAINER_BROKEN."\n");
fakeMissingContainerRemote($this->fakeDockerDir);
VolumeBackupRecoveryJob::recoverWithBounds($execution);
$execution->refresh();
expect($execution->stop_recovery_pending)->toBeTrue()
->and($execution->recovery_needs_attention)->toBeTrue()
->and(trim(File::get(VolumeBackupRecoveryJob::stateFile($execution))))->toBe(MISSING_CONTAINER_BROKEN);
})->with(['root user' => ['root'], 'non-root user' => ['ubuntu']]);
@@ -0,0 +1,24 @@
<?php
function auditAndPrivateKeyOpenApiDocument(): array
{
return json_decode((string) file_get_contents(__DIR__.'/../../openapi.json'), true, flags: JSON_THROW_ON_ERROR);
}
test('documents the audit events endpoint', function () {
$operation = auditAndPrivateKeyOpenApiDocument()['paths']['/audit-events']['get'] ?? null;
expect($operation)->not->toBeNull()
->and($operation['operationId'])->toBe('list-audit-events')
->and($operation['security'])->toBe([['bearerAuth' => []]])
->and(collect($operation['parameters'])->pluck('name')->all())->toBe(['per_page', 'page', 'search', 'action', 'source'])
->and($operation['responses'])->toHaveKeys(['200', '401', '403', '422']);
});
test('documents the private key update on the uuid path', function () {
$paths = auditAndPrivateKeyOpenApiDocument()['paths'];
expect($paths['/security/keys'])->not->toHaveKey('patch')
->and($paths['/security/keys/{uuid}']['patch']['operationId'])->toBe('update-private-key')
->and($paths['/security/keys/{uuid}']['patch']['parameters'][0])->toMatchArray(['name' => 'uuid', 'in' => 'path', 'required' => true]);
});
@@ -0,0 +1,18 @@
<?php
use App\Jobs\ScheduledJobManager;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Tests\TestCase;
uses(TestCase::class);
it('uses the shared lock for a job queued before scheduled job types existed', function () {
// v4.3.23 serialized ScheduledJobManager without a type property.
$job = unserialize('O:'.strlen(ScheduledJobManager::class).':"'.ScheduledJobManager::class.'":0:{}');
$middleware = $job->middleware();
expect($job->type)->toBeNull()
->and($middleware[0])->toBeInstanceOf(WithoutOverlapping::class)
->and($middleware[0]->key)->toBe('scheduled-job-manager');
});