mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 10:18:20 +00:00
feat(v5): add server reconciliation and canvas APIs
Split V5 dashboard behavior into domain controllers and policies, add agent token rotation/revocation, status reconciliation jobs, ingress firewall syncing, and canvas connection APIs. Add migrations for V5 status tracking, server capabilities, resource connection aliases, and revoked agent tokens.
This commit is contained in:
1 parent
df854feee8
commit
6ae45684f9
147 files changed
+22444
-10407
No files matched your search
@@ -0,0 +1,151 @@
|
||||
<?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');
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ 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);
|
||||
@@ -48,6 +49,143 @@ it('generates a caddy ingress compose file with health endpoint and application
|
||||
->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',
|
||||
@@ -103,6 +241,7 @@ it('does not generate app routes when the internal port is missing', function ()
|
||||
|
||||
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'],
|
||||
@@ -113,7 +252,7 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->with(
|
||||
'100.64.0.10',
|
||||
Mockery::type('string'),
|
||||
'caddy',
|
||||
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'respond /coolify-health 200')),
|
||||
[]
|
||||
@@ -122,7 +261,7 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with('100.64.0.10', [
|
||||
->with(Mockery::type('string'), [
|
||||
'id' => 'v5-caddy-ingress:80',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
@@ -132,17 +271,8 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
|
||||
])
|
||||
->andReturn('Firewall rule applied.');
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with('100.64.0.10', [
|
||||
'id' => 'v5-caddy-ingress:443',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => 443,
|
||||
])
|
||||
->andReturn('Firewall rule applied.');
|
||||
->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);
|
||||
@@ -162,27 +292,26 @@ it('does not start caddy ingress for non-ingress servers', function () {
|
||||
|
||||
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' => [],
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->with('100.64.0.10', 'caddy')
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->andReturn('Caddy ingress stopped.');
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with('100.64.0.10', 'v5-caddy-ingress:80')
|
||||
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
|
||||
->andReturn('Firewall rule removed.');
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with('100.64.0.10', 'v5-caddy-ingress:443')
|
||||
->andReturn('Firewall rule removed.');
|
||||
->shouldNotReceive('revokeFirewallRule')
|
||||
->with(Mockery::type('string'), 'v5-caddy-ingress:443');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$result = StopCaddyIngress::run($server);
|
||||
@@ -190,6 +319,78 @@ it('stops caddy ingress through flux instead of ssh', function () {
|
||||
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.');
|
||||
@@ -315,6 +516,8 @@ it('dispatches image pull and container lifecycle primitives for v5 apps', funct
|
||||
['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');
|
||||
|
||||
@@ -332,7 +535,9 @@ it('dispatches image pull and container lifecycle primitives for v5 apps', funct
|
||||
'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->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) ?: '';
|
||||
@@ -343,7 +548,10 @@ it('dispatches image pull and container lifecycle primitives for v5 apps', funct
|
||||
->toContain('"network_aliases":["coolify-v5-nginx-test"]')
|
||||
->toContain('"dns_search":["default.coolify.internal"]')
|
||||
->toContain('"type":"containers.start"')
|
||||
->toContain('"type":"containers.inspect"');
|
||||
->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 () {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
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);
|
||||
@@ -33,3 +38,205 @@ it('builds an nginx container spec for the coold mesh runtime', function () {
|
||||
->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',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?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;
|
||||
|
||||
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;
|
||||
|
||||
foreach (['update', 'delete'] as $ability) {
|
||||
$response = $policy->{$ability}($member, $connection, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -50,6 +50,11 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
|
||||
'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'newline before dot' => ["evil\n.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'newline inside label' => ["evil\ncom", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'carriage return' => ["evil\r.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'tab character' => ["evil\t.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'caddy block injection' => ["evil.com {\n} http://x", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
]);
|
||||
|
||||
it('accepts empty hostname', function () {
|
||||
|
||||
Reference in new issue
Block a user