diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php
index 5bdf249a8d..08bc78b0d2 100644
--- a/app/Actions/Database/StartDatabaseImport.php
+++ b/app/Actions/Database/StartDatabaseImport.php
@@ -194,10 +194,10 @@ class StartDatabaseImport
throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.');
}
$helper = "s3-restore-{$operation}";
- $this->startS3Helper($storage, $server, $helper, $network);
+ $resolveOptions = $this->startS3Helper($storage, $server, $helper, $network);
$sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}");
$commandList = [
- 'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore',
+ 'docker exec '.escapeshellarg($helper).' mc cp'.$resolveOptions.' '.$sourceArg.' /tmp/restore',
...$this->streamFromHelper($helper, $container, $containerPath),
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
];
@@ -329,10 +329,12 @@ class StartDatabaseImport
* Starts the S3 restore helper and sets its `s3temp` mc alias. The S3 keys go to mc through the
* stdin of a separate SSH process, so they are not in the arguments of any process on Coolify or
* on the server. The helper is removed when either step fails.
+ *
+ * @return string The mc `--resolve` option that pins the endpoint host.
*/
- private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): void
+ private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): string
{
- $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion());
+ $image = coolifyHelperImage().':'.getHelperVersion();
try {
instant_remote_process([
@@ -340,9 +342,12 @@ class StartDatabaseImport
'docker run -d --network '.escapeshellarg($network)
.' --name '.escapeshellarg($helper)
.' '.$storage->minioClientRegionOption()
- .' '.$image.' sleep 86400',
+ .' '.escapeshellarg($image).' sleep 86400',
], $server);
- $this->setMinioClientAlias($storage, $server, $helper);
+ $resolveOptions = $storage->minioClientResolveOptions($server, $image, $network);
+ $this->setMinioClientAlias($storage, $server, $helper, $resolveOptions);
+
+ return $resolveOptions;
} catch (Throwable) {
instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false);
@@ -350,10 +355,10 @@ class StartDatabaseImport
}
}
- private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper): void
+ private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper, string $resolveOptions): void
{
$remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($helper)
- .' mc alias set'.$storage->minioClientAliasOptions().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null';
+ .' mc alias set'.$resolveOptions.$storage->minioClientAliasOptions().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null';
$timeout = (int) config('constants.ssh.command_timeout');
SshRetryHandler::retry(
diff --git a/app/Http/Controllers/Api/ServerTransferController.php b/app/Http/Controllers/Api/ServerTransferController.php
index efd8f428b7..0dc0229a30 100644
--- a/app/Http/Controllers/Api/ServerTransferController.php
+++ b/app/Http/Controllers/Api/ServerTransferController.php
@@ -232,7 +232,7 @@ class ServerTransferController extends Controller
Server::throttleValidation($teamId, $ip);
$probeIp = Server::normalizeIp($ip);
if (isCloud() && ! filter_var($probeIp, FILTER_VALIDATE_IP)) {
- $probeIp = gethostbyname($probeIp);
+ $probeIp = resolveHostToIp($probeIp);
}
$probeHost = filter_var($probeIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? "[{$probeIp}]" : $probeIp;
$probeAllowed = ! isCloud() || (filter_var($probeIp, FILTER_VALIDATE_IP)
diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php
index 6429bb9c9c..e3951796d7 100644
--- a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php
+++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php
@@ -13,6 +13,32 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
+#[OA\Schema(
+ schema: 'SharedEnvironmentVariableCreateRequest',
+ type: 'object',
+ required: ['key'],
+ properties: [
+ new OA\Property(property: 'key', type: 'string'),
+ new OA\Property(property: 'value', type: 'string', nullable: true),
+ new OA\Property(property: 'is_literal', type: 'boolean'),
+ new OA\Property(property: 'is_multiline', type: 'boolean'),
+ new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'),
+ new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true),
+ ],
+)]
+#[OA\Schema(
+ schema: 'SharedEnvironmentVariableUpdateRequest',
+ description: 'Send at least one field.',
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'key', type: 'string'),
+ new OA\Property(property: 'value', type: 'string', nullable: true),
+ new OA\Property(property: 'is_literal', type: 'boolean'),
+ new OA\Property(property: 'is_multiline', type: 'boolean'),
+ new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'),
+ new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true),
+ ],
+)]
class SharedEnvironmentVariablesController extends Controller
{
private const ALLOWED_FIELDS = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment'];
@@ -350,20 +376,7 @@ class SharedEnvironmentVariablesController extends Controller
operationId: 'create-team-shared-env',
security: [['bearerAuth' => []]],
tags: ['Shared Environment Variables'],
- requestBody: new OA\RequestBody(
- required: true,
- content: new OA\JsonContent(
- required: ['key'],
- properties: [
- new OA\Property(property: 'key', type: 'string'),
- new OA\Property(property: 'value', type: 'string', nullable: true),
- new OA\Property(property: 'is_literal', type: 'boolean'),
- new OA\Property(property: 'is_multiline', type: 'boolean'),
- new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'),
- new OA\Property(property: 'comment', type: 'string', nullable: true),
- ],
- ),
- ),
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')),
responses: [
new OA\Response(response: 201, description: 'Environment variable created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -391,6 +404,7 @@ class SharedEnvironmentVariablesController extends Controller
parameters: [
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')),
responses: [
new OA\Response(response: 200, description: 'Environment variable updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -479,6 +493,7 @@ class SharedEnvironmentVariablesController extends Controller
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')),
responses: [
new OA\Response(response: 201, description: 'Environment variable created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -515,6 +530,7 @@ class SharedEnvironmentVariablesController extends Controller
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')),
responses: [
new OA\Response(response: 200, description: 'Environment variable updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -636,6 +652,7 @@ class SharedEnvironmentVariablesController extends Controller
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')),
responses: [
new OA\Response(response: 201, description: 'Environment variable created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -678,6 +695,7 @@ class SharedEnvironmentVariablesController extends Controller
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')),
responses: [
new OA\Response(response: 200, description: 'Environment variable updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -803,6 +821,7 @@ class SharedEnvironmentVariablesController extends Controller
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')),
responses: [
new OA\Response(response: 201, description: 'Environment variable created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -839,6 +858,7 @@ class SharedEnvironmentVariablesController extends Controller
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')),
responses: [
new OA\Response(response: 200, description: 'Environment variable updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index cca93293ac..f95a85a87a 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -231,6 +231,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private Collection|string $build_secrets;
+ /**
+ * Build-time variables that the helper container has in its environment for build secrets.
+ *
+ * @var array- Add one or more database names. Leave empty to include the default database. + @if ($isServiceSqlDatabase) + Add one or more database names. Leave empty to back up the database that the container env names. + @else + Add one or more database names. Leave empty to include the default database. + @endif
{{ $task->container }}
diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
index 3d6707505f..ebdecee8da 100644
--- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
+++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
@@ -1842,6 +1842,56 @@ it('keeps the old runtime env format for variables saved before exact escaping',
->toContain('EXACT_PLAIN='.escapeComposeEnvFileValue('a\'b"c\\d', allowInterpolation: true));
});
+it('gives legacy compose variables the value that the compose file got before exact escaping', function (bool $rawCompose, string $expectedDefault) {
+ $compose = <<<'YAML'
+services:
+ app:
+ image: alpine
+ environment:
+ - TRUSTED_HOST_PATTERNS=${TRUSTED_HOST_PATTERNS:-}
+ - PLAIN_REFERENCE=${PLAIN_REFERENCE}
+YAML;
+ [$application, $server] = makeDeploymentControlVarFixture([
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => $compose,
+ 'docker_compose' => $compose,
+ ]);
+ $application->settings()->update(['is_raw_compose_deployment_enabled' => $rawCompose]);
+
+ foreach (['TRUSTED_HOST_PATTERNS', 'PLAIN_REFERENCE'] as $key) {
+ $variable = createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => '^example\\.com$', 'is_runtime' => true, 'is_buildtime' => false]);
+ $variable->uses_legacy_escaping = true;
+ $variable->save();
+ }
+
+ [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
+
+ /** @var Collection $runtimeEnvs */
+ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
+
+ expect($runtimeEnvs)
+ ->toContain('TRUSTED_HOST_PATTERNS='.$expectedDefault)
+ ->toContain('PLAIN_REFERENCE=^example\\\\.com$');
+})->with([
+ 'parsed compose' => [false, '"^example\\\\.com$"'],
+ 'raw compose deployment' => [true, '^example\\\\.com$'],
+]);
+
+it('writes a readable runtime env line for legacy literal values with a single quote', function () {
+ [$application, $server] = makeDeploymentControlVarFixture();
+
+ $variable = createApplicationEnvironmentVariable($application, ['key' => 'LEGACY_QUOTE', 'value' => "it's", 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => false]);
+ $variable->uses_legacy_escaping = true;
+ $variable->save();
+
+ [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
+
+ /** @var Collection $runtimeEnvs */
+ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
+
+ expect($runtimeEnvs)->toContain('LEGACY_QUOTE="it\'s"');
+});
+
it('keeps the old runtime env format for preview variables saved before exact escaping', function () {
[$application, $server] = makeDeploymentControlVarFixture();
@@ -2143,3 +2193,113 @@ YAML;
'production' => 0,
'preview' => 42,
]);
+
+it('passes port-suffixed compose service URL variables to production and preview deployments', function (int $pullRequestId) {
+ $compose = <<<'YAML'
+services:
+ docmost:
+ build: .
+ environment:
+ - SERVICE_URL_DOCMOST_3000
+ - APP_URL=$SERVICE_URL_DOCMOST_3000
+YAML;
+
+ [$application, $server] = makeDeploymentControlVarFixture([
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => $compose,
+ 'docker_compose' => $compose,
+ 'docker_compose_domains' => json_encode(['docmost' => ['domain' => 'https://docs.example.com']]),
+ ]);
+
+ foreach ([
+ 'SERVICE_URL_DOCMOST' => 'https://docs.example.com',
+ 'SERVICE_FQDN_DOCMOST' => 'docs.example.com',
+ 'SERVICE_URL_DOCMOST_3000' => 'https://stale.example.com:3000',
+ 'SERVICE_FQDN_DOCMOST_3000' => 'stale.example.com:3000',
+ ] as $key => $value) {
+ createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value, 'is_runtime' => true, 'is_buildtime' => true]);
+ }
+
+ $overrides = ['pull_request_id' => $pullRequestId];
+ $expectedHost = 'docs.example.com';
+ if ($pullRequestId !== 0) {
+ $expectedHost = '7.docs.example.com';
+ $overrides['preview'] = ApplicationPreview::create([
+ 'application_id' => $application->id,
+ 'pull_request_id' => $pullRequestId,
+ 'pull_request_html_url' => 'https://github.com/example/repo/pull/7',
+ 'docker_compose_domains' => json_encode(['docmost' => ['domain' => "https://{$expectedHost}"]]),
+ ]);
+ }
+
+ [$job, $reflection] = makeControlVarFilteringJob($application, $server, $overrides);
+
+ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
+ expect($runtimeEnvs)
+ ->toContain("SERVICE_URL_DOCMOST=https://{$expectedHost}")
+ ->toContain("SERVICE_URL_DOCMOST_3000=https://{$expectedHost}:3000")
+ ->toContain("SERVICE_FQDN_DOCMOST_3000={$expectedHost}:3000")
+ ->and($runtimeEnvs->implode("\n"))->not->toContain('stale.example.com');
+
+ $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')->implode("\n");
+ expect($buildtimeEnvs)
+ ->toContain("SERVICE_URL_DOCMOST_3000='https://{$expectedHost}:3000'")
+ ->toContain("SERVICE_FQDN_DOCMOST_3000='{$expectedHost}:3000'")
+ ->not->toContain('stale.example.com');
+})->with([
+ 'production' => [0],
+ 'preview' => [7],
+]);
+
+it('hides the build secret values of the helper container from commands that start Compose services', function () {
+ [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']);
+ $application->settings()->update(['use_build_secrets' => true]);
+ createApplicationEnvironmentVariable($application, [
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgresql://user:pass@db:5432/app',
+ 'is_literal' => true,
+ ]);
+
+ [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockercompose']);
+ $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets');
+ expect($envFlags)->toBe("-e 'DATABASE_URL=postgresql://user:pass@db:5432/app'");
+
+ // The helper container has the variable in its environment, as `docker run -e` gives it.
+ $command = invokeDeploymentJobMethod($job, $reflection, 'withoutHelperBuildSecretEnvironment', 'echo "[${DATABASE_URL-unset}]"');
+ $process = new Process(['bash', '-c', $command], env: ['DATABASE_URL' => "'postgresql://user:pass@db:5432/app'"]);
+ $process->mustRun();
+
+ expect(trim($process->getOutput()))->toBe('[unset]');
+});
+
+it('leaves commands unchanged when the helper container has no build secret values', function () {
+ [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']);
+ createApplicationEnvironmentVariable($application, ['key' => 'DATABASE_URL', 'value' => 'value']);
+
+ [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockercompose']);
+ expect(invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'))->toBe('');
+
+ expect(invokeDeploymentJobMethod($job, $reflection, 'withoutHelperBuildSecretEnvironment', 'docker compose up -d'))
+ ->toBe('docker compose up -d');
+});
+
+it('gives build secrets the stored value without the quotes that Coolify adds', function () {
+ [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerfile']);
+ $application->settings()->update(['use_build_secrets' => true]);
+ createApplicationEnvironmentVariable($application, ['key' => 'LITERAL', 'value' => "'quoted' value", 'is_literal' => true]);
+ createApplicationEnvironmentVariable($application, ['key' => 'MULTILINE', 'value' => "line1\nline2", 'is_multiline' => true]);
+ createApplicationEnvironmentVariable($application, ['key' => 'NORMAL', 'value' => "'kept'"]);
+
+ [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockerfile']);
+ $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets');
+
+ // Read the values as the helper container gets them from `docker run -e`.
+ $process = new Process(['bash', '-c', 'printf "%s\n" '.$envFlags.' | sed -n "s/^\\(LITERAL\\|MULTILINE\\|NORMAL\\)=/&/p"']);
+ $process->mustRun();
+ $output = $process->getOutput();
+
+ expect($output)
+ ->toContain("LITERAL='quoted' value\n")
+ ->toContain("MULTILINE=line1\n")
+ ->toContain("NORMAL='kept'\n");
+});
diff --git a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php
index 4dc81bc4b4..eb86f1a293 100644
--- a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php
+++ b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php
@@ -128,6 +128,21 @@ test('deploy key application keeps the base directory for every build pack', fun
expect(Application::query()->sole()->base_directory)->toBe('/apps/api');
})->with(['dockerfile', 'railpack', 'static', 'dockercompose']);
+test('deploy key application accepts a base directory without a leading slash', function (string $typed) {
+ actAsDeployKeyUser($this->admin, $this->team);
+
+ deployKeyComponent()
+ ->call('setPrivateKey', $this->privateKey->id)
+ ->set('repository_url', 'git@example.com:owner/monorepo.git')
+ ->set('branch', 'main')
+ ->set('build_pack', 'dockerfile')
+ ->set('base_directory', $typed)
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ expect(Application::query()->sole()->base_directory)->toBe('/apps/api');
+})->with(['apps/api', 'apps/api/', ' /apps/api/ ']);
+
test('deploy key application uses the repository root when the base directory is empty', function () {
actAsDeployKeyUser($this->admin, $this->team);
diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php
index 93d38c78e5..a052562811 100644
--- a/tests/Feature/BackupEditValidationTest.php
+++ b/tests/Feature/BackupEditValidationTest.php
@@ -678,3 +678,39 @@ it('toggles the schedule from the form and notifies the page header', function (
expect((bool) $backup->fresh()->enabled)->toBeFalse();
});
+
+it('lets a service database backup choose the databases to back up', function (string $image, bool $showsSelection) {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $service = Service::factory()->create([
+ 'server_id' => $server->id,
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ]);
+ $database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => $image]);
+ $backup = ScheduledDatabaseBackup::create([
+ 'frequency' => '0 0 * * *',
+ 'database_type' => $database->getMorphClass(),
+ 'database_id' => $database->id,
+ 'team_id' => $this->team->id,
+ ]);
+
+ $component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect()]);
+
+ $showsSelection
+ ? $component->assertSee('Database selection')->assertSee('Databases to back up')
+ : $component->assertDontSee('Database selection');
+
+ if ($showsSelection) {
+ $component->set('databasesToBackup', 'shop')->call('submit')->assertHasNoErrors();
+ expect($backup->fresh()->databases_to_backup)->toBe('shop');
+ }
+})->with([
+ 'mariadb' => ['mariadb:11', true],
+ 'mysql' => ['mysql:8', true],
+ 'postgres' => ['postgres:16-alpine', true],
+ 'mongodb' => ['mongo:7', false],
+]);
diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php
index b058cbadab..a349afb277 100644
--- a/tests/Feature/BackupRunSafetyTest.php
+++ b/tests/Feature/BackupRunSafetyTest.php
@@ -361,6 +361,35 @@ it('backs up a service database without a database env variable from the backup
'mysql, selected database' => ['mysql:8', 'MYSQL_ROOT_PASSWORD=secret', ['databases_to_backup' => 'shop'], 'shop'],
]);
+it('backs up the databases from the backup settings instead of the container env database', function (string $image, string $env) {
+ $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image);
+ $backup = backupRunSafetySchedule($database, $this->team, ['databases_to_backup' => 'shop,blog']);
+ backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]);
+
+ (new DatabaseBackupJob($backup))->handle();
+
+ expect(ScheduledDatabaseBackupExecution::query()->orderBy('id')->pluck('database_name')->all())->toBe(['shop', 'blog']);
+})->with([
+ 'mariadb' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMARIADB_DATABASE=app"],
+ 'mysql' => ['mysql:8', "MYSQL_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"],
+ 'postgres' => ['postgres:16', "POSTGRES_PASSWORD=secret\nPOSTGRES_DB=app"],
+]);
+
+it('backs up the container env database when the backup settings name none', function (string $image, string $env) {
+ $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image);
+ $backup = backupRunSafetySchedule($database, $this->team);
+ backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]);
+
+ (new DatabaseBackupJob($backup))->handle();
+
+ expect(ScheduledDatabaseBackupExecution::query()->sole()->database_name)->toBe('app');
+})->with([
+ 'mariadb' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMARIADB_DATABASE=app"],
+ 'mariadb with mysql env' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"],
+ 'mysql' => ['mysql:8', "MYSQL_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"],
+ 'postgres' => ['postgres:16', "POSTGRES_PASSWORD=secret\nPOSTGRES_DB=app"],
+]);
+
it('records a failed execution and notifies when no service database to back up is known', function (string $image, string $env) {
$database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image);
$backup = backupRunSafetySchedule($database, $this->team);
diff --git a/tests/Feature/CleanupHelperContainersJobDedupTest.php b/tests/Feature/CleanupHelperContainersJobDedupTest.php
index 9fd52d4404..7a6cbde9cc 100644
--- a/tests/Feature/CleanupHelperContainersJobDedupTest.php
+++ b/tests/Feature/CleanupHelperContainersJobDedupTest.php
@@ -1,10 +1,16 @@
false]);
+ $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+ $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]);
+ foreach (['finished-uuid' => ApplicationDeploymentStatus::FINISHED, 'failed-uuid' => ApplicationDeploymentStatus::FAILED, 'running-uuid' => ApplicationDeploymentStatus::IN_PROGRESS] as $uuid => $status) {
+ ApplicationDeploymentQueue::create(['application_id' => '1', 'deployment_uuid' => $uuid, 'server_id' => $server->id, 'status' => $status->value]);
+ }
+ $containers = collect([
+ 'finished-uuid' => 'aaa111',
+ 'failed-uuid' => 'bbb222',
+ 'running-uuid' => 'ccc333',
+ 'volume-upload-abc' => 'ddd444',
+ 'volume-backup-abc' => 'eee555',
+ 'backup-of-abc' => 'fff666',
+ ])->map(fn (string $id, string $name): array => ['ID' => $id, 'Names' => $name, 'Image' => 'ghcr.io/coollabsio/coolify-helper:1.0.13'])->values();
+
+ $removed = [];
+ Process::fake(function (PendingProcess $process) use ($containers, &$removed) {
+ $script = remoteProcessScript($process);
+ if (preg_match("/docker container rm -f '?([a-z0-9]+)/", $script, $match) === 1) {
+ $removed[] = $match[1];
+ }
+
+ return Process::result(str_contains($script, 'docker container ps') ? $containers->toJson() : '');
+ });
+
+ (new CleanupHelperContainersJob($server))->handle();
+
+ expect($removed)->toBe(['aaa111', 'bbb222']);
+});
diff --git a/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php b/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php
index f71811b24a..4f9fed3fe7 100644
--- a/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php
+++ b/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php
@@ -16,7 +16,7 @@ test('generateDockerBuildArgs returns only keys without values', function () {
test('generateDockerBuildArgs works with collection of objects', function () {
$variables = collect([
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
- (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
+ (object) ['key' => 'VAR2', 'value' => "multiline\nvalue"],
]);
$buildArgs = generateDockerBuildArgs($variables);
@@ -59,7 +59,7 @@ test('generateDockerBuildArgs handles variables without is_multiline', function
test('generateDockerEnvFlags produces correct format', function () {
$variables = [
['key' => 'NORMAL_VAR', 'value' => 'value', 'is_multiline' => false],
- ['key' => 'MULTILINE_VAR', 'value' => "'line1\nline2'", 'is_multiline' => true],
+ ['key' => 'MULTILINE_VAR', 'value' => "line1\nline2"],
];
$envFlags = generateDockerEnvFlags($variables);
@@ -73,7 +73,7 @@ test('generateDockerEnvFlags produces correct format', function () {
test('generateDockerEnvFlags works with collection input', function () {
$variables = collect([
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
- (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
+ (object) ['key' => 'VAR2', 'value' => "multiline\nvalue"],
]);
$envFlags = generateDockerEnvFlags($variables);
diff --git a/tests/Feature/EnvironmentVariableLegacyEscapingTest.php b/tests/Feature/EnvironmentVariableLegacyEscapingTest.php
index 96382a231c..6823dfc7b5 100644
--- a/tests/Feature/EnvironmentVariableLegacyEscapingTest.php
+++ b/tests/Feature/EnvironmentVariableLegacyEscapingTest.php
@@ -95,6 +95,40 @@ test('legacy service variables keep the old env file format', function () {
->and($this->service->composeEnvironmentFileLine($empty))->toBe('EMPTY=');
});
+test('legacy service variables keep the value that the compose file got before exact escaping', function () {
+ $this->service->update(['docker_compose_raw' => <<<'YAML'
+services:
+ app:
+ image: alpine
+ environment:
+ - WITH_DEFAULT=${WITH_DEFAULT:-x}
+ - OTHER_NAME=${RENAMED}
+ - NESTED=${NESTED:-${NESTED_DEFAULT}}
+ - PLAIN=${PLAIN}
+YAML]);
+ $variables = collect(['WITH_DEFAULT', 'RENAMED', 'NESTED_DEFAULT', 'PLAIN'])
+ ->mapWithKeys(fn (string $key) => [$key => legacyVariable($this->service, ['key' => $key, 'value' => 'a\\.b'])]);
+
+ expect($this->service->composeEnvironmentFileLine($variables['WITH_DEFAULT']))->toBe('WITH_DEFAULT="a\\\\.b"')
+ ->and($this->service->composeEnvironmentFileLine($variables['RENAMED']))->toBe('RENAMED="a\\\\.b"')
+ ->and($this->service->composeEnvironmentFileLine($variables['NESTED_DEFAULT']))->toBe('NESTED_DEFAULT="a\\\\.b"')
+ ->and($this->service->composeEnvironmentFileLine($variables['PLAIN']))->toBe('PLAIN=a\\\\.b')
+ ->and($this->service->legacyEscapingChangesValue($variables['WITH_DEFAULT']))->toBeFalse()
+ ->and($this->service->legacyEscapingChangesValue($variables['PLAIN']))->toBeTrue()
+ ->and($variables['WITH_DEFAULT']->fresh()->real_value)->toBe('a\\.b');
+});
+
+test('legacy literal and multiline variables with a single quote get a line that docker compose can read', function () {
+ $literal = legacyVariable($this->service, ['key' => 'LITERAL_QUOTE', 'value' => "it's", 'is_literal' => true]);
+ $multiline = legacyVariable($this->service, ['key' => 'MULTILINE_QUOTE', 'value' => "it's\n\"two\"", 'is_multiline' => true]);
+ $withoutQuote = legacyVariable($this->service, ['key' => 'LITERAL_PLAIN', 'value' => 'x$y', 'is_literal' => true]);
+
+ expect($this->service->composeEnvironmentFileLine($literal))->toBe('LITERAL_QUOTE="it\'s"')
+ ->and($this->service->composeEnvironmentFileLine($multiline))->toBe("MULTILINE_QUOTE=\"it's\n\\\"two\\\"\"")
+ ->and($this->service->composeEnvironmentFileLine($withoutQuote))->toBe("LITERAL_PLAIN='x\$y'")
+ ->and($this->service->legacyEscapingChangesValue($literal))->toBeFalse();
+});
+
test('legacy database variables keep the old compose environment format', function () {
$database = StandalonePostgresql::forceCreate([
'uuid' => 'postgres-legacy-test',
diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php
index 67c713cad1..9b522e060c 100644
--- a/tests/Feature/EnvironmentVariableSearchTest.php
+++ b/tests/Feature/EnvironmentVariableSearchTest.php
@@ -396,6 +396,45 @@ YAML;
->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose));
});
+it('stores the Compose default for a variable that another service references without one', function (string $dockerCompose) {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
+ 'docker_compose_raw' => $dockerCompose,
+ ]);
+
+ $service->parse();
+
+ expect($service->environment_variables()->where('key', 'POSTGRES_DB')->value('value'))->toBe('umami');
+})->with([
+ 'reference before default' => "services:\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\$POSTGRES_DB\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n",
+ 'default before reference' => "services:\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\${POSTGRES_DB}\n",
+]);
+
+it('does not store a nested Compose default for a bare reference', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
+ 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n DB: \${DB:-\${APP_NAME}}\n URL: postgres://db/\$DB\n",
+ ]);
+
+ $service->parse();
+
+ expect($service->environment_variables()->where('key', 'DB')->firstOrFail()->value)->toBeNull();
+});
+
+it('does not store a Compose default when the defaults for a bare reference conflict', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
+ 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-info}\n LOG: level=\$LEVEL\n worker:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-warn}\n",
+ ]);
+
+ $service->parse();
+
+ expect($service->environment_variables()->where('key', 'LEVEL')->firstOrFail()->value)->toBeNull();
+});
+
it('keeps editable and literal rows when services use the same key differently', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
diff --git a/tests/Feature/S3StorageMinioClientResolveTest.php b/tests/Feature/S3StorageMinioClientResolveTest.php
new file mode 100644
index 0000000000..69fe5e0967
--- /dev/null
+++ b/tests/Feature/S3StorageMinioClientResolveTest.php
@@ -0,0 +1,89 @@
+ InstanceSettings::query()->updateOrCreate(['id' => 0], [
+ 'webhook_allowed_internal_hosts' => ['localhost'],
+ 'webhook_allow_localhost' => true,
+ ]));
+});
+
+afterEach(function () {
+ Server::flushIdentityMap();
+});
+
+/**
+ * @return array{0: Server, 1: S3Storage}
+ */
+function minioResolveTestSetup(string $endpoint, string $sshUser = 'root'): array
+{
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'user' => $sshUser,
+ 'ip' => '203.0.113.10',
+ 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
+ ]);
+ $storage = S3Storage::create([
+ 'name' => 'Destination',
+ 'region' => 'us-east-1',
+ 'key' => 'key',
+ 'secret' => 'secret',
+ 'bucket' => 'bucket',
+ 'endpoint' => $endpoint,
+ 'team_id' => $team->id,
+ 'is_usable' => true,
+ ]);
+
+ return [$server, $storage];
+}
+
+it('pins the IPv6 address of a dual-stack S3 host when only IPv6 connects from the upload network', function () {
+ [$server, $storage] = minioResolveTestSetup('http://localhost:9000');
+ fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']);
+
+ $options = $storage->minioClientResolveOptions($server, 'ghcr.io/coollabsio/coolify-helper:1.0.0', 'coolify');
+
+ expect($options)->toBe(" --resolve 'localhost:9000=::1'");
+ Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker run --rm --network 'coolify' 'ghcr.io/coollabsio/coolify-helper:1.0.0' bash -c")
+ && str_contains(remoteProcessScript($process), "for ip in '\\''127.0.0.1'\\'' '\\''::1'\\''; do")
+ && str_contains(remoteProcessScript($process), '/dev/tcp/$ip/9000'));
+});
+
+it('pins the IPv4 address when it connects or when no address connects', function (string $probeOutput) {
+ [$server, $storage] = minioResolveTestSetup('http://localhost:9000');
+ fakeRemoteProcesses(['*/dev/tcp/*' => $probeOutput, '*' => '']);
+
+ expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(" --resolve 'localhost:9000=127.0.0.1'");
+})->with([
+ 'IPv4 connects' => '127.0.0.1',
+ 'no address connects' => '',
+ 'unexpected output' => '10.0.0.1',
+]);
+
+it('runs the probe with sudo on servers with a non-root SSH user', function () {
+ [$server, $storage] = minioResolveTestSetup('http://localhost:9000', 'deploy');
+ fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']);
+
+ expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(" --resolve 'localhost:9000=::1'");
+ Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo docker run --rm 'helper:1.0.0' bash -c")
+ && str_contains(remoteProcessScript($process), 'if timeout 5 bash -c "exec 3<>/dev/tcp/$ip/9000" 2>/dev/null; then echo "$ip"; exit 0; fi; done'));
+});
+
+it('does not pin or probe an S3 endpoint that is an IP address', function () {
+ [$server, $storage] = minioResolveTestSetup('https://8.8.8.8');
+ Process::fake();
+
+ expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe('');
+ Process::assertNothingRan();
+});
diff --git a/tests/Feature/ServerMetricsPrefetchTest.php b/tests/Feature/ServerMetricsPrefetchTest.php
index 12a5cb6078..6d84e68b35 100644
--- a/tests/Feature/ServerMetricsPrefetchTest.php
+++ b/tests/Feature/ServerMetricsPrefetchTest.php
@@ -52,20 +52,20 @@ it('keeps the Sentinel error of one prefetched metric', function () {
->and(fn () => $this->server->getMemoryMetrics(5))->toThrow(Exception::class, 'Unauthorized');
});
-it('falls back to one fetch per metric when the batch fails', function () {
+it('does not retry an unavailable server after the batch fails', function () {
$commands = [];
Process::fake(function (PendingProcess $process) use (&$commands) {
$commands[] = remoteProcessScript($process);
- return count($commands) === 1
- ? Process::result('', 'ssh: connect to host failed', 1)
- : Process::result('[{"time":2000,"percent":5}]');
+ return Process::result('', 'ssh: connect to host failed', 1);
});
$this->server->prefetchMetrics(['cpu', 'memory'], 5);
- expect($this->server->getCpuMetrics(5))->toBe([[2000, 5.0]])
- ->and($commands)->toHaveCount(2);
+ expect($this->server->getCpuMetrics(5))->toBeNull()
+ ->and($this->server->getMemoryMetrics(5))->toBeNull()
+ ->and($this->server->getLoadMetrics(5))->toBeNull()
+ ->and($commands)->toHaveCount(1);
});
it('quotes the bucketed history URL in a single fetch for a non-root user', function () {
@@ -96,7 +96,7 @@ it('runs the batched metrics script with sudo for a non-root user', function ()
expect($commands)->toHaveCount(1)
->and($commands[0])->toContain('sudo docker exec -i coolify-sentinel sh -s')
- ->and($commands[0])->toContain("\ncurl -s -H @- ")
+ ->and($commands[0])->toContain("\ncurl -sS --connect-timeout 2 --max-time 3 -H @- ")
->and($commands[0])->not->toContain('sudo curl')
->and($commands[0])->not->toContain('sudo printf');
});
diff --git a/tests/Feature/StartDatabaseImportS3CredentialsTest.php b/tests/Feature/StartDatabaseImportS3CredentialsTest.php
index 15b0041535..045d5c1db9 100644
--- a/tests/Feature/StartDatabaseImportS3CredentialsTest.php
+++ b/tests/Feature/StartDatabaseImportS3CredentialsTest.php
@@ -282,6 +282,43 @@ function importS3ArgvTestRunAliasProcess(array $aliasProcess): array
}
}
+it('pins the reachable IP of a dual-stack S3 host for the restore mc commands', function () {
+ InstanceSettings::query()->whereKey(0)->update([
+ 'webhook_allowed_internal_hosts' => ['localhost'],
+ 'webhook_allow_localhost' => true,
+ ]);
+ $storage = S3Storage::create([
+ 'name' => 'Import S3',
+ 'region' => 'us-east-1',
+ 'key' => 'key',
+ 'secret' => 'secret',
+ 'bucket' => 'test-bucket',
+ 'endpoint' => 'http://localhost:9000',
+ 'is_usable' => true,
+ 'team_id' => $this->team->id,
+ ]);
+ $disk = Mockery::mock(FilesystemAdapter::class);
+ $disk->shouldReceive('exists')->once()->andReturn(true);
+ $disk->shouldReceive('size')->once()->andReturn(1024);
+ $filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial();
+ $filesystem->shouldReceive('build')->once()->andReturn($disk);
+ Storage::swap($filesystem);
+ fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']);
+ Queue::fake();
+
+ $activity = app(StartDatabaseImport::class)->handle(
+ $this->database,
+ new DatabaseImportSource('s3', path: 'backups/restore.sql', s3StorageUuid: $storage->uuid),
+ $this->team->id,
+ );
+
+ Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker run --rm --network 'coolify'")
+ && str_contains(remoteProcessScript($process), '/dev/tcp/$ip/9000'));
+ Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mc alias set --resolve '\\''localhost:9000=::1'\\'' --api S3v4 s3temp"));
+ expect((string) RemoteProcessCommand::read($activity))
+ ->toContain("mc cp --resolve 'localhost:9000=::1' ".escapeshellarg('s3temp/test-bucket/backups/restore.sql'));
+});
+
it('passes the S3 keys of an import to mc on stdin, not in a command line', function (string $sshUser) {
[$processes, $activity, $error] = importS3ArgvTestRun($this, $sshUser);
diff --git a/tests/Feature/VolumeCloneJobTest.php b/tests/Feature/VolumeCloneJobTest.php
index b0f6e627d4..9df9cb6e85 100644
--- a/tests/Feature/VolumeCloneJobTest.php
+++ b/tests/Feature/VolumeCloneJobTest.php
@@ -6,6 +6,8 @@ use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Server;
+use App\Models\Service;
+use App\Models\ServiceApplication;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
@@ -192,3 +194,32 @@ test('a non-root server runs the volume create as one sudo shell script', functi
expect(volumeCloneCommands())->toContain('sudo sh -c '.escapeshellarg($inner))
->and(volumeCloneCommands())->toContain('sudo docker volume inspect');
});
+
+test('a service clone creates the target volume with the Compose labels of the service project', function () {
+ $service = Service::factory()->create();
+ $serviceApplication = ServiceApplication::create(['name' => 'gitea', 'service_id' => $service->id]);
+ $this->volume->update(['resource_type' => ServiceApplication::class, 'resource_id' => $serviceApplication->id]);
+ fakeVolumeCloneServer(['Name' => 'clone-source', 'Driver' => 'local', 'Options' => null]);
+
+ (new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume->fresh()))->handle();
+
+ expect(volumeCloneCommands())->toContain(escapeshellarg(
+ "docker volume create --label 'com.docker.compose.project={$service->uuid}' --label 'com.docker.compose.volume=clone-target' 'clone-target'"
+ ));
+});
+
+test('an application clone creates the target volume with the Compose labels of the application project', function () {
+ $application = Application::factory()->create();
+ $this->volume->update(['resource_type' => Application::class, 'resource_id' => $application->id]);
+ fakeVolumeCloneServer([
+ 'Name' => 'clone-source',
+ 'Driver' => 'local',
+ 'Options' => ['type' => 'none', 'o' => 'bind', 'device' => '/srv/app-data'],
+ ]);
+
+ (new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume->fresh()))->handle();
+
+ expect(volumeCloneCommands())->toContain(escapeshellarg(
+ "docker volume create --label 'com.docker.compose.project={$application->uuid}' --label 'com.docker.compose.volume=clone-target' 'clone-target'"
+ ));
+});
diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php
index 83d8c9557c..abd7e85937 100644
--- a/tests/Unit/SafeWebhookUrlTest.php
+++ b/tests/Unit/SafeWebhookUrlTest.php
@@ -278,17 +278,16 @@ it('builds MinIO client resolve options for S3 backup uploads', function () {
expect($options)->toContain('localhost:9000=127.0.0.1');
});
-it('collapses dual-stack S3 hosts to a single bracket-free IPv4 resolve entry', function () {
+it('lists every S3 host IP as a bracket-free resolve entry, IPv4 first', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['localhost'],
'webhook_allow_localhost' => true,
]));
- // localhost resolves to both 127.0.0.1 and ::1. The MinIO client keeps one IP per
- // host and rejects bracketed IPv6, so exactly one bracket-free IPv4 entry is produced.
+ // mc rejects bracketed IPv6, and S3Storage::minioClientResolveOptions() picks the reachable entry.
$options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
- expect($options)->toBe(['localhost:9000=127.0.0.1']);
+ expect($options)->toBe(['localhost:9000=127.0.0.1', 'localhost:9000=::1']);
});
it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () {
diff --git a/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php b/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php
new file mode 100644
index 0000000000..1ed2a710af
--- /dev/null
+++ b/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php
@@ -0,0 +1,37 @@
+toBeTrue()
+ ->and($operation['requestBody']['content']['application/json']['schema']['$ref'] ?? null)
+ ->toBe("#/components/schemas/{$schemaName}");
+})->with([
+ ['/team/envs', 'post', 'SharedEnvironmentVariableCreateRequest'],
+ ['/team/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'],
+ ['/projects/{uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'],
+ ['/projects/{uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'],
+ ['/projects/{uuid}/environments/{environment_name_or_uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'],
+ ['/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'],
+ ['/servers/{uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'],
+ ['/servers/{uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'],
+]);
+
+it('documents the fields accepted by the shared env validator', function () {
+ $schemas = sharedEnvOpenApiSpec()['components']['schemas'];
+ $fields = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment'];
+
+ expect(array_keys($schemas['SharedEnvironmentVariableCreateRequest']['properties']))->toBe($fields)
+ ->and($schemas['SharedEnvironmentVariableCreateRequest']['required'])->toBe(['key'])
+ ->and(array_keys($schemas['SharedEnvironmentVariableUpdateRequest']['properties']))->toBe($fields)
+ ->and($schemas['SharedEnvironmentVariableUpdateRequest'])->not->toHaveKey('required');
+});