chore(v5): archive V5 implementation and remove runtime integration

Move V5 source, migrations, UI, scripts, and tests into documentation, then remove V5 routes, models, jobs, configuration, dependencies, and application hooks.
This commit is contained in:
Andras Bacsai committed 2026-08-15 19:13:30 +02:00
1 parent 1440d35750
commit 76030a30d4
237 files changed
+288 -7079

No files matched your search

+1 -4
View File
@@ -4,7 +4,4 @@ test('app layouts always reserve space for the vertical scrollbar', function (st
$contents = file_get_contents(dirname(__DIR__, 2).'/'.$layout);
expect($contents)->toMatch('/<body(?:\s+[^>]*)?class="[^"]*overflow-y-scroll[^"]*"[^>]*>/');
})->with([
'current interface' => 'resources/views/layouts/base.blade.php',
'v5 interface' => 'resources/views/v5/app.blade.php',
]);
})->with(['resources/views/layouts/base.blade.php']);
+1 -6
View File
@@ -2,11 +2,6 @@
it('uses a thin warning yellow focus ring in dark mode', function () {
$appStyles = file_get_contents(dirname(__DIR__, 2).'/resources/css/app.css');
$v5Styles = file_get_contents(dirname(__DIR__, 2).'/resources/css/v5/app.css');
expect($appStyles)
->toContain('ring-2 ring-coollabs dark:ring-1 dark:ring-warning')
->and($v5Styles)
->toMatch('/\.dark\s*\{[^}]*--ring:\s*var\(--warning\);/s')
->toContain('ring-2 dark:ring-1 ring-ring');
->toContain('ring-2 ring-coollabs dark:ring-1 dark:ring-warning');
});
+2 -8
View File
@@ -13,16 +13,10 @@ it('uses at least sixteen pixel form controls on mobile to prevent input focus z
->and($css)->toContain(':root textarea,')
->and($css)->toContain(':root select')
->and($css)->toContain('font-size: 16px !important;');
})->with([
'current interface' => 'resources/css/app.css',
'v5 interface' => 'resources/css/v5/app.css',
]);
})->with(['resources/css/app.css']);
it('keeps user zoom enabled in the viewport meta tags', function () {
$layouts = [
'resources/views/layouts/base.blade.php',
'resources/views/v5/app.blade.php',
];
$layouts = ['resources/views/layouts/base.blade.php'];
foreach ($layouts as $layout) {
expect(file_get_contents(dirname(__DIR__, 2).'/'.$layout))
-23
View File
@@ -44,29 +44,6 @@ it('downloads postgres upgrade script during install and upgrade without auto-ru
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('does not provision a flux laravel api token before v5 production activation', function (string $path) {
$source = file_get_contents(getcwd().'/'.$path);
expect($source)->not->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN');
})->with([
'production env' => '.env.production',
'stable install' => 'scripts/install.sh',
'nightly install' => 'other/nightly/install.sh',
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('does not provision flux storage before v5 production activation', function (string $path) {
$source = file_get_contents(getcwd().'/'.$path);
expect($source)->not->toContain('/data/coolify/flux');
})->with([
'stable install' => 'scripts/install.sh',
'nightly install' => 'other/nightly/install.sh',
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses the selected registry url when extracting upgrade images', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
-151
View File
@@ -1,151 +0,0 @@
<?php
use App\Models\V5\Server as V5Server;
use App\Services\Flux\AgentTokenIssuer;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
/**
* @return array{0: string, 1: string}
*/
function createAgentTokenIssuerKeypair(): array
{
$directory = storage_path('framework/testing/agent-token-keys-'.bin2hex(random_bytes(4)));
mkdir($directory, 0777, true);
$privateKeyPath = $directory.'/jwt.priv';
$publicKeyPath = $directory.'/jwt.pub';
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
expect($code)->toBe(0);
chmod($privateKeyPath, 0600);
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
expect($code)->toBe(0);
return [$privateKeyPath, $publicKeyPath];
}
function decodeAgentToken(string $token, string $publicKeyPath, ?stdClass &$headers = null): stdClass
{
// Firebase's JWT::decode only populates $headers when it is non-null on
// entry (it opts out of clobbering by default), so seed a placeholder.
if (func_num_args() >= 3) {
$headers = new stdClass;
}
return JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'), $headers);
}
beforeEach(function () {
[$this->privateKeyPath, $this->publicKeyPath] = createAgentTokenIssuerKeypair();
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
});
it('mints the explicit advertised capability list by default, not the wildcard profile', function () {
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
$claims = decodeAgentToken($token, $this->publicKeyPath);
expect((array) $claims->caps)->toBe(config('flux.host_capabilities'))
->and((array) $claims->caps)->toContain('containers.create')
->and((array) $claims->caps)->toContain('ingress.apply')
->and((array) $claims->caps)->toContain('host.jwt.set')
->and((array) $claims->caps)->not->toContain('host-agent:default')
->and((array) $claims->caps)->not->toContain('host-agent:dev')
->and((array) $claims->caps)->not->toContain('*');
});
it('mints the escape-hatch capability profile when configured', function () {
Config::set('flux.host_capability_profile', 'host-agent:default');
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
$claims = decodeAgentToken($token, $this->publicKeyPath);
expect((array) $claims->caps)->toBe(['host-agent:default']);
});
it('includes a unique jti on every minted token', function () {
$issuer = app(AgentTokenIssuer::class);
$first = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
$second = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
expect($first->jti)->toBeString()->not->toBe('')
->and($second->jti)->toBeString()->not->toBe('')
->and($first->jti)->not->toBe($second->jti);
});
it('derives the token lifetime from config', function () {
Config::set('flux.host_token_ttl', 1234);
$before = time();
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
expect($claims->exp - $claims->iat)->toBe(1234)
->and($claims->exp)->toBeGreaterThanOrEqual($before + 1234);
});
it('defaults host token lifetime to the flux max token lifetime', function () {
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
expect($claims->exp - $claims->iat)->toBe(3600)
->and(config('flux.host_token_refresh_threshold'))->toBeLessThan(config('flux.host_token_ttl'));
});
it('clamps a below-floor ttl to 60 seconds', function () {
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10', null, 5), $this->publicKeyPath);
expect($claims->exp - $claims->iat)->toBe(60);
});
it('sets the configured kid header for future key rotation', function () {
Config::set('flux.jwt_kid', 'flux-2026');
$headers = null;
decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath, $headers);
expect($headers->kid)->toBe('flux-2026')
->and($headers->alg)->toBe('ES256');
});
it('refuses to mint a host token before the server has a stable Flux host id', function () {
$server = new V5Server([
'node_address' => '203.0.113.10',
'wireguard_management_ip' => '100.64.0.10',
]);
expect(fn () => app(AgentTokenIssuer::class)->issueForServer($server))
->toThrow(RuntimeException::class, 'Server is missing a valid Flux host id.');
});
it('mints server host tokens for the stable server uuid, not the WireGuard management IP', function () {
$server = new V5Server([
'uuid' => 'server_abc123',
'team_id' => 12,
'cluster_id' => 34,
'node_address' => '203.0.113.10',
'wireguard_management_ip' => '100.64.0.10',
]);
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issueForServer($server), $this->publicKeyPath);
expect($claims->sub)->toBe('server_abc123')
->and($claims->server_id)->toBe('server_abc123')
->and($claims->wireguard_management_ip)->toBe('100.64.0.10');
});
it('warns when the private key file has insecure permissions', function () {
chmod($this->privateKeyPath, 0644);
Log::shouldReceive('warning')
->once()
->withArgs(fn (string $message, array $context) => str_contains($message, 'insecure permissions')
&& $context['expected'] === '0600');
app(AgentTokenIssuer::class)->issue('100.64.0.10');
});
-100
View File
@@ -1,100 +0,0 @@
<?php
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Server as V5Server;
use App\Support\V5\CanvasResourceSerializer;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
});
/**
* @param array<string, mixed> $attributes
*/
function createBroadcastPayloadServer(array $attributes = []): V5Server
{
[$user, $team] = createV5UserWithTeam();
return V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.10',
...$attributes,
]);
}
it('serializes a null cluster payload when the cluster is gone before the queued broadcast runs', function () {
[, $team] = createV5UserWithTeam();
$event = new V5ClusterUpdated($team->id, 999);
expect($event->broadcastWith())->toBe(['cluster' => null]);
});
it('omits the caddy ingress payload for a non-ingress server', function () {
$server = createBroadcastPayloadServer(['is_ingress' => false]);
$event = new V5CanvasResourceUpdated($server->team_id, null, $server->id);
expect($event->broadcastWith()['caddyIngress'])->toBeNull();
});
it('serializes the caddy ingress payload for an ingress server', function () {
$server = createBroadcastPayloadServer([
'is_ingress' => true,
'ingress_type' => 'caddy',
'ingress_status' => 'running',
]);
$payload = (new V5CanvasResourceUpdated($server->team_id, null, $server->id))->broadcastWith();
expect($payload['caddyIngress'])->toBe(
app(CanvasResourceSerializer::class)->serializeCaddyIngress($server->fresh()),
)->and($payload['caddyIngress']['id'])->toBe($server->uuid);
});
it('broadcasts the same application shape as the initial-load canvas serializer', function () {
$server = createBroadcastPayloadServer();
$team = $server->team;
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Payload Parity', 'production');
$application = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $server->created_by_user_id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-parity',
'status' => 'running',
'status_message' => 'Container started.',
'runtime_container_id' => 'container-123',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->domains()->create(['domain' => 'parity.example.com']);
$payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith();
$expected = app(CanvasResourceSerializer::class)->serializeApplication(
V5Application::query()->with(['server', 'domains'])->findOrFail($application->id),
);
expect($payload['application'])->toBe($expected)
->and($payload['application']['id'])->toBe($application->uuid)
->and($payload['application']['domains'])->toBe(['parity.example.com'])
->and($payload['application']['projectUuid'])->toBe($project->uuid)
->and($payload['application']['environmentUuid'])->toBe($environment->uuid);
});
@@ -1,744 +0,0 @@
<?php
use App\Actions\V5\Proxy\GenerateCaddyIngressConfiguration;
use App\Actions\V5\Proxy\StartCaddyIngress;
use App\Actions\V5\Proxy\StopCaddyIngress;
use App\Models\V5\Application;
use App\Models\V5\ApplicationDomain;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
it('generates a caddy ingress compose file with health endpoint and application routes', function () {
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain([
'domain' => 'nginx.example.com',
]),
new ApplicationDomain([
'domain' => 'www.nginx.example.com',
]),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['compose'])->toContain('container_name: coolify-v5-caddy')
->and($configuration['compose'])->toContain("image: 'docker.io/library/caddy:2-alpine'")
->and($configuration['compose'])->toContain('80:80')
->and($configuration['compose'])->not->toContain('443:443')
->and($configuration['compose'])->toContain('./Caddyfile:/etc/caddy/Caddyfile:ro')
->and($configuration['compose'])->toContain('./apps:/etc/caddy/apps:ro')
->and($configuration['caddyfile'])->toContain('respond /coolify-health 200')
->and($configuration['caddyfile'])->toContain('respond 404')
->and($configuration['caddyfile'])->toContain('import apps/*.caddy')
->and($configuration['apps'])->toHaveCount(1)
->and($configuration['apps'][0]['caddyfile'])->toContain('http://nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->toContain('http://www.nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->not->toContain('https://')
->and($configuration['apps'][0]['caddyfile'])->toContain('reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080');
});
it('generates byte-identical caddy routes for valid domains', function () {
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'nginx.example.com']),
new ApplicationDomain(['domain' => 'www.nginx.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
'http://nginx.example.com {',
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
'}',
'',
'http://www.nginx.example.com {',
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
'}',
]));
});
it('never emits domains with caddyfile injection payloads into the configuration', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => "evil.com {\n} http://x"]),
new ApplicationDomain(['domain' => "evil\n.com"]),
new ApplicationDomain(['domain' => 'safe.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toHaveCount(1)
->and($configuration['apps'][0]['caddyfile'])->not->toContain('evil')
->and($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
'http://safe.example.com {',
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
'}',
]));
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
->twice();
});
it('skips whole applications when every domain is malicious', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'evil.com { respond "pwned" }']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
->once();
});
it('never emits unsafe container names or namespaces into the configuration', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => "coolify {\n} injected",
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'app.example.com']),
]));
$namespaceApplication = new Application([
'name' => 'nginx-test-2',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => "default {\n}",
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$namespaceApplication->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'other.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application, $namespaceApplication]));
expect($configuration['apps'])->toBe([]);
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an unsafe container name or namespace.', Mockery::type('array'))
->twice();
});
it('never emits out-of-range internal ports into the configuration', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
]);
$application->internal_port = 70000;
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'app.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an out-of-range internal port.', Mockery::type('array'))
->once();
});
it('does not generate app routes for applications without domains', function () {
$application = new Application([
'name' => 'private-app',
'container_name' => 'coolify-v5-private',
'mesh_namespace' => 'default',
]);
$application->setRelation('domains', new Collection);
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['caddyfile'])
->toContain('respond /coolify-health 200')
->and($configuration['apps'])->toBe([]);
});
it('does not generate app routes when ingress is disabled even with domains', function () {
$application = new Application([
'name' => 'private-app',
'container_name' => 'coolify-v5-private',
'mesh_namespace' => 'default',
'ingress_enabled' => false,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain([
'domain' => 'private.example.com',
]),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
});
it('does not generate app routes when the internal port is missing', function () {
$application = new Application([
'name' => 'needs-port',
'container_name' => 'coolify-v5-needs-port',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => null,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain([
'domain' => 'needs-port.example.com',
]),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
});
it('applies caddy ingress configuration through flux instead of ssh', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '10.0.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyIngress')
->once()
->with(
Mockery::type('string'),
'caddy',
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'respond /coolify-health 200')),
[]
)
->andReturn('Caddy ingress applied.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with(Mockery::type('string'), [
'id' => 'v5-caddy-ingress:80',
'namespace' => 'default',
'src' => '0.0.0.0/0',
'dst' => 'coolify-v5-caddy',
'proto' => 'tcp',
'port' => 80,
])
->andReturn('Firewall rule applied.');
$fluxClient
->shouldNotReceive('applyFirewallRule')
->with(Mockery::type('string'), Mockery::on(fn (array $rule): bool => ($rule['port'] ?? null) === 443));
app()->instance(FluxClient::class, $fluxClient);
$result = StartCaddyIngress::run($server);
expect($result)->toBe('Caddy ingress applied.');
});
it('does not start caddy ingress for non-ingress servers', function () {
$server = new Server([
'capabilities' => [],
]);
$result = StartCaddyIngress::run($server);
expect($result)->toBe('Server is not an ingress server.');
});
it('stops caddy ingress through flux instead of ssh', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '10.0.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('stopIngress')
->once()
->with(Mockery::type('string'), 'caddy')
->andReturn('Caddy ingress stopped.');
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
->andReturn('Firewall rule removed.');
$fluxClient
->shouldNotReceive('revokeFirewallRule')
->with(Mockery::type('string'), 'v5-caddy-ingress:443');
app()->instance(FluxClient::class, $fluxClient);
$result = StopCaddyIngress::run($server);
expect($result)->toBe('Caddy ingress stopped.');
});
it('does not stop caddy ingress for servers that never had ingress', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => [],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldNotReceive('stopIngress');
$fluxClient->shouldNotReceive('revokeFirewallRule');
app()->instance(FluxClient::class, $fluxClient);
expect(StopCaddyIngress::run($server))->toBe('Server is not an ingress server.');
});
it('revokes the caddy firewall rule before stopping the ingress container', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
->ordered()
->andReturn('Firewall rule removed.');
$fluxClient
->shouldReceive('stopIngress')
->once()
->with(Mockery::type('string'), 'caddy')
->ordered()
->andThrow(new RuntimeException('podman rm failed'));
app()->instance(FluxClient::class, $fluxClient);
expect(fn () => StopCaddyIngress::run($server))->toThrow(RuntimeException::class, 'podman rm failed');
});
it('tolerates already-revoked caddy firewall rules when stopping ingress', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->andThrow(new RuntimeException('firewall rule not found'));
$fluxClient
->shouldReceive('stopIngress')
->once()
->andReturn('Caddy ingress stopped.');
app()->instance(FluxClient::class, $fluxClient);
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
});
it('reports an unknown ingress status until coold confirms one', function () {
$installed = new Server(['capabilities' => ['ingress']]);
$installed->status = 'installed';
expect($installed->ingressStatus())->toBe('unknown');
$running = new Server(['capabilities' => ['ingress'], 'ingress_status' => 'running']);
expect($running->ingressStatus())->toBe('running');
});
it('includes flux error response details when dispatch returns a non success status', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 500,
'message' => 'start Caddy ingress: podman exited with status 125',
], JSON_THROW_ON_ERROR);
withFakeFluxSocket(
"HTTP/1.1 500 Internal Server Error\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
fn () => (new FluxClient)->applyIngress('100.64.0.10', 'caddy', 'example.com { respond "ok" }')
);
})->throws(RuntimeException::class, 'start Caddy ingress: podman exited with status 125');
it('uses a friendly message when flux returns an invalid http response', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
withFakeFluxSocket(
'',
fn () => (new FluxClient)->applyIngress('100.64.0.10', 'caddy', 'example.com { respond "ok" }')
);
})->throws(RuntimeException::class, 'Flux did not return a response before the timeout.');
it('uses separate flux timeouts for health checks and command dispatches', function () {
expect(config('flux.health_timeout_seconds'))->toBe(1.0)
->and(config('flux.connection_timeout_seconds'))->toBe(1.0)
->and(config('flux.dispatch_timeout_seconds'))->toBe(35.0);
});
function withFakeFluxSocket(string $response, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$connection = stream_socket_accept($server, 5);
if ($connection !== false) {
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
fwrite($connection, $response);
fclose($connection);
}
fclose($server);
exit(0);
}
fclose($server);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.health_timeout_seconds', 1.0);
Config::set('flux.connection_timeout_seconds', 1.0);
Config::set('flux.dispatch_timeout_seconds', 1.0);
try {
$callback();
} finally {
pcntl_waitpid($pid, $status);
@unlink($socketPath);
}
}
it('dispatches container inventory through the containers list primitive', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'ok',
'data' => [],
], JSON_THROW_ON_ERROR);
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
withFakeFluxSocketCapturingRequest(
"HTTP/1.1 200 OK\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
$requestPath,
fn () => (new FluxClient)->listContainers('100.64.0.10')
);
$request = file_get_contents($requestPath) ?: '';
@unlink($requestPath);
expect($request)->toContain('"type":"containers.list"')
->not->toContain('list_containers');
});
it('dispatches image pull and container lifecycle primitives for v5 apps', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$responses = [
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Image pulled.']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['id' => 'container-123']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container started.']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['State' => ['Running' => true]]],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container stopped.']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container removed.']],
];
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
withFakeFluxSocketCapturingRequests($responses, $requestPath, function (): void {
$fluxClient = new FluxClient;
expect($fluxClient->pullImage('100.64.0.10', 'docker.io/library/nginx:alpine'))->toBe('Image pulled.')
->and($fluxClient->createContainer('100.64.0.10', [
'name' => 'coolify-v5-nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'networks' => ['coolify-default-mesh'],
'network_aliases' => ['coolify-v5-nginx-test'],
'dns' => ['10.210.0.1'],
'dns_search' => ['default.coolify.internal'],
'restart_policy' => 'unless-stopped',
]))->toBe('container-123')
->and($fluxClient->startContainer('100.64.0.10', 'container-123'))->toBe('Container started.')
->and($fluxClient->inspectContainer('100.64.0.10', 'container-123'))->toBe(['State' => ['Running' => true]])
->and($fluxClient->stopContainer('100.64.0.10', 'container-123'))->toBe('Container stopped.')
->and($fluxClient->removeContainer('100.64.0.10', 'container-123', true))->toBe('Container removed.');
});
$request = file_get_contents($requestPath) ?: '';
@unlink($requestPath);
expect($request)->toContain('"type":"images.pull"')
->toContain('"type":"containers.create"')
->toContain('"network_aliases":["coolify-v5-nginx-test"]')
->toContain('"dns_search":["default.coolify.internal"]')
->toContain('"type":"containers.start"')
->toContain('"type":"containers.inspect"')
->toContain('"type":"containers.stop"')
->toContain('"type":"containers.delete"')
->toContain('"force":true');
});
it('dispatches firewall allow through the firewall allow primitive', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'ok',
'data' => ['id' => 'rule-123', 'output' => 'Firewall rule applied.'],
], JSON_THROW_ON_ERROR);
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
withFakeFluxSocketCapturingRequest(
"HTTP/1.1 200 OK\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
$requestPath,
fn () => (new FluxClient)->applyFirewallRule('100.64.0.10', [
'id' => 'rule-123',
'namespace' => 'default',
'src' => 'coolify-v5-api',
'dst' => 'coolify-v5-postgres',
'proto' => 'tcp',
'port' => 5432,
])
);
$request = file_get_contents($requestPath) ?: '';
@unlink($requestPath);
expect($request)->toContain('"type":"firewall.allow"')
->toContain('"id":"rule-123"')
->toContain('"src":"coolify-v5-api"')
->toContain('"dst":"coolify-v5-postgres"')
->toContain('"port":5432');
});
it('dispatches firewall revoke through the firewall revoke primitive', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'ok',
'data' => ['output' => 'Firewall rule removed.'],
], JSON_THROW_ON_ERROR);
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
withFakeFluxSocketCapturingRequest(
"HTTP/1.1 200 OK\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
$requestPath,
fn () => (new FluxClient)->revokeFirewallRule('100.64.0.10', 'rule-123')
);
$request = file_get_contents($requestPath) ?: '';
@unlink($requestPath);
expect($request)->toContain('"type":"firewall.revoke"')
->toContain('"id":"rule-123"');
});
function withFakeFluxSocketCapturingRequests(array $responsePayloads, string $requestPath, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$capturedRequests = '';
foreach ($responsePayloads as $payload) {
$connection = stream_socket_accept($server, 5);
if ($connection === false) {
continue;
}
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
if (preg_match('/Content-Length: (\d+)/i', $request, $matches) === 1) {
$remaining = (int) $matches[1] - strlen(substr($request, strpos($request, "\r\n\r\n") + 4));
while ($remaining > 0 && ! feof($connection)) {
$chunk = fread($connection, $remaining);
$request .= $chunk;
$remaining -= strlen($chunk);
}
}
$capturedRequests .= $request."\n---REQUEST---\n";
$body = json_encode($payload, JSON_THROW_ON_ERROR);
fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n{$body}");
fclose($connection);
}
file_put_contents($requestPath, $capturedRequests);
fclose($server);
exit(0);
}
fclose($server);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.health_timeout_seconds', 1.0);
Config::set('flux.connection_timeout_seconds', 1.0);
Config::set('flux.dispatch_timeout_seconds', 1.0);
try {
$callback();
} finally {
pcntl_waitpid($pid, $status);
@unlink($socketPath);
}
}
function withFakeFluxSocketCapturingRequest(string $response, string $requestPath, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$connection = stream_socket_accept($server, 5);
if ($connection !== false) {
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
if (preg_match('/Content-Length: (\d+)/i', $request, $matches) === 1) {
$remaining = (int) $matches[1] - strlen(substr($request, strpos($request, "\r\n\r\n") + 4));
while ($remaining > 0 && ! feof($connection)) {
$chunk = fread($connection, $remaining);
$request .= $chunk;
$remaining -= strlen($chunk);
}
}
file_put_contents($requestPath, $request);
fwrite($connection, $response);
fclose($connection);
}
fclose($server);
exit(0);
}
fclose($server);
try {
config(['flux.unix_socket_path' => $socketPath]);
$callback();
pcntl_waitpid($pid, $status);
} finally {
@unlink($socketPath);
}
}
-314
View File
@@ -1,314 +0,0 @@
<?php
use App\Actions\V5\Proxy\StartCaddyIngress;
use App\Actions\V5\Proxy\StopCaddyIngress;
use App\Exceptions\V5\UnsupportedCooldVerb;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use App\Support\V5\ConnectionFirewallSync;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
use Tests\TestCase;
uses(TestCase::class);
it('detects unsupported coold verbs from flux http 501 responses', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
// Flux rejects verbs the node's coold did not advertise with HTTP 501 and
// "primitive <verb> is not supported by host" (coold repo:
// flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245).
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 501,
'message' => 'primitive firewall.allow is not supported by host',
], JSON_THROW_ON_ERROR);
$caught = null;
withCooldVerbContractFluxSocket(
"HTTP/1.1 501 Not Implemented\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
function () use (&$caught): void {
try {
(new FluxClient)->applyFirewallRule('100.64.0.10', [
'id' => 'rule-1',
'namespace' => 'default',
'src' => '0.0.0.0/0',
'dst' => 'coolify-v5-caddy',
'proto' => 'tcp',
'port' => 80,
]);
} catch (RuntimeException $exception) {
$caught = $exception;
}
}
);
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
->and($caught->verb)->toBe('firewall.allow')
->and($caught->getMessage())->toBe('primitive firewall.allow is not supported by host');
});
it('detects unsupported coold verbs from the flux message even without a 501 status', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 502,
'message' => 'primitive coold.logs is not supported by host',
], JSON_THROW_ON_ERROR);
$caught = null;
withCooldVerbContractFluxSocket(
"HTTP/1.1 502 Bad Gateway\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
function () use (&$caught): void {
try {
(new FluxClient)->cooldLogs('100.64.0.10');
} catch (RuntimeException $exception) {
$caught = $exception;
}
}
);
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
->and($caught->verb)->toBe('coold.logs');
});
it('keeps generic flux dispatch failures as plain runtime exceptions', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 500,
'message' => 'apply firewall rule: nft exited with status 1',
], JSON_THROW_ON_ERROR);
$caught = null;
withCooldVerbContractFluxSocket(
"HTTP/1.1 500 Internal Server Error\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
function () use (&$caught): void {
try {
(new FluxClient)->applyFirewallRule('100.64.0.10', [
'id' => 'rule-1',
'namespace' => 'default',
'src' => '0.0.0.0/0',
'dst' => 'coolify-v5-caddy',
'proto' => 'tcp',
'port' => 80,
]);
} catch (RuntimeException $exception) {
$caught = $exception;
}
}
);
expect($caught)->toBeInstanceOf(RuntimeException::class)
->not->toBeInstanceOf(UnsupportedCooldVerb::class)
->and($caught->getMessage())->toBe('apply firewall rule: nft exited with status 1');
});
it('marks caddy ingress running with a warning when the node coold lacks firewall support', function () {
Event::fake();
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
V5TestSchema::createServersTable();
V5TestSchema::createApplicationsTable();
$server = Server::query()->create([
'uuid' => 'cooldverb-ingress-server-uuid',
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'ingress-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'capabilities' => ['ingress'],
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyIngress')
->once()
->andReturn('Caddy ingress applied.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
app()->instance(FluxClient::class, $fluxClient);
$result = StartCaddyIngress::run($server);
$server->refresh();
expect($result)->toBe('Caddy ingress applied.')
->and($server->ingress_type)->toBe('caddy')
->and($server->ingress_status)->toBe('running')
->and($server->last_status_check)->toBe('flux')
->and($server->last_status_output)->toContain('does not support firewall.allow');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
});
it('still surfaces supported-but-failed firewall errors when starting caddy ingress', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyIngress')
->once()
->andReturn('Caddy ingress applied.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
app()->instance(FluxClient::class, $fluxClient);
expect(fn () => StartCaddyIngress::run($server))
->toThrow(RuntimeException::class, 'apply firewall rule: nft exited with status 1');
});
it('stops caddy ingress even when the node coold lacks firewall support', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
$fluxClient
->shouldReceive('stopIngress')
->once()
->with(Mockery::type('string'), 'caddy')
->andReturn('Caddy ingress stopped.');
app()->instance(FluxClient::class, $fluxClient);
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
});
it('skips resource connection firewall sync rules when the node coold lacks firewall support', function () {
$sync = new ConnectionFirewallSync;
$oldRule = [
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
'hostId' => '100.64.0.11',
'rule' => [
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
'namespace' => 'default',
'src' => 'coolify-v5-api',
'dst' => 'coolify-v5-postgres',
'proto' => 'tcp',
'port' => 5432,
],
];
$newRule = [
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
'hostId' => '100.64.0.10',
'rule' => [
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
'namespace' => 'default',
'src' => 'coolify-v5-api',
'dst' => 'coolify-v5-redis',
'proto' => 'tcp',
'port' => 6379,
],
];
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with(Mockery::type('string'), $oldRule['id'])
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with(Mockery::type('string'), $newRule['rule'])
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
$sync->sync($fluxClient, collect([$oldRule]), collect([$newRule]));
expect(true)->toBeTrue();
});
function withCooldVerbContractFluxSocket(string $response, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$socketServer = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($socketServer)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$connection = stream_socket_accept($socketServer, 5);
if ($connection !== false) {
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
fwrite($connection, $response);
fclose($connection);
}
fclose($socketServer);
exit(0);
}
fclose($socketServer);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.connection_timeout_seconds', 1.0);
Config::set('flux.dispatch_timeout_seconds', 1.0);
try {
$callback();
} finally {
pcntl_waitpid($pid, $status);
@unlink($socketPath);
}
}
@@ -1,57 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { resolveCanvasNodeLayout, resolveCanvasNodePosition } from '../../../../resources/js/v5/lib/canvas-collision.ts';
test('moves a dragged canvas node to the closest non-overlapping side with a gap', () => {
const settledPosition = resolveCanvasNodePosition(
{ id: 'dragged', x: 110, y: 10, width: 320, height: 136 },
[{ id: 'existing', x: 0, y: 0, width: 320, height: 136 }],
16,
);
assert.deepEqual(settledPosition, { x: 110, y: 152 });
});
test('keeps moving until the closest side is clear', () => {
const settledPosition = resolveCanvasNodePosition(
{ id: 'dragged', x: 110, y: 10, width: 320, height: 136 },
[
{ id: 'left-blocker', x: 0, y: 0, width: 320, height: 136 },
{ id: 'bottom-blocker', x: 110, y: 152, width: 320, height: 136 },
{ id: 'top-blocker', x: 110, y: -152, width: 320, height: 136 },
],
16,
);
assert.deepEqual(settledPosition, { x: -336, y: 10 });
});
test('ignores the dragged node when comparing canvas collisions', () => {
const settledPosition = resolveCanvasNodePosition(
{ id: 'app-1', x: 24, y: 32, width: 320, height: 136 },
[{ id: 'app-1', x: 24, y: 32, width: 320, height: 136 }],
16,
);
assert.deepEqual(settledPosition, { x: 24, y: 32 });
});
test('spreads an overlapping canvas layout in order', () => {
const settledNodes = resolveCanvasNodeLayout(
[
{ id: 'first', x: 0, y: 0, width: 320, height: 136 },
{ id: 'second', x: 110, y: 10, width: 320, height: 136 },
],
16,
);
assert.deepEqual(
settledNodes.map((node) => ({ id: node.id, x: node.x, y: node.y })),
[
{ id: 'first', x: 0, y: 0 },
{ id: 'second', x: 110, y: 152 },
],
);
});
@@ -1,242 +0,0 @@
<?php
use App\Actions\V5\Application\DeployNginxApplication;
use App\Models\V5\Application;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
use Tests\TestCase;
uses(TestCase::class);
it('builds an nginx container spec for the coold mesh runtime', function () {
$application = new Application([
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'status' => 'creating',
]);
$action = new DeployNginxApplication(Mockery::mock(FluxClient::class));
$method = new ReflectionMethod($action, 'containerSpec');
$method->setAccessible(true);
$spec = $method->invoke($action, $application);
expect($spec)
->toMatchArray([
'name' => 'coolify-v5-nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'networks' => ['coolify-default-mesh'],
'network_aliases' => ['coolify-v5-nginx-test'],
'dns_search' => ['default.coolify.internal'],
'restart_policy' => 'unless-stopped',
])
->not->toHaveKey('command')
->not->toHaveKey('privileged');
});
it('stops and removes the created container when starting it fails', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient
->shouldReceive('startContainer')
->once()
->with(Mockery::type('string'), 'container-123')
->andThrow(new RuntimeException('podman start exited with status 125'));
$fluxClient
->shouldReceive('stopContainer')
->once()
->with(Mockery::type('string'), 'container-123')
->andReturn('Container stopped.');
$fluxClient
->shouldReceive('removeContainer')
->once()
->with(Mockery::type('string'), 'container-123', true)
->andReturn('Container removed.');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('podman start exited with status 125')
->and($result->runtime_container_id)->toBeNull();
});
it('does not clean up any container when creating the container fails', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient
->shouldReceive('createContainer')
->once()
->andThrow(new RuntimeException('Flux did not return a container id.'));
$fluxClient->shouldNotReceive('startContainer');
$fluxClient->shouldNotReceive('stopContainer');
$fluxClient->shouldNotReceive('removeContainer');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('Flux did not return a container id.')
->and($result->runtime_container_id)->toBeNull();
});
it('keeps the original deploy failure and logs a warning when cleanup itself fails', function () {
Event::fake();
Log::spy();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient
->shouldReceive('startContainer')
->once()
->andThrow(new RuntimeException('original deploy failure'));
$fluxClient
->shouldReceive('stopContainer')
->once()
->andThrow(new RuntimeException('stop failed'));
$fluxClient
->shouldReceive('removeContainer')
->once()
->andThrow(new RuntimeException('remove failed'));
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('original deploy failure')
->and($result->runtime_container_id)->toBeNull();
Log::shouldHaveReceived('warning')
->with('Could not stop the container created by a failed v5 deploy.', Mockery::type('array'))
->once();
Log::shouldHaveReceived('warning')
->with('Could not remove the container created by a failed v5 deploy.', Mockery::type('array'))
->once();
});
it('cleans up the container when it does not stay running after start', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
$fluxClient
->shouldReceive('inspectContainer')
->once()
->andReturn(['State' => ['Running' => false]]);
$fluxClient
->shouldReceive('stopContainer')
->once()
->with(Mockery::type('string'), 'container-123')
->andReturn('Container stopped.');
$fluxClient
->shouldReceive('removeContainer')
->once()
->with(Mockery::type('string'), 'container-123', true)
->andReturn('Container removed.');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('Container did not stay running.')
->and($result->runtime_container_id)->toBeNull();
});
it('persists the container id only for a successful deploy', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
$fluxClient
->shouldReceive('inspectContainer')
->once()
->andReturn(['State' => ['Running' => true]]);
$fluxClient->shouldNotReceive('stopContainer');
$fluxClient->shouldNotReceive('removeContainer');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('running')
->and($result->runtime_container_id)->toBe('container-123');
});
it('persists the runtime container id immediately after create, before start', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$persistedBeforeStart = null;
$fluxClient
->shouldReceive('startContainer')
->once()
->andReturnUsing(function () use ($application, &$persistedBeforeStart) {
// Read the row straight from the database to prove the id is durable
// before start runs, not just held in the in-memory model.
$persistedBeforeStart = Application::query()->find($application->getKey())?->runtime_container_id;
throw new RuntimeException('podman start exited with status 125');
});
$fluxClient->shouldReceive('stopContainer')->once()->andReturn('Container stopped.');
$fluxClient->shouldReceive('removeContainer')->once()->andReturn('Container removed.');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($persistedBeforeStart)->toBe('container-123')
->and($result->status)->toBe('failed')
->and($result->runtime_container_id)->toBeNull();
});
function createNginxDeploymentApplication(): Application
{
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
V5TestSchema::createServersTable();
V5TestSchema::createApplicationsTable();
$server = Server::query()->forceCreate([
'uuid' => 'nginx-deployment-server-uuid',
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'node-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
return Application::query()->forceCreate([
'uuid' => 'nginx-deployment-application-uuid',
'team_id' => 1,
'project_id' => 1,
'environment_id' => 1,
'created_by_user_id' => 1,
'server_id' => $server->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'status' => 'creating',
'mesh_namespace' => 'default',
]);
}
-265
View File
@@ -1,265 +0,0 @@
<?php
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Cluster;
use App\Models\V5\ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Policies\V5\ApplicationPolicy;
use App\Policies\V5\ClusterPolicy;
use App\Policies\V5\ResourceConnectionPolicy;
use App\Policies\V5\ServerPolicy;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Tests\TestCase;
uses(TestCase::class);
/**
* Build a user whose team membership carries the given role, so the policies'
* $user->isAdminOfTeam() role gate can be exercised without touching the DB.
*/
function v5PolicyUser(string $role = 'owner', int $teamId = 10): User
{
$user = (new User)->forceFill(['id' => 1]);
$team = (new Team)->forceFill(['id' => $teamId]);
$pivot = new Pivot;
$pivot->role = $role;
$team->setRelation('pivot', $pivot);
$user->setRelation('teams', collect([$team]));
return $user;
}
function v5PolicyMemberUser(int $teamId = 10): User
{
return v5PolicyUser('member', $teamId);
}
function v5PolicyTeam(int $id = 10): Team
{
return (new Team)->forceFill(['id' => $id]);
}
it('allows adding a server to a cluster of the current team', function () {
$team = v5PolicyTeam();
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $cluster);
expect($response->allowed())->toBeTrue();
});
it('denies adding a server to another teams cluster as forbidden instead of not found', function () {
$team = v5PolicyTeam(10);
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $foreignCluster);
// A plain deny carries no status override, so the framework renders the
// historical 403 instead of a 404.
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
});
it('denies server update and delete outside the current team as not found', function () {
$team = v5PolicyTeam(10);
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
$foreignServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'cluster_id' => 5]);
$policy = new ServerPolicy;
$update = $policy->update(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
$delete = $policy->delete(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
expect($update->denied())->toBeTrue()
->and($update->status())->toBe(404)
->and($delete->denied())->toBeTrue()
->and($delete->status())->toBe(404);
});
it('denies server update for a server outside the addressed cluster as not found', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$serverInOtherCluster = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 6]);
$response = (new ServerPolicy)->update(v5PolicyUser(), $serverInOtherCluster, $team, $cluster);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
});
it('allows moving the caddy ingress canvas card only for ingress servers of the current team', function () {
$team = v5PolicyTeam(10);
$policy = new ServerPolicy;
$ingressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => true]);
expect($policy->updateCanvasPosition(v5PolicyUser(), $ingressServer, $team)->allowed())->toBeTrue();
});
it('denies moving the canvas card of a non-ingress server as not found', function () {
$team = v5PolicyTeam(10);
$nonIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => false]);
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $nonIngressServer, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
});
it('denies moving another teams ingress canvas card as not found', function () {
$team = v5PolicyTeam(10);
$foreignIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'is_ingress' => true]);
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $foreignIngressServer, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
});
it('scopes cluster viewing and deletion to the current team with not-found denials', function () {
$team = v5PolicyTeam(10);
$policy = new ClusterPolicy;
$ownCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$foreignCluster = (new Cluster)->forceFill(['id' => 6, 'team_id' => 99]);
expect($policy->view(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue()
->and($policy->delete(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue();
$view = $policy->view(v5PolicyUser(), $foreignCluster, $team);
$delete = $policy->delete(v5PolicyUser(), $foreignCluster, $team);
expect($view->denied())->toBeTrue()
->and($view->status())->toBe(404)
->and($delete->denied())->toBeTrue()
->and($delete->status())->toBe(404);
});
it('scopes application mutations to the current team with not-found denials', function () {
$team = v5PolicyTeam(10);
$policy = new ApplicationPolicy;
$ownApplication = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
$foreignApplication = (new V5Application)->forceFill(['id' => 4, 'team_id' => 99]);
expect($policy->update(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
->and($policy->updateIngress(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
->and($policy->delete(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue();
foreach (['update', 'updateIngress', 'delete'] as $ability) {
$response = $policy->{$ability}(v5PolicyUser(), $foreignApplication, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
}
});
it('scopes resource connection mutations to the current team with not-found denials', function () {
$team = v5PolicyTeam(10);
$policy = new ResourceConnectionPolicy;
$ownConnection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
$foreignConnection = (new ResourceConnection)->forceFill(['id' => 4, 'team_id' => 99]);
expect($policy->update(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue()
->and($policy->delete(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue();
foreach (['update', 'delete'] as $ability) {
$response = $policy->{$ability}(v5PolicyUser(), $foreignConnection, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
}
});
it('denies server mutations for a member of the current team as forbidden not not-found', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5, 'is_ingress' => true]);
$member = v5PolicyMemberUser();
$policy = new ServerPolicy;
expect($policy->create($member, $team, $cluster)->denied())->toBeTrue()
->and($policy->create($member, $team, $cluster)->status())->toBeNull();
foreach (['update', 'delete', 'check', 'bootstrap'] as $ability) {
$response = $policy->{$ability}($member, $server, $team, $cluster);
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
}
expect($policy->updateCanvasPosition($member, $server, $team)->denied())->toBeTrue()
->and($policy->updateCanvasPosition($member, $server, $team)->status())->toBeNull();
});
it('still lets a member read server diagnostics of the current team', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5]);
expect((new ServerPolicy)->viewDiagnostics(v5PolicyMemberUser(), $server, $team, $cluster)->allowed())->toBeTrue();
});
it('denies cluster create and delete for a member of the current team as forbidden', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$member = v5PolicyMemberUser();
$policy = new ClusterPolicy;
expect($policy->create($member, $team)->denied())->toBeTrue()
->and($policy->create($member, $team)->status())->toBeNull()
->and($policy->delete($member, $cluster, $team)->denied())->toBeTrue()
->and($policy->delete($member, $cluster, $team)->status())->toBeNull();
// Members may still read.
expect($policy->view($member, $cluster, $team)->allowed())->toBeTrue();
});
it('allows an admin to create a cluster in the current team', function () {
$team = v5PolicyTeam(10);
expect((new ClusterPolicy)->create(v5PolicyUser('admin'), $team)->allowed())->toBeTrue();
});
it('denies application mutations for a member of the current team as forbidden', function () {
$team = v5PolicyTeam(10);
$application = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
$member = v5PolicyMemberUser();
$policy = new ApplicationPolicy;
expect($policy->create($member, $team)->denied())->toBeTrue();
foreach (['update', 'updateIngress', 'delete'] as $ability) {
$response = $policy->{$ability}($member, $application, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
}
});
it('denies resource connection mutations for a member of the current team as forbidden', function () {
$team = v5PolicyTeam(10);
$connection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
$member = v5PolicyMemberUser();
$policy = new ResourceConnectionPolicy;
expect($policy->create($member, $team)->denied())->toBeTrue();
foreach (['update', 'delete'] as $ability) {
$response = $policy->{$ability}($member, $connection, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
}
});
it('allows admins to create applications and resource connections', function () {
$team = v5PolicyTeam(10);
$admin = v5PolicyUser('admin');
expect((new ApplicationPolicy)->create($admin, $team)->allowed())->toBeTrue()
->and((new ResourceConnectionPolicy)->create($admin, $team)->allowed())->toBeTrue();
});
@@ -1,14 +0,0 @@
<?php
it('includes v5 applications in the v4 resource index and links them to v5', function () {
$component = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($component)
->toContain('V5Application')
->toContain("route('v5.dashboard'")
->toContain("? 'v5' : 'v4'")
->and($view)
->toContain("item.version === 'v5'")
->toContain('V5');
});
-73
View File
@@ -1,73 +0,0 @@
<?php
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Events\V5RealtimeTestEvent;
use App\Jobs\V5BootstrapServerJob;
use App\Jobs\V5DeployApplicationJob;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
uses(TestCase::class);
it('locks v5 deploy jobs per application for the job timeout plus a margin', function () {
$job = new V5DeployApplicationJob(42);
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
->and($job->uniqueId())->toBe('42')
->and($job->uniqueFor)->toBe($job->timeout + 60);
});
it('locks v5 bootstrap jobs per server aligned with the controller bootstrap claim window', function () {
$job = new V5BootstrapServerJob(7, 13);
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
->and($job->uniqueId())->toBe('13')
->and($job->uniqueFor)->toBe(V5BootstrapServerJob::TIMEOUT_SECONDS + 300);
});
it('does not queue a second v5 deploy job while one is pending for the same application', function () {
Config::set('cache.default', 'array');
Queue::fake();
V5DeployApplicationJob::dispatch(101);
V5DeployApplicationJob::dispatch(101);
V5DeployApplicationJob::dispatch(202);
Queue::assertPushed(V5DeployApplicationJob::class, 2);
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 101);
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 202);
});
it('does not queue a second v5 bootstrap job while one is pending for the same server', function () {
Config::set('cache.default', 'array');
Queue::fake();
V5BootstrapServerJob::dispatch(1, 55);
V5BootstrapServerJob::dispatch(2, 55);
V5BootstrapServerJob::dispatch(1, 66);
Queue::assertPushed(V5BootstrapServerJob::class, 2);
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 55);
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 66);
});
it('queues v5 canvas and cluster broadcasts after commit instead of pushing on the request thread', function () {
$canvasEvent = new V5CanvasResourceUpdated(1, 2);
$clusterEvent = new V5ClusterUpdated(1, 2);
expect($canvasEvent)->toBeInstanceOf(ShouldBroadcast::class)
->not->toBeInstanceOf(ShouldBroadcastNow::class)
->and($canvasEvent->afterCommit)->toBeTrue()
->and($clusterEvent)->toBeInstanceOf(ShouldBroadcast::class)
->not->toBeInstanceOf(ShouldBroadcastNow::class)
->and($clusterEvent->afterCommit)->toBeTrue();
});
it('keeps the manual v5 realtime test ping broadcasting immediately', function () {
expect(new V5RealtimeTestEvent(1, 'ping'))->toBeInstanceOf(ShouldBroadcastNow::class);
});