mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-26 02:26:03 +00:00
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.
371 lines
15 KiB
PHP
371 lines
15 KiB
PHP
<?php
|
|
|
|
use App\Exceptions\V5\UnsupportedCooldVerb;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Models\V5\Application as V5Application;
|
|
use App\Models\V5\ResourceConnection;
|
|
use App\Models\V5\Server as V5Server;
|
|
use App\Services\Flux\FluxClient;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Exceptions;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Tests\Support\V5TestSchema;
|
|
|
|
beforeEach(function () {
|
|
Config::set('broadcasting.default', 'log');
|
|
Config::set('cache.default', 'array');
|
|
|
|
dropConnectionFirewallConsistencyTables();
|
|
createConnectionFirewallConsistencyTables();
|
|
});
|
|
|
|
it('restores db rules and rolls back node firewall when flux fails after the db write', function () {
|
|
Exceptions::fake();
|
|
|
|
$context = createConnectionFirewallConsistencyContext();
|
|
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
|
|
|
$connection->rules()->create([
|
|
'source_resource_type' => $source->getMorphClass(),
|
|
'source_resource_id' => $source->id,
|
|
'target_resource_type' => $target->getMorphClass(),
|
|
'target_resource_id' => $target->id,
|
|
'protocol' => 'tcp',
|
|
'port' => 5432,
|
|
]);
|
|
|
|
$oldRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
|
$newRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:6379";
|
|
$oldRule = connectionFirewallConsistencyRule($oldRuleId, 5432);
|
|
$newRule = connectionFirewallConsistencyRule($newRuleId, 6379);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
// Forward sync: revoke the removed old rule, then fail applying the new one.
|
|
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $oldRuleId)->andReturn('Firewall rule removed.');
|
|
$fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $newRule)->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
|
|
// Compensation: revoke the attempted new rule and re-apply the restored old rule.
|
|
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $newRuleId)->andReturn('Firewall rule removed.');
|
|
$fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $oldRule)->andReturn('Firewall rule applied.');
|
|
app()->instance(FluxClient::class, $fluxClient);
|
|
|
|
patchConnectionFirewallConsistency($this, $user, $team, $connection, [
|
|
"{$source->uuid}->{$target->uuid}" => [6379],
|
|
])
|
|
->assertStatus(502)
|
|
->assertJsonPath('message', 'Could not sync firewall rules through Flux.')
|
|
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
|
|
|
expect($connection->rules()->pluck('port')->all())->toBe([5432]);
|
|
|
|
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'apply firewall rule: nft exited with status 1');
|
|
});
|
|
|
|
it('keeps committed db rules and succeeds when the node coold lacks firewall support', function () {
|
|
$context = createConnectionFirewallConsistencyContext();
|
|
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
|
|
|
$connection->rules()->create([
|
|
'source_resource_type' => $source->getMorphClass(),
|
|
'source_resource_id' => $source->id,
|
|
'target_resource_type' => $target->getMorphClass(),
|
|
'target_resource_id' => $target->id,
|
|
'protocol' => 'tcp',
|
|
'port' => 5432,
|
|
]);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
|
$fluxClient->shouldReceive('applyFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
|
|
app()->instance(FluxClient::class, $fluxClient);
|
|
|
|
patchConnectionFirewallConsistency($this, $user, $team, $connection, [
|
|
"{$source->uuid}->{$target->uuid}" => [6379],
|
|
])
|
|
->assertSuccessful()
|
|
->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '6379');
|
|
|
|
expect($connection->rules()->pluck('port')->all())->toBe([6379]);
|
|
});
|
|
|
|
it('still surfaces the original flux error when the firewall rollback also fails', function () {
|
|
Exceptions::fake();
|
|
Log::spy();
|
|
|
|
$context = createConnectionFirewallConsistencyContext();
|
|
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
|
|
|
$connection->rules()->create([
|
|
'source_resource_type' => $source->getMorphClass(),
|
|
'source_resource_id' => $source->id,
|
|
'target_resource_type' => $target->getMorphClass(),
|
|
'target_resource_id' => $target->id,
|
|
'protocol' => 'tcp',
|
|
'port' => 5432,
|
|
]);
|
|
|
|
$oldRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
|
$newRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:6379";
|
|
$newRule = connectionFirewallConsistencyRule($newRuleId, 6379);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $oldRuleId)->andReturn('Firewall rule removed.');
|
|
$fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $newRule)->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
|
|
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $newRuleId)->andThrow(new RuntimeException('revoke firewall rule: coold connection reset'));
|
|
app()->instance(FluxClient::class, $fluxClient);
|
|
|
|
patchConnectionFirewallConsistency($this, $user, $team, $connection, [
|
|
"{$source->uuid}->{$target->uuid}" => [6379],
|
|
])
|
|
->assertStatus(502)
|
|
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
|
|
|
expect($connection->rules()->pluck('port')->all())->toBe([5432]);
|
|
|
|
Log::shouldHaveReceived('warning')
|
|
->withArgs(fn (string $message): bool => str_contains($message, 'firewall rollback failed'))
|
|
->once();
|
|
});
|
|
|
|
it('does not delete the connection when revoking firewall rules fails with a real error', function () {
|
|
Exceptions::fake();
|
|
|
|
$context = createConnectionFirewallConsistencyContext();
|
|
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
|
|
|
$connection->rules()->create([
|
|
'source_resource_type' => $source->getMorphClass(),
|
|
'source_resource_id' => $source->id,
|
|
'target_resource_type' => $target->getMorphClass(),
|
|
'target_resource_id' => $target->id,
|
|
'protocol' => 'tcp',
|
|
'port' => 5432,
|
|
]);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new RuntimeException('revoke firewall rule: nft exited with status 1'));
|
|
app()->instance(FluxClient::class, $fluxClient);
|
|
|
|
deleteConnectionFirewallConsistency($this, $user, $team, $connection)
|
|
->assertStatus(502)
|
|
->assertJsonPath('detail', 'The connection was not deleted. Check the server diagnostics and try again.');
|
|
|
|
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeTrue()
|
|
->and($connection->rules()->count())->toBe(1);
|
|
|
|
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'revoke firewall rule: nft exited with status 1');
|
|
});
|
|
|
|
it('reports but tolerates an unbuildable firewall snapshot when deleting the connection', function () {
|
|
Exceptions::fake();
|
|
|
|
$context = createConnectionFirewallConsistencyContext(sourceServerless: true);
|
|
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
|
|
|
$connection->rules()->create([
|
|
'source_resource_type' => $source->getMorphClass(),
|
|
'source_resource_id' => $source->id,
|
|
'target_resource_type' => $target->getMorphClass(),
|
|
'target_resource_id' => $target->id,
|
|
'protocol' => 'tcp',
|
|
'port' => 5432,
|
|
]);
|
|
|
|
// The node cannot be addressed without a host id, so no flux call may happen.
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
app()->instance(FluxClient::class, $fluxClient);
|
|
|
|
deleteConnectionFirewallConsistency($this, $user, $team, $connection)
|
|
->assertNoContent();
|
|
|
|
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse();
|
|
|
|
Exceptions::assertReported(fn (RuntimeException $exception): bool => str_contains($exception->getMessage(), 'has no reachable server host id'));
|
|
});
|
|
|
|
it('deletes the connection when the node coold lacks firewall revoke support', function () {
|
|
$context = createConnectionFirewallConsistencyContext();
|
|
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
|
|
|
$connection->rules()->create([
|
|
'source_resource_type' => $source->getMorphClass(),
|
|
'source_resource_id' => $source->id,
|
|
'target_resource_type' => $target->getMorphClass(),
|
|
'target_resource_id' => $target->id,
|
|
'protocol' => 'tcp',
|
|
'port' => 5432,
|
|
]);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
|
app()->instance(FluxClient::class, $fluxClient);
|
|
|
|
deleteConnectionFirewallConsistency($this, $user, $team, $connection)
|
|
->assertNoContent();
|
|
|
|
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse();
|
|
});
|
|
|
|
/**
|
|
* @param array<string, array<int, int>> $portsByDirection
|
|
*/
|
|
function patchConnectionFirewallConsistency(mixed $test, User $user, Team $team, ResourceConnection $connection, array $portsByDirection): mixed
|
|
{
|
|
return $test
|
|
->actingAs($user)
|
|
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
|
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
|
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
|
'ports_by_direction' => $portsByDirection,
|
|
]);
|
|
}
|
|
|
|
function deleteConnectionFirewallConsistency(mixed $test, User $user, Team $team, ResourceConnection $connection): mixed
|
|
{
|
|
return $test
|
|
->actingAs($user)
|
|
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
|
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
|
->deleteJson("/v5/resource-connections/{$connection->uuid}");
|
|
}
|
|
|
|
/**
|
|
* @return array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}
|
|
*/
|
|
function connectionFirewallConsistencyRule(string $ruleId, int $port): array
|
|
{
|
|
return [
|
|
'id' => $ruleId,
|
|
'namespace' => 'default',
|
|
'src' => 'coolify-v5-consistency-api',
|
|
'dst' => 'coolify-v5-consistency-postgres',
|
|
'proto' => 'tcp',
|
|
'port' => $port,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{user: User, team: Team, source: V5Application, target: V5Application, connection: ResourceConnection}
|
|
*/
|
|
function createConnectionFirewallConsistencyContext(bool $sourceServerless = false): array
|
|
{
|
|
$user = User::withoutEvents(fn () => User::query()->create([
|
|
'name' => 'Margaret Hamilton',
|
|
'email' => 'margaret@example.com',
|
|
'email_verified_at' => now(),
|
|
'password' => 'password',
|
|
]));
|
|
$team = Team::withoutEvents(fn () => Team::query()->create([
|
|
'name' => 'V5 Firewall Team',
|
|
'description' => null,
|
|
'personal_team' => false,
|
|
'show_boarding' => false,
|
|
]));
|
|
$user->teams()->attach($team, ['role' => 'owner']);
|
|
|
|
$server = 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',
|
|
]);
|
|
|
|
$source = V5Application::query()->create([
|
|
'team_id' => $team->id,
|
|
'project_id' => 1,
|
|
'environment_id' => 1,
|
|
'server_id' => $sourceServerless ? null : $server->id,
|
|
'created_by_user_id' => $user->id,
|
|
'name' => 'api',
|
|
'image' => 'docker.io/library/nginx:alpine',
|
|
'container_name' => 'coolify-v5-consistency-api',
|
|
'mesh_namespace' => 'default',
|
|
'status' => 'running',
|
|
]);
|
|
$target = V5Application::query()->create([
|
|
'team_id' => $team->id,
|
|
'project_id' => 1,
|
|
'environment_id' => 1,
|
|
'server_id' => $server->id,
|
|
'created_by_user_id' => $user->id,
|
|
'name' => 'postgres',
|
|
'image' => 'docker.io/library/postgres:16',
|
|
'container_name' => 'coolify-v5-consistency-postgres',
|
|
'mesh_namespace' => 'default',
|
|
'status' => 'running',
|
|
]);
|
|
|
|
$connection = ResourceConnection::query()->create([
|
|
'team_id' => $team->id,
|
|
'project_id' => 1,
|
|
'environment_id' => 1,
|
|
'resource_one_type' => $source->getMorphClass(),
|
|
'resource_one_id' => $source->id,
|
|
'resource_two_type' => $target->getMorphClass(),
|
|
'resource_two_id' => $target->id,
|
|
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
|
'created_by_user_id' => $user->id,
|
|
]);
|
|
|
|
return [
|
|
'user' => $user,
|
|
'team' => $team,
|
|
'source' => $source,
|
|
'target' => $target,
|
|
'connection' => $connection,
|
|
];
|
|
}
|
|
|
|
function createConnectionFirewallConsistencyTables(): void
|
|
{
|
|
Schema::create('users', function ($table) {
|
|
$table->id();
|
|
$table->string('name')->default('Anonymous');
|
|
$table->string('email');
|
|
$table->timestamp('email_verified_at')->nullable();
|
|
$table->string('password')->nullable();
|
|
$table->rememberToken();
|
|
$table->timestamps();
|
|
});
|
|
|
|
Schema::create('teams', function ($table) {
|
|
$table->id();
|
|
$table->string('name');
|
|
$table->string('description')->nullable();
|
|
$table->boolean('personal_team')->default(false);
|
|
$table->boolean('show_boarding')->default(false);
|
|
$table->timestamps();
|
|
});
|
|
|
|
Schema::create('team_user', function ($table) {
|
|
$table->id();
|
|
$table->foreignId('team_id');
|
|
$table->foreignId('user_id');
|
|
$table->string('role')->default('member');
|
|
$table->timestamps();
|
|
|
|
$table->unique(['team_id', 'user_id']);
|
|
});
|
|
|
|
V5TestSchema::createServersTable();
|
|
V5TestSchema::createApplicationsTable();
|
|
V5TestSchema::createResourceConnectionsTable();
|
|
V5TestSchema::createResourceConnectionRulesTable();
|
|
}
|
|
|
|
function dropConnectionFirewallConsistencyTables(): void
|
|
{
|
|
Schema::dropIfExists('v5_resource_connection_rules');
|
|
Schema::dropIfExists('v5_resource_connections');
|
|
Schema::dropIfExists('v5_applications');
|
|
Schema::dropIfExists('v5_servers');
|
|
Schema::dropIfExists('team_user');
|
|
Schema::dropIfExists('teams');
|
|
Schema::dropIfExists('users');
|
|
}
|