mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 22:17:19 +00:00
feat(v5): sync server status and ingress access
Add Flux agent token issuing, open and revoke Caddy ingress firewall rules, broadcast server-scoped application status updates, and update the dashboard to show unreachable servers as unknown.
This commit is contained in:
1 parent
75cbcad240
commit
e3e91b7741
14 files changed
+557
-47
No files matched your search
@@ -1,10 +1,50 @@
|
||||
<?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\Artisan;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
it('issues production host tokens with the default capability profile', function () {
|
||||
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
|
||||
|
||||
Config::set('flux.jwt_private_key_path', $privateKeyPath);
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Executable
+78
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user