diff --git a/app/Actions/V5/Proxy/StartCaddyIngress.php b/app/Actions/V5/Proxy/StartCaddyIngress.php index 3b2e8b6da..c5a1208ae 100644 --- a/app/Actions/V5/Proxy/StartCaddyIngress.php +++ b/app/Actions/V5/Proxy/StartCaddyIngress.php @@ -12,6 +12,8 @@ class StartCaddyIngress { use AsAction; + private const FIREWALL_RULE_ID = 'v5-caddy-ingress:80'; + public function __construct(private readonly FluxClient $fluxClient) {} public function handle(Server $server): string @@ -28,6 +30,14 @@ class StartCaddyIngress $configuration = GenerateCaddyIngressConfiguration::run($this->applications($server)); $output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps'])); + $this->fluxClient->applyFirewallRule($hostId, [ + 'id' => self::FIREWALL_RULE_ID, + 'namespace' => 'default', + 'src' => '0.0.0.0/0', + 'dst' => 'coolify-v5-caddy', + 'proto' => 'tcp', + 'port' => 80, + ]); if ($server->exists) { $server->update([ diff --git a/app/Actions/V5/Proxy/StopCaddyIngress.php b/app/Actions/V5/Proxy/StopCaddyIngress.php index 79645236d..aa1899554 100644 --- a/app/Actions/V5/Proxy/StopCaddyIngress.php +++ b/app/Actions/V5/Proxy/StopCaddyIngress.php @@ -10,6 +10,8 @@ class StopCaddyIngress { use AsAction; + private const FIREWALL_RULE_ID = 'v5-caddy-ingress:80'; + public function __construct(private readonly FluxClient $fluxClient) {} public function handle(Server $server): string @@ -21,6 +23,7 @@ class StopCaddyIngress } $output = $this->fluxClient->stopIngress($hostId, 'caddy'); + $this->fluxClient->revokeFirewallRule($hostId, self::FIREWALL_RULE_ID); if ($server->exists) { $server->update(['ingress_status' => 'exited']); diff --git a/app/Console/Commands/FluxDev.php b/app/Console/Commands/FluxDev.php index ff2e12aeb..202abf91b 100644 --- a/app/Console/Commands/FluxDev.php +++ b/app/Console/Commands/FluxDev.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Firebase\JWT\JWT; +use App\Services\Flux\AgentTokenIssuer; use Illuminate\Console\Command; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; @@ -28,7 +28,7 @@ class FluxDev extends Command ]; } - public function handle(): int + public function handle(AgentTokenIssuer $agentTokenIssuer): int { if (! app()->environment(['local', 'development', 'testing']) && ! $this->option('force')) { $this->error('This command is intended for development only. Use --force to override.'); @@ -36,17 +36,8 @@ class FluxDev extends Command return self::FAILURE; } - $privateKeyPath = config('flux.jwt_private_key_path'); - - if (! is_string($privateKeyPath) || $privateKeyPath === '' || ! File::isReadable($privateKeyPath)) { - $this->error("Flux JWT private key not found at {$privateKeyPath}."); - - return self::FAILURE; - } - $hostId = (string) $this->argument('host_id'); $ttl = max(60, (int) $this->option('ttl')); - $now = time(); $caps = collect(explode(',', (string) $this->option('caps'))) ->map(fn (string $cap) => trim($cap)) ->filter() @@ -58,13 +49,13 @@ class FluxDev extends Command $caps = $this->defaultCapabilities(); } - $token = JWT::encode([ - 'sub' => $hostId, - 'aud' => 'coold', - 'caps' => $caps, - 'iat' => $now, - 'exp' => $now + $ttl, - ], File::get($privateKeyPath), 'ES256'); + try { + $token = $agentTokenIssuer->issue($hostId, $caps, $ttl); + } catch (\RuntimeException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } $output = $this->option('output'); diff --git a/app/Events/V5CanvasResourceUpdated.php b/app/Events/V5CanvasResourceUpdated.php index b74e2aed4..ac8cef307 100644 --- a/app/Events/V5CanvasResourceUpdated.php +++ b/app/Events/V5CanvasResourceUpdated.php @@ -18,6 +18,7 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow public int $teamId, public ?int $applicationId = null, public ?int $caddyIngressServerId = null, + public ?int $serverId = null, ) {} public function broadcastOn(): array @@ -33,19 +34,29 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow } /** - * @return array{application: array|null, caddyIngress: array|null} + * @return array{application: array|null, applications: array>, caddyIngress: array|null} */ public function broadcastWith(): array { $application = $this->applicationId !== null - ? V5Application::query()->with('server')->find($this->applicationId) + ? V5Application::query()->with(['server', 'domains'])->find($this->applicationId) : null; + $applications = $this->serverId !== null + ? V5Application::query() + ->where('server_id', $this->serverId) + ->with(['server', 'domains']) + ->get() + : collect(); $caddyIngress = $this->caddyIngressServerId !== null ? V5Server::query()->find($this->caddyIngressServerId) : null; return [ 'application' => $application instanceof V5Application ? $this->serializeApplication($application) : null, + 'applications' => $applications + ->map(fn (V5Application $application) => $this->serializeApplication($application)) + ->values() + ->all(), 'caddyIngress' => $caddyIngress instanceof V5Server && $caddyIngress->isIngress() ? $this->serializeCaddyIngress($caddyIngress) : null, @@ -57,6 +68,9 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow */ private function serializeApplication(V5Application $application): array { + $server = $application->server; + $isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server); + return [ 'id' => (string) $application->id, 'name' => $application->name, @@ -64,26 +78,50 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow 'containerName' => $application->container_name, 'status' => $application->status, 'statusMessage' => $application->status_message, + 'effectiveStatus' => $isServerReachable ? $application->status : 'unknown', + 'effectiveStatusMessage' => $isServerReachable + ? $application->status_message + : $this->serverStatusMessage($server), 'runtimeContainerId' => $application->runtime_container_id, - 'serverName' => $application->server?->name, + 'serverName' => $server?->name, + 'serverStatus' => $server?->status, + 'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null, + 'isServerReachable' => $isServerReachable, + 'serverIngressEnabled' => (bool) $server?->isIngress(), 'meshNamespace' => $application->mesh_namespace, + 'ingressEnabled' => $application->ingress_enabled, + 'internalPort' => $application->internal_port, + 'domains' => $application->domains->pluck('domain')->values()->all(), 'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal', 'canvasX' => $application->canvas_x, 'canvasY' => $application->canvas_y, ]; } + private function isServerReachable(V5Server $server): bool + { + return $server->status !== 'unreachable'; + } + + private function serverStatusMessage(?V5Server $server): ?string + { + return $server?->last_status_output ?: null; + } + /** * @return array */ private function serializeCaddyIngress(V5Server $server): array { + $isServerReachable = $this->isServerReachable($server); + return [ 'id' => (string) $server->id, 'name' => $server->name, 'host' => $server->host, 'type' => $server->ingressType(), - 'status' => $server->ingressStatus(), + 'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable', + 'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server), 'canvasX' => $server->canvas_x ?? -352, 'canvasY' => $server->canvas_y ?? 0, ]; diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index e1d38e3fa..669157852 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -1592,7 +1592,7 @@ class DashboardController extends Controller 'containerName' => $application->container_name, 'status' => $application->status, 'statusMessage' => $application->status_message, - 'effectiveStatus' => $isServerReachable ? $application->status : 'unreachable', + 'effectiveStatus' => $isServerReachable ? $application->status : 'unknown', 'effectiveStatusMessage' => $isServerReachable ? $application->status_message : $this->serverStatusMessage($server), diff --git a/app/Models/V5/Server.php b/app/Models/V5/Server.php index bec89bedd..040618c5c 100644 --- a/app/Models/V5/Server.php +++ b/app/Models/V5/Server.php @@ -63,6 +63,17 @@ class Server extends V5Model V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id); } + if ($server->wasChanged('status')) { + V5CanvasResourceUpdated::dispatch( + $server->team_id, + null, + $server->isIngress() ? $server->id : null, + $server->id, + ); + + return; + } + if ($server->isIngress()) { V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id); } diff --git a/app/Services/Flux/AgentTokenIssuer.php b/app/Services/Flux/AgentTokenIssuer.php new file mode 100644 index 000000000..4a06a6735 --- /dev/null +++ b/app/Services/Flux/AgentTokenIssuer.php @@ -0,0 +1,75 @@ + $capabilities + * @param array $extraClaims + */ + public function issue(string $hostId, array $capabilities = [self::DEFAULT_PROFILE], int $ttl = 86400, array $extraClaims = []): string + { + if ($hostId === '') { + throw new RuntimeException('Flux host id is required.'); + } + + $privateKeyPath = config('flux.jwt_private_key_path'); + + if (! is_string($privateKeyPath) || $privateKeyPath === '' || ! File::isReadable($privateKeyPath)) { + throw new RuntimeException("Flux JWT private key not found at {$privateKeyPath}."); + } + + $now = time(); + + return JWT::encode(array_merge($extraClaims, [ + 'sub' => $hostId, + 'aud' => 'coold', + 'caps' => $this->normalizeCapabilities($capabilities), + 'iat' => $now, + 'exp' => $now + max(60, $ttl), + ]), File::get($privateKeyPath), 'ES256'); + } + + public function issueForServer(V5Server $server, int $ttl = 86400): string + { + $hostId = $server->wireguard_management_ip ?: $server->node_address; + + if (! is_string($hostId) || $hostId === '') { + throw new RuntimeException('Server is missing its Flux host id.'); + } + + return $this->issue($hostId, [self::DEFAULT_PROFILE], $ttl, [ + 'team_id' => $server->team_id, + 'cluster_id' => $server->cluster_id, + 'server_id' => $server->id, + ]); + } + + /** + * @param array $capabilities + * @return array + */ + private function normalizeCapabilities(array $capabilities): array + { + $normalized = collect($capabilities) + ->map(fn (string $capability) => trim($capability)) + ->filter() + ->unique() + ->values() + ->all(); + + if ($normalized === []) { + return [self::DEFAULT_PROFILE]; + } + + return $normalized; + } +} diff --git a/resources/js/v5/Pages/Dashboard.tsx b/resources/js/v5/Pages/Dashboard.tsx index baee9f8b1..41ff84983 100644 --- a/resources/js/v5/Pages/Dashboard.tsx +++ b/resources/js/v5/Pages/Dashboard.tsx @@ -30,6 +30,7 @@ type ConnectionEndpoint = { type V5CanvasResourceUpdatedEvent = { application: V5Application | null; + applications?: V5Application[]; caddyIngress: V5CaddyIngress | null; }; @@ -117,6 +118,10 @@ function statusBadgeClass(status: string): string | false { return 'bg-warning/15 text-warning'; } + if (status === 'unknown') { + return 'bg-muted text-muted-foreground'; + } + if (['failed', 'exited', 'unreachable'].includes(status)) { return 'bg-destructive/15 text-destructive'; } @@ -185,6 +190,7 @@ export default function Dashboard({ const [ingressModal, setIngressModal] = useState(null); const [isSavingIngress, setIsSavingIngress] = useState(false); const [savingIngressApplicationId, setSavingIngressApplicationId] = useState(null); + const [deletingApplicationIds, setDeletingApplicationIds] = useState>(() => new Set()); const canvasRef = useRef(null); const hasCanvasNodes = applications.length > 0 || ingresses.length > 0; @@ -192,7 +198,7 @@ export default function Dashboard({ () => ({ running: applications.filter((application) => application.effectiveStatus === 'running').length, failed: applications.filter((application) => application.effectiveStatus === 'failed').length, - unreachable: applications.filter((application) => application.effectiveStatus === 'unreachable').length, + unknown: applications.filter((application) => application.effectiveStatus === 'unknown').length, }), [applications], ); @@ -259,6 +265,16 @@ export default function Dashboard({ ); } + if (event.applications && event.applications.length > 0) { + setApplications((currentApplications) => + currentApplications.map((application) => { + const updatedApplication = event.applications?.find((candidate) => candidate.id === application.id); + + return updatedApplication ?? application; + }), + ); + } + if (event.caddyIngress) { setIngresses((currentIngresses) => currentIngresses.map((ingress) => @@ -660,6 +676,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection async function removeApplication(application: V5Application): Promise { setNotice(null); + setDeletingApplicationIds((currentIds) => new Set(currentIds).add(application.id)); try { @@ -689,6 +706,14 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection ); } catch (error) { setNotice(error instanceof Error ? error.message : 'Could not delete application.'); + } finally { + setDeletingApplicationIds((currentIds) => { + const nextIds = new Set(currentIds); + + nextIds.delete(application.id); + + return nextIds; + }); } } @@ -1329,10 +1354,10 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection {statusCounts.failed} failed )} - {statusCounts.unreachable > 0 && ( + {statusCounts.unknown > 0 && ( <> - {statusCounts.unreachable} unreachable + {statusCounts.unknown} unknown )} @@ -1623,18 +1648,21 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection ); })} - {applications.map((application) => ( -
startApplicationDrag(event, application)} - onDoubleClick={(event) => openApplicationInspector(event, application)} - > + {applications.map((application) => { + const isDeletingApplication = deletingApplicationIds.has(application.id); + + return ( +
startApplicationDrag(event, application)} + onDoubleClick={(event) => openApplicationInspector(event, application)} + > {CONNECTOR_SIDES.map((side) => (
@@ -1722,8 +1751,9 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection - - ))} + + ); + })} diff --git a/resources/js/v5/types.ts b/resources/js/v5/types.ts index 1914b0bc5..26a115d31 100644 --- a/resources/js/v5/types.ts +++ b/resources/js/v5/types.ts @@ -92,7 +92,7 @@ export type V5Application = { containerName: string; status: 'creating' | 'running' | 'failed' | string; statusMessage: string | null; - effectiveStatus: 'creating' | 'running' | 'failed' | 'unreachable' | string; + effectiveStatus: 'creating' | 'running' | 'failed' | 'unknown' | string; effectiveStatusMessage: string | null; runtimeContainerId: string | null; serverName: string | null; diff --git a/scripts/dev.sh b/scripts/dev.sh index 7fba66f38..d20e76a1c 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -456,6 +456,22 @@ coold_vm() { scripts/coold-vm.sh "$@" } +coold_vm_shell() { + local instance="${1:-}" + + if [ -z "$instance" ]; then + instance="$(coold_vm_instance 1)" + fi + + if [[ "$instance" =~ ^[0-9]+$ ]]; then + echo "ERROR: Use the Lima hostname, not a numeric VM index." >&2 + echo "Example: scripts/dev.sh shell $(coold_vm_instance 1)" >&2 + exit 1 + fi + + COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell +} + coold_vm_up_with_retry() { local index="$1" local attempt @@ -1117,7 +1133,8 @@ Commands: down Stop the dev coold agent and Spin stack down --cleanup Stop the dev stack, then delete the coold Lima VM(s) and VM-local state - shell [n] Open a shell inside coold VM n (default: 1) + shell [hostname] + Open a shell inside a coold VM by Lima hostname (default: coold-dev) list Show Lima instances clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup) naked-vm Recreate the naked Lima VM used for bootstrap testing @@ -1143,7 +1160,7 @@ case "$cmd" in fresh ;; shell) - coold_vm "${1:-1}" shell + coold_vm_shell "${1:-}" ;; list) limactl list diff --git a/tests/Feature/FluxDevCommandTest.php b/tests/Feature/FluxDevCommandTest.php index 067c17883..6abe5a12c 100644 --- a/tests/Feature/FluxDevCommandTest.php +++ b/tests/Feature/FluxDevCommandTest.php @@ -1,10 +1,50 @@ issue('100.64.0.10'); + $claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256')); + + expect($claims->sub)->toBe('100.64.0.10') + ->and($claims->aud)->toBe('coold') + ->and($claims->caps)->toBe(['host-agent:default']) + ->and($claims->exp)->toBeGreaterThan(time()); +}); + +it('issues production server tokens with server identity claims', function () { + [$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair(); + + Config::set('flux.jwt_private_key_path', $privateKeyPath); + + $server = new V5Server; + $server->forceFill([ + 'id' => 123, + 'team_id' => 7, + 'cluster_id' => 'cluster-456', + 'wireguard_management_ip' => '100.64.0.10', + 'node_address' => '203.0.113.10', + ]); + + $token = app(AgentTokenIssuer::class)->issueForServer($server); + $claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256')); + + expect($claims->sub)->toBe('100.64.0.10') + ->and($claims->caps)->toBe(['host-agent:default']) + ->and($claims->team_id)->toBe(7) + ->and($claims->cluster_id)->toBe('cluster-456') + ->and($claims->server_id)->toBe(123); +}); + it('mints a host jwt signed by the configured flux private key', function () { [$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair(); diff --git a/tests/Feature/V5/DashboardTest.php b/tests/Feature/V5/DashboardTest.php index 0450c2b88..c0aa71485 100644 --- a/tests/Feature/V5/DashboardTest.php +++ b/tests/Feature/V5/DashboardTest.php @@ -198,6 +198,7 @@ it('generates http-only caddy routes for application ingress', function () { && str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000')) ) ->andReturn('Caddy ingress applied.'); + expectCaddyIngressFirewallRule($fluxClient); app()->instance(FluxClient::class, $fluxClient); $this @@ -323,6 +324,20 @@ it('shows v5 application connector dots after selecting a canvas card', function ->toContain('opacity-100'); }); +it('shows a loading state on v5 application delete buttons', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + foreach ([ + 'deletingApplicationIds', + 'const isDeletingApplication = deletingApplicationIds.has(application.id)', + 'setDeletingApplicationIds', + 'disabled={isDeletingApplication}', + "{isDeletingApplication ? 'Deleting…' : 'Delete'}", + ] as $expectedSource) { + $this->assertTrue(str_contains($dashboardSource, $expectedSource), "Missing source: {$expectedSource}"); + } +}); + it('uses a larger mobile touch target for v5 application connector dots', function () { $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); @@ -805,7 +820,7 @@ it('serves v5 dashboard applications as canvas nodes', function () { ->assertDontSee('other-nginx-test', false); }); -it('marks v5 application status as stale when its server is unreachable', function () { +it('marks v5 application status as unknown when its server is unreachable', function () { app()->detectEnvironment(fn () => 'local'); $this->withoutVite(); @@ -849,7 +864,7 @@ it('marks v5 application status as stale when its server is unreachable', functi ->get('/v5') ->assertSuccessful() ->assertSee('"status":"running"', false) - ->assertSee('"effectiveStatus":"unreachable"', false) + ->assertSee('"effectiveStatus":"unknown"', false) ->assertSee('"effectiveStatusMessage":"coold heartbeat timed out."', false) ->assertSee('"serverStatus":"unreachable"', false) ->assertSee('"isServerReachable":false', false); @@ -1767,6 +1782,70 @@ it('applies flux ingress server status updates to the database and broadcasts cl && $event->caddyIngressServerId === $server->id); }); +it('broadcasts v5 canvas application updates when a non-ingress server goes unreachable', function () { + 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 Cluster', + ]); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'worker-01', + 'host' => '203.0.113.11', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => [], + 'wireguard_management_ip' => '100.64.0.6', + ]); + $application = 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' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'status_message' => 'Container started.', + ]); + + Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]); + + $resource = ApplyFluxResourceStatusUpdate::run([ + 'resource_type' => 'server', + 'host_id' => '100.64.0.6', + 'status' => 'unreachable', + 'message' => 'coold heartbeat timed out.', + ]); + + expect($resource)->toBeInstanceOf(V5Server::class) + ->and($server->refresh()->status)->toBe('unreachable'); + + Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id + && $event->serverId === $server->id); + + $payload = (new V5CanvasResourceUpdated($team->id, serverId: $server->id))->broadcastWith(); + expect($payload['applications']) + ->toHaveCount(1) + ->and($payload['applications'][0]) + ->toMatchArray([ + 'id' => (string) $application->id, + 'status' => 'running', + 'effectiveStatus' => 'unknown', + 'effectiveStatusMessage' => 'coold heartbeat timed out.', + 'serverStatus' => 'unreachable', + 'isServerReachable' => false, + ]); +}); + it('applies flux caddy ingress container status updates without changing server install status', function () { createSharedUserAndTeamTables(); @@ -1959,6 +2038,108 @@ it('broadcasts v5 canvas resource updates when application state changes', funct && $event->applicationId === $application->id); }); +it('broadcasts the full v5 application canvas shape after application state changes', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $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', + 'capabilities' => ['ingress'], + ]); + $application = 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' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'status_message' => 'Container started.', + 'runtime_container_id' => 'nginx-container-id', + 'ingress_enabled' => true, + 'internal_port' => 80, + 'canvas_x' => 0, + 'canvas_y' => 0, + ]); + V5ApplicationDomain::query()->create([ + 'application_id' => $application->id, + 'domain' => 'nginx.example.com', + ]); + + $application->update([ + 'status' => 'exited', + 'status_message' => 'Container stopped.', + ]); + + $payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith(); + + expect($payload['application']) + ->toMatchArray([ + 'id' => (string) $application->id, + 'status' => 'exited', + 'statusMessage' => 'Container stopped.', + 'effectiveStatus' => 'exited', + 'effectiveStatusMessage' => 'Container stopped.', + 'serverName' => 'edge-01', + 'serverStatus' => 'installed', + 'isServerReachable' => true, + 'serverIngressEnabled' => true, + 'ingressEnabled' => true, + 'internalPort' => 80, + 'domains' => ['nginx.example.com'], + ]); +}); + +it('broadcasts v5 application status as unknown when its server is unreachable', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $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' => 'unreachable', + 'last_status_output' => 'coold heartbeat timed out.', + 'capabilities' => ['ingress'], + ]); + $application = 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' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'status_message' => 'Container started.', + ]); + + $payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith(); + + expect($payload['application']) + ->toMatchArray([ + 'status' => 'running', + 'effectiveStatus' => 'unknown', + 'effectiveStatusMessage' => 'coold heartbeat timed out.', + 'serverStatus' => 'unreachable', + 'isServerReachable' => false, + ]); +}); + it('broadcasts v5 cluster and canvas updates when ingress server state changes', function () { createSharedUserAndTeamTables(); @@ -3596,6 +3777,7 @@ it('enables application ingress without publishing domains by default', function [] ) ->andReturn('Caddy ingress applied.'); + expectCaddyIngressFirewallRule($fluxClient); app()->instance(FluxClient::class, $fluxClient); $this @@ -3724,6 +3906,7 @@ it('enables application ingress with explicit domains and port', function () { && str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000')) ) ->andReturn('Caddy ingress applied.'); + expectCaddyIngressFirewallRule($fluxClient); app()->instance(FluxClient::class, $fluxClient); $this @@ -3868,6 +4051,7 @@ it('syncs caddy ingress routes through flux when enabling ingress on an installe && str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080')) ) ->andReturn('Caddy ingress applied.'); + expectCaddyIngressFirewallRule($fluxClient); app()->instance(FluxClient::class, $fluxClient); $this @@ -5099,6 +5283,22 @@ function fakeSuccessfulNginxFluxDeployment(string $image = 'docker.io/library/ng app()->instance(FluxClient::class, $mock); } +function expectCaddyIngressFirewallRule(mixed $fluxClient): void +{ + $fluxClient + ->shouldReceive('applyFirewallRule') + ->once() + ->with('100.64.0.10', [ + 'id' => 'v5-caddy-ingress:80', + 'namespace' => 'default', + 'src' => '0.0.0.0/0', + 'dst' => 'coolify-v5-caddy', + 'proto' => 'tcp', + 'port' => 80, + ]) + ->andReturn('Firewall rule applied.'); +} + function createSharedUserAndTeamTables(): void { Schema::create('users', function ($table) { diff --git a/tests/Scripts/dev-shell-test.sh b/tests/Scripts/dev-shell-test.sh new file mode 100755 index 000000000..75b6b89ba --- /dev/null +++ b/tests/Scripts/dev-shell-test.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +mkdir -p "$TMP_DIR/bin" +cat > "$TMP_DIR/bin/limactl" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail + +case "${1:-}" in + list) + cat <<'LIST' +NAME STATUS +coold-dev Running +coold-dev-2 Running +LIST + ;; + shell) + printf '%s\n' "$*" > "$LIMACTL_SHELL_ARGS_FILE" + ;; + *) + printf '%s\n' "$*" > "${LIMACTL_OTHER_ARGS_FILE:-/dev/null}" + ;; +esac +STUB +chmod +x "$TMP_DIR/bin/limactl" + +export PATH="$TMP_DIR/bin:$PATH" +export LIMACTL_SHELL_ARGS_FILE="$TMP_DIR/limactl-shell-args" +export LIMACTL_OTHER_ARGS_FILE="$TMP_DIR/limactl-other-args" + +assert_equals() { + local expected="$1" + local actual="$2" + local message="$3" + + if [ "$expected" != "$actual" ]; then + echo "FAIL: $message" >&2 + echo "Expected: $expected" >&2 + echo "Actual: $actual" >&2 + exit 1 + fi +} + +assert_contains() { + local needle="$1" + local haystack="$2" + local message="$3" + + if [[ "$haystack" != *"$needle"* ]]; then + echo "FAIL: $message" >&2 + echo "Expected output to contain: $needle" >&2 + echo "Actual output: $haystack" >&2 + exit 1 + fi +} + +( + cd "$ROOT" + scripts/dev.sh shell coold-dev-2 >/dev/null +) +assert_equals "shell coold-dev-2 -- sudo env TERM=xterm-256color SYSTEMD_PAGER=cat SYSTEMD_LESS=FRXMK bash -l" "$(cat "$LIMACTL_SHELL_ARGS_FILE")" "shell accepts a Lima hostname" + +set +e +numeric_output="$(cd "$ROOT" && scripts/dev.sh shell 1 2>&1 >/dev/null)" +numeric_status=$? +set -e + +if [ "$numeric_status" -eq 0 ]; then + echo "FAIL: numeric shell target should be rejected" >&2 + exit 1 +fi +assert_contains "Use the Lima hostname" "$numeric_output" "numeric shell target explains hostname usage" + +echo "dev-shell-test: ok" diff --git a/tests/Unit/V5/CaddyIngressConfigurationTest.php b/tests/Unit/V5/CaddyIngressConfigurationTest.php index 4ac135324..ce407a55a 100644 --- a/tests/Unit/V5/CaddyIngressConfigurationTest.php +++ b/tests/Unit/V5/CaddyIngressConfigurationTest.php @@ -119,6 +119,18 @@ it('applies caddy ingress configuration through flux instead of ssh', function ( [] ) ->andReturn('Caddy ingress applied.'); + $fluxClient + ->shouldReceive('applyFirewallRule') + ->once() + ->with('100.64.0.10', [ + 'id' => 'v5-caddy-ingress:80', + 'namespace' => 'default', + 'src' => '0.0.0.0/0', + 'dst' => 'coolify-v5-caddy', + 'proto' => 'tcp', + 'port' => 80, + ]) + ->andReturn('Firewall rule applied.'); app()->instance(FluxClient::class, $fluxClient); $result = StartCaddyIngress::run($server); @@ -149,6 +161,11 @@ it('stops caddy ingress through flux instead of ssh', function () { ->once() ->with('100.64.0.10', 'caddy') ->andReturn('Caddy ingress stopped.'); + $fluxClient + ->shouldReceive('revokeFirewallRule') + ->once() + ->with('100.64.0.10', 'v5-caddy-ingress:80') + ->andReturn('Firewall rule removed.'); app()->instance(FluxClient::class, $fluxClient); $result = StopCaddyIngress::run($server);