feat(v5): sync resource firewall rules via Flux

This commit is contained in:
Andras Bacsai committed 2026-06-21 21:16:08 +02:00
1 parent 22e3d6960e
commit 4061a3844b
8 files changed
+397 -66

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ class SyncDevLimaServers
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
$capabilities = ['coold'];
$capabilities = [];
foreach ($servers as $server) {
$wireguardManagementIp = $server['wireguard_management_ip'] ?? null;
+26 -2
View File
@@ -11,13 +11,37 @@ class FluxDev extends Command
{
protected $signature = 'flux:dev
{host_id=coold-dev : Stable coold host id}
{--caps=coold : Comma-separated host capabilities}
{--caps= : Comma-separated host capabilities}
{--ttl=3600 : Token lifetime in seconds}
{--output= : Optional path to write the token with 0600 permissions}
{--force : Allow running outside local/development environments}';
protected $description = 'Run Flux development helpers.';
/**
* @return array<int, string>
*/
private function defaultCapabilities(): array
{
return [
'images.pull',
'images.list',
'images.delete',
'containers.create',
'containers.start',
'containers.stop',
'containers.restart',
'containers.delete',
'containers.inspect',
'containers.list',
'containers.logs',
'containers.exec',
'containers.healthcheck.run',
'ingress.apply',
'ingress.stop',
];
}
public function handle(): int
{
if (! app()->environment(['local', 'development', 'testing']) && ! $this->option('force')) {
@@ -45,7 +69,7 @@ class FluxDev extends Command
->all();
if ($caps === []) {
$caps = ['coold'];
$caps = $this->defaultCapabilities();
}
$token = JWT::encode([
+109 -4
View File
@@ -552,7 +552,7 @@ class DashboardController extends Controller
], $connection->wasRecentlyCreated ? 201 : 200);
}
public function updateResourceConnection(Request $request, ResourceConnection $connection): JsonResponse
public function updateResourceConnection(Request $request, ResourceConnection $connection, FluxClient $fluxClient): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
@@ -566,6 +566,8 @@ class DashboardController extends Controller
'ports_by_direction.*.*' => ['integer', 'min:1', 'max:65535', 'distinct'],
]);
$oldFirewallRules = $this->connectionFirewallRules($connection->load('rules'));
DB::transaction(function () use ($connection, $validated): void {
$connection->rules()->delete();
@@ -589,12 +591,24 @@ class DashboardController extends Controller
}
});
$connection->refresh()->load('rules');
$newFirewallRules = $this->connectionFirewallRules($connection);
try {
$this->syncConnectionFirewallRules($fluxClient, $oldFirewallRules, $newFirewallRules);
} catch (\RuntimeException $exception) {
return response()->json([
'message' => 'Could not sync firewall rules through Flux.',
'detail' => $exception->getMessage(),
], 502);
}
return response()->json([
'connection' => $this->serializeResourceConnection($connection->refresh()->load('rules')),
'connection' => $this->serializeResourceConnection($connection),
]);
}
public function destroyResourceConnection(Request $request, ResourceConnection $connection): \Illuminate\Http\Response
public function destroyResourceConnection(Request $request, ResourceConnection $connection, FluxClient $fluxClient): \Illuminate\Http\Response|JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
@@ -602,6 +616,17 @@ class DashboardController extends Controller
abort(404);
}
$oldFirewallRules = $this->connectionFirewallRules($connection->load('rules'));
try {
$this->syncConnectionFirewallRules($fluxClient, $oldFirewallRules, collect());
} catch (\RuntimeException $exception) {
return response()->json([
'message' => 'Could not sync firewall rules through Flux.',
'detail' => $exception->getMessage(),
], 502);
}
$connection->delete();
return response()->noContent();
@@ -1363,6 +1388,86 @@ class DashboardController extends Controller
: $connection->resource_two_type;
}
/**
* @return Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}>
*/
private function connectionFirewallRules(ResourceConnection $connection): Collection
{
$applicationIds = $connection->rules
->flatMap(fn ($rule) => [$rule->source_resource_id, $rule->target_resource_id])
->unique()
->values();
$applications = V5Application::query()
->whereIn('id', $applicationIds)
->with('server')
->get()
->keyBy('id');
return $connection->rules
->map(function ($rule) use ($applications, $connection): ?array {
$source = $applications->get($rule->source_resource_id);
$target = $applications->get($rule->target_resource_id);
if (! $source instanceof V5Application || ! $target instanceof V5Application || ! $target->server instanceof V5Server) {
return null;
}
$hostId = $target->server->wireguard_management_ip ?: $target->server->node_address;
if (! is_string($hostId) || $hostId === '') {
return null;
}
$firewallRule = [
'id' => $this->connectionFirewallRuleId($connection, $rule),
'namespace' => $target->mesh_namespace ?: 'default',
'src' => $source->container_name,
'dst' => $target->container_name,
'proto' => $rule->protocol ?: 'tcp',
'port' => (int) $rule->port,
];
return [
'id' => $firewallRule['id'],
'hostId' => $hostId,
'rule' => $firewallRule,
];
})
->filter()
->values();
}
/**
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $oldRules
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $newRules
*/
private function syncConnectionFirewallRules(FluxClient $fluxClient, Collection $oldRules, Collection $newRules): void
{
$newRuleIds = $newRules->pluck('id')->all();
$oldRuleIds = $oldRules->pluck('id')->all();
$oldRules
->reject(fn (array $oldRule): bool => in_array($oldRule['id'], $newRuleIds, true))
->each(fn (array $oldRule): string => $fluxClient->revokeFirewallRule($oldRule['hostId'], $oldRule['id']));
$newRules
->reject(fn (array $newRule): bool => in_array($newRule['id'], $oldRuleIds, true))
->each(fn (array $newRule): string => $fluxClient->applyFirewallRule($newRule['hostId'], $newRule['rule']));
}
private function connectionFirewallRuleId(ResourceConnection $connection, mixed $rule): string
{
return implode(':', [
'v5-resource-connection',
$connection->id,
$rule->source_resource_id,
$rule->target_resource_id,
$rule->protocol ?: 'tcp',
(int) $rule->port,
]);
}
/**
* @param array{uuid: string} $selectedProject
* @param array{uuid: string} $selectedEnvironment
@@ -1498,7 +1603,7 @@ class DashboardController extends Controller
*/
private function serverCapabilities(bool $builderEnabled, bool $ingressEnabled): array
{
return collect(['coold'])
return collect()
->when($ingressEnabled, fn ($capabilities) => $capabilities->push('ingress'))
->unique()
->values()
+26
View File
@@ -47,6 +47,32 @@ class FluxClient
return $this->output($payload, 'Ingress stopped.');
}
/**
* @param array{id: string, namespace: string, src: string, dst: string, proto: string, port: int} $rule
*/
public function applyFirewallRule(string $hostId, array $rule): string
{
$payload = $this->dispatch($hostId, [
'type' => 'firewall.allow',
...$rule,
]);
$data = $payload['data'] ?? [];
$id = is_array($data) && is_string($data['id'] ?? null) ? $data['id'] : '';
return $id !== '' ? $id : $this->output($payload, 'Firewall rule applied.');
}
public function revokeFirewallRule(string $hostId, string $id): string
{
$payload = $this->dispatch($hostId, [
'type' => 'firewall.revoke',
'id' => $id,
]);
return $this->output($payload, 'Firewall rule removed.');
}
/**
* @param array<string, mixed> $command
* @return array<string, mixed>
+2 -2
View File
@@ -1704,7 +1704,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
}
}}
>
<SheetContent side="right" className="w-full overflow-y-auto bg-background sm:rounded-l-xl sm:border data-[side=right]:sm:!inset-y-4 data-[side=right]:sm:!h-auto data-[side=right]:sm:!w-[45vw] data-[side=right]:sm:!max-w-[45vw]" showCloseButton blurOverlay={false}>
<SheetContent side="right" className="w-full overflow-hidden bg-background sm:rounded-l-xl sm:border data-[side=right]:sm:!inset-y-4 data-[side=right]:sm:!h-auto data-[side=right]:sm:!w-[45vw] data-[side=right]:sm:!max-w-[45vw]" showCloseButton blurOverlay={false}>
{selectedInspectorApplication && (
<>
<SheetHeader className="p-6 pb-4">
@@ -1717,7 +1717,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
<div className="flex flex-1 flex-col gap-6 px-6 pb-6">
<Tabs defaultValue="overview" className="gap-4">
<TabsList className="w-full justify-start overflow-x-auto" variant="line">
<TabsList className="w-full justify-start" variant="line">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="networking">Networking</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
+6 -4
View File
@@ -12,7 +12,7 @@ it('mints a host jwt signed by the configured flux private key', function () {
$exitCode = Artisan::call('flux:dev', [
'host_id' => 'coold-dev',
'--caps' => 'coold',
'--caps' => 'containers.list,ingress.apply',
'--ttl' => '600',
]);
@@ -23,11 +23,11 @@ it('mints a host jwt signed by the configured flux private key', function () {
expect($claims->sub)->toBe('coold-dev')
->and($claims->aud)->toBe('coold')
->and($claims->caps)->toBe(['coold'])
->and($claims->caps)->toBe(['containers.list', 'ingress.apply'])
->and($claims->exp)->toBeGreaterThan(time());
});
it('mints a host jwt with only the coarse coold capability by default', function () {
it('mints a host jwt with primitive capabilities by default', function () {
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
Config::set('flux.jwt_private_key_path', $privateKeyPath);
@@ -42,7 +42,9 @@ it('mints a host jwt with only the coarse coold capability by default', function
$token = trim(Artisan::output());
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
expect($claims->caps)->toBe(['coold']);
expect($claims->caps)->toContain('containers.list')
->and($claims->caps)->toContain('ingress.apply')
->and($claims->caps)->not->toContain('coold');
});
it('writes the host jwt to an output path with owner-only permissions', function () {
+156 -50
View File
@@ -20,6 +20,7 @@ use App\Models\V5\Application as V5Application;
use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
use App\Models\V5\Cluster;
use App\Models\V5\ContainerStatus;
use App\Models\V5\ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\FluxClient;
use App\Services\Flux\FluxHealth;
@@ -162,7 +163,7 @@ it('generates http-only caddy routes for application ingress', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
@@ -753,7 +754,7 @@ it('serves v5 dashboard applications as canvas nodes', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
]);
$application = V5Application::query()->create([
@@ -820,7 +821,7 @@ it('persists generic v5 resource connections and direction-specific ports', func
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
]);
$source = V5Application::query()->create([
'team_id' => $team->id,
@@ -893,6 +894,110 @@ it('persists generic v5 resource connections and direction-specific ports', func
->assertSee("\"{$target->id}->{$source->id}\":[\"443\"]", false);
});
it('syncs v5 resource connection ports through flux firewall primitives', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
$this->withoutExceptionHandling();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->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',
'capabilities' => [],
]);
$source = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'api',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-api',
'mesh_namespace' => 'default',
'status' => 'running',
]);
$target = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'postgres',
'image' => 'docker.io/library/postgres:16',
'container_name' => 'coolify-v5-postgres',
'mesh_namespace' => 'default',
'status' => 'running',
]);
$connection = ResourceConnection::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'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,
]);
$firewallRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with('100.64.0.10', [
'id' => $firewallRuleId,
'namespace' => 'default',
'src' => 'coolify-v5-api',
'dst' => 'coolify-v5-postgres',
'proto' => 'tcp',
'port' => 5432,
])
->andReturn('rule-api-postgres');
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with('100.64.0.10', $firewallRuleId)
->andReturn('Firewall rule removed.');
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
->patchJson("/v5/resource-connections/{$connection->id}", [
'ports_by_direction' => [
"{$source->id}->{$target->id}" => [5432],
],
])
->assertSuccessful();
$this
->actingAs($user)
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
->deleteJson("/v5/resource-connections/{$connection->id}")
->assertNoContent();
});
it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
app()->detectEnvironment(fn () => 'local');
@@ -912,7 +1017,7 @@ it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
'ssh_port' => 22,
'status' => 'installed',
'ingress_status' => 'running',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'canvas_x' => -160,
'canvas_y' => 240,
]);
@@ -925,7 +1030,7 @@ it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
'ssh_port' => 22,
'status' => 'installed',
'ingress_status' => 'exited',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
]);
V5Server::query()->create([
'team_id' => $team->id,
@@ -935,7 +1040,7 @@ it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
]);
$this
@@ -969,7 +1074,7 @@ it('creates an nginx v5 application on the first installed team server', functio
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'last_bootstrapped_at' => now(),
]);
@@ -1019,7 +1124,7 @@ it('creates an nginx v5 application on the selected team server', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'last_bootstrapped_at' => now(),
]);
$selectedServer = V5Server::query()->create([
@@ -1031,7 +1136,7 @@ it('creates an nginx v5 application on the selected team server', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'last_bootstrapped_at' => now(),
]);
@@ -1073,7 +1178,7 @@ it('places a new nginx v5 application next to existing canvas nodes', function (
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'last_bootstrapped_at' => now(),
]);
@@ -1125,7 +1230,7 @@ it('marks an nginx v5 application failed when the launch command fails', functio
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'last_bootstrapped_at' => now(),
]);
@@ -1162,7 +1267,7 @@ it('does not create an nginx v5 application on another teams selected server', f
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'last_bootstrapped_at' => now(),
]);
@@ -1215,7 +1320,7 @@ it('deletes a v5 application for the current team', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
@@ -1253,7 +1358,7 @@ it('stops and deletes the nginx container before deleting a v5 application', fun
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
@@ -1303,7 +1408,7 @@ it('does not delete another teams v5 application', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
]);
$application = V5Application::query()->create([
'team_id' => $otherTeam->id,
@@ -1339,7 +1444,7 @@ it('updates v5 application canvas position for the current team', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
@@ -1382,7 +1487,7 @@ it('updates v5 caddy ingress canvas position for the current team', function ()
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'canvas_x' => -352,
'canvas_y' => 0,
]);
@@ -1415,7 +1520,7 @@ it('applies flux application status updates to the database and broadcasts to th
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$application = V5Application::query()->create([
@@ -1467,7 +1572,7 @@ it('maps generic flux container status updates to v5 applications', function ()
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$application = V5Application::query()->create([
@@ -1521,7 +1626,7 @@ it('applies flux ingress server status updates to the database and broadcasts cl
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'wireguard_management_ip' => '100.64.0.5',
]);
@@ -1559,7 +1664,7 @@ it('applies flux caddy ingress container status updates without changing server
'ssh_port' => 22,
'status' => 'installed',
'ingress_status' => 'running',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'wireguard_management_ip' => '100.64.0.5',
]);
@@ -1646,7 +1751,7 @@ it('accepts flux resource status http updates and stores them in the database',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$application = V5Application::query()->create([
@@ -1709,7 +1814,7 @@ it('broadcasts v5 canvas resource updates when application state changes', funct
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
@@ -1756,7 +1861,7 @@ it('broadcasts v5 cluster and canvas updates when ingress server state changes',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
]);
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
@@ -1782,7 +1887,7 @@ it('refreshes v5 application state from flux container inventory', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$application = V5Application::query()->create([
@@ -1847,7 +1952,7 @@ it('refreshes v5 caddy ingress state from flux container inventory', function ()
'ssh_port' => 22,
'status' => 'installed',
'ingress_status' => 'running',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'wireguard_management_ip' => '100.64.0.6',
]);
@@ -2070,7 +2175,7 @@ it('shows v5 clusters with their servers on the cluster page', function () {
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'builder'],
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
@@ -2292,7 +2397,7 @@ it('adds a v5 server to a cluster for the current team', function () {
->assertJsonPath('cluster.servers.0.builderCpuQuota', '200%')
->assertJsonPath('cluster.servers.0.ingressEnabled', false)
->assertJsonPath('cluster.servers.0.ingressType', null)
->assertJsonPath('cluster.servers.0.capabilities', ['coold'])
->assertJsonPath('cluster.servers.0.capabilities', [])
->assertJsonPath('cluster.servers.0.wireguardListenPortOverride', 51821)
->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'prod-01.example.com:51821')
->assertJsonPath('cluster.servers.0.wireguardManagementIp', null)
@@ -2311,7 +2416,7 @@ it('adds a v5 server to a cluster for the current team', function () {
->exists())->toBeTrue();
expect(V5Server::query()->where('name', 'prod-01')->first()->capabilities)
->toBe(['coold']);
->toBe([]);
});
it('adds a v5 server with caddy ingress enabled', function () {
@@ -2343,11 +2448,11 @@ it('adds a v5 server with caddy ingress enabled', function () {
->assertCreated()
->assertJsonPath('cluster.servers.0.ingressEnabled', true)
->assertJsonPath('cluster.servers.0.ingressType', 'caddy')
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']);
->assertJsonPath('cluster.servers.0.capabilities', ['ingress']);
$server = V5Server::query()->where('name', 'edge-01')->first();
expect($server->capabilities)->toBe(['coold', 'ingress'])
expect($server->capabilities)->toBe(['ingress'])
->and($server->ingress_type)->toBe('caddy')
->and($server->isIngress())->toBeTrue();
});
@@ -2381,13 +2486,13 @@ it('keeps added v5 server builder capacity when builder is disabled', function (
->assertCreated()
->assertJsonPath('cluster.servers.0.builderEnabled', false)
->assertJsonPath('cluster.servers.0.builderCapacity', 3)
->assertJsonPath('cluster.servers.0.capabilities', ['coold']);
->assertJsonPath('cluster.servers.0.capabilities', []);
$server = V5Server::query()->where('name', 'prod-01')->first();
expect($server->builder_enabled)->toBeFalse()
->and($server->builder_capacity)->toBe(3)
->and($server->capabilities)->toBe(['coold']);
->and($server->capabilities)->toBe([]);
});
it('requires positive v5 server builder capacity when builder is enabled', function () {
@@ -3106,7 +3211,7 @@ it('updates editable v5 server builder details without changing networking', fun
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '100%',
@@ -3144,7 +3249,7 @@ it('updates editable v5 server builder details without changing networking', fun
expect($server->builder_enabled)->toBeTrue()
->and($server->builder_capacity)->toBe(5)
->and($server->builder_cpu_quota)->toBe('350%')
->and($server->capabilities)->toBe(['coold'])
->and($server->capabilities)->toBe([])
->and($server->host)->toBe('203.0.113.10')
->and($server->ssh_user)->toBe('root')
->and($server->ssh_port)->toBe(22)
@@ -3171,7 +3276,7 @@ it('updates editable v5 server caddy ingress capability independently from build
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'capabilities' => ['coold', 'builder'],
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'builder_cpu_quota' => '200%',
@@ -3191,11 +3296,11 @@ it('updates editable v5 server caddy ingress capability independently from build
->assertJsonPath('cluster.servers.0.builderEnabled', false)
->assertJsonPath('cluster.servers.0.ingressEnabled', true)
->assertJsonPath('cluster.servers.0.ingressType', 'caddy')
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']);
->assertJsonPath('cluster.servers.0.capabilities', ['ingress']);
$server->refresh();
expect($server->capabilities)->toBe(['coold', 'ingress'])
expect($server->capabilities)->toBe(['ingress'])
->and($server->ingress_type)->toBe('caddy')
->and($server->builder_enabled)->toBeFalse()
->and($server->isIngress())->toBeTrue();
@@ -3221,7 +3326,7 @@ it('rejects application ingress when server ingress is disabled', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
@@ -3280,7 +3385,7 @@ it('enables application ingress without publishing domains by default', function
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
@@ -3352,7 +3457,7 @@ it('validates application ingress domains', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
@@ -3410,7 +3515,7 @@ it('enables application ingress with explicit domains and port', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
@@ -3483,7 +3588,7 @@ it('returns flux error details when application ingress sync fails', function ()
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
@@ -3543,7 +3648,7 @@ it('syncs caddy ingress routes through flux when enabling ingress on an installe
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
@@ -3627,7 +3732,7 @@ it('returns flux error details when server ingress activation fails', function (
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
@@ -3694,7 +3799,7 @@ it('keeps editable v5 server builder capacity when disabling builder', function
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'builder'],
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 5,
'builder_cpu_quota' => '350%',
@@ -3717,7 +3822,7 @@ it('keeps editable v5 server builder capacity when disabling builder', function
expect($server->builder_enabled)->toBeFalse()
->and($server->builder_capacity)->toBe(5)
->and($server->builder_cpu_quota)->toBe('350%')
->and($server->capabilities)->toBe(['coold']);
->and($server->capabilities)->toBe([]);
});
it('validates editable v5 server builder details', function () {
@@ -3776,7 +3881,7 @@ it('requires positive editable v5 server builder capacity when builder is enable
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'capabilities' => [],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
@@ -3873,7 +3978,7 @@ it('does not delete a v5 cluster that has servers', function () {
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'builder'],
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
@@ -4098,11 +4203,12 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function ()
->toContain('selectedInspectorApplication')
->toContain('onDoubleClick={(event) => openApplicationInspector(event, application)}')
->toContain('open={selectedInspectorApplication !== null}')
->toContain('<SheetContent side="right" className="w-full overflow-y-auto bg-background sm:rounded-l-xl sm:border data-[side=right]:sm:!inset-y-4 data-[side=right]:sm:!h-auto data-[side=right]:sm:!w-[45vw] data-[side=right]:sm:!max-w-[45vw]"')
->toContain('<SheetContent side="right" className="w-full overflow-hidden bg-background sm:rounded-l-xl sm:border data-[side=right]:sm:!inset-y-4 data-[side=right]:sm:!h-auto data-[side=right]:sm:!w-[45vw] data-[side=right]:sm:!max-w-[45vw]"')
->toContain('showCloseButton blurOverlay={false}')
->toContain('<SheetHeader className="p-6 pb-4">')
->toContain('<div className="flex flex-1 flex-col gap-6 px-6 pb-6">')
->toContain('<Tabs defaultValue="overview"')
->toContain('<TabsList className="w-full justify-start" variant="line">')
->toContain('<TabsTrigger value="overview">Overview</TabsTrigger>')
->toContain('<TabsTrigger value="networking">Networking</TabsTrigger>')
->toContain('<TabsTrigger value="advanced">Advanced</TabsTrigger>')
@@ -105,7 +105,7 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
$server = new Server([
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '10.0.0.10',
'capabilities' => ['coold', 'ingress'],
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
@@ -128,7 +128,7 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
it('does not start caddy ingress for non-ingress servers', function () {
$server = new Server([
'capabilities' => ['coold'],
'capabilities' => [],
]);
$result = StartCaddyIngress::run($server);
@@ -140,7 +140,7 @@ it('stops caddy ingress through flux instead of ssh', function () {
$server = new Server([
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '10.0.0.10',
'capabilities' => ['coold'],
'capabilities' => [],
]);
$fluxClient = Mockery::mock(FluxClient::class);
@@ -271,6 +271,74 @@ it('dispatches container inventory through the containers list primitive', funct
->not->toContain('list_containers');
});
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 withFakeFluxSocketCapturingRequest(string $response, string $requestPath, Closure $callback): void
{
$directory = storage_path('framework/testing');