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

This commit is contained in:
github-actions[bot]
2026-10-30 11:16:06 +00:00
15 changed files with 307 additions and 78 deletions
@@ -0,0 +1,93 @@
<?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\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
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]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'port-mappings-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$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([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
function patchPortMappings(string $bearerToken, Application $application, ?string $portsMappings)
{
return test()->withHeaders(['Authorization' => 'Bearer '.$bearerToken])
->patchJson("/api/v1/applications/{$application->uuid}", ['ports_mappings' => $portsMappings]);
}
test('accepts every port mapping format that the UI accepts', function (string $portsMappings) {
patchPortMappings($this->bearerToken, $this->application, $portsMappings)->assertOk();
expect($this->application->refresh()->ports_mappings)->toBe($portsMappings);
})->with([
'plain' => '8080:80',
'udp protocol' => '8080:80/udp',
'sctp protocol' => '8080:80/sctp',
'port range' => '8000-8010:8000-8010',
'ipv4 binding' => '127.0.0.1:8080:80',
'ipv6 binding with random host port' => '[::1]::80',
'same port for tcp and udp' => '53:53/tcp,53:53/udp',
'same port on different host ips' => '127.0.0.1:8080:80,10.0.0.1:8080:80',
'random host ports on one ip' => '127.0.0.1::80,127.0.0.1::81',
]);
test('clears port mappings with null', function () {
$this->application->update(['ports_mappings' => '8080:80']);
patchPortMappings($this->bearerToken, $this->application, null)->assertOk();
expect($this->application->refresh()->ports_mappings)->toBeNull();
});
test('rejects invalid port mappings', function (string $portsMappings) {
patchPortMappings($this->bearerToken, $this->application, $portsMappings)
->assertUnprocessable()
->assertJsonValidationErrors('ports_mappings');
})->with([
'missing container port' => '8080',
'unknown protocol' => '8080:80/icmp',
'shell characters' => '8080:80;id',
]);
test('rejects duplicate host bindings', function (string $portsMappings) {
patchPortMappings($this->bearerToken, $this->application, $portsMappings)
->assertUnprocessable()
->assertJsonPath('errors.ports_mappings', 'Each host IP, port, and protocol can only be used once.');
})->with([
'same host port' => '8080:80,8080:81',
'same host port with explicit tcp' => '8080:80,8080:81/tcp',
'same ip and host port' => '127.0.0.1:8080:80,127.0.0.1:8080:81',
]);
@@ -228,6 +228,18 @@ it('redacts a locked redis password and its connection urls in the api', functio
$list = $this->withHeaders($headers)->getJson('/api/v1/databases')->assertOk();
expect($list->getContent())->not->toContain('locked-redis-secret');
$resources = $this->withHeaders($headers)->getJson('/api/v1/resources')->assertOk();
expect($resources->getContent())->not->toContain('locked-redis-secret')
->and($resources->json('0.uuid'))->toBe($redis->uuid)
->and($resources->json('0.internal_db_url'))->toBeNull();
$environment = $this->withHeaders($headers)
->getJson("/api/v1/projects/{$this->environment->project->uuid}/{$this->environment->uuid}")
->assertOk();
expect($environment->getContent())->not->toContain('locked-redis-secret')
->and($environment->json('redis.0.uuid'))->toBe($redis->uuid)
->and($environment->json('redis.0.internal_db_url'))->toBeNull();
});
it('still returns the connection url of an unlocked redis password in the api', function () {
@@ -164,3 +164,24 @@ it('hides locked values in the generated compose returned by the API', function
expectLockedValuesRedacted($response->json('docker_compose'));
expect($response->json('docker_compose_raw'))->toBe(LOCKED_COMPOSE_RAW);
})->with(['service', 'application']);
it('hides locked values in the generated compose returned by the resource list API', function () {
$response = $this->withToken(lockedComposeApiToken())->getJson('/api/v1/resources')->assertOk();
foreach ([$this->service, $this->application] as $resource) {
expectLockedValuesRedacted(collect($response->json())->firstWhere('uuid', $resource->uuid)['docker_compose']);
}
});
it('hides locked values in the generated compose returned by the environment API', function (string $relation, string $resource) {
$response = $this->withToken(lockedComposeApiToken())
->getJson("/api/v1/projects/{$this->project->uuid}/{$this->environment->uuid}")
->assertOk();
expectLockedValuesRedacted($response->json("{$relation}.0.docker_compose"));
expect($response->json("{$relation}.0.uuid"))->toBe($this->{$resource}->uuid)
->and($response->json("{$relation}.0.docker_compose_raw"))->toBe(LOCKED_COMPOSE_RAW);
})->with([
'service' => ['services', 'service'],
'application' => ['applications', 'application'],
]);
@@ -148,7 +148,7 @@ test('strict teams use an available dedicated build server', function () {
]);
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer);
$deploymentQueue->shouldReceive('setAttribute')->with('build_server_id', $buildServer->id)->once()->andReturnSelf();
$deploymentQueue->shouldReceive('update')->with(['build_server_id' => $buildServer->id])->once()->andReturnTrue();
$deploymentQueue->shouldReceive('addLogEntry')
->once()
->with("Found a suitable build server ({$buildServer->name}).");
@@ -179,7 +179,7 @@ test('build server selection only picks dedicated build servers', function () {
$buildServer = makeRoleServer($team, 'build');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer);
$deploymentQueue->shouldReceive('setAttribute')->with('build_server_id', $buildServer->id)->once()->andReturnSelf();
$deploymentQueue->shouldReceive('update')->with(['build_server_id' => $buildServer->id])->once()->andReturnTrue();
$deploymentQueue->shouldReceive('addLogEntry')->once()->with("Found a suitable build server ({$buildServer->name}).");
invokeBuildServerSelection($job);
@@ -194,7 +194,7 @@ test('fallback builds on the deployment server instead of another combined serve
makeRoleServer($team, 'both');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer);
$deploymentQueue->shouldNotReceive('setAttribute');
$deploymentQueue->shouldNotReceive('update');
$deploymentQueue->shouldReceive('addLogEntry')->once()->with('No suitable build server found. Using the deployment server.');
invokeBuildServerSelection($job);
@@ -210,7 +210,7 @@ test('deployments-only servers build on a dedicated build server without the app
$buildServer = makeRoleServer($team, 'build');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], buildServerEnabled: false);
$deploymentQueue->shouldReceive('setAttribute')->with('build_server_id', $buildServer->id)->once()->andReturnSelf();
$deploymentQueue->shouldReceive('update')->with(['build_server_id' => $buildServer->id])->once()->andReturnTrue();
$deploymentQueue->shouldReceive('addLogEntry')->once()->with("Found a suitable build server ({$buildServer->name}).");
invokeBuildServerSelection($job);
@@ -252,7 +252,7 @@ test('deployments-only servers restart without a registry image', function () {
$buildServer = makeRoleServer($team, 'build');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: false, restartOnly: true);
$deploymentQueue->shouldReceive('setAttribute')->with('build_server_id', $buildServer->id)->once()->andReturnSelf();
$deploymentQueue->shouldReceive('update')->with(['build_server_id' => $buildServer->id])->once()->andReturnTrue();
$deploymentQueue->shouldReceive('addLogEntry')->once()->with("Found a suitable build server ({$buildServer->name}).");
invokeBuildServerSelection($job);
@@ -312,7 +312,7 @@ test('additional deployments-only servers pull the registry image without a buil
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], $buildServerEnabled);
markAsAdditionalServer($job);
$deploymentQueue->shouldNotReceive('setAttribute');
$deploymentQueue->shouldNotReceive('update');
$deploymentQueue->shouldReceive('addLogEntry')->once()->with('Additional server: pulls the image from the registry, no build server needed.');
invokeBuildServerSelection($job);
@@ -331,7 +331,7 @@ test('additional servers do not use a build server when the application enables
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app']);
markAsAdditionalServer($job);
$deploymentQueue->shouldNotReceive('setAttribute');
$deploymentQueue->shouldNotReceive('update');
$deploymentQueue->shouldReceive('addLogEntry')->once()->with('Additional server: pulls the image from the registry, no build server needed.');
invokeBuildServerSelection($job);
@@ -436,7 +436,7 @@ test('strict teams restart on the deployment server when no build server is avai
$deploymentServer = makeRoleServer($team, $role);
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: $buildServerEnabled, restartOnly: true);
$deploymentQueue->shouldNotReceive('setAttribute');
$deploymentQueue->shouldNotReceive('update');
$deploymentQueue->shouldReceive('addLogEntry')->once()->with('No suitable build server found. Using the deployment server.');
invokeBuildServerSelection($job);
@@ -454,7 +454,7 @@ test('strict teams restart on an available build server', function () {
$buildServer = makeRoleServer($team, 'build');
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: false, restartOnly: true);
$deploymentQueue->shouldReceive('setAttribute')->with('build_server_id', $buildServer->id)->once()->andReturnSelf();
$deploymentQueue->shouldReceive('update')->with(['build_server_id' => $buildServer->id])->once()->andReturnTrue();
$deploymentQueue->shouldReceive('addLogEntry')->once()->with("Found a suitable build server ({$buildServer->name}).");
invokeBuildServerSelection($job);
@@ -481,3 +481,23 @@ test('a restart that has to rebuild applies the build server rules again', funct
'deployments-only server' => ['deployment', false, 'is set to deployments only, and no usable build server was found.'],
'application uses a build server' => ['both', true, 'No available dedicated build server was found.'],
]);
test('the selected build server is saved on the deployment so a cancel finds the helper container', function () {
$team = Team::factory()->create();
$deploymentServer = makeRoleServer($team, 'both');
$buildServer = makeRoleServer($team, 'build');
[$job] = makeBuildServerSelectionJob($team, $deploymentServer);
$deployment = ApplicationDeploymentQueue::query()->create([
'application_id' => '1',
'deployment_uuid' => 'build-server-cancel-test',
'server_id' => $deploymentServer->id,
]);
(new ReflectionProperty(ApplicationDeploymentJob::class, 'application_deployment_queue'))->setValue($job, $deployment);
invokeBuildServerSelection($job);
// execute_remote_command() refreshes the deployment before each command.
$deployment->refresh();
expect($deployment->build_server_id)->toBe($buildServer->id)
->and(ApplicationDeploymentQueue::query()->find($deployment->id)->build_server_id)->toBe($buildServer->id);
});
@@ -0,0 +1,70 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Process::fake();
});
function makeCancelledDeploymentJob(): array
{
$team = Team::factory()->create();
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
$deployment = ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => 'cancelled-helper',
'server_id' => $server->id,
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
foreach ([
'application' => $application,
'application_deployment_queue' => $deployment,
'server' => $server,
'saved_outputs' => collect(),
] as $property => $value) {
(new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
}
return [$job, $deployment];
}
test('a cancelled deployment still stops its helper container', function () {
[$job, $deployment] = makeCancelledDeploymentJob();
(new ReflectionMethod(ApplicationDeploymentJob::class, 'graceful_shutdown_container'))->invoke($job, 'cancelled-helper', true);
Process::assertRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker stop') && str_contains(remoteProcessScript($process), 'cancelled-helper'));
expect($deployment->refresh()->logs ?? '')->not->toContain('Error stopping container');
});
test('a cancelled deployment does not run other remote commands', function () {
[$job] = makeCancelledDeploymentJob();
expect(fn () => $job->execute_remote_command(['echo build', 'hidden' => true]))
->toThrow(RuntimeException::class, 'Deployment cancelled by user');
Process::assertNothingRan();
});
@@ -95,10 +95,14 @@ test('coolify dashboard routes follow the server certificate resolver', function
preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d/", $command, $matches);
$routers = Yaml::parse(base64_decode($matches[1]))['http']['routers'];
foreach (['coolify-https', 'coolify-reverb-wss', 'coolify-reverb-api-https', 'coolify-terminal-wss'] as $router) {
foreach (['coolify-https', 'coolify-reverb-wss', 'coolify-terminal-wss'] as $router) {
expect($routers[$router])->toHaveKey('tls')
->and($routers[$router]['tls'] ?? [])->toBe($expectedTls);
}
expect($routers)->not->toHaveKeys(['coolify-reverb-api', 'coolify-reverb-api-https'])
->and($routers['coolify-reverb-ws']['rule'])->toBe('Host(`coolify.example.com`) && PathPrefix(`/app/`)')
->and($routers['coolify-reverb-wss']['rule'])->toBe('Host(`coolify.example.com`) && PathPrefix(`/app/`)');
})->with([
'letsencrypt' => ['letsencrypt', ['certresolver' => 'letsencrypt']],
'no resolver' => [null, []],