chore(v5): archive V5 implementation and remove runtime integration

Move V5 source, migrations, UI, scripts, and tests into documentation, then remove V5 routes, models, jobs, configuration, dependencies, and application hooks.
This commit is contained in:
Andras Bacsai committed 2026-08-15 19:13:30 +02:00
1 parent 1440d35750
commit 76030a30d4
237 files changed
+288 -7079

No files matched your search

-22
View File
@@ -58,25 +58,3 @@ function runContainerRoleHelper(string $roles, string $serviceRole, ?string $wor
->env(['COOLIFY_CONTAINER_ROLE' => $roles])
->run($command);
}
it('does not register a separate v5 flux status listener service', function () {
foreach (['development', 'production'] as $environment) {
$base = base_path("docker/{$environment}/etc/s6-overlay/s6-rc.d");
expect(file_exists("{$base}/v5-flux-status-listener"))->toBeFalse()
->and(file_exists("{$base}/user/contents.d/v5-flux-status-listener"))->toBeFalse();
}
});
it('configures development flux to publish v5 resource statuses to laravel over local http by default', function () {
$runScript = file_get_contents(base_path('docker/development/etc/s6-overlay/s6-rc.d/flux/run'));
expect($runScript)
->toContain('COOLIFY_FLUX_LARAVEL_API_URL')
->toContain('http://127.0.0.1:8080')
->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN')
->not->toContain('APP_KEY')
->not->toContain("grep -E '^APP_KEY=' .env")
->not->toContain('COOLIFY_FLUX_REDIS_URL')
->not->toContain('COOLIFY_FLUX_RESOURCE_STATUS_CHANNEL');
});
+1 -52
View File
@@ -1,60 +1,9 @@
<?php
it('does not include coold dev tooling defaults in the development env example', function (string $variable) {
$developmentExample = file_get_contents(base_path('.env.development.example'));
expect($developmentExample)->not->toContain($variable.'=');
})->with([
'coold package version default' => 'COOLIFY_COOLD_VERSION',
'flux package version default' => 'COOLIFY_FLUX_VERSION',
'corrosion package version default' => 'COOLIFY_CORROSION_VERSION',
'coold VM count default' => 'COOLIFY_COOLD_VM_COUNT',
'coold VM flux URL default' => 'COOLIFY_COOLD_VM_FLUX_URL',
'coold VM WireGuard IP 1 default' => 'COOLIFY_COOLD_VM_WG_IP_1',
'coold VM WireGuard IP 2 default' => 'COOLIFY_COOLD_VM_WG_IP_2',
'coold VM WireGuard port 1 default' => 'COOLIFY_COOLD_VM_WG_PORT_1',
'coold VM WireGuard port 2 default' => 'COOLIFY_COOLD_VM_WG_PORT_2',
'coold VM enabled default' => 'COOLIFY_COOLD_VM_ENABLED',
'coold VM stop on down default' => 'COOLIFY_COOLD_VM_STOP_ON_DOWN',
'dev follow logs default' => 'COOLIFY_DEV_FOLLOW_LOGS',
]);
it('defaults coold dev VM settings in Laravel config', function () {
expect(config('coold.coolify_cli_bin'))->toBe('/usr/local/bin/coolify')
->and(config('coold.coold_version'))->toBe('nightly')
->and(config('coold.corrosion_version'))->toBe('v1.0.0')
->and(config('coold.dev_ssh_user'))->toBe('coolify')
->and(config('coold.dev_ssh_user'))->toBe('coolify');
});
it('runs the v5 dev Lima seeder with the normal development database seeder', function () {
it('runs the normal development database seeder without v5 seeders', function () {
$databaseSeeder = file_get_contents(database_path('seeders/DatabaseSeeder.php'));
$developmentSeederBlock = str($databaseSeeder)->after("if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) {")->before(' }')->toString();
expect($developmentSeederBlock)->toContain('DevelopmentRailpackExamplesSeeder::class')
->and($developmentSeederBlock)->not->toContain('V5DevLimaSeeder::class');
});
it('disables Flux host binding by default in the Docker development environment', function () {
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
$runScript = file_get_contents(base_path('docker/development/etc/s6-overlay/s6-rc.d/flux/run'));
expect($compose)->toContain('COOLIFY_FLUX_REQUIRE_HOST_BINDING: "${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}"')
->and($runScript)->toContain('COOLIFY_FLUX_REQUIRE_HOST_BINDING="${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}"');
});
it('cache busts mutable nightly coold assets with resolved checksums', function () {
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
$dockerfile = file_get_contents(base_path('docker/development/Dockerfile'));
$devScript = file_get_contents(base_path('scripts/dev.sh'));
expect($compose)->toContain('COOLIFY_FLUX_CHECKSUM=${COOLIFY_FLUX_CHECKSUM:-unknown}')
->and($compose)->toContain('COOLIFY_CLI_CHECKSUM=${COOLIFY_CLI_CHECKSUM:-unknown}')
->and($dockerfile)->toContain('ARG COOLIFY_FLUX_CHECKSUM')
->and($dockerfile)->toContain('ARG COOLIFY_CLI_CHECKSUM')
->and($dockerfile)->toContain('Flux checksum: ${COOLIFY_FLUX_CHECKSUM}')
->and($dockerfile)->toContain('Coolify CLI checksum: ${COOLIFY_CLI_CHECKSUM}')
->and($devScript)->toContain('resolve_coold_asset_checksum')
->and($devScript)->toContain('export COOLIFY_FLUX_CHECKSUM=')
->and($devScript)->toContain('export COOLIFY_CLI_CHECKSUM=');
});
@@ -1,201 +0,0 @@
<?php
it('does not expose removed dev firewall commands', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->not->toContain('coolify_firewall()')
->and($script)->not->toContain('scripts/dev.sh firewall')
->and($script)->not->toContain('firewall <command>')
->and($script)->not->toContain('/api/v1/firewall/allow')
->and($script)->not->toContain('firewall_api_for_each_vm');
});
it('installs the coolify CLI in both application container images', function (string $dockerfile) {
$contents = file_get_contents(base_path($dockerfile));
expect($contents)->toContain('ARG COOLIFY_CLI_VERSION=nightly')
->and($contents)->toContain('coolify-linux-musl-${COOLIFY_CLI_ARCH}.tar.gz')
->and($contents)->toContain('install -m 0755 /tmp/coolify /usr/local/bin/coolify');
})->with([
'development image' => 'docker/development/Dockerfile',
'production image' => 'docker/production/Dockerfile',
]);
it('runs the coolify CLI from the development application container', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain("printf '%s\\n' '/usr/local/bin/coolify'")
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -T coolify "$(coolify_cli_bin)" init bootstrap')
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -T coolify "$(coolify_cli_bin)" "$@"')
->and($script)->toContain('ensure_coolify_container_ssh_key')
->and($script)->not->toContain('.dev/bin/coolify')
->and($script)->not->toContain('coolify-${os}-${arch}.tar.gz');
});
it('syncs host-resolved Lima local names into the Coolify container hosts file', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('resolve_lima_dns_name()')
->and($script)->toContain('dscacheutil -q host -a name "$name"')
->and($script)->toContain('getent ahostsv4 "$name"')
->and($script)->toContain('sync_lima_hosts_into_coolify_container()')
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -T -u root coolify sh -lc')
->and($script)->toContain('cat "$next" > /etc/hosts')
->and($script)->toContain('sync_lima_hosts_into_coolify_container')
->and($script)->toContain('if [ "$naked" = "true" ]; then');
});
it('does not require predefined UI node environment variables in the development app container', function () {
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
$config = file_get_contents(base_path('config/coold.php'));
expect($compose)->not->toContain('COOLIFY_CLI_NODES:')
->and($compose)->not->toContain('COOLIFY_CLI_SSH_CONFIG:')
->and($compose)->not->toContain('COOLIFY_CLI_WG_LISTEN_PORT_OVERRIDES:')
->and($compose)->not->toContain('COOLIFY_CLI_WG_ENDPOINT_OVERRIDES:')
->and($compose)->not->toContain('COOLIFY_CLI_TIMEOUT:')
->and($config)->not->toContain('cli_nodes')
->and($config)->not->toContain('COOLIFY_CLI_NODES');
});
it('supports a naked up mode that starts VMs and Docker Compose but skips server bootstrap wiring', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('local naked=false')
->and($script)->toContain('--naked')
->and($script)->toContain('if [ "${#compose_args[@]}" -gt 0 ]; then')
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d "${compose_args[@]}"')
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d')
->and($script)->toContain('if [ "$naked" = "true" ]; then')
->and($script)->toContain('Skipping coolify bootstrap and Flux VM wiring')
->and($script)->toContain('coolify_bootstrap_with_retry')
->and($script)->toContain('--concurrency "$(coolify_bootstrap_concurrency)"')
->and($script)->toContain('--ssh-timeout "$(coolify_bootstrap_ssh_timeout)"')
->and($script)->toContain('--debug')
->and($script)->toContain('configure_flux_dev_for_vm "$index"')
->and($script)->toContain('sync_v5_dev_lima_servers');
});
it('retries dev coolify bootstrap because fresh Lima setup can complete across partial phases', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('coolify_bootstrap_with_retry()')
->and($script)->toContain('local attempts=5')
->and($script)->toContain('if coolify_bootstrap; then')
->and($script)->toContain('diagnose_coold_bootstrap_failure')
->and($script)->toContain('systemctl --failed --no-pager')
->and($script)->toContain('systemctl --no-pager --full status wg-quick@wg0 podman.socket corrosion coold')
->and($script)->toContain('fresh Lima hosts can finish setup after partial bootstrap phases');
});
it('seeds bootstrapped Lima VMs into v5 development server state', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
expect($script)->toContain('sync_v5_dev_lima_servers()')
->and($script)->toContain('COOLIFY_CLI_SSH_USER="$ssh_user"')
->and($script)->toContain('COOLIFY_CLI_SSH_USER="$(coolify_ssh_user)" docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d')
->and($script)->toContain('coold_vm_dns_name()')
->and($script)->toContain('$(coold_vm_dns_name "$index")')
->and($script)->toContain('v5:sync-dev-lima-servers')
->and($script)->toContain('--server="${instance}|$(coold_vm_dns_name "$index")|${ssh_user}|22|$(coold_vm_wg_ip "$index")"')
->and($compose)->toContain('COOLIFY_CLI_SSH_USER: "${COOLIFY_CLI_SSH_USER:-}"')
->and($script)->not->toContain('db:seed --class=V5DevLimaSeeder --force')
->and($script)->not->toContain('host.docker.internal|${ssh_user}');
});
it('uses the shared Lima template directly instead of generated per-VM YAML', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
expect($script)->toContain('coold_vm_dns_name()')
->and($script)->not->toContain('COOLIFY_COOLD_VM_SSH_PORT="$(coold_vm_ssh_port "$index")"')
->and($vmScript)->toContain('limactl start --tty=false --name="$INSTANCE" "$TEMPLATE"')
->and($vmScript)->not->toContain('GENERATED=')
->and($vmScript)->not->toContain('generate_yaml()')
->and($template)->not->toContain('{{COOLIFY_COOLD_VM_SSH_PORT}}');
});
it('sets the Lima guest hostname to the instance name for predictable mDNS', function () {
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
expect($vmScript)->toContain('ensure_mdns_hostname()')
->and($vmScript)->toContain('hostnamectl set-hostname "$INSTANCE"')
->and($vmScript)->toContain('systemctl restart avahi-daemon.service')
->and($vmScript)->toContain('ensure_mdns_hostname')
->and($template)->toContain('hostnamectl set-hostname "{{.Name}}"')
->and($template)->not->toContain('hostnamectl set-hostname myvm');
});
it('authorizes the seeded testing host key in dev Lima VMs', function () {
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
expect($template)->toContain('coolify_test_public_key=')
->and($template)->toContain('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68')
->and($template)->toContain('authorized_keys')
->and($template)->toContain('grep -qxF "$coolify_test_public_key"')
->and($template)->toContain('install_public_key root')
->and($template)->toContain('install_public_key coolify')
->and($template)->toContain('owner="$(stat -c \'%u:%g\' "$home")"')
->and($template)->toContain('hostnamectl set-hostname "{{.Name}}"')
->and($template)->not->toContain('hostnamectl set-hostname myvm')
->and($template)->not->toContain('mode: user')
->and($template)->not->toContain('user="$(basename "$home")"');
});
it('hardcodes the naked Lima VM ssh port for bootstrap testing', function () {
$template = file_get_contents(base_path('.dev/lima/coolify-naked-test.yaml'));
expect($template)->toContain('ssh:')
->and($template)->toContain('localPort: 60003');
});
it('supports down cleanup as the preferred VM cleanup command', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('local cleanup=false')
->and($script)->toContain('--cleanup')
->and($script)->toContain('if [ "$cleanup" = "true" ]; then')
->and($script)->toContain('clean_vms')
->and($script)->toContain('down --cleanup')
->and($script)->toContain('clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)');
});
it('scripts a full fresh dev reset with bounded Lima startup retries', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
expect($script)->toContain('fresh()')
->and($script)->toContain('down --cleanup')
->and($script)->toContain('COOLIFY_DEV_FOLLOW_LOGS=false up')
->and($script)->toContain('php artisan migrate:fresh --seed --force')
->and($script)->toContain('sync_v5_dev_lima_servers')
->and($script)->toContain('recreate_naked_lima_vm')
->and($script)->toContain('COOLIFY_NAKED_VM_START_TIMEOUT')
->and($script)->toContain('limactl start --tty=false --name="$instance" "$config"')
->and($script)->toContain('cleanup_lima_instance_processes "$instance"')
->and($script)->toContain('did not become ready after ${attempts} attempts')
->and($script)->toContain('refresh_test_host_key')
->and($script)->toContain('coold_vm_up_with_retry()')
->and($script)->toContain('COOLIFY_COOLD_BOOTSTRAP_CONCURRENCY 1')
->and($script)->toContain('COOLIFY_COOLD_BOOTSTRAP_SSH_TIMEOUT 90s')
->and($script)->toContain('local attempts=2')
->and($script)->toContain('deleting and retrying with a fresh Lima instance')
->and($script)->toContain('cleanup_lima_instance_processes()')
->and($script)->toContain('kill_matching_processes()')
->and($script)->toContain('kill -9 $pids')
->and($script)->toContain('limactl hostagent .*${instance}')
->and($script)->toContain('ssh: .*/.lima/${instance}/ssh.sock')
->and($vmScript)->toContain('COOLIFY_COOLD_VM_START_TIMEOUT')
->and($vmScript)->toContain('lima_shell_timeout()')
->and($vmScript)->toContain('cleanup_lima_probe_processes()')
->and($vmScript)->toContain('cleanup_lima_hostagent_processes()')
->and($vmScript)->toContain('kill_matching_processes()')
->and($vmScript)->toContain('pkill -P "$pid"')
->and($vmScript)->toContain('lima_shell_timeout 5 true')
->and($vmScript)->toContain('Lima start timed out after ${START_TIMEOUT}s')
->and($vmScript)->toContain('Guest SSH timed out after ${START_TIMEOUT}s')
->and($vmScript)->toContain('Guest provisioning timed out after ${START_TIMEOUT}s');
});
-126
View File
@@ -1,126 +0,0 @@
<?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((array) $claims->caps)->toBe(config('flux.host_capabilities'))
->and((array) $claims->caps)->not->toContain('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([
'uuid' => 'server_prod_123',
'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('server_prod_123')
->and((array) $claims->caps)->toBe(config('flux.host_capabilities'))
->and($claims->team_id)->toBe('7')
->and($claims->cluster_id)->toBe('cluster-456')
->and($claims->server_id)->toBe('server_prod_123')
->and($claims->wireguard_management_ip)->toBe('100.64.0.10');
});
it('mints a host jwt signed by the configured flux private key', function () {
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
Config::set('flux.jwt_private_key_path', $privateKeyPath);
$exitCode = Artisan::call('flux:dev', [
'host_id' => 'coold-dev',
'--caps' => 'containers.list,ingress.apply',
'--ttl' => '600',
]);
expect($exitCode)->toBe(0);
$token = trim(Artisan::output());
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
expect($claims->sub)->toBe('coold-dev')
->and($claims->aud)->toBe('coold')
->and($claims->caps)->toBe(['containers.list', 'ingress.apply'])
->and($claims->exp)->toBeGreaterThan(time());
});
it('mints a host jwt with the dev capability profile by default', function () {
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
Config::set('flux.jwt_private_key_path', $privateKeyPath);
$exitCode = Artisan::call('flux:dev', [
'host_id' => 'coold-dev',
'--ttl' => '600',
]);
expect($exitCode)->toBe(0);
$token = trim(Artisan::output());
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
expect($claims->caps)->toBe(['host-agent:dev']);
});
it('writes the host jwt to an output path with owner-only permissions', function () {
[$privateKeyPath] = createFluxJwtKeypair();
$outputPath = storage_path('framework/testing/host-jwt');
Config::set('flux.jwt_private_key_path', $privateKeyPath);
$exitCode = Artisan::call('flux:dev', [
'host_id' => 'coold-dev',
'--output' => $outputPath,
]);
expect($exitCode)->toBe(0);
expect($outputPath)->toBeFile()
->and(substr(sprintf('%o', fileperms($outputPath)), -4))->toBe('0600');
});
/**
* @return array{0: string, 1: string}
*/
function createFluxJwtKeypair(): array
{
$directory = storage_path('framework/testing/flux-keys-'.bin2hex(random_bytes(4)));
mkdir($directory, 0777, true);
$privateKeyPath = $directory.'/jwt.priv';
$publicKeyPath = $directory.'/jwt.pub';
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $output, $exitCode);
expect($exitCode)->toBe(0);
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $output, $exitCode);
expect($exitCode)->toBe(0);
return [$privateKeyPath, $publicKeyPath];
}
-12
View File
@@ -49,18 +49,6 @@ it('schedules every production job with onOneServer', function () {
});
});
it('runs v5 agent token rotation often enough for the default ttl and refresh threshold', function () {
$schedule = app(Schedule::class);
$event = collect($schedule->events())->first(
fn ($e) => str_contains((string) $e->description, 'V5RotateAgentTokensJob')
);
expect($event)->not->toBeNull();
expect($event->expression)->toBe('*/15 * * * *');
expect($event->onOneServer)->toBeTrue();
});
it('does not schedule Stripe subscription reconciliation automatically', function () {
$schedule = app(Schedule::class);
@@ -1,167 +0,0 @@
<?php
use App\Actions\V5\Server\RemoveBootstrapMarker;
use App\Models\V5\RevokedAgentToken;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\AgentTokenIssuer;
use App\Services\Flux\FluxClient;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
/**
* @return array{0: string, 1: string}
*/
function createRevocationJwtKeypair(): array
{
$directory = storage_path('framework/testing/revocation-keys-'.bin2hex(random_bytes(4)));
mkdir($directory, 0777, true);
$privateKeyPath = $directory.'/jwt.priv';
$publicKeyPath = $directory.'/jwt.pub';
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
expect($code)->toBe(0);
chmod($privateKeyPath, 0600);
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
expect($code)->toBe(0);
return [$privateKeyPath, $publicKeyPath];
}
function createRevocationServer(): V5Server
{
return V5Server::query()->create([
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.5',
]);
}
beforeEach(function () {
Config::set('broadcasting.default', 'log');
Config::set('cache.default', 'array');
Schema::dropIfExists('v5_revoked_agent_tokens');
Schema::dropIfExists('v5_servers');
V5TestSchema::createServersTable();
V5TestSchema::createRevokedAgentTokensTable();
[$this->privateKeyPath, $this->publicKeyPath] = createRevocationJwtKeypair();
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
});
afterEach(function () {
Schema::dropIfExists('v5_revoked_agent_tokens');
Schema::dropIfExists('v5_servers');
});
it('persists the issued jti on the server and reports it as not revoked', function () {
$server = createRevocationServer();
$token = app(AgentTokenIssuer::class)->issueForServer($server);
$claims = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'));
expect($server->fresh()->agent_token_jti)->toBe($claims->jti)
->and(app(AgentTokenIssuer::class)->isRevoked($claims->jti))->toBeFalse();
});
it('revokes the currently-issued token and clears the server jti', function () {
$issuer = app(AgentTokenIssuer::class);
$server = createRevocationServer();
$token = $issuer->issueForServer($server);
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
$issuer->revoke($server->fresh());
expect($issuer->isRevoked($jti))->toBeTrue()
->and(RevokedAgentToken::query()->where('jti', $jti)->where('server_id', $server->id)->exists())->toBeTrue()
->and($server->fresh()->agent_token_jti)->toBeNull();
});
it('is idempotent when revoking a server with no issued token', function () {
$server = createRevocationServer();
app(AgentTokenIssuer::class)->revoke($server);
expect(RevokedAgentToken::query()->count())->toBe(0);
});
it('pushes the revocation to flux with the jti and expires_at', function () {
$issuer = app(AgentTokenIssuer::class);
$server = createRevocationServer();
$token = $issuer->issueForServer($server);
$server = $server->fresh();
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
$expiresAtUnix = $server->agent_token_expires_at->getTimestamp();
$flux = Mockery::mock(FluxClient::class);
$flux->shouldReceive('revokeToken')->once()->with($jti, $expiresAtUnix);
app()->instance(FluxClient::class, $flux);
$issuer->revoke($server);
expect($issuer->isRevoked($jti))->toBeTrue()
->and(RevokedAgentToken::query()->where('jti', $jti)->exists())->toBeTrue();
});
it('does not throw and keeps the local revocation record when the flux push fails', function () {
$issuer = app(AgentTokenIssuer::class);
$server = createRevocationServer();
$token = $issuer->issueForServer($server);
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
$flux = Mockery::mock(FluxClient::class);
$flux->shouldReceive('revokeToken')->once()->andThrow(new RuntimeException('flux socket unreachable'));
app()->instance(FluxClient::class, $flux);
// Flux is down: revoke must not throw, and the local record still stands.
$issuer->revoke($server->fresh());
expect($issuer->isRevoked($jti))->toBeTrue()
->and(RevokedAgentToken::query()->where('jti', $jti)->exists())->toBeTrue()
->and($server->fresh()->agent_token_jti)->toBeNull();
});
it('revokeForServer revokes the token and pushes to flux like revoke', function () {
$issuer = app(AgentTokenIssuer::class);
$server = createRevocationServer();
$token = $issuer->issueForServer($server);
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
$flux = Mockery::mock(FluxClient::class);
$flux->shouldReceive('revokeToken')->once()->with($jti, Mockery::type('int'));
app()->instance(FluxClient::class, $flux);
$issuer->revokeForServer($server->fresh());
expect($issuer->isRevoked($jti))->toBeTrue()
->and($server->fresh()->agent_token_jti)->toBeNull();
});
it('revokes the host token when the bootstrap marker is removed', function () {
$issuer = app(AgentTokenIssuer::class);
$server = createRevocationServer();
$token = $issuer->issueForServer($server);
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
// The server has no private key, so the SSH cleanup returns false, but the
// revocation (a pure DB write) still runs first.
$result = RemoveBootstrapMarker::run($server->fresh());
expect($result)->toBeFalse()
->and($issuer->isRevoked($jti))->toBeTrue();
});
-366
View File
@@ -1,366 +0,0 @@
<?php
use App\Jobs\V5RotateAgentTokenJob;
use App\Jobs\V5RotateAgentTokensJob;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\AgentTokenIssuer;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
/**
* @return array{0: string, 1: string}
*/
function createRotationJwtKeypair(): array
{
$directory = storage_path('framework/testing/rotation-keys-'.bin2hex(random_bytes(4)));
mkdir($directory, 0777, true);
$privateKeyPath = $directory.'/jwt.priv';
$publicKeyPath = $directory.'/jwt.pub';
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
expect($code)->toBe(0);
chmod($privateKeyPath, 0600);
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
expect($code)->toBe(0);
return [$privateKeyPath, $publicKeyPath];
}
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
[$this->user, $this->team] = createV5UserWithTeam();
[$this->privateKeyPath, $this->publicKeyPath] = createRotationJwtKeypair();
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
});
/**
* @param array<string, mixed> $attributes
*/
function createRotationServer(array $attributes = []): V5Server
{
static $counter = 0;
$counter++;
return V5Server::query()->create([
'team_id' => test()->team->id,
'created_by_user_id' => test()->user->id,
'name' => 'edge-'.$counter,
'host' => '203.0.113.'.$counter,
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'wireguard_management_ip' => '100.64.0.'.$counter,
'last_bootstrapped_at' => now(),
...$attributes,
]);
}
it('issueForServer persists agent_token_expires_at approximately now plus the ttl', function () {
Config::set('flux.host_token_ttl', 3600);
$server = createRotationServer();
app(AgentTokenIssuer::class)->issueForServer($server);
$server->refresh();
expect($server->agent_token_jti)->toBeString()->not->toBe('')
->and($server->agent_token_expires_at)->not->toBeNull()
->and($server->agent_token_expires_at->timestamp)
->toBeGreaterThanOrEqual(now()->addSeconds(3600)->subSeconds(30)->timestamp)
->toBeLessThanOrEqual(now()->addSeconds(3600)->addSeconds(30)->timestamp);
});
it('dispatches rotation only for eligible servers whose token is missing or near expiry', function () {
Config::set('flux.host_token_refresh_threshold', 43200);
Queue::fake();
$neverIssued = createRotationServer(['agent_token_expires_at' => null]);
$expiringSoon = createRotationServer(['agent_token_expires_at' => now()->addHours(6)]);
$stillFresh = createRotationServer(['agent_token_expires_at' => now()->addHours(20)]);
$notInstalled = createRotationServer(['status' => 'added', 'agent_token_expires_at' => null]);
$withoutCoold = createRotationServer(['capabilities' => [], 'agent_token_expires_at' => null]);
$notBootstrapped = createRotationServer(['last_bootstrapped_at' => null, 'agent_token_expires_at' => null]);
(new V5RotateAgentTokensJob)->handle();
Queue::assertPushed(V5RotateAgentTokenJob::class, 2);
Queue::assertPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $neverIssued->id);
Queue::assertPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $expiringSoon->id);
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $stillFresh->id);
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $notInstalled->id);
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $withoutCoold->id);
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $notBootstrapped->id);
});
it('delivers the fresh token over RPC and never touches SSH on RPC success', function () {
Config::set('flux.host_token_ttl', 86400);
Process::fake();
Log::spy();
$server = createRotationServer([
'agent_token_jti' => 'old-jti',
'agent_token_expires_at' => now()->addHour(),
]);
$privateKey = createV5PrivateKey($server->team, 'Rotation Key');
$server->update(['private_key_id' => $privateKey->id]);
$flux = Mockery::mock(FluxClient::class);
$flux->shouldReceive('pushHostToken')
->once()
->with($server->fluxHostId(), Mockery::type('string'));
app()->instance(FluxClient::class, $flux);
(new V5RotateAgentTokenJob($server->id))->handle();
$server->refresh();
expect($server->agent_token_jti)->not->toBe('old-jti')->toBeString()->not->toBe('')
->and($server->agent_token_expires_at->timestamp)
->toBeGreaterThan(now()->addHours(20)->timestamp);
Process::assertNothingRan();
Log::shouldHaveReceived('debug')->withArgs(
fn (string $message, array $context = []) => ($context['delivery'] ?? null) === 'rpc'
);
});
it('falls back to the SSH push when RPC delivery fails and advances jti on SSH success', function () {
Config::set('flux.host_token_ttl', 86400);
Process::fake(['*' => Process::result(output: '')]);
Log::spy();
$server = createRotationServer([
'agent_token_jti' => 'old-jti',
'agent_token_expires_at' => now()->addHour(),
]);
$privateKey = createV5PrivateKey($server->team, 'Rotation Key');
$server->update(['private_key_id' => $privateKey->id]);
$flux = Mockery::mock(FluxClient::class);
$flux->shouldReceive('pushHostToken')
->once()
->andThrow(new RuntimeException('host is not connected'));
app()->instance(FluxClient::class, $flux);
(new V5RotateAgentTokenJob($server->id))->handle();
$server->refresh();
expect($server->agent_token_jti)->not->toBe('old-jti')->toBeString()->not->toBe('')
->and($server->agent_token_expires_at->timestamp)
->toBeGreaterThan(now()->addHours(20)->timestamp);
Process::assertRan(function ($process): bool {
$command = $process->command;
if (! is_array($command) || ($command[0] ?? null) !== 'ssh') {
return false;
}
$script = end($command);
return str_contains($script, '/etc/coolify/host-jwt')
&& str_contains($script, 'chmod 600 /etc/coolify/host-jwt')
&& str_contains($script, 'tee /etc/coolify/host-jwt');
});
Log::shouldHaveReceived('debug')->withArgs(
fn (string $message, array $context = []) => ($context['delivery'] ?? null) === 'ssh'
);
});
it('leaves jti and expires_at unchanged when both RPC and SSH delivery fail', function () {
Config::set('flux.host_token_ttl', 86400);
Process::fake(['*' => Process::result(errorOutput: 'connection refused', exitCode: 255)]);
$expiresAt = now()->addHour();
$server = createRotationServer([
'agent_token_jti' => 'old-jti',
'agent_token_expires_at' => $expiresAt,
]);
$privateKey = createV5PrivateKey($server->team, 'Rotation Key');
$server->update(['private_key_id' => $privateKey->id]);
$flux = Mockery::mock(FluxClient::class);
$flux->shouldReceive('pushHostToken')
->once()
->andThrow(new RuntimeException('host is not connected'));
app()->instance(FluxClient::class, $flux);
(new V5RotateAgentTokenJob($server->id))->handle();
$server->refresh();
expect($server->agent_token_jti)->toBe('old-jti')
->and($server->agent_token_expires_at->timestamp)->toBe($expiresAt->timestamp);
});
it('pushHostToken dispatches the host.jwt.set command with the jwt for the target host', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$responseBody = json_encode([
'request_id' => 'test-request',
'status' => 'ok',
'data' => [],
], JSON_THROW_ON_ERROR);
$captured = withCapturingFluxSocket(
"HTTP/1.1 200 OK\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($responseBody)."\r\n".
"\r\n".
$responseBody,
function (): void {
(new FluxClient)->pushHostToken('100.64.0.42', 'the-new-host-jwt');
}
);
$position = strpos($captured, "\r\n\r\n");
$requestBody = $position === false ? '' : substr($captured, $position + 4);
$decoded = json_decode($requestBody, true);
expect($decoded)->toBeArray()
->and($decoded['host_id'])->toBe('100.64.0.42')
->and($decoded['command'])->toBe(['type' => 'host.jwt.set', 'jwt' => 'the-new-host-jwt']);
});
it('revokeToken posts the jti and expires_at to the flux revocation endpoint', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$responseBody = json_encode(['revoked' => 'the-jti'], JSON_THROW_ON_ERROR);
$captured = withCapturingFluxSocket(
"HTTP/1.1 200 OK\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($responseBody)."\r\n".
"\r\n".
$responseBody,
function (): void {
(new FluxClient)->revokeToken('the-jti', 1893456000);
}
);
$requestLine = strtok($captured, "\r\n");
$position = strpos($captured, "\r\n\r\n");
$requestBody = $position === false ? '' : substr($captured, $position + 4);
$decoded = json_decode($requestBody, true);
expect($requestLine)->toBe('POST /v1/tokens/revoke HTTP/1.1')
->and($decoded)->toBe(['jti' => 'the-jti', 'expires_at' => 1893456000]);
});
it('revokeToken omits expires_at from the body when it is null', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$responseBody = json_encode(['revoked' => 'the-jti'], JSON_THROW_ON_ERROR);
$captured = withCapturingFluxSocket(
"HTTP/1.1 200 OK\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($responseBody)."\r\n".
"\r\n".
$responseBody,
function (): void {
(new FluxClient)->revokeToken('the-jti');
}
);
$position = strpos($captured, "\r\n\r\n");
$requestBody = $position === false ? '' : substr($captured, $position + 4);
expect(json_decode($requestBody, true))->toBe(['jti' => 'the-jti']);
});
it('runs the rotation jobs on the dedicated v5-reconcile queue', function () {
expect((new V5RotateAgentTokensJob)->queue)->toBe('v5-reconcile')
->and((new V5RotateAgentTokenJob(1))->queue)->toBe('v5-reconcile');
});
/**
* Runs $callback against a fake Flux Unix socket that records the full HTTP
* request (headers + body) it receives, then returns the captured raw request
* so the wire command can be asserted.
*/
function withCapturingFluxSocket(string $response, Closure $callback): string
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$capturePath = $directory.'/flux-capture-'.bin2hex(random_bytes(8)).'.txt';
$socketServer = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($socketServer)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$connection = stream_socket_accept($socketServer, 5);
if ($connection !== false) {
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
if (preg_match('/Content-Length:\s*(\d+)/i', $request, $matches) === 1) {
$headerEnd = strpos($request, "\r\n\r\n") + 4;
$needed = (int) $matches[1];
while (strlen($request) - $headerEnd < $needed && ! feof($connection)) {
$chunk = fread($connection, 8192);
if ($chunk === '' || $chunk === false) {
break;
}
$request .= $chunk;
}
}
file_put_contents($capturePath, $request);
fwrite($connection, $response);
fclose($connection);
}
fclose($socketServer);
exit(0);
}
fclose($socketServer);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.connection_timeout_seconds', 1.0);
Config::set('flux.dispatch_timeout_seconds', 1.0);
try {
$callback();
} finally {
pcntl_waitpid($pid, $status);
}
$captured = is_file($capturePath) ? (string) file_get_contents($capturePath) : '';
@unlink($socketPath);
@unlink($capturePath);
return $captured;
}
-17
View File
@@ -1,17 +0,0 @@
<?php
it('renders the Coolify dashboard icon smaller than the nav height', function () {
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.tsx'));
expect($navbar)->toContain('<img src="/coolify-logo.svg" alt="Coolify" className="size-6" />')
->and($navbar)->not->toContain('<img src="/coolify-logo.svg" alt="Coolify" className="size-8" />');
});
it('shows keyboard focus on ghost select dropdown triggers', function () {
$selectComponent = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
expect($selectComponent)
->toContain("variant === 'ghost'")
->toContain('focus-visible:border-ring')
->not->toContain('focus-visible:border-transparent');
});
File diff suppressed because it is too large. Load diff
-251
View File
@@ -1,251 +0,0 @@
<?php
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Jobs\V5BootstrapServerJob;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\AgentTokenIssuer;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
beforeEach(function () {
Config::set('broadcasting.default', 'log');
Config::set('cache.default', 'array');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
Schema::dropIfExists('private_keys');
Schema::dropIfExists('team_user');
Schema::dropIfExists('teams');
Schema::dropIfExists('users');
});
function createBootstrapPreflightTables(): 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']);
});
Schema::create('private_keys', function ($table) {
$table->id();
$table->string('uuid')->unique();
$table->string('name');
$table->string('description')->nullable();
$table->longText('private_key');
$table->string('fingerprint')->nullable();
$table->boolean('is_git_related')->default(false);
$table->foreignId('team_id');
$table->timestamps();
});
V5TestSchema::createClustersTable();
V5TestSchema::createServersTable();
}
/**
* @return array{0: User, 1: Team}
*/
function createBootstrapPreflightUserWithTeam(): 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 Tooling Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
return [$user, $team];
}
function createBootstrapPreflightPrivateKey(Team $team): PrivateKey
{
return PrivateKey::withoutEvents(fn () => PrivateKey::query()->forceCreate([
'uuid' => 'preflight-key-uuid',
'name' => 'Preflight SSH Key',
'description' => null,
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
'fingerprint' => 'preflight-key',
'is_git_related' => false,
'team_id' => $team->id,
]));
}
/**
* @return array{0: User, 1: Team, 2: Cluster, 3: V5Server}
*/
function createBootstrapPreflightClusterWithServer(array $clusterAttributes = [], array $serverAttributes = []): array
{
[$user, $team] = createBootstrapPreflightUserWithTeam();
$privateKey = createBootstrapPreflightPrivateKey($team);
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
...$clusterAttributes,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
...$serverAttributes,
]);
return [$user, $team, $cluster, $server];
}
it('rejects bootstrap when flux url is not configured', function () {
createBootstrapPreflightTables();
Config::set('coold.flux_url', '');
[$user, $team, $cluster, $server] = createBootstrapPreflightClusterWithServer();
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
->assertUnprocessable()
->assertJsonPath('message', 'COOLIFY_COOLD_FLUX_URL is not configured, so bootstrapped servers cannot be enrolled into Flux. Set it and retry the bootstrap.');
Queue::assertNothingPushed();
// The claim must not have been written either.
expect($server->refresh()->last_bootstrap_status)->toBeNull();
});
it('queues the bootstrap when the flux url is configured', function () {
createBootstrapPreflightTables();
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
[$user, $team, $cluster, $server] = createBootstrapPreflightClusterWithServer();
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
->assertAccepted()
->assertJsonPath('message', 'Bootstrap queued.');
Queue::assertPushed(V5BootstrapServerJob::class);
});
it('writes the marker with the same coold version that is persisted on the server row', function () {
createBootstrapPreflightTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
[, , $cluster, $server] = createBootstrapPreflightClusterWithServer([
'namespaces' => ['default'],
'coold_version' => 'v0.9.9',
], [
'last_bootstrap_status' => 'queued',
'last_bootstrap_ran_at' => now(),
]);
$issuer = Mockery::mock(AgentTokenIssuer::class);
$issuer->shouldReceive('issueForServer')->andReturn('signed-host-jwt');
app()->instance(AgentTokenIssuer::class, $issuer);
// The CLI reports a newer coold version than the cluster asked for; the
// marker and the database row must both use the reported version.
Process::fake([
'*' => Process::sequence()
->push(Process::result(output: ''))
->push(Process::result(output: json_encode([
'results' => [],
'coold_version' => 'v1.2.3',
'verified' => [[
'host' => "v5-server-{$server->uuid}",
'wireguard_ip' => '100.64.0.1',
'peer_count' => 0,
'status' => 'ok',
]],
], JSON_THROW_ON_ERROR)."\n"))
->push(Process::result(output: "public-key\n"))
->push(Process::result(output: "default=10.210.0.0/24\n"))
->push(Process::result(output: "marker written\n"))
->push(Process::result(output: "coold restarted\n")),
]);
Event::fake([V5ClusterUpdated::class, V5CanvasResourceUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
expect($server->last_bootstrap_status)->toBe('succeeded')
->and($server->status)->toBe('installed')
->and($server->coold_version)->toBe('v1.2.3');
$markerPayload = null;
Process::assertRan(function ($process) use (&$markerPayload): bool {
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
if (! str_contains($command, 'v5-node.json') || ! str_contains($command, 'base64 -d')) {
return false;
}
if (preg_match("/payload='([^']+)'/", $command, $matches) === 1) {
$markerPayload = json_decode(base64_decode($matches[1], true), true);
}
return true;
});
expect($markerPayload)->toBeArray()
->and($markerPayload['coold_version'])->toBe('v1.2.3')
->and($markerPayload['coold_version'])->toBe($server->coold_version)
->and($markerPayload['server_uuid'])->toBe($server->uuid)
->and($markerPayload['wireguard_management_ip'])->toBe('100.64.0.1');
});
@@ -1,71 +0,0 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Config;
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
Config::set('broadcasting.default', 'pusher');
Config::set('broadcasting.connections.pusher.key', 'test-key');
Config::set('broadcasting.connections.pusher.secret', 'test-secret');
Config::set('broadcasting.connections.pusher.app_id', 'test-app-id');
Config::set('broadcasting.connections.pusher.options', [
'host' => '127.0.0.1',
'port' => 6001,
'scheme' => 'http',
'useTLS' => false,
]);
});
it('authorizes team members on their private team channel', function () {
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->postJson('/broadcasting/auth', [
'channel_name' => "private-team.{$team->id}",
'socket_id' => '1234.5678',
])
->assertSuccessful()
->assertJsonStructure(['auth']);
});
it('rejects users outside the team on the private team channel', function () {
[, $team] = createV5UserWithTeam();
$outsider = User::withoutEvents(fn () => User::query()->create([
'name' => 'Outsider',
'email' => 'outsider@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Another Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$outsider->teams()->attach($otherTeam, ['role' => 'owner']);
$this
->actingAs($outsider)
->postJson('/broadcasting/auth', [
'channel_name' => "private-team.{$team->id}",
'socket_id' => '1234.5678',
])
->assertForbidden();
});
it('rejects guests on the private team channel', function () {
[, $team] = createV5UserWithTeam();
$this
->postJson('/broadcasting/auth', [
'channel_name' => "private-team.{$team->id}",
'socket_id' => '1234.5678',
])
->assertForbidden();
});
-32
View File
@@ -1,32 +0,0 @@
<?php
it('uses yellow focus borders for normal v5 controls and purple focus borders for coolify buttons', function () {
$v5Css = file_get_contents(resource_path('css/v5/app.css'));
$buttonComponent = file_get_contents(resource_path('js/v5/components/ui/button.tsx'));
$inputComponent = file_get_contents(resource_path('js/v5/components/ui/input.tsx'));
$selectComponent = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
$textareaComponent = file_get_contents(resource_path('js/v5/components/ui/textarea.tsx'));
expect($v5Css)
->toContain(':focus-visible')
->toContain('ring-2 ring-ring ring-offset-2')
->toContain('--tw-ring-offset-color: var(--background)')
->and($buttonComponent)
->toContain('focus-visible:border-ring')
->toContain('focus-visible:border-coollabs-100')
->toContain('dark:focus-visible:border-coollabs-100')
->not->toContain('dark:focus-visible:border-coollabs-50')
->not->toContain('focus-visible:ring-')
->not->toContain('focus-visible:border-destructive')
->and($inputComponent)
->toContain('focus:border-ring')
->toContain('focus:ring-0')
->not->toContain('focus:ring-1')
->and($selectComponent)
->toContain('focus-visible:border-ring')
->not->toContain('focus-visible:ring-')
->and($textareaComponent)
->toContain('focus:border-ring')
->toContain('focus:ring-0')
->not->toContain('focus:ring-1');
});
@@ -1,16 +0,0 @@
<?php
it('does not render server capability summaries on the cluster page', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->not->toContain('Capabilities:')
->not->toContain('normalizeCapabilities');
});
it('shows server update error messages returned by the v5 api', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain("payload?.message ?? 'Unable to update this server. Please try again.'");
});
-417
View File
@@ -1,417 +0,0 @@
<?php
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster;
use App\Models\V5\Server as V5Server;
beforeEach(function () {
resetV5DashboardTestState();
});
it('returns fresh v5 cluster bootstrap state for realtime fallback refreshes', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrap_action' => 'bootstrap',
'last_bootstrap_status' => 'running',
'last_bootstrap_output' => 'Starting Coolify CLI bootstrap for prod-01...',
'last_bootstrap_ran_at' => now(),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->getJson("/v5/clusters/{$cluster->uuid}")
->assertSuccessful()
->assertJsonPath('cluster.id', $cluster->uuid)
->assertJsonPath('cluster.servers.0.id', $server->uuid)
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'running')
->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Starting Coolify CLI bootstrap for prod-01...');
});
it('shows v5 clusters with their servers on the cluster page', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Lima Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'coold-dev',
'host' => 'lima-coold-dev',
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5/clusters')
->assertSuccessful()
->assertSee('Clusters', false)
->assertSee('"clusters":[', false)
->assertSee('"privateKeys":[', false)
->assertSee('"name":"Lima Key"', false)
->assertSee('"name":"Development-Lima"', false)
->assertSee('"serversCount":1', false)
->assertSee('"name":"coold-dev"', false)
->assertSee('"host":"lima-coold-dev"', false)
->assertDontSee('"sshUser"', false)
->assertDontSee('"sshPort"', false)
->assertSee('"builderEnabled":true', false)
->assertSee('"builderCapacity":2', false)
->assertSee('"wireguardInterface":"wg0"', false)
->assertSee('"wireguardManagementPool":"100.64.0.0\\/16"', false)
->assertSee('"containerNetworkPool":"10.210.0.0\\/16"', false)
->assertSee('"namespaces":["default"]', false)
->assertSee('"defaultDenyContainers":true', false)
->assertSee('"cooldVersion":"nightly"', false)
->assertSee('"corrosionVersion":"v1.0.0"', false)
->assertSee('"builderCpuQuota":"200%"', false)
->assertSee('"builderMemoryMax":"2G"', false)
->assertSee('"builderTimeoutSecs":1800', false)
->assertSee('"lastCliStatus":null', false)
->assertSee('"privateKeyName":"Lima Key"', false)
->assertSee('"nodeAddress":null', false)
->assertSee('"wireguardManagementIp":null', false)
->assertSee('"containerSubnets":[]', false)
->assertSee('"lastBootstrappedAt":"', false);
});
it('creates a v5 cluster for the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => 'Primary production cluster.',
])
->assertCreated()
->assertJsonPath('cluster.name', 'Production Mesh')
->assertJsonPath('cluster.description', 'Primary production cluster.')
->assertJsonPath('cluster.wireguardInterface', 'wg0')
->assertJsonPath('cluster.wireguardManagementPool', '100.64.0.0/16')
->assertJsonPath('cluster.wireguardListenPort', 51820)
->assertJsonPath('cluster.containerNetworkPool', '10.210.0.0/16')
->assertJsonPath('cluster.containerNetworkPrefix', 24)
->assertJsonPath('cluster.namespaces', ['default'])
->assertJsonPath('cluster.defaultDenyContainers', true)
->assertJsonPath('cluster.cooldVersion', 'nightly')
->assertJsonPath('cluster.corrosionVersion', 'v1.0.0')
->assertJsonPath('cluster.corrosionGossipPort', 8787)
->assertJsonPath('cluster.corrosionApiPort', 8080)
->assertJsonPath('cluster.builderEnabled', true)
->assertJsonPath('cluster.builderCapacity', 2)
->assertJsonPath('cluster.builderCpuQuota', '200%')
->assertJsonPath('cluster.builderMemoryMax', '2G')
->assertJsonPath('cluster.builderTimeoutSecs', 1800)
->assertJsonPath('cluster.lastCliAction', null)
->assertJsonPath('cluster.lastCliStatus', null)
->assertJsonPath('cluster.lastCliSummary', null)
->assertJsonPath('cluster.lastCliRanAt', null)
->assertJsonPath('cluster.serversCount', 0)
->assertJsonPath('cluster.servers', []);
expect(Cluster::query()
->where('team_id', $team->id)
->where('created_by_user_id', $user->id)
->where('name', 'Production Mesh')
->where('description', 'Primary production cluster.')
->where('wireguard_interface', 'wg0')
->where('wireguard_management_pool', '100.64.0.0/16')
->where('container_network_pool', '10.210.0.0/16')
->exists())->toBeTrue();
});
it('creates a v5 cluster with advanced cli configuration', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Custom Mesh',
'description' => null,
'wireguard_interface' => 'wg-prod',
'wireguard_management_pool' => '100.65.0.0/16',
'wireguard_listen_port' => 51830,
'container_network_pool' => '10.211.0.0/16',
'container_network_prefix' => 25,
'namespaces' => ['default', 'preview'],
'default_deny_containers' => false,
'coold_version' => 'v0.2.0',
'corrosion_version' => 'v1.1.0',
'corrosion_gossip_port' => 8788,
'corrosion_api_port' => 8081,
'builder_enabled' => true,
'builder_capacity' => 4,
'builder_cpu_quota' => '400%',
'builder_memory_max' => '4G',
'builder_timeout_secs' => 2400,
])
->assertCreated()
->assertJsonPath('cluster.wireguardInterface', 'wg-prod')
->assertJsonPath('cluster.wireguardManagementPool', '100.65.0.0/16')
->assertJsonPath('cluster.wireguardListenPort', 51830)
->assertJsonPath('cluster.containerNetworkPool', '10.211.0.0/16')
->assertJsonPath('cluster.containerNetworkPrefix', 25)
->assertJsonPath('cluster.namespaces', ['default', 'preview'])
->assertJsonPath('cluster.defaultDenyContainers', false)
->assertJsonPath('cluster.cooldVersion', 'v0.2.0')
->assertJsonPath('cluster.corrosionVersion', 'v1.1.0')
->assertJsonPath('cluster.builderCapacity', 4)
->assertJsonPath('cluster.builderCpuQuota', '400%')
->assertJsonPath('cluster.builderMemoryMax', '4G')
->assertJsonPath('cluster.builderTimeoutSecs', 2400);
});
it('validates advanced v5 cluster cli configuration', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Broken Mesh',
'wireguard_management_pool' => 'not-a-cidr',
'container_network_pool' => '10.211.0.0',
'wireguard_listen_port' => 70000,
'namespaces' => ['Default'],
])
->assertUnprocessable()
->assertJsonValidationErrors([
'wireguard_management_pool',
'container_network_pool',
'wireguard_listen_port',
'namespaces.0',
]);
});
it('requires positive v5 cluster builder capacity when builders are enabled', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Zero Builder Mesh',
'builder_enabled' => true,
'builder_capacity' => 0,
])
->assertUnprocessable()
->assertJsonValidationErrors(['builder_capacity']);
});
it('validates v5 cluster creation input', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => '',
'description' => str_repeat('a', 1001),
])
->assertUnprocessable()
->assertJsonValidationErrors(['name', 'description']);
});
it('rejects duplicate v5 cluster names in the same team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => null,
])
->assertUnprocessable()
->assertJsonValidationErrors(['name']);
});
it('deletes an empty v5 cluster in the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Empty Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson('/v5/clusters/'.$cluster->uuid)
->assertNoContent();
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeFalse();
});
it('does not delete a v5 cluster that has servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Lima Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => null,
]);
V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'coold-dev',
'host' => 'lima-coold-dev',
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson('/v5/clusters/'.$cluster->uuid)
->assertUnprocessable()
->assertJsonPath('message', 'Only empty clusters can be deleted.');
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
});
it('does not delete a v5 cluster outside the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$otherUser = User::withoutEvents(fn () => User::query()->create([
'name' => 'Other User',
'email' => 'other-delete@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$cluster = Cluster::query()->create([
'team_id' => $otherTeam->id,
'created_by_user_id' => $otherUser->id,
'name' => 'Other Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson('/v5/clusters/'.$cluster->uuid)
->assertNotFound();
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
});
it('allows the same v5 cluster name in another team without leaking it', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$otherUser = User::withoutEvents(fn () => User::query()->create([
'name' => 'Other User',
'email' => 'other@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
Cluster::query()->create([
'team_id' => $otherTeam->id,
'created_by_user_id' => $otherUser->id,
'name' => 'Production Mesh',
'description' => 'Other team cluster.',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => 'Current team cluster.',
])
->assertCreated()
->assertJsonPath('cluster.description', 'Current team cluster.');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5/clusters')
->assertSuccessful()
->assertSee('Current team cluster.')
->assertDontSee('Other team cluster.');
});
@@ -1,11 +0,0 @@
<?php
it('keeps the cluster selector and add button the same height', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)->toContain('<SelectTrigger')
->and($clustersPage)->toContain('className="w-full sm:w-72"')
->and($clustersPage)->toContain('variant="coolify"')
->and($clustersPage)->toContain('size="default"')
->and($clustersPage)->not->toContain('variant="coolify"\n size="sm"');
});
@@ -1,514 +0,0 @@
<?php
use App\Events\V5RealtimeTestEvent;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Application;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Server as V5Server;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Support\Facades\Event;
beforeEach(function () {
resetV5DashboardTestState();
});
it('serves the v5 inertia shell', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
$this->withoutExceptionHandling();
fakeFluxHealth();
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'V5 Shared Team',
'description' => 'Shared team details',
'personal_team' => true,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('<html lang="en" class="dark">', false)
->assertSee('coolify-logo-dev-transparent.png', false)
->assertDontSee('coolify-logo.svg', false)
->assertSee('js/echo.js', false)
->assertSee('js/pusher.js', false)
->assertSee('window.Echo', false)
->assertSee('v5-app', false)
->assertSee('Dashboard', false)
->assertDontSee('Home', false)
->assertDontSee('v5-ready', false)
->assertDontSee('This page is served from Laravel through Inertia and React')
->assertDontSee('Bootstrap server')
->assertDontSee('privateKeys', false)
->assertSee('Running')
->assertSee('Flux is running.')
->assertDontSee('"clusters":', false)
->assertDontSee('cooldServers', false)
->assertDontSee('coold-dev')
->assertDontSee('Create cluster')
->assertDontSee('Cluster details')
->assertDontSee('100.64.0.1')
->assertDontSee('Current team')
->assertDontSee('Your teams')
->assertSee('currentTeam', false)
->assertSee('"currentTeam":{"id":'.$team->id, false)
->assertDontSee('teams', false)
->assertDontSee('V5 Shared Team')
->assertDontSee('Shared team details');
});
it('serves v5 dashboard applications as canvas nodes', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($team, 'Staging Project', 'Staging');
$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' => [],
]);
$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' => 'abc123',
'mesh_namespace' => 'default',
'canvas_x' => 120,
'canvas_y' => -80,
]);
$application = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $otherProject->id,
'environment_id' => $otherEnvironment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'other-nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-other',
'status' => 'running',
]);
$this
->actingAs($user)
->withSession([
'currentTeam' => $team,
'v5.selectedProjectUuid' => $project->uuid,
'v5.selectedEnvironmentUuid' => $environment->uuid,
])
->get('/v5')
->assertSuccessful()
->assertSee('"applications":[', false)
->assertSee('"name":"nginx-test"', false)
->assertSee('"serverName":"edge-01"', false)
->assertSee('"meshNamespace":"default"', false)
->assertSee('"meshFqdn":"coolify-v5-nginx-1.default.coolify.internal"', false)
->assertSee('"canvasX":120', false)
->assertSee('"canvasY":-80', false)
->assertDontSee('other-nginx-test', false);
});
it('marks v5 application status as unknown when its server is unreachable', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
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' => [],
]);
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.',
]);
$this
->actingAs($user)
->withSession([
'currentTeam' => $team,
'v5.selectedProjectUuid' => $project->uuid,
'v5.selectedEnvironmentUuid' => $environment->uuid,
])
->get('/v5')
->assertSuccessful()
->assertSee('"status":"running"', false)
->assertSee('"effectiveStatus":"unknown"', false)
->assertSee('"effectiveStatusMessage":"coold heartbeat timed out."', false)
->assertSee('"serverStatus":"unreachable"', false)
->assertSee('"isServerReachable":false', false);
});
it('shows v5 caddy ingress as unreachable when its server is unreachable', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-ingress-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'unreachable',
'ingress_status' => 'running',
'last_status_output' => 'coold heartbeat timed out.',
'capabilities' => ['ingress'],
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('"caddyIngresses":[', false)
->assertSee('"status":"unreachable"', false)
->assertSee('"statusMessage":"coold heartbeat timed out."', false);
});
it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-ingress-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'ingress_status' => 'running',
'capabilities' => ['ingress'],
'canvas_x' => -160,
'canvas_y' => 240,
]);
V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-ingress-02',
'host' => '203.0.113.22',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'ingress_status' => 'exited',
'capabilities' => ['ingress'],
]);
V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-worker-01',
'host' => '203.0.113.21',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => [],
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('"caddyIngresses":[', false)
->assertSee('"name":"edge-ingress-01"', false)
->assertSee('"host":"203.0.113.20"', false)
->assertSee('"type":"caddy"', false)
->assertSee('"status":"running"', false)
->assertSee('"name":"edge-ingress-02"', false)
->assertSee('"status":"exited"', false)
->assertSee('"canvasX":-160', false)
->assertSee('"canvasY":240', false);
});
it('broadcasts manual v5 realtime test events immediately', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
Event::fake([V5RealtimeTestEvent::class]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/realtime-test', [
'message' => 'hello socket',
])
->assertAccepted()
->assertJsonPath('message', 'Realtime test event broadcasted.');
Event::assertDispatched(V5RealtimeTestEvent::class, fn (V5RealtimeTestEvent $event): bool => $event->teamId === $team->id
&& $event->message === 'hello socket'
&& $event->broadcastAs() === 'v5.realtime.test'
&& $event instanceof ShouldBroadcastNow);
});
it('serves the production v5 favicon outside local environments', function () {
app()->detectEnvironment(fn () => 'production');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('coolify-logo.svg', false)
->assertDontSee('coolify-logo-dev-transparent.png', false);
});
it('shares existing projects and environments with the v5 dashboard page', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'gravy-truck', 'production');
createV5ProjectWithEnvironment($team, 'rocket-bike', 'staging');
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('"projects":[', false)
->assertSee('"name":"gravy-truck"', false)
->assertSee('"uuid":"'.$project->uuid.'"', false)
->assertSee('"environments":[', false)
->assertSee('"name":"production"', false)
->assertSee('"uuid":"'.$environment->uuid.'"', false)
->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false)
->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false)
->assertDontSee('"id":"'.$project->id.'","uuid":"'.$project->uuid.'"', false)
->assertDontSee('"id":"'.$environment->id.'","uuid":"'.$environment->uuid.'"', false)
->assertDontSee($otherProject->name)
->assertDontSee($otherProject->uuid)
->assertDontSee($otherEnvironment->name)
->assertDontSee($otherEnvironment->uuid);
});
it('opens a linked v5 application in its project and environment', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'linked-project', 'production');
$application = Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'created_by_user_id' => $user->id,
'name' => 'linked-nginx',
'image' => 'nginx:alpine',
'container_name' => 'linked-v5-nginx',
]);
$this->actingAs($user)
->withSession(['currentTeam' => $team])
->get(route('v5.dashboard', [
'project' => $project->uuid,
'environment' => $environment->uuid,
'application' => $application->uuid,
]))
->assertSuccessful()
->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false)
->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false)
->assertSee('"selectedApplicationUuid":"'.$application->uuid.'"', false);
});
it('persists the selected v5 project and environment in the session', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
createV5ProjectWithEnvironment($team, 'alpha-project', 'production');
[$selectedProject, $selectedEnvironment] = createV5ProjectWithEnvironment($team, 'zebra-project', 'staging');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/selection', [
'project_uuid' => $selectedProject->uuid,
'environment_uuid' => $selectedEnvironment->uuid,
])
->assertNoContent()
->assertSessionHas('v5.selectedProjectUuid', $selectedProject->uuid)
->assertSessionHas('v5.selectedEnvironmentUuid', $selectedEnvironment->uuid);
$this
->actingAs($user)
->get('/v5')
->assertSuccessful()
->assertSee('"selectedProjectUuid":"'.$selectedProject->uuid.'"', false)
->assertSee('"selectedEnvironmentUuid":"'.$selectedEnvironment->uuid.'"', false);
});
it('rejects persisted v5 selections outside the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/selection', [
'project_uuid' => $otherProject->uuid,
'environment_uuid' => $otherEnvironment->uuid,
])
->assertForbidden()
->assertSessionMissing('v5.selectedProjectUuid')
->assertSessionMissing('v5.selectedEnvironmentUuid');
});
it('serves v5 dev assets from the current request host', function (string $url, string $viteHost) {
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$hotFile = public_path('hot');
$originalHotFile = file_exists($hotFile) ? file_get_contents($hotFile) : null;
file_put_contents($hotFile, 'http://configured-vite-host.test:5173');
try {
$response = $this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get($url);
$response
->assertSuccessful()
->assertSee("import RefreshRuntime from 'http://{$viteHost}:5173/@react-refresh'", false)
->assertSee("src=\"http://{$viteHost}:5173/@vite/client\"", false)
->assertSee("src=\"http://{$viteHost}:5173/resources/js/v5/app.tsx\"", false)
->assertDontSee('configured-vite-host.test', false);
} finally {
if ($originalHotFile === null) {
@unlink($hotFile);
} else {
file_put_contents($hotFile, $originalHotFile);
}
}
})->with([
'localhost' => ['http://localhost:8000/v5', 'localhost'],
'tailscale ip' => ['http://100.64.0.10:8000/v5', '100.64.0.10'],
]);
it('configures the vite dev server for remote v5 access', function () {
$viteConfig = file_get_contents(base_path('vite.config.js'));
expect($viteConfig)
->toContain('host: "0.0.0.0"')
->toContain('allowedHosts: true')
->toContain('cors: true')
->toContain('const viteHmrHost = env.VITE_HMR_HOST || null;')
->toContain('hmr: viteHmrHost')
->not->toContain('hmr: viteHost');
});
it('shows when flux is unavailable', function () {
$this->withoutVite();
fakeFluxHealth(false, 'Flux socket was not found.');
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Katherine Johnson',
'email' => 'katherine@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Flux Test Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('Unavailable')
->assertSee('Flux socket was not found.');
});
-153
View File
@@ -1,153 +0,0 @@
<?php
use App\Http\Middleware\V5\EnsureCurrentTeam;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
beforeEach(function () {
Config::set('cache.default', 'array');
Schema::dropIfExists('team_user');
Schema::dropIfExists('teams');
Schema::dropIfExists('users');
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']);
});
});
function createEnsureCurrentTeamUser(string $email = 'margaret@example.com'): User
{
return User::withoutEvents(fn () => User::query()->create([
'name' => 'Margaret Hamilton',
'email' => $email,
'email_verified_at' => now(),
'password' => 'password',
]));
}
function createEnsureCurrentTeamTeam(string $name = 'V5 Tooling Team'): Team
{
return Team::withoutEvents(fn () => Team::query()->create([
'name' => $name,
'description' => 'The team that ships v5.',
'personal_team' => false,
'show_boarding' => false,
]));
}
/**
* Runs the middleware against a request the way the v5.authenticated group
* would: an authenticated user with an active session store.
*
* @return array{0: Response, 1: Request}
*/
function runEnsureCurrentTeam(?User $user): array
{
$request = Request::create('/v5', 'GET');
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(fn () => $user);
$response = (new EnsureCurrentTeam)->handle($request, fn () => response()->noContent());
return [$response, $request];
}
it('aborts 403 when the user belongs to no team', function () {
$user = createEnsureCurrentTeamUser();
expect(fn () => runEnsureCurrentTeam($user))
->toThrow(fn (HttpException $exception) => expect($exception->getStatusCode())->toBe(403));
});
it('falls back to first team when the session team is no longer a member', function () {
$user = createEnsureCurrentTeamUser();
$memberTeam = createEnsureCurrentTeamTeam('Member Team');
$foreignTeam = createEnsureCurrentTeamTeam('Foreign Team');
$user->teams()->attach($memberTeam, ['role' => 'owner']);
session(['currentTeam' => $foreignTeam]);
[, $request] = runEnsureCurrentTeam($user);
expect($request->attributes->get('v5.currentTeam')->id)->toBe($memberTeam->id)
->and(session('currentTeam'))->toBeInstanceOf(Team::class)
->and(session('currentTeam')->id)->toBe($memberTeam->id)
->and(session('currentTeam')->name)->toBe('Member Team');
});
it('stores the full team model so v4 session reads keep working', function () {
$user = createEnsureCurrentTeamUser();
$team = createEnsureCurrentTeamTeam();
$user->teams()->attach($team, ['role' => 'owner']);
runEnsureCurrentTeam($user);
$sessionTeam = session('currentTeam');
// v4 reads columns beyond id/name/description/personal_team off the
// session team (e.g. show_boarding, custom limits), so the stored model
// must not be a partial select.
expect($sessionTeam)->toBeInstanceOf(Team::class)
->and($sessionTeam->getAttributes())->toHaveKeys([
'id',
'name',
'description',
'personal_team',
'show_boarding',
'created_at',
'updated_at',
])
->and($sessionTeam->created_at)->not->toBeNull()
->and((bool) $sessionTeam->show_boarding)->toBeFalse();
});
it('does not rewrite the session when the team is unchanged', function () {
$user = createEnsureCurrentTeamUser();
$team = createEnsureCurrentTeamTeam();
$user->teams()->attach($team, ['role' => 'owner']);
// Prime the session with the same team but a sentinel attribute; if the
// middleware skips the redundant write, the sentinel survives the request.
$sessionTeam = Team::query()->findOrFail($team->id);
$sessionTeam->name = 'Session Sentinel';
session(['currentTeam' => $sessionTeam]);
[, $request] = runEnsureCurrentTeam($user);
expect($request->attributes->get('v5.currentTeam'))->toBeInstanceOf(Team::class)
->and($request->attributes->get('v5.currentTeam')->id)->toBe($team->id)
->and($request->attributes->get('v5.currentTeam')->name)->toBe('V5 Tooling Team')
->and(session('currentTeam'))->toBe($sessionTeam)
->and(session('currentTeam')->name)->toBe('Session Sentinel');
});
-81
View File
@@ -1,81 +0,0 @@
<?php
use App\Services\Flux\AgentTokenIssuer;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
beforeEach(function () {
$this->keyDir = storage_path('framework/testing/flux-keygen-'.bin2hex(random_bytes(4)));
$this->privateKeyPath = $this->keyDir.'/jwt.priv';
$this->publicKeyPath = $this->keyDir.'/jwt.pub';
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
Config::set('flux.jwt_public_key_path', $this->publicKeyPath);
});
afterEach(function () {
File::deleteDirectory($this->keyDir);
});
/**
* @return string octal file/dir mode, e.g. "0600"
*/
function fluxKeyMode(string $path): string
{
clearstatcache();
return sprintf('%04o', fileperms($path) & 0777);
}
it('generates a working ES256 keypair at the configured paths with strict perms', function () {
$this->artisan('v5:flux-generate-keys')->assertSuccessful();
expect(File::exists($this->privateKeyPath))->toBeTrue()
->and(File::exists($this->publicKeyPath))->toBeTrue()
->and(fluxKeyMode($this->privateKeyPath))->toBe('0600')
->and(fluxKeyMode($this->publicKeyPath))->toBe('0644')
->and(fluxKeyMode($this->keyDir))->toBe('0700');
// The generated private key must be usable by the real issuer...
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
expect(substr_count($token, '.'))->toBe(2);
// ...and verifiable with the generated public key (proves the pair matches).
$claims = JWT::decode($token, new Key(File::get($this->publicKeyPath), 'ES256'));
expect($claims->sub)->toBe('100.64.0.10');
});
it('refuses to overwrite an existing private key without --force', function () {
File::ensureDirectoryExists($this->keyDir);
File::put($this->privateKeyPath, 'SENTINEL-PRIVATE-KEY');
$this->artisan('v5:flux-generate-keys')
->expectsOutputToContain('already exists')
->assertSuccessful();
expect(File::get($this->privateKeyPath))->toBe('SENTINEL-PRIVATE-KEY');
});
it('overwrites an existing private key with --force and produces a valid keypair', function () {
File::ensureDirectoryExists($this->keyDir);
File::put($this->privateKeyPath, 'SENTINEL-PRIVATE-KEY');
$this->artisan('v5:flux-generate-keys --force')->assertSuccessful();
expect(File::get($this->privateKeyPath))->not->toBe('SENTINEL-PRIVATE-KEY')
->and(fluxKeyMode($this->privateKeyPath))->toBe('0600');
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
expect(substr_count($token, '.'))->toBe(2);
});
it('prints the public key when --show-public is passed', function () {
$this->artisan('v5:flux-generate-keys --show-public')
->expectsOutputToContain('BEGIN PUBLIC KEY')
->assertSuccessful();
});
-72
View File
@@ -1,72 +0,0 @@
<?php
use App\Models\V5\Server as V5Server;
use Illuminate\Support\Facades\Config;
use Illuminate\Testing\TestResponse;
use Tests\Support\V5TestSchema;
beforeEach(function () {
Config::set('broadcasting.default', 'log');
Config::set('cache.default', 'array');
V5TestSchema::dropAllTables();
V5TestSchema::createAllTables();
V5Server::query()->create([
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.5',
]);
});
afterEach(function () {
V5TestSchema::dropAllTables();
});
function postFluxContainerStatus(string $bearer): TestResponse
{
return test()->postJson('/api/v1/internal/flux/resource-status', [
'resource_type' => 'container',
'host_id' => '100.64.0.5',
'container_id' => 'external-container-id',
'container_name' => 'external-container',
'status' => 'running',
], [
'Authorization' => 'Bearer '.$bearer,
]);
}
it('accepts an inbound token listed in the rotatable token array', function () {
Config::set('flux.laravel_api_token', null);
Config::set('flux.laravel_api_tokens', ['old-token', 'new-token']);
postFluxContainerStatus('new-token')->assertSuccessful();
postFluxContainerStatus('old-token')->assertSuccessful();
});
it('still accepts the single fallback token alongside the array', function () {
Config::set('flux.laravel_api_token', 'single-token');
Config::set('flux.laravel_api_tokens', ['array-token']);
postFluxContainerStatus('single-token')->assertSuccessful();
postFluxContainerStatus('array-token')->assertSuccessful();
});
it('rejects a token that is in neither the array nor the fallback', function () {
Config::set('flux.laravel_api_token', 'single-token');
Config::set('flux.laravel_api_tokens', ['array-token']);
postFluxContainerStatus('unknown-token')->assertUnauthorized();
});
it('rejects an empty bearer token even when no tokens are configured', function () {
Config::set('flux.laravel_api_token', null);
Config::set('flux.laravel_api_tokens', []);
postFluxContainerStatus('')->assertUnauthorized();
});
@@ -1,341 +0,0 @@
<?php
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
use App\Enums\V5\ApplicationStatus;
use App\Enums\V5\ContainerState;
use App\Enums\V5\ServerStatus;
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ContainerStatus;
use App\Models\V5\Server as V5Server;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
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');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_container_statuses');
Schema::dropIfExists('v5_servers');
V5TestSchema::createServersTable();
V5TestSchema::createApplicationsTable();
V5TestSchema::createContainerStatusesTable();
});
afterEach(function () {
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_container_statuses');
Schema::dropIfExists('v5_servers');
});
/**
* @param array<string, mixed> $overrides
*/
function createFluxIngestionServer(array $overrides = []): V5Server
{
return V5Server::query()->create([
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => ServerStatus::Installed->value,
'capabilities' => [],
'wireguard_management_ip' => '100.64.0.5',
...$overrides,
]);
}
/**
* @param array<string, mixed> $overrides
*/
function createFluxIngestionApplication(V5Server $server, array $overrides = []): V5Application
{
return V5Application::query()->create([
'team_id' => $server->team_id,
'project_id' => 1,
'environment_id' => 1,
'server_id' => $server->id,
'created_by_user_id' => 1,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-1',
'status' => ApplicationStatus::Running->value,
'status_message' => 'Container started.',
'runtime_container_id' => 'nginx-container-id',
...$overrides,
]);
}
it('maps unknown flux application status values to unknown and logs a warning', function () {
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
Log::spy();
$server = createFluxIngestionServer();
$application = createFluxIngestionApplication($server);
ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'nginx-container-id',
'status' => 'Exploded',
]);
expect($application->refresh()->status)->toBe(ApplicationStatus::Unknown->value);
Log::shouldHaveReceived('warning')
->withArgs(fn (string $message, array $context = []): bool => str_contains($message, 'unknown flux resource status')
&& ($context['raw_status'] ?? null) === 'Exploded'
&& ($context['status_enum'] ?? null) === ApplicationStatus::class)
->once();
});
it('maps unknown flux container states to unknown when upserting container statuses', function () {
Log::spy();
$server = createFluxIngestionServer();
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'container',
'host_id' => '100.64.0.5',
'container_id' => 'external-container-id',
'container_name' => 'external-container',
'status' => 'levitating',
]);
expect($resource)->toBeInstanceOf(ContainerStatus::class)
->and($resource->status)->toBe(ContainerState::Unknown->value);
Log::shouldHaveReceived('warning')
->withArgs(fn (string $message, array $context = []): bool => ($context['raw_status'] ?? null) === 'levitating'
&& ($context['status_enum'] ?? null) === ContainerState::class)
->once();
});
it('still applies known statuses case insensitively without warnings', function () {
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
Log::spy();
$server = createFluxIngestionServer();
$application = createFluxIngestionApplication($server);
ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'nginx-container-id',
'status' => 'EXITED',
]);
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value);
Log::shouldNotHaveReceived('warning');
});
it('drops flux application status updates that are older than the persisted observation', function () {
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
Log::spy();
$server = createFluxIngestionServer();
$application = createFluxIngestionApplication($server, [
'status_observed_at' => '2026-07-05T12:00:00Z',
]);
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'nginx-container-id',
'status' => 'exited',
'observed_at' => '2026-07-05T11:00:00Z',
]);
expect($resource)->toBeInstanceOf(V5Application::class)
->and($application->refresh()->status)->toBe(ApplicationStatus::Running->value)
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
Log::shouldHaveReceived('debug')
->withArgs(fn (string $message): bool => str_contains($message, 'stale flux application status'))
->once();
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
});
it('applies flux application status updates with a newer observation and persists the timestamp', function () {
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createFluxIngestionServer();
$application = createFluxIngestionApplication($server, [
'status_observed_at' => '2026-07-05T10:00:00Z',
]);
ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'nginx-container-id',
'status' => 'exited',
'status_message' => 'Container exited.',
'observed_at' => '2026-07-05T12:00:00Z',
]);
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value)
->and($application->status_message)->toBe('Container exited.')
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
Event::assertDispatched(V5CanvasResourceUpdated::class);
});
it('keeps applying flux application status updates that carry no observation timestamp', function () {
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createFluxIngestionServer();
$application = createFluxIngestionApplication($server, [
'status_observed_at' => '2026-07-05T12:00:00Z',
]);
ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'nginx-container-id',
'status' => 'exited',
]);
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value)
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
});
it('drops stale flux server status updates but applies newer observations', function () {
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
Log::spy();
$server = createFluxIngestionServer([
'status_observed_at' => '2026-07-05T12:00:00Z',
]);
ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'server',
'host_id' => '100.64.0.5',
'status' => 'unreachable',
'observed_at' => '2026-07-05T11:59:59Z',
]);
expect($server->refresh()->status)->toBe(ServerStatus::Installed->value);
Log::shouldHaveReceived('debug')
->withArgs(fn (string $message): bool => str_contains($message, 'stale flux server status'))
->once();
ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'server',
'host_id' => '100.64.0.5',
'status' => 'unreachable',
'observed_at' => '2026-07-05T12:00:01Z',
]);
expect($server->refresh()->status)->toBe(ServerStatus::Unreachable->value)
->and($server->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:01+00:00');
});
it('drops stale flux container status updates that are older than the persisted observation', function () {
Log::spy();
$server = createFluxIngestionServer();
ContainerStatus::query()->create([
'team_id' => $server->team_id,
'server_id' => $server->id,
'container_id' => 'external-container-id',
'container_name' => 'external-container',
'status' => ContainerState::Running->value,
'status_observed_at' => '2026-07-05T12:00:00Z',
]);
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'container',
'host_id' => '100.64.0.5',
'container_id' => 'external-container-id',
'container_name' => 'external-container',
'status' => 'exited',
'observed_at' => '2026-07-05T11:00:00Z',
]);
expect($resource)->toBeInstanceOf(ContainerStatus::class)
->and($resource->status)->toBe(ContainerState::Running->value);
Log::shouldHaveReceived('debug')
->withArgs(fn (string $message): bool => str_contains($message, 'stale flux container status'))
->once();
});
it('logs a warning when a flux host id matches multiple v5 servers', function () {
Log::spy();
$serverOne = createFluxIngestionServer();
$serverTwo = createFluxIngestionServer([
'name' => 'edge-02',
'host' => '203.0.113.11',
]);
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'server',
'host_id' => '100.64.0.5',
'status' => 'unreachable',
]);
expect($resource)->toBeNull()
->and($serverOne->refresh()->status)->toBe(ServerStatus::Installed->value)
->and($serverTwo->refresh()->status)->toBe(ServerStatus::Installed->value);
Log::shouldHaveReceived('warning')
->withArgs(fn (string $message, array $context = []): bool => str_contains($message, 'matches multiple v5 servers')
&& ($context['host_id'] ?? null) === '100.64.0.5'
&& ($context['server_ids'] ?? null) === [$serverOne->id, $serverTwo->id])
->once();
});
it('accepts flux resource status http payloads with a valid observed_at timestamp', function () {
Config::set('flux.laravel_api_token', 'test-flux-token');
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createFluxIngestionServer();
$application = createFluxIngestionApplication($server);
$this
->withToken('test-flux-token')
->postJson('/api/v1/internal/flux/resource-status', [
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'nginx-container-id',
'status' => 'exited',
'observed_at' => '2026-07-05T12:00:00Z',
])
->assertSuccessful()
->assertJsonPath('message', 'Resource status updated.');
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value)
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
});
it('rejects flux resource status http payloads with an invalid observed_at value', function () {
Config::set('flux.laravel_api_token', 'test-flux-token');
$this
->withToken('test-flux-token')
->postJson('/api/v1/internal/flux/resource-status', [
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'status' => 'exited',
'observed_at' => 'not-a-timestamp',
])
->assertInvalid(['observed_at']);
});
@@ -1,36 +0,0 @@
<?php
/**
* @param string|array<int, string> $queue
* @return array<int, string>
*/
function horizonSupervisorQueues(string|array $queue): array
{
$queues = is_array($queue) ? $queue : explode(',', $queue);
return array_map('trim', $queues);
}
it('defines a horizon supervisor that consumes the v5-reconcile queue', function () {
$supervisors = config('horizon.defaults');
$handlesReconcile = collect($supervisors)->contains(
fn (array $config): bool => in_array('v5-reconcile', horizonSupervisorQueues($config['queue'] ?? ''), true)
);
expect($handlesReconcile)->toBeTrue();
});
it('provisions the v5-reconcile supervisor in both production and local environments', function () {
expect(config('horizon.environments.production.v5reconcile'))->toBeArray()
->and(config('horizon.environments.local.v5reconcile'))->toBeArray()
->and(config('horizon.defaults.v5reconcile.queue'))->toBe('v5-reconcile');
});
it('keeps the v5-reconcile supervisor isolated from the user-facing deploy queues', function () {
$reconcileQueues = horizonSupervisorQueues(config('horizon.defaults.v5reconcile.queue'));
expect($reconcileQueues)->not->toContain('high')
->and($reconcileQueues)->not->toContain('default')
->and(config('horizon.defaults.v5reconcile.nice'))->toBeGreaterThan(0);
});
-295
View File
@@ -1,295 +0,0 @@
<?php
use App\Jobs\V5ReconcileServersJob;
use App\Jobs\V5ReconcileServerStateJob;
use App\Models\Application;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ApplicationDomain;
use App\Models\V5\ContainerStatus;
use App\Models\V5\ResourceConnection;
use App\Models\V5\ResourceConnectionRule;
use App\Models\V5\Server as V5Server;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
beforeEach(function () {
Config::set('broadcasting.default', 'log');
Config::set('cache.default', 'array');
dropModelHygieneTables();
createModelHygieneTables();
});
afterEach(function () {
dropModelHygieneTables();
});
it('registers the v5 application morph alias without enforcing a global morph map', function () {
expect((new V5Application)->getMorphClass())->toBe('v5.application')
->and(Relation::getMorphedModel('v5.application'))->toBe(V5Application::class)
// v4 models keep storing FQCNs — the map must not be enforced.
->and((new Application)->getMorphClass())->toBe(Application::class);
});
it('stores v5 resource connections with the morph alias and resolves it back', function () {
$source = createModelHygieneApplication('api');
$target = createModelHygieneApplication('postgres');
$connection = ResourceConnection::query()->create([
'team_id' => 1,
'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' => collect([
$source->getMorphClass().':'.$source->getKey(),
$target->getMorphClass().':'.$target->getKey(),
])->sort()->implode('|'),
'created_by_user_id' => 1,
]);
$storedRow = DB::table('v5_resource_connections')->where('id', $connection->id)->first();
expect($storedRow->resource_one_type)->toBe('v5.application')
->and($storedRow->resource_two_type)->toBe('v5.application')
->and($storedRow->resource_pair_key)->toBe("v5.application:{$source->id}|v5.application:{$target->id}")
->and($connection->refresh()->resourceOne)->toBeInstanceOf(V5Application::class)
->and($connection->resourceOne->id)->toBe($source->id)
->and($connection->resourceTwo->id)->toBe($target->id);
});
it('rewrites legacy fqcn morph rows to the v5 application alias', function () {
$fqcn = 'App\Models\V5\Application';
DB::table('v5_resource_connections')->insert([
'uuid' => 'legacy-connection',
'team_id' => 1,
'project_id' => 1,
'environment_id' => 1,
'resource_one_type' => $fqcn,
'resource_one_id' => 1,
'resource_two_type' => $fqcn,
'resource_two_id' => 2,
'resource_pair_key' => "{$fqcn}:1|{$fqcn}:2",
'created_by_user_id' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('v5_resource_connection_rules')->insert([
'connection_id' => 1,
'source_resource_type' => $fqcn,
'source_resource_id' => 1,
'target_resource_type' => $fqcn,
'target_resource_id' => 2,
'protocol' => 'tcp',
'port' => 5432,
'created_at' => now(),
'updated_at' => now(),
]);
$migration = include database_path('migrations-v5/2026_07_06_090000_v5_convert_resource_connection_morphs_to_aliases.php');
$migration->up();
$connection = DB::table('v5_resource_connections')->where('uuid', 'legacy-connection')->first();
$rule = DB::table('v5_resource_connection_rules')->first();
expect($connection->resource_one_type)->toBe('v5.application')
->and($connection->resource_two_type)->toBe('v5.application')
->and($connection->resource_pair_key)->toBe('v5.application:1|v5.application:2')
->and($rule->source_resource_type)->toBe('v5.application')
->and($rule->target_resource_type)->toBe('v5.application');
});
it('maps the capabilities array onto the indexed booleans', function () {
$server = new V5Server(['capabilities' => ['ingress']]);
expect($server->is_ingress)->toBeTrue()
->and($server->has_coold)->toBeFalse()
->and($server->isIngress())->toBeTrue()
->and($server->hasCapability('ingress'))->toBeTrue()
->and($server->hasCapability('coold'))->toBeFalse()
->and($server->hasCapability('builder'))->toBeFalse()
->and($server->capabilities)->toBe(['ingress']);
$server->has_coold = true;
expect($server->capabilities)->toBe(['coold', 'ingress']);
$server->capabilities = [];
expect($server->has_coold)->toBeFalse()
->and($server->is_ingress)->toBeFalse()
->and($server->capabilities)->toBe([]);
});
it('persists capabilities writes into the boolean columns', function () {
$server = createModelHygieneServer(['capabilities' => ['coold']]);
$storedRow = DB::table('v5_servers')->where('id', $server->id)->first();
expect((bool) $storedRow->has_coold)->toBeTrue()
->and((bool) $storedRow->is_ingress)->toBeFalse()
->and($server->fresh()->capabilities)->toBe(['coold']);
});
it('backfills the capability booleans from the legacy json column', function () {
Schema::dropIfExists('v5_servers');
Schema::create('v5_servers', function ($table) {
$table->id();
$table->string('uuid')->nullable()->unique();
$table->string('name');
$table->json('capabilities')->nullable();
$table->timestamps();
});
DB::table('v5_servers')->insert([
['name' => 'full', 'capabilities' => json_encode(['coold', 'ingress'])],
['name' => 'ingress-only', 'capabilities' => json_encode(['ingress'])],
['name' => 'unknown-only', 'capabilities' => json_encode(['builder'])],
['name' => 'empty', 'capabilities' => null],
]);
$migration = include database_path('migrations-v5/2026_07_06_090100_v5_convert_server_capabilities_to_booleans.php');
$migration->up();
$serversByName = DB::table('v5_servers')->get()->keyBy('name');
expect(Schema::hasColumn('v5_servers', 'capabilities'))->toBeFalse()
->and((bool) $serversByName['full']->has_coold)->toBeTrue()
->and((bool) $serversByName['full']->is_ingress)->toBeTrue()
->and((bool) $serversByName['ingress-only']->has_coold)->toBeFalse()
->and((bool) $serversByName['ingress-only']->is_ingress)->toBeTrue()
->and((bool) $serversByName['unknown-only']->has_coold)->toBeFalse()
->and((bool) $serversByName['unknown-only']->is_ingress)->toBeFalse()
->and((bool) $serversByName['empty']->has_coold)->toBeFalse()
->and((bool) $serversByName['empty']->is_ingress)->toBeFalse();
});
it('selects reconcile-eligible servers through the indexed has_coold column', function () {
Queue::fake();
$eligible = createModelHygieneServer(['name' => 'coold-node', 'host' => '203.0.113.10', 'has_coold' => true]);
createModelHygieneServer(['name' => 'plain-node', 'host' => '203.0.113.11', 'has_coold' => false]);
DB::enableQueryLog();
(new V5ReconcileServersJob)->handle();
$queries = collect(DB::getQueryLog())->pluck('query')->implode(' ');
DB::disableQueryLog();
DB::flushQueryLog();
expect($queries)->toContain('has_coold');
Queue::assertPushed(V5ReconcileServerStateJob::class, 1);
Queue::assertPushed(
V5ReconcileServerStateJob::class,
fn (V5ReconcileServerStateJob $job) => $job->serverId === $eligible->id,
);
});
it('generates uuids only for models whose tables have a uuid column', function () {
$server = createModelHygieneServer();
expect($server->uuid)->not->toBeNull()
->and($server->getRouteKeyName())->toBe('uuid');
// v5_container_statuses has no uuid column: the insert would fail if the
// base model still tried to assign one.
$containerStatus = ContainerStatus::query()->create([
'team_id' => 1,
'server_id' => $server->id,
'container_id' => 'abc123',
'status' => 'running',
]);
expect($containerStatus->getAttribute('uuid'))->toBeNull()
->and($containerStatus->getRouteKeyName())->toBe('id')
->and((new ApplicationDomain)->getRouteKeyName())->toBe('id')
->and((new ResourceConnectionRule)->getRouteKeyName())->toBe('id');
});
it('keeps explicitly provided uuids', function () {
$server = createModelHygieneServer(['uuid' => 'chosen-uuid']);
expect($server->uuid)->toBe('chosen-uuid');
});
it('regenerates a colliding public id before inserting', function () {
createModelHygieneServer(['uuid' => 'taken-uuid']);
$colliding = new class extends V5Server
{
/** @var array<int, string> */
public array $candidates = ['taken-uuid', 'fresh-uuid'];
protected function newPublicIdCandidate(): string
{
return array_shift($this->candidates);
}
};
$colliding->forceFill([
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'collision-node',
'host' => '203.0.113.99',
'ssh_user' => 'root',
'ssh_port' => 22,
])->save();
expect($colliding->uuid)->toBe('fresh-uuid');
});
function dropModelHygieneTables(): void
{
Schema::dropIfExists('v5_resource_connection_rules');
Schema::dropIfExists('v5_resource_connections');
Schema::dropIfExists('v5_application_domains');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_container_statuses');
Schema::dropIfExists('v5_servers');
}
function createModelHygieneTables(): void
{
V5TestSchema::createServersTable();
V5TestSchema::createContainerStatusesTable();
V5TestSchema::createApplicationsTable();
V5TestSchema::createApplicationDomainsTable();
V5TestSchema::createResourceConnectionsTable();
V5TestSchema::createResourceConnectionRulesTable();
}
/**
* @param array<string, mixed> $overrides
*/
function createModelHygieneServer(array $overrides = []): V5Server
{
return V5Server::query()->create([
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'edge-01',
'host' => '203.0.113.'.random_int(2, 250),
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
...$overrides,
]);
}
function createModelHygieneApplication(string $name): V5Application
{
return V5Application::query()->create([
'team_id' => 1,
'project_id' => 1,
'environment_id' => 1,
'created_by_user_id' => 1,
'name' => $name,
'image' => "docker.io/library/{$name}:latest",
'container_name' => "coolify-v5-{$name}",
]);
}
@@ -1,397 +0,0 @@
<?php
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Jobs\V5ReconcileServersJob;
use App\Jobs\V5ReconcileServerStateJob;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ContainerStatus;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
beforeEach(function () {
Config::set('broadcasting.default', 'log');
Config::set('cache.default', 'array');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_container_statuses');
Schema::dropIfExists('v5_servers');
});
function createReconcileV5Tables(): void
{
V5TestSchema::createServersTable();
V5TestSchema::createContainerStatusesTable();
V5TestSchema::createApplicationsTable();
}
/**
* @param array<string, mixed> $attributes
*/
function createReconcileServer(array $attributes = []): V5Server
{
return V5Server::query()->create([
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'wireguard_management_ip' => '100.64.0.10',
...$attributes,
]);
}
/**
* @param array<string, mixed> $attributes
*/
function createReconcileApplication(V5Server $server, array $attributes = []): V5Application
{
return V5Application::query()->create([
'team_id' => $server->team_id,
'project_id' => 1,
'environment_id' => 1,
'server_id' => $server->id,
'created_by_user_id' => 1,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'status' => 'running',
'runtime_container_id' => 'container-live',
...$attributes,
]);
}
it('marks a stale running application exited when its container is gone', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer();
$application = createReconcileApplication($server);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->with($server->fluxHostId())->andReturn([]);
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($application->refresh())
->status->toBe('exited')
->status_message->toBe('Container not found on server during reconcile.');
expect($server->refresh())
->status->toBe('installed')
->last_status_check->toBe('reconcile')
->last_status_checked_at->not->toBeNull();
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->applicationId === $application->id);
});
it('updates application status from the observed container state and refreshes container statuses', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer();
$application = createReconcileApplication($server, ['runtime_container_id' => null]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
[
'id' => 'container-live',
'name' => 'coolify-v5-nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'state' => 'Restarting',
],
]);
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($application->refresh())
->status->toBe('restarting')
->runtime_container_id->toBe('container-live');
$containerStatus = ContainerStatus::query()
->where('server_id', $server->id)
->where('container_id', 'container-live')
->first();
expect($containerStatus)->not->toBeNull()
->and($containerStatus->status)->toBe('restarting')
->and($containerStatus->last_seen_at)->not->toBeNull();
});
it('leaves a creating application without a container id alone during reconcile', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer();
$application = createReconcileApplication($server, [
'status' => 'creating',
'runtime_container_id' => null,
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->andReturn([]);
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($application->refresh())->status->toBe('creating');
});
it('marks an installed server unreachable when the node cannot be reached', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer();
$application = createReconcileApplication($server);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->andThrow(new RuntimeException('Flux did not return a response before the timeout.'));
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($server->refresh())
->status->toBe('unreachable')
->last_status_check->toBe('reconcile')
->last_status_output->toContain('Flux did not return a response');
expect($application->refresh())->status->toBe('running');
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->serverId === $server->id);
});
it('does not mark a failed server unreachable when the node cannot be reached', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer(['status' => 'failed']);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->andThrow(new RuntimeException('boom'));
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($server->refresh())
->status->toBe('failed')
->last_status_check->toBe('reconcile');
});
it('restores an unreachable server to installed on a successful reconcile', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer(['status' => 'unreachable']);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->andReturn([]);
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($server->refresh())
->status->toBe('installed')
->last_status_check->toBe('reconcile');
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->serverId === $server->id);
});
it('dispatches per-server reconcile jobs only for managed servers', function () {
createReconcileV5Tables();
Queue::fake();
$managed = createReconcileServer(['host' => '203.0.113.10', 'wireguard_management_ip' => '100.64.0.10']);
$unreachable = createReconcileServer([
'name' => 'edge-02',
'host' => '203.0.113.11',
'wireguard_management_ip' => '100.64.0.11',
'status' => 'unreachable',
]);
$withoutCoold = createReconcileServer([
'name' => 'edge-03',
'host' => '203.0.113.12',
'wireguard_management_ip' => '100.64.0.12',
'capabilities' => [],
]);
$notInstalled = createReconcileServer([
'name' => 'edge-04',
'host' => '203.0.113.13',
'wireguard_management_ip' => '100.64.0.13',
'status' => 'added',
]);
(new V5ReconcileServersJob)->handle();
Queue::assertPushed(V5ReconcileServerStateJob::class, 2);
Queue::assertPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $managed->id);
Queue::assertPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $unreachable->id);
Queue::assertNotPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $withoutCoold->id);
Queue::assertNotPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $notInstalled->id);
});
it('drops a reconcile write when the stored observation is newer than the snapshot', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer();
$application = createReconcileApplication($server, [
'status' => 'running',
'runtime_container_id' => 'container-live',
// A fresher webhook already advanced the watermark past this pull.
'status_observed_at' => now()->addMinutes(5),
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'exited'],
]);
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($application->refresh())
->status->toBe('running')
->status_observed_at->not->toBeNull();
Event::assertNotDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->applicationId === $application->id);
});
it('advances status_observed_at when reconcile writes an application status', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer();
$application = createReconcileApplication($server, [
'status' => 'running',
'runtime_container_id' => 'container-live',
'status_observed_at' => null,
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'restarting'],
]);
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($application->refresh())
->status->toBe('restarting')
->status_observed_at->not->toBeNull();
});
it('normalizes the coold configured state to a valid status on reconcile', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer();
$application = createReconcileApplication($server);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'configured'],
]);
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($application->refresh()->status)->toBe('configured');
$containerStatus = ContainerStatus::query()
->where('server_id', $server->id)
->where('container_id', 'container-live')
->first();
expect($containerStatus)->not->toBeNull()
->and($containerStatus->status)->toBe('configured');
});
it('normalizes the coold configured state on the flux webhook path', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer();
$application = createReconcileApplication($server, ['runtime_container_id' => 'container-live']);
ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'server_uuid' => $server->uuid,
'application_uuid' => $application->uuid,
'status' => 'configured',
'runtime_container_id' => 'container-live',
]);
expect($application->refresh()->status)->toBe('configured');
});
it('does not broadcast when reconcile finds the application status unchanged', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server = createReconcileServer();
$application = createReconcileApplication($server, [
'status' => 'running',
'runtime_container_id' => 'container-live',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'running'],
]);
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
expect($application->refresh()->status)->toBe('running');
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
});
it('runs the reconcile jobs on the dedicated v5-reconcile queue', function () {
expect((new V5ReconcileServersJob)->queue)->toBe('v5-reconcile')
->and((new V5ReconcileServerStateJob(1))->queue)->toBe('v5-reconcile');
});
it('prunes stale and orphaned container statuses but keeps fresh and live rows', function () {
createReconcileV5Tables();
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
Queue::fake();
$server = createReconcileServer();
createReconcileApplication($server, ['runtime_container_id' => 'container-live']);
$staleRow = ContainerStatus::query()->create([
'team_id' => 1,
'server_id' => $server->id,
'container_id' => 'container-stale',
'status' => 'running',
'last_seen_at' => now()->subHours(V5ReconcileServersJob::CONTAINER_STATUS_TTL_HOURS + 1),
]);
$staleButLiveRow = ContainerStatus::query()->create([
'team_id' => 1,
'server_id' => $server->id,
'container_id' => 'container-live',
'status' => 'running',
'last_seen_at' => now()->subHours(V5ReconcileServersJob::CONTAINER_STATUS_TTL_HOURS + 1),
]);
$freshRow = ContainerStatus::query()->create([
'team_id' => 1,
'server_id' => $server->id,
'container_id' => 'container-fresh',
'status' => 'running',
'last_seen_at' => now()->subHour(),
]);
$orphanedServerRow = ContainerStatus::query()->create([
'team_id' => 1,
'server_id' => $server->id + 999,
'container_id' => 'container-orphaned',
'status' => 'running',
'last_seen_at' => now()->subHour(),
]);
(new V5ReconcileServersJob)->handle();
expect(ContainerStatus::query()->find($staleRow->id))->toBeNull()
->and(ContainerStatus::query()->find($orphanedServerRow->id))->toBeNull()
->and(ContainerStatus::query()->find($staleButLiveRow->id))->not->toBeNull()
->and(ContainerStatus::query()->find($freshRow->id))->not->toBeNull();
});
@@ -1,111 +0,0 @@
<?php
use App\Actions\V5\Server\RemoveBootstrapMarker;
use App\Models\V5\Server as V5Server;
use Illuminate\Support\Facades\Process;
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
});
/**
* @param array<string, mixed> $attributes
*/
function createRemoveMarkerServer(array $attributes = []): V5Server
{
[$user, $team] = createV5UserWithTeam();
return V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 2222,
'status' => 'installed',
...$attributes,
]);
}
/**
* @param array<int, mixed> $command
*/
function removeMarkerSshKeyPath(array $command): ?string
{
$index = array_search('-i', $command, true);
return $index === false ? null : ($command[$index + 1] ?? null);
}
it('returns false and runs no ssh command when the server has no private key', function () {
Process::fake();
$server = createRemoveMarkerServer(['private_key_id' => null]);
expect(RemoveBootstrapMarker::run($server))->toBeFalse();
Process::assertNothingRan();
});
it('removes the on-host bootstrap identity over ssh and deletes the temporary key file', function () {
Process::fake(['*' => Process::result(output: '')]);
$server = createRemoveMarkerServer();
$privateKey = createV5PrivateKey($server->team, 'Marker Removal Key');
$server->update(['private_key_id' => $privateKey->id]);
expect(RemoveBootstrapMarker::run($server->fresh()))->toBeTrue();
$keyPath = null;
Process::assertRan(function ($process) use (&$keyPath): bool {
$command = $process->command;
if (! is_array($command) || ($command[0] ?? null) !== 'ssh') {
return false;
}
$script = end($command);
if (! str_contains($script, '$SUDO rm -f /etc/coolify/v5-node.json /etc/coolify/host-jwt /etc/systemd/system/coold.service.d/10-flux.conf')) {
return false;
}
if (! in_array('root@203.0.113.10', $command, true) || ! in_array('2222', $command, true)) {
return false;
}
$keyPath = removeMarkerSshKeyPath($command);
return true;
});
expect($keyPath)->not->toBeNull()
->and(file_exists($keyPath))->toBeFalse();
});
it('returns false but still deletes the temporary key file when the ssh command fails', function () {
Process::fake(['*' => Process::result(errorOutput: 'connection refused', exitCode: 255)]);
$server = createRemoveMarkerServer();
$privateKey = createV5PrivateKey($server->team, 'Marker Removal Key');
$server->update(['private_key_id' => $privateKey->id]);
expect(RemoveBootstrapMarker::run($server->fresh()))->toBeFalse();
$keyPath = null;
Process::assertRan(function ($process) use (&$keyPath): bool {
if (! is_array($process->command) || ($process->command[0] ?? null) !== 'ssh') {
return false;
}
$keyPath = removeMarkerSshKeyPath($process->command);
return true;
});
expect($keyPath)->not->toBeNull()
->and(file_exists($keyPath))->toBeFalse();
});
@@ -1,626 +0,0 @@
<?php
use App\Models\V5\Application as V5Application;
use App\Models\V5\Cluster;
use App\Models\V5\ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Facades\Exceptions;
beforeEach(function () {
resetV5DashboardTestState();
});
it('uses v5 resource uuids for resource connection requests and responses', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$server = V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => [],
'wireguard_management_ip' => '100.64.0.10',
]);
$source = V5Application::query()->create([
'uuid' => 'source-application-uuid',
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'source',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'source-container',
'status' => 'running',
]);
$target = V5Application::query()->create([
'uuid' => 'target-application-uuid',
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'target',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'target-container',
'status' => 'running',
]);
$response = $this
->actingAs($user)
->withSession([
'currentTeam' => $team,
'v5.selectedProjectUuid' => $project->uuid,
'v5.selectedEnvironmentUuid' => $environment->uuid,
])
->postJson('/v5/resource-connections', [
'resource_one' => ['type' => 'application', 'uuid' => $source->uuid],
'resource_two' => ['type' => 'application', 'uuid' => $target->uuid],
])
->assertCreated()
->assertJsonPath('connection.fromApplicationId', $source->uuid)
->assertJsonPath('connection.toApplicationId', $target->uuid);
$connectionUuid = $response->json('connection.id');
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('applyFirewallRule')->once()->andReturn('Firewall rule applied.');
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/resource-connections/{$connectionUuid}", [
'ports_by_direction' => [
"{$source->uuid}->{$target->uuid}" => [8080],
],
])
->assertSuccessful()
->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '8080');
expect(ResourceConnection::query()->first())
->resource_one_id->toBe($source->id)
->resource_two_id->toBe($target->id);
});
it('persists generic v5 resource connections and direction-specific ports', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
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' => [],
'wireguard_management_ip' => '100.64.0.10',
]);
$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' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-source',
'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' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-target',
'status' => 'running',
]);
$response = $this
->actingAs($user)
->withSession([
'currentTeam' => $team,
'v5.selectedProjectUuid' => $project->uuid,
'v5.selectedEnvironmentUuid' => $environment->uuid,
'_token' => 'test-csrf-token',
])
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
->postJson('/v5/resource-connections', [
'resource_one' => ['type' => 'application', 'uuid' => $source->uuid],
'resource_two' => ['type' => 'application', 'uuid' => $target->uuid],
])
->assertCreated()
->assertJsonPath('connection.applicationIds.0', $source->uuid)
->assertJsonPath('connection.applicationIds.1', $target->uuid);
$connectionId = $response->json('connection.id');
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('applyFirewallRule')->twice()->andReturn('Firewall rule applied.');
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/{$connectionId}", [
'ports_by_direction' => [
"{$source->uuid}->{$target->uuid}" => [80],
"{$target->uuid}->{$source->uuid}" => [443],
],
])
->assertSuccessful()
->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '80')
->assertJsonPath("connection.portsByDirection.{$target->uuid}->{$source->uuid}.0", '443');
$this
->actingAs($user)
->withSession([
'currentTeam' => $team,
'v5.selectedProjectUuid' => $project->uuid,
'v5.selectedEnvironmentUuid' => $environment->uuid,
])
->get('/v5')
->assertSuccessful()
->assertSee('"resourceConnections":[', false)
->assertSee("\"id\":\"{$connectionId}\"", false)
->assertSee("\"{$source->uuid}->{$target->uuid}\":[\"80\"]", false)
->assertSee("\"{$target->uuid}->{$source->uuid}\":[\"443\"]", false);
});
it('reports flux failures when syncing v5 resource connection firewall rules', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
createSharedUserAndTeamTables();
Exceptions::fake();
[$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,
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->andThrow(new RuntimeException('resolve firewall endpoint coolify-v5-api on coolify-default-mesh'));
// The compensation pass rolls the node back by revoking the attempted rule.
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with(Mockery::type('string'), "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432")
->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->uuid}", [
'ports_by_direction' => [
"{$source->uuid}->{$target->uuid}" => [5432],
],
])
->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()->count())->toBe(0);
// The raw Flux/coold error is never leaked to the client, only reported server-side.
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'resolve firewall endpoint coolify-v5-api on coolify-default-mesh');
});
it('syncs cross-server v5 resource connection ports on both endpoint hosts', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
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',
]);
$sourceServer = 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' => [],
]);
$targetServer = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'edge-02',
'host' => '203.0.113.11',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.11',
'capabilities' => [],
]);
$source = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $sourceServer->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' => $targetServer->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";
$expectedRule = [
'id' => $firewallRuleId,
'namespace' => 'default',
'src' => 'coolify-v5-api',
'dst' => 'coolify-v5-postgres',
'proto' => 'tcp',
'port' => 5432,
];
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with(Mockery::type('string'), $expectedRule)
->andReturn('Firewall rule applied on source host.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with(Mockery::type('string'), $expectedRule)
->andReturn('Firewall rule applied on target host.');
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->uuid}", [
'ports_by_direction' => [
"{$source->uuid}->{$target->uuid}" => [5432],
],
])
->assertSuccessful();
});
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(Mockery::type('string'), [
'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(Mockery::type('string'), $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->uuid}", [
'ports_by_direction' => [
"{$source->uuid}->{$target->uuid}" => [5432],
],
])
->assertSuccessful();
$this
->actingAs($user)
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
->deleteJson("/v5/resource-connections/{$connection->uuid}")
->assertNoContent();
});
it('rejects firewall sync when a connected application has no server host id', function () {
createSharedUserAndTeamTables();
Exceptions::fake();
[$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' => [],
]);
$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',
'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',
'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,
]);
$this
->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' => [
"{$source->uuid}->{$target->uuid}" => [5432],
],
])
->assertStatus(502)
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
expect($connection->rules()->count())->toBe(0);
});
it('still deletes a v5 resource connection whose endpoint lost its server host id', function () {
createSharedUserAndTeamTables();
Exceptions::fake();
[$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' => [],
]);
$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',
'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',
'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,
]);
$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,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
->deleteJson("/v5/resource-connections/{$connection->uuid}")
->assertNoContent();
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse();
});
@@ -1,370 +0,0 @@
<?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');
}
File diff suppressed because it is too large. Load diff
@@ -1,920 +0,0 @@
<?php
use App\Events\V5ClusterUpdated;
use App\Jobs\V5BootstrapServerJob;
use App\Models\V5\Cluster;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\AgentTokenIssuer;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
resetV5DashboardTestState();
});
it('broadcasts v5 cluster updates when bootstrap state changes', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Queue::fake();
Event::fake([V5ClusterUpdated::class]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
->assertAccepted();
Event::assertDispatched(V5ClusterUpdated::class, fn ($event): bool => $event->clusterId === $cluster->id
&& $event->teamId === $team->id);
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
expect(Event::dispatched(V5ClusterUpdated::class)->count())->toBeGreaterThanOrEqual(2);
});
it('writes quiet ssh options for v5 bootstrap ssh config', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
]);
$server->load('privateKey');
$tempDirectory = storage_path('framework/testing/v5_bootstrap_'.str()->random(8));
mkdir($tempDirectory, 0700, true);
try {
$job = new V5BootstrapServerJob($cluster->id, $server->id);
$reflection = new ReflectionClass($job);
$method = $reflection->getMethod('writeBootstrapSshConfig');
$method->setAccessible(true);
$sshConfigLocation = $method->invoke($job, collect([$server]), $tempDirectory);
$config = file_get_contents($sshConfigLocation);
expect($config)->toContain(' LogLevel ERROR')
->and($config)->toContain(' UserKnownHostsFile /dev/null')
->and($config)->toContain(' StrictHostKeyChecking no');
} finally {
collect(scandir($tempDirectory) ?: [])
->reject(fn (string $file) => in_array($file, ['.', '..'], true))
->each(fn (string $file) => @unlink($tempDirectory.'/'.$file));
@rmdir($tempDirectory);
}
});
it('bootstraps a single v5 server with the Coolify CLI', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
fakeAgentTokenIssuer();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
'namespaces' => ['default', 'preview'],
'container_network_pool' => '10.211.0.0/16',
'container_network_prefix' => 25,
'wireguard_management_pool' => '100.65.0.0/16',
'wireguard_interface' => 'wg-prod',
'wireguard_listen_port' => 51830,
'coold_version' => 'v0.2.0',
'corrosion_version' => 'v1.1.0',
'corrosion_gossip_port' => 8788,
'corrosion_api_port' => 8081,
'builder_enabled' => true,
'builder_capacity' => 4,
'builder_cpu_quota' => '400%',
'builder_memory_max' => '4G',
'builder_timeout_secs' => 2400,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 2222,
'status' => 'added',
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 4,
'wireguard_listen_port_override' => 51831,
'wireguard_endpoint_override' => 'prod-01.example.com:51831',
]);
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
->assertAccepted()
->assertJsonPath('message', 'Bootstrap queued.')
->assertJsonPath('cluster.servers.0.status', 'added')
->assertJsonPath('cluster.servers.0.lastBootstrapAction', 'bootstrap')
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'queued')
->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Queued Coolify bootstrap for prod-01.');
Queue::assertPushed(V5BootstrapServerJob::class);
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
Process::assertRan(function ($process): bool {
$command = $process->command;
$node = cliFlagValue($command, '--nodes');
return $command[0] === '/tmp/coolify'
&& in_array('init', $command, true)
&& in_array('bootstrap', $command, true)
&& cliFlagValue($command, '--format') === 'json'
&& in_array('--ssh-config', $command, true)
&& cliFlagValue($command, '--ssh-user') === 'root'
&& is_string($node)
&& str_starts_with($node, 'v5-server-')
&& cliFlagValue($command, '--namespaces') === 'default,preview'
&& cliFlagValue($command, '--container-pool') === '10.211.0.0/16'
&& cliFlagValue($command, '--wg-mgmt-pool') === '100.65.0.0/16'
&& cliFlagValue($command, '--wg-interface') === 'wg-prod'
&& cliFlagValue($command, '--coold-version') === 'v0.2.0'
&& cliFlagValue($command, '--corrosion-version') === 'v1.1.0'
&& cliFlagValue($command, '--wg-listen-port-overrides') === $node.'=51831'
&& cliFlagValue($command, '--wg-endpoint-overrides') === $node.'=prod-01.example.com:51831'
&& ! in_array('--enable-builder', $command, true)
&& in_array('--yes', $command, true)
&& ! in_array('--new-nodes', $command, true);
});
Process::assertRan(fn ($process): bool => in_array('bootstrap', $process->command, true) && $process->timeout === 7200);
$cluster->refresh();
expect($server->status)->toBe('installed')
->and($server->last_bootstrap_status)->toBe('succeeded')
->and($server->last_bootstrap_output)->toContain('Bootstrap completed')
->and($server->last_bootstrapped_at)->not->toBeNull();
});
it('does not run a macOS development Coolify CLI binary from Docker during v5 server bootstrap', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/usr/local/bin/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
->assertAccepted();
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
Process::assertRan(fn ($process): bool => $process->command[0] === '/usr/local/bin/coolify');
});
it('keeps a v5 server added when Coolify CLI bootstrap fails', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
->assertAccepted()
->assertJsonPath('cluster.servers.0.status', 'added')
->assertJsonPath('cluster.servers.0.lastBootstrappedAt', null)
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'queued');
Process::fake([
'*' => Process::result(errorOutput: "bootstrap failed\n", exitCode: 1),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
expect($server->status)->toBe('added')
->and($server->last_bootstrap_status)->toBe('failed')
->and($server->last_bootstrap_output)->toBe('bootstrap failed')
->and($server->last_bootstrapped_at)->toBeNull();
});
it('persists v5 bootstrap mesh assignments and enrolls coold into flux', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
'namespaces' => ['default', 'preview'],
'wireguard_management_pool' => '100.65.0.0/16',
'container_network_pool' => '10.211.0.0/16',
'container_network_prefix' => 25,
'coold_version' => 'v0.9.9',
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'ubuntu',
'ssh_port' => 2222,
'status' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrap_status' => 'queued',
'last_bootstrap_ran_at' => now(),
]);
$issuer = Mockery::mock(AgentTokenIssuer::class);
$issuer
->shouldReceive('issueForServer')
->once()
->with(Mockery::on(fn (V5Server $server): bool => $server->wireguard_management_ip === '100.65.0.1'))
->andReturn('signed-host-jwt');
app()->instance(AgentTokenIssuer::class, $issuer);
Process::fake([
'*' => Process::sequence()
->push(Process::result(output: ''))
->push(Process::result(output: json_encode([
'results' => [],
'verified' => [[
'host' => "v5-server-{$server->uuid}",
'wireguard_ip' => '100.65.0.1',
'peer_count' => 0,
'status' => 'ok',
]],
], JSON_THROW_ON_ERROR)."\n"))
->push(Process::result(output: "public-key\n"))
->push(Process::result(output: "default=10.211.0.0/25\npreview=10.211.0.128/25\n"))
->push(Process::result(output: "marker written\n"))
->push(Process::result(output: "coold restarted\n")),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
expect($server->wireguard_management_ip)->toBe('100.65.0.1')
->and($server->wireguard_public_key)->toBe('public-key')
->and($server->container_subnets)->toBe([
'default' => '10.211.0.0/25',
'preview' => '10.211.0.128/25',
])
->and($server->node_address)->toBe('100.65.0.1')
->and($server->status)->toBe('installed')
->and($server->coold_version)->toBe('v0.9.9')
->and($server->last_bootstrapped_at)->not->toBeNull();
Process::assertRan(function ($process) use ($server): bool {
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
return str_contains($command, 'COOLIFY_COOLD_FLUX_URL=http://coolify.example.com:6443')
&& str_contains($command, 'COOLIFY_COOLD_HOST_ID='.$server->uuid)
&& str_contains($command, 'COOLIFY_COOLD_HOST_JWT_PATH=/etc/coolify/host-jwt')
&& str_contains($command, 'signed-host-jwt')
&& str_contains($command, 'chmod 600 /etc/coolify/host-jwt')
&& str_contains($command, 'systemctl restart coold.service');
});
Process::assertRan(function ($process): bool {
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
return str_contains($command, 'wg show')
&& str_contains($command, "if [ \"$(id -u)\" != \"0\" ]; then SUDO='sudo'; fi");
});
Process::assertRan(function ($process): bool {
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
return str_contains($command, 'podman network inspect "coolify-${ns}-mesh"')
&& str_contains($command, "if [ \"$(id -u)\" != \"0\" ]; then SUDO='sudo'; fi");
});
});
it('waits for the bootstrapped host to connect to flux before marking the server installed', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
Config::set('flux.bootstrap_host_connection_timeout_seconds', 1);
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'wireguard_management_pool' => '100.65.0.0/16',
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'ubuntu',
'ssh_port' => 2222,
'status' => 'added',
'last_bootstrap_status' => 'queued',
'last_bootstrap_ran_at' => now(),
]);
fakeAgentTokenIssuer();
$this->mock(FluxClient::class, function ($mock) use ($server): void {
$mock->shouldReceive('cooldLogs')
->once()
->with($server->uuid, 1)
->andReturn('coold connected');
});
Process::fake([
'*' => Process::sequence()
->push(Process::result(output: ''))
->push(Process::result(output: json_encode([
'verified' => [[
'host' => "v5-server-{$server->uuid}",
'wireguard_ip' => '100.65.0.1',
]],
], JSON_THROW_ON_ERROR)."\n"))
->push(Process::result(output: "public-key\n"))
->push(Process::result(output: "default=10.211.0.0/25\n"))
->push(Process::result(output: "marker written\n"))
->push(Process::result(output: "coold restarted\n")),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
expect($server->status)->toBe('installed')
->and($server->last_bootstrap_status)->toBe('succeeded')
->and($server->wireguard_management_ip)->toBe('100.65.0.1');
});
it('reads the WireGuard management IP from the host when bootstrap output does not include it', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'wireguard_interface' => 'wg-prod',
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'ubuntu',
'ssh_port' => 2222,
'status' => 'added',
'node_address' => '203.0.113.10',
'last_bootstrap_status' => 'queued',
'last_bootstrap_ran_at' => now(),
]);
$issuer = Mockery::mock(AgentTokenIssuer::class);
$issuer
->shouldReceive('issueForServer')
->once()
->with(Mockery::on(fn (V5Server $server): bool => $server->wireguard_management_ip === '100.65.0.1'))
->andReturn('signed-host-jwt');
app()->instance(AgentTokenIssuer::class, $issuer);
Process::fake([
'*' => Process::sequence()
->push(Process::result(output: ''))
->push(Process::result(output: json_encode([
'results' => [],
'verified' => [],
], JSON_THROW_ON_ERROR)."\n"))
->push(Process::result(output: "100.65.0.1\n"))
->push(Process::result(output: "public-key\n"))
->push(Process::result(output: "default=10.211.0.0/25\n"))
->push(Process::result(output: "marker written\n"))
->push(Process::result(output: "coold restarted\n")),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
expect($server->wireguard_management_ip)->toBe('100.65.0.1')
->and($server->node_address)->toBe('100.65.0.1')
->and($server->status)->toBe('installed');
Process::assertRan(function ($process): bool {
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
return str_contains($command, 'ip -4 -o addr show dev')
&& str_contains($command, 'wg-prod');
});
});
it('fails the bootstrap when the flux url is not configured', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
Config::set('coold.flux_url', '');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$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,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'last_bootstrap_status' => 'queued',
'last_bootstrap_ran_at' => now(),
]);
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
expect($server->status)->toBe('added')
->and($server->last_bootstrap_status)->toBe('failed')
->and($server->last_bootstrap_output)->toContain('COOLIFY_COOLD_FLUX_URL')
->and($server->last_bootstrapped_at)->toBeNull();
});
it('writes the bootstrap marker before flux enrollment so a failed enrollment can resume', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
fakeAgentTokenIssuer();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$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,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'last_bootstrap_status' => 'queued',
'last_bootstrap_ran_at' => now(),
]);
Process::fake([
'*systemctl restart coold*' => Process::result(errorOutput: "enrollment boom\n", exitCode: 1),
'*' => Process::result(output: "Bootstrap completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
expect($server->status)->toBe('added')
->and($server->last_bootstrap_status)->toBe('failed')
->and($server->last_bootstrap_output)->toContain('enrollment boom')
->and($server->last_bootstrap_output)->toContain('retrying this bootstrap is safe')
->and($server->last_bootstrapped_at)->toBeNull();
Process::assertRan(function ($process): bool {
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
return str_contains($command, 'v5-node.json') && str_contains($command, 'base64 -d');
});
});
it('exits the bootstrap job when its claim was superseded', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$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,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'last_bootstrap_status' => 'failed',
'last_bootstrap_ran_at' => now(),
]);
Process::fake();
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
Process::assertNothingRan();
expect($server->refresh()->last_bootstrap_status)->toBe('failed');
});
it('recovers a stale queued v5 bootstrap claim', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
]);
$staleServer = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'last_bootstrap_status' => 'queued',
'last_bootstrap_ran_at' => now()->subMinutes(20),
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-02',
'host' => '203.0.113.11',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
]);
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
->assertAccepted()
->assertJsonPath('message', 'Bootstrap queued.');
Queue::assertPushed(V5BootstrapServerJob::class);
expect($staleServer->refresh()->last_bootstrap_status)->toBe('failed')
->and($staleServer->last_bootstrap_output)->toContain('timed out or its worker died')
->and($server->refresh()->last_bootstrap_status)->toBe('queued');
});
it('extends a v5 cluster when bootstrapping a new server', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
fakeAgentTokenIssuer();
[$user, $team] = createV5UserWithTeam();
$oldPrivateKey = createV5PrivateKey($team, 'Old SSH Key');
$newPrivateKey = createV5PrivateKey($team, 'New SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
'coold_version' => 'v0.3.0',
'corrosion_version' => 'v1.2.0',
]);
$oldServer = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $oldPrivateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'ubuntu',
'ssh_port' => 2222,
'status' => 'installed',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrapped_at' => now()->subDay(),
]);
$newServer = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $newPrivateKey->id,
'name' => 'prod-02',
'host' => '203.0.113.11',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$newServer->uuid}/bootstrap")
->assertAccepted()
->assertJsonPath('cluster.servers.1.lastBootstrapAction', 'extend')
->assertJsonPath('cluster.servers.1.lastBootstrapStatus', 'queued');
Process::fake([
'*' => Process::result(output: "Extend completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $newServer->id))->handle();
Process::assertRan(function ($process) use ($oldServer, $newServer): bool {
$command = $process->command;
$oldNode = "v5-server-{$oldServer->uuid}";
$newNode = "v5-server-{$newServer->uuid}";
return in_array('extend', $command, true)
&& in_array("{$oldNode},{$newNode}", $command, true)
&& in_array('--new-nodes', $command, true)
&& in_array($newNode, $command, true)
&& cliFlagValue($command, '--ssh-user') === 'root'
&& in_array('--ssh-config', $command, true)
&& cliFlagValue($command, '--coold-version') === 'v0.3.0'
&& cliFlagValue($command, '--corrosion-version') === 'v1.2.0';
});
$oldServer->refresh();
$newServer->refresh();
expect($oldServer->status)->toBe('installed')
->and($newServer->status)->toBe('installed')
->and($newServer->last_bootstrap_status)->toBe('succeeded')
->and($newServer->last_bootstrap_output)->toContain('Extend completed')
->and($newServer->last_bootstrapped_at)->not->toBeNull();
});
it('adopts a re-added v5 server that is already bootstrapped for the same cluster', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
fakeAgentTokenIssuer();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrap_status' => 'queued',
'last_bootstrap_ran_at' => now(),
]);
Process::fake([
'*' => Process::result(output: json_encode([
'cluster_id' => $cluster->id,
'cluster_uuid' => $cluster->uuid,
'server_uuid' => 'server-previous-uuid',
'wireguard_management_ip' => '100.64.0.10',
'wireguard_public_key' => 'public-key',
'container_subnets' => ['default' => '10.210.0.0/24'],
], JSON_THROW_ON_ERROR)."\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
Process::assertNotRan(fn ($process): bool => $process->command[0] === '/tmp/coolify');
$server->refresh();
expect($server->status)->toBe('installed')
->and($server->uuid)->toBe('server-previous-uuid')
->and($server->wireguard_management_ip)->toBe('100.64.0.10')
->and($server->wireguard_public_key)->toBe('public-key')
->and($server->container_subnets)->toBe(['default' => '10.210.0.0/24'])
->and($server->last_bootstrap_status)->toBe('succeeded')
->and($server->last_bootstrap_output)->toBe('Adopted existing Coolify bootstrap state for this cluster.')
->and($server->last_bootstrapped_at)->not->toBeNull();
});
it('blocks bootstrapping a v5 server that belongs to another cluster', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrap_status' => 'queued',
'last_bootstrap_ran_at' => now(),
]);
Process::fake([
'*' => Process::result(output: json_encode([
'cluster_id' => $cluster->id + 100,
'server_uuid' => 'server-other-uuid',
], JSON_THROW_ON_ERROR)."\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
Process::assertNotRan(fn ($process): bool => $process->command[0] === '/tmp/coolify');
$server->refresh();
expect($server->status)->toBe('added')
->and($server->last_bootstrap_status)->toBe('failed')
->and($server->last_bootstrap_output)->toContain('already bootstrapped for another cluster')
->and($server->last_bootstrapped_at)->toBeNull();
});
-292
View File
@@ -1,292 +0,0 @@
<?php
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
use App\Models\V5\Cluster;
use App\Models\V5\Server as V5Server;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
beforeEach(function () {
resetV5DashboardTestState();
});
it('broadcasts v5 canvas resource updates when 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' => [],
]);
$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',
'canvas_x' => 0,
'canvas_y' => 0,
]);
Event::fake([V5CanvasResourceUpdated::class]);
$application->update([
'status' => 'exited',
'status_message' => 'Container stopped.',
]);
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
&& $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' => $application->uuid,
'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();
[$user, $team] = createV5UserWithTeam();
$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' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['ingress'],
]);
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$server->update(['status' => 'unreachable']);
Event::assertDispatched(V5ClusterUpdated::class, fn (V5ClusterUpdated $event) => $event->teamId === $team->id
&& $event->clusterId === $cluster->id);
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
&& $event->caddyIngressServerId === $server->id);
});
it('broadcasts v5 cluster updates with uuid identifiers and the full server shape', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'uuid' => 'cluster-public-uuid',
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'uuid' => 'server-public-uuid',
'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',
'capabilities' => ['ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'ingress_type' => 'caddy',
'last_status_output' => 'coold heartbeat ok.',
'last_status_checked_at' => now(),
]);
$payload = (new V5ClusterUpdated($team->id, $cluster->id))->broadcastWith();
expect($payload['cluster']['id'])
->toBe($cluster->uuid)
->and($payload['cluster']['id'])->not->toBe((string) $cluster->id)
->and($payload['cluster']['servers'][0]['id'])->not->toBe((string) $server->id)
->and($payload['cluster']['servers'][0])->toMatchArray([
'id' => $server->uuid,
'uuid' => $server->uuid,
'ingressEnabled' => true,
'ingressType' => 'caddy',
'lastStatusOutput' => 'coold heartbeat ok.',
])
->and($payload['cluster']['servers'][0])->toHaveKey('lastStatusCheckedAt');
});
it('resolves an empty v5 canvas broadcast payload when the models are deleted before the queued broadcast runs', function () {
createSharedUserAndTeamTables();
$payload = (new V5CanvasResourceUpdated(1, 999, 998, 997))->broadcastWith();
expect($payload)->toBe([
'application' => null,
'applications' => [],
'caddyIngress' => null,
]);
});
it('resolves a null v5 cluster broadcast payload when the cluster is deleted before the queued broadcast runs', function () {
createSharedUserAndTeamTables();
$payload = (new V5ClusterUpdated(1, 999))->broadcastWith();
expect($payload)->toBe(['cluster' => null]);
});
it('broadcasts v5 canvas updates only after the database transaction commits', 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' => [],
]);
$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' => 'creating',
]);
Event::fake([V5CanvasResourceUpdated::class]);
DB::transaction(function () use ($application): void {
$application->update(['status' => 'running']);
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
});
Event::assertDispatched(V5CanvasResourceUpdated::class);
});
@@ -1,33 +0,0 @@
<?php
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection;
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
});
it('prevents team members from creating v5 applications', function () {
[$user, $team] = createV5UserWithTeam();
$user->teams()->updateExistingPivot($team->id, ['role' => 'member']);
$this->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/applications/nginx')
->assertForbidden();
expect(V5Application::query()->count())->toBe(0);
});
it('prevents team members from creating v5 resource connections', function () {
[$user, $team] = createV5UserWithTeam();
$user->teams()->updateExistingPivot($team->id, ['role' => 'member']);
$this->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/resource-connections')
->assertForbidden();
expect(ResourceConnection::query()->count())->toBe(0);
});
-139
View File
@@ -1,139 +0,0 @@
<?php
use App\Models\V5\Cluster;
use App\Models\V5\Server as V5Server;
use Database\Seeders\V5DevLimaSeeder;
use Illuminate\Support\Facades\Artisan;
beforeEach(function () {
resetV5DashboardTestState();
});
it('syncs dev Lima VMs into v5 clusters and servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Dev Lima Key');
$exitCode = Artisan::call('v5:sync-dev-lima-servers', [
'--team-id' => $team->id,
'--user-id' => $user->id,
'--cluster' => 'Development-Lima',
'--server' => [
'coold-dev|host.docker.internal|developer|61332|100.64.0.1',
'coold-dev-2|host.docker.internal|developer|61379|100.64.0.2',
],
]);
expect($exitCode)->toBe(0)
->and(Cluster::query()->where('name', 'Development-Lima')->count())->toBe(1)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('node_address', '100.64.0.1')->where('wireguard_management_ip', '100.64.0.1')->where('ssh_port', 61332)->where('private_key_id', $privateKey->id)->exists())->toBeTrue()
->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'host.docker.internal')->where('node_address', '100.64.0.2')->where('wireguard_management_ip', '100.64.0.2')->where('ssh_port', 61379)->where('private_key_id', $privateKey->id)->exists())->toBeTrue();
});
it('updates legacy dev Lima hostnames to Docker reachable SSH endpoints', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Dev Lima Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'coold-dev',
'host' => 'lima-coold-dev',
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
]);
$exitCode = Artisan::call('v5:sync-dev-lima-servers', [
'--team-id' => $team->id,
'--user-id' => $user->id,
'--cluster' => 'Development-Lima',
'--server' => [
'coold-dev|host.docker.internal|developer|61332',
],
]);
expect($exitCode)->toBe(0)
->and(V5Server::query()->where('name', 'coold-dev')->count())->toBe(1)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_port', 61332)->exists())->toBeTrue()
->and(V5Server::query()->where('host', 'lima-coold-dev')->exists())->toBeFalse();
});
it('seeds dev Lima VMs into v5 clusters and servers idempotently', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
createV5PrivateKey($team, 'Dev Lima Key');
(new V5DevLimaSeeder)->run();
(new V5DevLimaSeeder)->run();
$cluster = Cluster::query()->where('name', 'Development-Lima')->sole();
expect($cluster->team_id)->toBe($team->id)
->and($cluster->created_by_user_id)->toBe($user->id)
->and($cluster->description)->toBe('Local Lima development cluster managed by scripts/dev.sh.')
->and(V5Server::query()->count())->toBe(2)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'coold-dev.local')->where('ssh_user', 'coolify')->where('ssh_port', 22)->exists())->toBeTrue()
->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'coold-dev-2.local')->where('ssh_user', 'coolify')->where('ssh_port', 22)->exists())->toBeTrue()
->and(V5Server::query()->where('name', 'coold-dev')->where('node_address', '100.64.0.1')->where('wireguard_management_ip', '100.64.0.1')->exists())->toBeTrue()
->and(V5Server::query()->where('name', 'coold-dev-2')->where('node_address', '100.64.0.2')->where('wireguard_management_ip', '100.64.0.2')->exists())->toBeTrue()
->and(V5Server::query()->where('status', 'installed')->count())->toBe(2)
->and(V5Server::query()->where('builder_enabled', false)->where('builder_capacity', 0)->count())->toBe(2)
->and(V5Server::query()->where('cluster_id', $cluster->id)->count())->toBe(2);
});
it('seeds dev Lima VMs by updating existing named servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
createV5PrivateKey($team, 'Dev Lima Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'coold-dev',
'host' => 'old-host.local',
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrapped_at' => now()->subDay(),
]);
(new V5DevLimaSeeder)->run();
expect(V5Server::query()->where('name', 'coold-dev')->count())->toBe(1)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'coold-dev.local')->where('ssh_port', 22)->exists())->toBeTrue()
->and(V5Server::query()->count())->toBe(2);
});
it('configures v5 dev lima host resolver for coolify internal dns', function () {
$script = file_get_contents(base_path('scripts/coold-vm.sh'));
expect($script)
->toContain('configure_system_resolved')
->toContain('ensure_mesh_dns_anchor')
->toContain('coolify-v5-mesh-dns-anchor')
->toContain('resolvectl dns podman1 "$CONTAINER_GATEWAY"')
->toContain("resolvectl domain podman1 '~coolify.internal'")
->toContain('resolvectl default-route podman1 false');
});
-639
View File
@@ -1,639 +0,0 @@
<?php
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Models\Team;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Cluster;
use App\Models\V5\ContainerStatus;
use App\Models\V5\Server as V5Server;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
beforeEach(function () {
resetV5DashboardTestState();
});
it('applies flux application status updates to the database and broadcasts to the team canvas', 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' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$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',
'canvas_x' => 0,
'canvas_y' => 0,
]);
Event::fake([V5CanvasResourceUpdated::class]);
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'nginx-container-id',
'status' => 'exited',
'status_message' => 'Status received from coold through flux.',
]);
expect($resource)->toBeInstanceOf(V5Application::class)
->and($application->refresh()->status)->toBe('exited')
->and($application->status_message)->toBe('Status received from coold through flux.')
->and($application->runtime_container_id)->toBe('nginx-container-id');
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
&& $event->applicationId === $application->id);
});
it('ignores stale flux status updates for a superseded container', 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' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$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' => 'new-container-id',
]);
Event::fake([V5CanvasResourceUpdated::class]);
ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'old-container-id',
'status' => 'exited',
'status_message' => 'Stale event for the old container.',
]);
expect($application->refresh()->status)->toBe('running')
->and($application->runtime_container_id)->toBe('new-container-id');
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
});
it('scopes flux application status updates to the reporting server', function () {
createSharedUserAndTeamTables();
[$userA, $teamA] = createV5UserWithTeam();
[$userB, $teamB] = createV5UserWithTeam('other@example.com');
[$projectA, $environmentA] = createV5ProjectWithEnvironment($teamA, 'Project A', 'Production');
[$projectB, $environmentB] = createV5ProjectWithEnvironment($teamB, 'Project B', 'Production');
$serverA = V5Server::query()->create([
'team_id' => $teamA->id,
'created_by_user_id' => $userA->id,
'name' => 'edge-a',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$serverB = V5Server::query()->create([
'team_id' => $teamB->id,
'created_by_user_id' => $userB->id,
'name' => 'edge-b',
'host' => '203.0.113.11',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => [],
'wireguard_management_ip' => '100.64.0.6',
]);
$applicationA = V5Application::query()->create([
'team_id' => $teamA->id,
'project_id' => $projectA->id,
'environment_id' => $environmentA->id,
'server_id' => $serverA->id,
'created_by_user_id' => $userA->id,
'name' => 'api',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-api-a',
'status' => 'running',
]);
$applicationB = V5Application::query()->create([
'uuid' => $applicationA->uuid.'-b',
'team_id' => $teamB->id,
'project_id' => $projectB->id,
'environment_id' => $environmentB->id,
'server_id' => $serverB->id,
'created_by_user_id' => $userB->id,
'name' => 'api',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-api-b',
'status' => 'running',
]);
// An update reported by team A's host must never resolve to team B's app,
// even when the payload carries team B's application uuid.
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'application_uuid' => $applicationB->uuid,
'status' => 'exited',
]);
expect($resource)->toBeNull()
->and($applicationA->refresh()->status)->toBe('running')
->and($applicationB->refresh()->status)->toBe('running');
});
it('ignores flux application status updates whose host does not resolve to a server', 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' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$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',
]);
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'application',
'host_id' => '100.99.99.99',
'application_uuid' => $application->uuid,
'status' => 'exited',
]);
expect($resource)->toBeNull()
->and($application->refresh()->status)->toBe('running');
});
it('maps generic flux container status updates to v5 applications', 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' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$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',
]);
Event::fake([V5CanvasResourceUpdated::class]);
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'container',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'nginx-container-id',
'status' => 'exited',
'status_message' => 'Container state received from coold.',
]);
expect($resource)->toBeInstanceOf(V5Application::class)
->and($application->refresh()->status)->toBe('exited')
->and($application->status_message)->toBe('Container state received from coold.');
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
&& $event->applicationId === $application->id);
});
it('applies flux ingress server status updates to the database and broadcasts cluster plus canvas updates', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$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' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['ingress'],
'wireguard_management_ip' => '100.64.0.5',
]);
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'server',
'host_id' => '100.64.0.5',
'status' => 'unreachable',
'message' => 'coold heartbeat timed out.',
]);
expect($resource)->toBeInstanceOf(V5Server::class)
->and($server->refresh()->status)->toBe('unreachable')
->and($server->last_status_check)->toBe('flux')
->and($server->last_status_output)->toBe('coold heartbeat timed out.')
->and($server->last_status_checked_at)->not->toBeNull();
Event::assertDispatched(V5ClusterUpdated::class, fn (V5ClusterUpdated $event) => $event->teamId === $team->id
&& $event->clusterId === $cluster->id);
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
&& $event->caddyIngressServerId === $server->id);
});
it('ignores flux server status updates when a host id matches multiple v5 servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Cluster',
]);
$serverOne = 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.5',
]);
$serverTwo = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'edge-02',
'host' => '203.0.113.11',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.5',
]);
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'server',
'host_id' => '100.64.0.5',
'status' => 'unreachable',
'message' => 'ambiguous host id.',
]);
expect($resource)->toBeNull()
->and($serverOne->refresh()->status)->toBe('installed')
->and($serverTwo->refresh()->status)->toBe('installed');
Event::assertNotDispatched(V5ClusterUpdated::class);
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
});
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' => $application->uuid,
'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();
[$user, $team] = createV5UserWithTeam();
$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',
'ingress_status' => 'running',
'capabilities' => ['ingress'],
'wireguard_management_ip' => '100.64.0.5',
]);
Event::fake([V5CanvasResourceUpdated::class]);
$resource = ApplyFluxResourceStatusUpdate::run([
'resource_type' => 'caddy_ingress',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-caddy',
'status' => 'exited',
'message' => 'Caddy container exited.',
]);
expect($resource)->toBeInstanceOf(V5Server::class)
->and($server->refresh()->status)->toBe('installed')
->and($server->ingress_type)->toBe('caddy')
->and($server->ingress_status)->toBe('exited')
->and($server->last_status_check)->toBe('flux')
->and($server->last_status_output)->toBe('Caddy container exited.')
->and($server->last_status_checked_at)->not->toBeNull();
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
&& $event->caddyIngressServerId === $server->id);
});
it('accepts flux status updates for non coolify managed containers', function () {
Config::set('flux.laravel_api_token', 'test-flux-token');
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
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.5',
]);
$this
->postJson('/api/v1/internal/flux/resource-status', [
'resource_type' => 'container',
'host_id' => '100.64.0.5',
'container_id' => 'external-container-id',
'container_name' => 'external-container',
'status' => 'running',
], [
'Authorization' => 'Bearer test-flux-token',
])
->assertSuccessful()
->assertJsonPath('message', 'Resource status updated.');
expect(ContainerStatus::query()
->where('container_id', 'external-container-id')
->where('container_name', 'external-container')
->where('status', 'running')
->exists())->toBeTrue();
});
it('rejects flux resource status http updates without the shared token', function () {
Config::set('flux.laravel_api_token', 'test-flux-token');
$this
->postJson('/api/v1/internal/flux/resource-status', [
'resource_type' => 'application',
'status' => 'running',
])
->assertUnauthorized();
});
it('accepts flux resource status http updates identified by resource uuids', function () {
createSharedUserAndTeamTables();
Config::set('flux.laravel_api_token', 'test-flux-token');
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
$server = V5Server::query()->create([
'uuid' => 'server-public-uuid',
'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' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$application = V5Application::query()->create([
'uuid' => 'application-public-uuid',
'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' => 'starting',
'status_message' => 'Container starting.',
]);
$this
->withToken('test-flux-token')
->postJson('/api/v1/internal/flux/resource-status', [
'resource_type' => 'application',
'server_uuid' => $server->uuid,
'application_uuid' => $application->uuid,
'container_id' => 'new-container-id',
'status' => 'running',
'status_message' => 'Status received from coold through flux.',
])
->assertSuccessful()
->assertJsonPath('message', 'Resource status updated.');
expect($application->refresh()->status)->toBe('running')
->and($application->runtime_container_id)->toBe('new-container-id');
});
it('rejects numeric laravel ids in flux resource status http payloads', function () {
Config::set('flux.laravel_api_token', 'test-flux-token');
$this
->withToken('test-flux-token')
->postJson('/api/v1/internal/flux/resource-status', [
'resource_type' => 'application',
'server_id' => 1,
'application_id' => 1,
'status' => 'running',
])
->assertInvalid(['server_id', 'application_id']);
});
it('accepts flux resource status http updates and stores them in the database', function () {
createSharedUserAndTeamTables();
Config::set('flux.laravel_api_token', 'test-flux-token');
[$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' => [],
'wireguard_management_ip' => '100.64.0.5',
]);
$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' => 'starting',
'status_message' => 'Container starting.',
'runtime_container_id' => 'old-container-id',
]);
Event::fake([V5CanvasResourceUpdated::class]);
$this
->withToken('test-flux-token')
->postJson('/api/v1/internal/flux/resource-status', [
'resource_type' => 'application',
'host_id' => '100.64.0.5',
'container_name' => 'coolify-v5-nginx-1',
'container_id' => 'old-container-id',
'status' => 'running',
'status_message' => 'Status received from coold through flux.',
])
->assertSuccessful()
->assertJsonPath('message', 'Resource status updated.');
expect($application->refresh()->status)->toBe('running')
->and($application->status_message)->toBe('Status received from coold through flux.')
->and($application->runtime_container_id)->toBe('old-container-id');
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
&& $event->applicationId === $application->id);
});
it('configures flux resource status updates for local http instead of redis', function () {
$configSource = file_get_contents(config_path('flux.php'));
expect($configSource)
->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN')
->not->toContain('APP_KEY')
->not->toContain('COOLIFY_FLUX_RESOURCE_STATUS_CHANNEL')
->not->toContain('resource_status_channel');
});
@@ -1,954 +0,0 @@
<?php
use App\Events\V5ClusterUpdated;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
beforeEach(function () {
resetV5DashboardTestState();
});
it('keeps long v5 application metadata inside the canvas card', function () {
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
$caddyIngressCardSource = file_get_contents(resource_path('js/v5/components/canvas/caddy-ingress-card.tsx'));
expect($caddyIngressCardSource)
->toContain('overflow-hidden')
->and($applicationCardSource)->toContain('grid grid-cols-[auto_minmax(0,1fr)]')
->and($applicationCardSource)->toContain('truncate text-right font-medium')
->and($applicationCardSource)->toContain('truncate text-right font-mono');
});
it('does not render v5 application status messages on dashboard cards', function () {
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
expect($applicationCardSource)
->not->toContain('{application.statusMessage && (')
->not->toContain('{application.statusMessage}</p>');
});
it('lets users dismiss v5 dashboard notices', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$noticeSource = file_get_contents(resource_path('js/v5/components/canvas/canvas-notice.tsx'));
expect($noticeSource)
->toContain('aria-label="Dismiss notice"')
->toContain('onClick={onDismiss}')
->toContain('<span className="sr-only">Dismiss notice</span>')
->toContain('bg-card')
->not->toContain('bg-destructive/10')
->not->toContain('bg-emerald-500/10')
->not->toContain('bg-blue-500/10');
expect($dashboardSource)->toContain('onDismiss={dismissNotice}');
});
it('uses the shared dialog and button components for the application ingress modal', function () {
$ingressDialogSource = file_get_contents(resource_path('js/v5/components/canvas/ingress-dialog.tsx'));
expect($ingressDialogSource)
->toContain('<Dialog')
->toContain('open')
->toContain('<DialogContent')
->toContain('showCloseButton')
->toContain('<Button type="submit" variant="coolify"')
->not->toContain('>Cancel</button>')
->not->toContain('>Close</button>');
});
it('shows a loading state while toggling application ingress', function () {
$ingressHookSource = file_get_contents(resource_path('js/v5/lib/use-application-ingress.ts'));
$ingressButtonSource = file_get_contents(resource_path('js/v5/components/canvas/application-ingress-button.tsx'));
$ingressDialogSource = file_get_contents(resource_path('js/v5/components/canvas/ingress-dialog.tsx'));
expect($ingressHookSource)
->toContain('const savingIngressApplications = usePendingIds<string>()')
->toContain('savingIngressApplications.start(application.id)')
->toContain('savingIngressApplications.finish(application.id)');
expect($ingressButtonSource)->toContain("isSaving ? 'Saving...'");
expect($ingressDialogSource)->toContain("isSaving ? 'Saving...' : 'Enable ingress'");
});
it('shows a dashboard refresh button next to the center button', function () {
$toolbarSource = file_get_contents(resource_path('js/v5/components/canvas/canvas-toolbar.tsx'));
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($toolbarSource)
->toContain('onClick={onCenter}')
->toContain('onClick={onRefresh}')
->toContain("isRefreshing ? 'Refreshing…' : 'Refresh state'");
expect($dashboardSource)
->toContain('centerOnCanvasNodes(applications, ingresses)')
->toContain('void refreshApplications()');
});
it('subscribes the v5 dashboard canvas to automatic resource status updates', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$channelHookSource = file_get_contents(resource_path('js/v5/lib/use-canvas-channel.ts'));
$teamChannelSource = file_get_contents(resource_path('js/v5/lib/use-team-channel.ts'));
$mergeHookSource = file_get_contents(resource_path('js/v5/lib/use-canvas-resource-merge.ts'));
expect($dashboardSource)
->toContain('currentTeam = null')
->toContain('useCanvasResourceMerge({')
->toContain('teamId: currentTeam?.id ?? null')
->not->toContain("fetch('/v5/canvas-state'");
expect($mergeHookSource)
->toContain('setApplications((currentApplications) =>')
->toContain('setIngresses((currentIngresses) =>')
->toContain('useCanvasResourceChannel(teamId, handleCanvasResourceEvent)');
expect($channelHookSource)
->toContain("useTeamChannel(teamId, '.v5.canvas.resource.updated'");
expect($teamChannelSource)
->toContain('channel.listen(eventName')
->toContain('Waiting for window.Echo before subscribing to ${eventName} updates');
});
it('allows zooming the v5 dashboard canvas with buttons and pinch gestures', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$viewportHookSource = file_get_contents(resource_path('js/v5/lib/use-canvas-viewport.ts'));
$toolbarSource = file_get_contents(resource_path('js/v5/components/canvas/canvas-toolbar.tsx'));
expect($viewportHookSource)
->toContain('zoom: number;')
->toContain('MIN_CANVAS_ZOOM')
->toContain('MAX_CANVAS_ZOOM')
->toContain('PINCH_CANVAS_ZOOM_STEP')
->toContain('zoomCanvas(')
->toContain('zoomCanvas(event.deltaY < 0 ? 1 : -1, PINCH_CANVAS_ZOOM_STEP')
->toContain('event.ctrlKey');
expect($toolbarSource)
->toContain('aria-label="Zoom out"')
->toContain('aria-label="Zoom in"')
->toContain('Math.round(zoom * 100)');
expect($dashboardSource)
->toContain('onWheel={handleCanvasWheel}')
->toContain('scale(${viewport.zoom})');
});
it('renders draggable connector dots on v5 application canvas cards', function () {
$geometrySource = file_get_contents(resource_path('js/v5/lib/canvas-geometry.ts'));
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
$connectionLinesSource = file_get_contents(resource_path('js/v5/components/canvas/connection-lines.tsx'));
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($geometrySource)->toContain("export type ConnectorSide = 'top' | 'right' | 'bottom' | 'left';");
expect($applicationCardSource)
->toContain('application-connector')
->toContain('data-connector-side={side}')
->toContain('onConnectorPointerDown(event, application.id, side)');
expect($connectionLinesSource)->toContain('<svg className="pointer-events-none absolute inset-0 overflow-visible">');
expect($dashboardSource)
->toContain('onConnectorPointerDown={startConnectionDrag}')
->toContain('draftConnection');
});
it('keeps v5 canvas connections selectable unique and shortest-path only', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$connectionsHookSource = file_get_contents(resource_path('js/v5/lib/use-canvas-connections.ts'));
$geometrySource = file_get_contents(resource_path('js/v5/lib/canvas-geometry.ts'));
$connectionLinesSource = file_get_contents(resource_path('js/v5/components/canvas/connection-lines.tsx'));
$portsEditorSource = file_get_contents(resource_path('js/v5/components/canvas/connection-ports-editor.tsx'));
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
expect($dashboardSource)
->toContain('resourceConnections: initialResourceConnections = []')
->toContain('selectedConnectionId')
->toContain('clearCanvasSelection')
->toContain('event.target !== event.currentTarget')
->toContain('connectionExists')
->toContain("closest<HTMLElement>('[data-application-card]')")
->toContain('persistNewConnection(pointerState.from.applicationId, targetApplicationId)');
expect($connectionsHookSource)
->toContain('type CanvasConnection = V5ResourceConnection;')
->toContain('useState<CanvasConnection[]>(initialConnections)')
->toContain("!['Backspace', 'Delete'].includes(event.key)")
->toContain('rollback: () =>')
->toContain('persistConnectionPorts(updatedConnection, connection)')
->toContain('/v5/resource-connections')
->toContain('ports_by_direction: portsByDirection')
->toContain('connectionDirectionKey(')
->toContain('pruneConnectionPortsByDirection(')
->toContain('Number.isInteger(portNumber)')
->toContain('setConnectionPortInput');
expect($geometrySource)->toContain('shortestConnectionPoints');
expect($connectionLinesSource)
->toContain('onClick={(event) => onSelect(event, connection.id)}')
->toContain("isSelected ? 'stroke-destructive' : 'stroke-warning'")
->toContain('aria-label="Select connection"')
->toContain('stroke="transparent"')
->toContain('strokeWidth={12}')
->toContain('id="dashboard-connection-arrow"')
->toContain('markerEnd={isSelected ? \'url(#dashboard-connection-arrow)\' : undefined}')
->toContain('markerWidth="16"')
->toContain('markerHeight="16"')
->toContain('strokeDasharray="6 6"');
expect($portsEditorSource)
->toContain('activeConnectionPorts(connection)')
->toContain('onAddPort(connection.id)')
->toContain('Allowed ports')
->toContain('onUpdateDirection(')
->toContain('applicationDirectionLabel(')
->toContain('application.id.slice(0, 8)')
->toContain('connection.applicationIds[0]')
->toContain('connection.applicationIds[1]')
->toContain('left: (points.from.x + points.to.x) / 2')
->toContain('top: (points.from.y + points.to.y) / 2')
->toContain('onDelete(connection.id)')
->toContain('Delete connection');
expect($applicationCardSource)
->toContain('data-application-card="application-card"')
->toContain('group/application')
->toContain('opacity-0')
->toContain('group-hover/application:opacity-100');
});
it('shows v5 application connector dots after selecting a canvas card', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
expect($dashboardSource)
->toContain('selectedApplicationId')
->toContain('setSelectedApplicationId(application.id)')
->toContain('selectedApplicationId === application.id');
expect($applicationCardSource)->toContain('opacity-100');
});
it('keeps side connector geometry aligned with the rendered v5 application card height', function () {
$geometrySource = file_get_contents(resource_path('js/v5/lib/canvas-geometry.ts'));
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
expect($geometrySource)->toContain('const APPLICATION_CARD_HEIGHT = 160;');
expect($applicationCardSource)->toContain('h-40 w-80');
});
it('shows a loading state on v5 application delete buttons', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
foreach ([
'const deletingApplications = usePendingIds<string>()',
'deletingApplications.start(application.id)',
'deletingApplications.finish(application.id)',
'isDeleting={deletingApplications.pendingIds.has(application.id)}',
] as $expectedSource) {
$this->assertTrue(str_contains($dashboardSource, $expectedSource), "Missing source: {$expectedSource}");
}
foreach ([
'disabled={isDeleting}',
"{isDeleting ? 'Deleting…' : 'Delete'}",
] as $expectedSource) {
$this->assertTrue(str_contains($applicationCardSource, $expectedSource), "Missing source: {$expectedSource}");
}
});
it('uses a larger mobile touch target for v5 application connector dots', function () {
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
expect($applicationCardSource)
->toContain('size-8')
->toContain('md:size-3')
->toContain('group/connector')
->toContain('group-hover/connector:scale-125')
->toContain('<span className="size-3 rounded-full border-2 border-card bg-warning shadow ring-2 ring-background transition group-hover/connector:scale-125 group-hover/connector:bg-warning/90" />');
});
it('detects the connection drop target from pointer coordinates for mobile drags', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($dashboardSource)
->toContain('connectionTargetFromPointer(event)')
->toContain('document.elementFromPoint(event.clientX, event.clientY)')
->toContain('pointer-captured mobile drags')
->toContain('targetApplicationId !== pointerState.from.applicationId');
});
it('renders coold diagnostics actions in the v5 server menu', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain('Coold logs')
->toContain('Corrosion tables')
->toContain('Restart coold')
->toContain('Restarting coold...')
->toContain('Firewall rules')
->toContain('/restart-coold')
->toContain('/coold-logs?tail=200')
->toContain('/corrosion-tables?limit=200')
->toContain('/firewall-rules')
->toContain("source: 'flux' | 'ssh';")
->toContain('setCooldLogsSource(payload?.source ?? null)')
->toContain('setCorrosionTablesSource(payload?.source ?? null)')
->toContain('setFirewallRulesSource(payload?.source ?? null)')
->toContain('Source: {diagnosticsSourceLabel(cooldLogsSource)}')
->toContain('Source: {diagnosticsSourceLabel(corrosionTablesSource)}')
->toContain('Source: ${diagnosticsSourceLabel(firewallRulesSource)}')
->toContain('Latest journalctl entries')
->toContain('Corrosion table snapshots')
->toContain('Defined coold allow rules')
->toContain('overflow-y-auto overflow-x-hidden')
->toContain('whitespace-pre-wrap wrap-anywhere');
});
it('renders v5 server status on cluster server cards', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain('server.status')
->toContain('server.lastStatusOutput')
->toContain('statusLabel(server.status)')
->toContain('statusBadgeClass(server.status)');
});
it('broadcasts v5 cluster updates from the queue with an explicit echo event name', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
$teamChannelSource = file_get_contents(resource_path('js/v5/lib/use-team-channel.ts'));
$event = new V5ClusterUpdated(1, 1);
expect($event)->toBeInstanceOf(ShouldBroadcast::class)
->not->toBeInstanceOf(ShouldBroadcastNow::class)
->and($event->broadcastAs())->toBe('v5.cluster.updated')
->and($clustersPage)->toContain("useTeamChannel(currentTeam?.id ?? null, '.v5.cluster.updated'")
->and($teamChannelSource)->toContain('Waiting for window.Echo before subscribing to ${eventName} updates');
});
it('serves a v5 realtime test page for manual websocket diagnostics', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$page = file_get_contents(resource_path('js/v5/Pages/RealtimeTest.tsx'));
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5/realtime-test')
->assertSuccessful()
->assertSee('RealtimeTest', false)
->assertSee((string) $team->id, false);
expect($page)
->toContain('useTeamChannel(')
->toContain("'.v5.realtime.test',")
->toContain('{ onDebug: addLog, onError: addLog }')
->toContain('Broadcast event');
expect(file_get_contents(resource_path('js/v5/lib/use-team-channel.ts')))
->toContain('Subscribing to private-');
});
it('defines the v5 dashboard page as a shadcn styled canvas shell', function () {
$dashboardPage = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$canvasApi = file_get_contents(resource_path('js/v5/lib/canvas-api.ts'));
$toolbar = file_get_contents(resource_path('js/v5/components/canvas/canvas-toolbar.tsx'));
$applicationCard = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
$caddyIngressCard = file_get_contents(resource_path('js/v5/components/canvas/caddy-ingress-card.tsx'));
$inspectorSheet = file_get_contents(resource_path('js/v5/components/canvas/application-inspector-sheet.tsx'));
$ingressDialog = file_get_contents(resource_path('js/v5/components/canvas/ingress-dialog.tsx'));
$app = file_get_contents(resource_path('js/v5/app.tsx'));
$navbarPath = resource_path('js/v5/components/app-navbar.tsx');
expect(file_exists($navbarPath))->toBeTrue();
$navbar = file_get_contents($navbarPath);
$sheetPath = resource_path('js/v5/components/ui/sheet.tsx');
expect(file_exists($sheetPath))->toBeTrue();
expect($app)
->toContain('progress: {')
->toContain('delay: 10')
->toContain("color: '#fcd452'")
->toContain('showSpinner: false')
->not->toContain('TopNavigationLoadingIndicator')
->not->toContain('withApp:');
expect($dashboardPage)
->toContain('Dashboard')
->toContain("import { AppNavbar } from '@/components/app-navbar';")
->not->toContain('function csrfToken()')
->toContain("import { canvasRequest } from '@/lib/canvas-api';")
->toContain("import { ApplicationInspectorSheet } from '@/components/canvas/application-inspector-sheet';")
->toContain("import { IngressDialog } from '@/components/canvas/ingress-dialog';")
->not->toContain("fetch('/v5/clusters'")
->toContain('<AppNavbar')
->toContain('bg-background text-foreground')
->toContain('h-dvh overflow-hidden bg-background text-foreground')
->toContain('relative h-full min-h-0 overflow-hidden pt-16')
->not->toContain('Add nginx')
->toContain('selectedNginxServerId')
->toContain('nginxImage')
->toContain('docker.io/library/nginx:alpine')
->toContain('server_uuid: selectedNginxServerId || null')
->toContain('image: nginxImage.trim() || DEFAULT_NGINX_IMAGE')
->toContain('openApplicationInspector')
->toContain('selectedInspectorApplication')
->toContain('application={selectedInspectorApplication}')
->toContain("method: 'DELETE'")
->toContain('removeApplication')
->toContain('useEffect(() => {')
->toContain('setApplications(settledResources.applications);')
->toContain('centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);')
->toContain('persistCaddyIngressPosition')
->toContain('startIngressDrag')
->toContain('canvasRequest(`/v5/caddy-ingresses/${ingress.id}/position`')
->toContain("canvasRequest('/v5/applications/nginx'")
->toContain('nginxServers = []')
->toContain('canvasRequest(`/v5/applications/${application.id}/position`')
->not->toContain('<header')
->not->toContain('h-[calc(100dvh-4rem)]')
->not->toContain('flex h-dvh flex-col overflow-hidden bg-background text-foreground')
->toContain('No applications on this canvas yet.')
->not->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';")
->not->toContain("fetch('/v5/selection'");
expect($canvasApi)
->not->toContain('function csrfToken()')
->toContain("import { csrfToken } from '@/lib/csrf';");
expect($toolbar)
->toContain('Deploy')
->toContain('Select nginx server')
->toContain('Center');
expect($applicationCard)
->toContain('Delete')
->toContain('onDoubleClick={(event) => onOpenInspector(event, application)}');
expect($caddyIngressCard)->toContain('Caddy ingress');
expect($inspectorSheet)
->toContain("import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';")
->toContain("import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';")
->toContain('App configuration')
->toContain('open={application !== null}')
->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>')
->toContain('Double-click an application card to open configuration.');
expect($ingressDialog)->toContain("import { Button } from '@/components/ui/button';");
expect(file_get_contents($sheetPath))
->toContain('blurOverlay = true')
->toContain('<SheetOverlay blur={blurOverlay} />');
expect($navbar)
->toContain("import { Link, router, usePage } from '@inertiajs/react';")
->toContain("import { cn } from '@/lib/utils';")
->toContain("import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';")
->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain('function csrfToken()')
->toContain('export function AppNavbar')
->toContain('/coolify-logo.svg')
->toContain('<Link')
->toContain('className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background"')
->not->toContain('className="sticky top-0 z-40 shrink-0 border-b border-border bg-background"')
->toContain('hover:bg-muted')
->toContain('text-muted-foreground')
->toContain('SelectGroup')
->toContain('variant="ghost"')
->toContain('position="popper"')
->toContain('sideOffset={4}')
->toContain('Select a project')
->toContain('Select an environment')
->toContain('const { url } = usePage();')
->toContain("const isClustersPage = url.startsWith('/v5/clusters');")
->toContain('href="/v5"')
->toContain('href="/v5/clusters"')
->toContain('Clusters')
->toContain('className="relative flex h-16 items-center gap-3 px-4 sm:px-6"')
->toContain('className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center gap-1 md:static md:flex-1 md:translate-x-0 md:justify-start md:gap-2"')
->toContain('className="max-w-[38vw] md:max-w-[10rem]"')
->toContain('className="max-w-[30vw] md:max-w-[10rem]"')
->toContain('aria-label="Open mobile menu"')
->toContain('<Sheet>')
->toContain('<SheetTrigger')
->toContain('<SheetContent side="right" className="w-72 max-w-[85vw] bg-background"')
->toContain('<SheetHeader>')
->toContain('<SheetTitle>Coolify</SheetTitle>')
->toContain('<SheetDescription className="sr-only">')
->toContain('<SheetClose')
->toContain('Move between Coolify v5 pages.')
->not->toContain('<SheetTitle className="sr-only">Navigation</SheetTitle>')
->toContain('className="hidden rounded-md px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:inline-flex"')
->toContain('className="inline-flex rounded-md p-2 text-warning transition-colors hover:bg-muted hover:text-warning md:hidden"')
->toContain('Dashboard')
->not->toContain('Home')
->toContain("fetch('/v5/selection'")
->toContain('router.reload({')
->toContain("'applications',")
->toContain("'caddyIngresses',")
->toContain("'resourceConnections',")
->toContain("'nginxServers',")
->toContain("'selectedProjectUuid',")
->toContain("'selectedEnvironmentUuid',")
->toContain('void persistSelection(nextProjectUuid, nextEnvironmentUuid).then((persisted) => {')
->toContain('void persistSelection(projectUuid, nextEnvironmentUuid).then((persisted) => {')
->toContain('refreshCurrentPageSelection();')
->toContain("'X-CSRF-TOKEN': csrfToken()")
->not->toContain('isMobileMenuOpen')
->not->toContain('setIsMobileMenuOpen')
->not->toContain('isProjectEnvironmentMenuOpen')
->not->toContain('setIsProjectEnvironmentMenuOpen')
->not->toContain('Open project and environment selector')
->not->toContain('Close project and environment selector')
->not->toContain('DropdownMenu')
->not->toContain('fixed inset-0 bg-black/80')
->not->toContain('fixed inset-y-0 right-0')
->not->toContain('<a')
->not->toContain("import { Button } from '@/components/ui/button';")
->not->toContain('<Button')
->not->toContain("import { Separator } from '@/components/ui/separator';")
->not->toContain('<Separator')
->not->toContain('min-h-[calc(100vh-4rem)]')
->not->toContain('py-10')
->not->toContain('bg-background/95')
->not->toContain('backdrop-blur')
->not->toContain('supports-[backdrop-filter]')
->not->toContain('border-coolgray')
->not->toContain('className="w-[12rem]"')
->not->toContain('<h1>Coolify v5</h1>')
->not->toContain('<h2 id="clusters-heading">Clusters</h2>');
expect(file_exists(resource_path('js/v5/components/top-navigation-loading-indicator.tsx')))->toBeFalse();
});
it('tracks v5 server actions with reusable per-id loading state', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
$pendingIdsHook = file_get_contents(resource_path('js/v5/lib/use-pending-ids.ts'));
expect($clustersPage)
->toContain("import { usePendingIds } from '@/lib/use-pending-ids';")
->toContain('const checkingServers = usePendingIds<string>()')
->toContain('const isCheckingServer = checkingServers.has(server.id)')
->toContain('checkingServers.start(server.id)')
->toContain('checkingServers.finish(server.id)')
->not->toContain('const [checkingServerId, setCheckingServerId] = useState<string | null>(null)')
->not->toContain('setCheckingServerId(server.id)')
->not->toContain('setCheckingServerId(null)');
expect($pendingIdsHook)
->toContain('export function usePendingIds')
->toContain('const [pendingIds, setPendingIds] = useState<Set<T>>(() => new Set())')
->toContain('start')
->toContain('finish')
->toContain('has')
->toContain('hasAny');
});
it('defines the v5 cluster management page and create cluster form', function () {
$clustersPagePath = resource_path('js/v5/Pages/Clusters.tsx');
$clustersPage = file_get_contents($clustersPagePath);
$buttonComponent = file_get_contents(resource_path('js/v5/components/ui/button.tsx'));
$dialogComponent = file_get_contents(resource_path('js/v5/components/ui/dialog.tsx'));
$types = file_get_contents(resource_path('js/v5/types.ts'));
expect(file_exists($clustersPagePath))->toBeTrue();
preg_match('/<Button\s+type="button"\s+variant="coolify"\s+aria-label="Add server to cluster"/m', $clustersPage, $addServerButtonMatches);
expect($addServerButtonMatches)->not->toBeEmpty();
expect($clustersPage)
->toContain("import { Button } from '@/components/ui/button';")
->toContain("} from '@/components/ui/dialog';")
->toContain("} from '@/components/ui/dropdown-menu';")
->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';")
->toContain("import { apiRequest } from '@/lib/api';")
->toContain("apiRequest('/v5/clusters'")
->toContain('aria-label="Select a cluster"')
->toContain('Cluster state')
->not->toContain('Last run')
->not->toContain('selectedCluster.lastCliStatus')
->not->toContain('selectedCluster.lastCliSummary')
->toContain('Generated mesh values are saved after bootstrap or extend runs.')
->not->toContain('CLI state')
->not->toContain('CLI-generated mesh values')
->toContain('Select a cluster')
->toContain('setSelectedClusterId(value)')
->toContain('aria-label="Create cluster"')
->toContain('setIsCreateDialogOpen(true)')
->toContain('Add cluster')
->toContain('variant="coolify"')
->not->toContain('border-warning bg-warning/10 text-foreground')
->not->toContain('border-primary bg-primary/10 text-foreground')
->toContain('<Dialog')
->toContain('<DialogTitle>Create cluster</DialogTitle>')
->toContain('<DialogDescription>')
->toContain('<DialogFooter>')
->not->toContain('<DialogClose')
->toContain('Cancel')
->toContain('<DialogContent className="max-w-md" showCloseButton={false}>')
->toContain('<DialogTitle>Confirm deletion</DialogTitle>')
->toContain('variant="delete"')
->toContain('aria-label="Add server to cluster"')
->toContain('setIsAddServerDialogOpen(true)')
->toContain('Add server')
->toContain('className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"')
->toContain('<DialogTitle>Add server</DialogTitle>')
->toContain('DialogContent className="max-w-3xl"')
->toContain('DialogContent className="max-w-2xl"')
->not->toContain('DialogContent className="max-h-[90dvh] max-w-3xl overflow-y-auto"')
->not->toContain('DialogContent className="max-h-[90dvh] max-w-2xl overflow-y-auto"')
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`')
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}`')
->toContain('hasBootstrapInProgress')
->toContain('Bootstrap')
->toContain('lastBootstrapOutput')
->toContain('Unable to queue bootstrap for this server.')
->toContain('border-destructive/30')
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}/servers`')
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`')
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`')
->toContain('apiRequest(`/v5/clusters/${cluster.id}/servers/${server.id}`')
->toContain("method: 'PATCH'")
->toContain("method: 'DELETE'")
->toContain('<DropdownMenu>')
->toContain('<DropdownMenuTrigger')
->toContain("import { DotsThreeIcon } from '@phosphor-icons/react';")
->toContain('variant="ghost"')
->toContain('size="icon-sm"')
->toContain('aria-label="Server actions"')
->toContain('<DotsThreeIcon data-icon="inline-start" weight="bold" />')
->not->toContain('>Server actions</DropdownMenuTrigger>')
->toContain('<DropdownMenuGroup>')
->toContain('<DropdownMenuSeparator />')
->toContain('Check connection')
->not->toContain('Check SSH')
->toContain('Not initialized')
->toContain('role="group"')
->toContain('aria-label="Server initialization"')
->toContain('rounded-l-md border border-r-0 border-destructive/30 bg-destructive/10')
->toContain('variant="coolify"')
->toContain('className="rounded-r-md"')
->toContain('onClick={() => void bootstrapServer(server)}')
->not->toContain('<DropdownMenuItem
disabled={isBootstrappingServer}')
->not->toContain('Bootstrap: {serverStatusLabel(server.status)}')
->not->toContain('Last bootstrap')
->not->toContain('{formatDate(server.lastBootstrappedAt)}')
->toContain("import { CanvasNotice } from '@/components/canvas/canvas-notice';")
->toContain('const [serverConnectionNotice, setServerConnectionNotice] = useState<ServerConnectionNotice | null>(null)')
->toContain('setServerConnectionNotice({')
->toContain('<CanvasNotice')
->toContain('variant={serverConnectionNotice.variant}')
->toContain('onDismiss={() => setServerConnectionNotice(null)}')
->not->toContain('aria-label="Dismiss server connection notification"')
->not->toContain('Latest SSH check: {latestSshCheck.status}')
->toContain('const notInitializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt === null) ?? []')
->toContain('const initializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt !== null) ?? []')
->toContain('Not initialized servers')
->toContain('{notInitializedServers.map(renderServerCard)}')
->toContain('{initializedServers.map(renderServerCard)}')
->toContain('{!isServerInitialized ? (')
->toContain('const [isBootstrapLogsDialogOpen, setIsBootstrapLogsDialogOpen] = useState(false)')
->toContain('const [bootstrapLogsServerId, setBootstrapLogsServerId] = useState<string | null>(null)')
->toContain('openBootstrapLogs(server)')
->toContain('View install logs')
->toContain('<DialogTitle>Install logs</DialogTitle>')
->toContain('const parsedBootstrapLogs = parseBootstrapOutput(bootstrapLogsServer?.lastBootstrapOutput ?? null)')
->toContain('parsedBootstrapLogs.summary.length > 0')
->toContain('parsedBootstrapLogs.visibleOutput')
->toContain('visibleOutput: hideRawBootstrapPlan(remainingOutput)')
->toContain("line.trim() === 'Plan:'")
->toContain('Action results')
->toContain('max-h-[70dvh]')
->toContain('{canShowBootstrapLogs ? (')
->not->toContain('Show install logs')
->not->toContain('{!isServerInitialized && isBootstrapLogVisible ? (')
->not->toContain('setVisibleBootstrapLogs')
->toContain('Delete server')
->toContain('This removes it from this cluster inventory so you can add it again later.')
->not->toContain('<Button\n type="button"\n variant="outline"\n size="sm"\n disabled={isCheckingServer}')
->toContain('<DialogTitle>Edit server</DialogTitle>')
->toContain('Edit server')
->toContain('Save server')
->toContain('editServerBuilderCapacity')
->toContain('editServerBuilderCpuQuota')
->toContain('Networking and bootstrap settings stay locked after creation.')
->toContain('Bootstrap SSH user')
->toContain('Bootstrap SSH port')
->not->toContain('{server.sshUser}@{server.host}:{server.sshPort}')
->toContain('showAdvancedServerConfiguration')
->toContain('Node address override')
->toContain('Defaults to server IP')
->not->toContain('CLI node address')
->toContain('wireguardListenPortOverride')
->toContain('wireguardEndpointOverride')
->toContain('privateKeys')
->toContain('selectedPrivateKeyId')
->toContain('Private key')
->toContain('Select a private key')
->toContain('appearance-none')
->toContain('backgroundImage: `url("data:image/svg+xml')
->not->toContain('No private key')
->toContain('Create cluster')
->toContain('Cluster details')
->toContain('Servers in this cluster')
->toContain('selectedCluster')
->toContain('Server IP')
->toContain('{server.host}')
->not->toContain('<dt className="text-muted-foreground">CLI node</dt>')
->not->toContain('CLI node')
->not->toContain('{server.nodeAddress ?? server.host}')
->toContain('builderCapacity')
->toContain('{server.builderEnabled ? (')
->toContain('<dt className="text-muted-foreground">Builder CPU quota</dt>')
->toContain('server.builderCpuQuota')
->toContain(') : null}')
->toContain('privateKeyName')
->toContain('lastBootstrappedAt')
->toContain('This removes it from this cluster inventory so you can add it again later.')
->toContain('deleteCluster')
->toContain('apiRequest(`/v5/clusters/${cluster.id}`')
->toContain("method: 'DELETE'")
->toContain('Delete cluster')
->toContain('selectedCluster.serversCount === 0')
->not->toContain("selectedCluster.serversCount === 1 ? 'server' : 'servers'")
->toContain('Only empty clusters can be deleted.')
->toContain('min-h-dvh overflow-y-auto bg-background text-foreground lg:h-dvh lg:overflow-hidden')
->toContain('flex min-h-dvh overflow-visible px-4 pt-16 lg:h-full lg:min-h-0 lg:overflow-hidden lg:px-6')
->toContain('flex w-full flex-col gap-4 py-4 lg:min-h-0 lg:py-6')
->toContain('rounded-lg border border-border bg-card p-4')
->toContain('flex items-start justify-between gap-3')
->toContain('min-w-0 flex-1')
->toContain('flex shrink-0 items-center justify-end gap-2 sm:flex-wrap')
->toContain('aria-label="Select a cluster"')
->toContain('setSelectedClusterId(value)')
->not->toContain('flex max-h-80 flex-col rounded-lg border border-border bg-card lg:max-h-none lg:min-h-0')
->toContain('overflow-visible rounded-lg border border-border bg-card lg:min-h-0 lg:overflow-y-auto')
->not->toContain('flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end')
->toContain('mt-4 grid grid-cols-1 gap-3 text-xs sm:grid-cols-2')
->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)]')
->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)_22rem]')
->not->toContain('New cluster')
->not->toContain('<aside className="rounded-lg border border-border bg-card p-5">')
->not->toContain('This is where the magic happens.');
expect(substr_count($clustersPage, 'variant="coolify"'))->toBe(6)
->and($buttonComponent)
->toContain('coolify:')
->toContain('bg-coollabs-50')
->toContain('hover:bg-coollabs')
->toContain('delete:');
expect($dialogComponent)
->toContain('@base-ui/react/dialog')
->toContain('z-50')
->toContain('max-h-[calc(100dvh-2rem)]')
->toContain('overflow-y-auto')
->toContain('top-1/2')
->toContain('-translate-y-1/2')
->not->toContain('top-4 bottom-4')
->not->toContain('top-1/2 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2')
->toContain('showCloseButton = true')
->toContain('aria-label="Close dialog"')
->toContain('<XIcon />')
->toContain('DialogTitle')
->toContain('DialogDescription')
->toContain("'mt-6 flex justify-end gap-2'");
$v5Css = file_get_contents(resource_path('css/v5/app.css'));
expect($v5Css)
->toContain('--color-warning: var(--warning);')
->toContain('--warning: #fcd452;')
->not->toContain('--ring: oklch(0.705 0.015 286.067);')
->not->toContain('--ring: oklch(0.552 0.016 285.938);');
expect(substr_count($v5Css, '--ring: var(--warning);'))->toBe(2);
expect($types)
->not->toContain('sshUser: string;')
->not->toContain('sshPort: number;')
->toContain('builderEnabled: boolean;')
->toContain('builderCapacity: number;')
->toContain('builderCpuQuota: string;')
->toContain('privateKeyName: string | null;')
->toContain('lastBootstrappedAt: string | null;')
->toContain('lastBootstrapStatus: string | null;')
->toContain('lastStatusOutput: string | null;');
});
it('uses the standard button size for the v5 delete cluster action', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain("variant=\"delete\"\n size=\"default\"\n onClick={openDeleteClusterDialog}")
->not->toContain("variant=\"delete\"\n size=\"sm\"\n onClick={openDeleteClusterDialog}");
});
it('shows the v5 server delete validation error in the confirmation dialog', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain('const payload = (await response?.json().catch(() => null)) as { message?: string } | null;')
->toContain('response?.status === 422')
->toContain("'Delete or move applications from this server before deleting it.'")
->toContain("'Unable to delete this server. Please try again.'")
->toContain('role="alert"')
->toContain('border-destructive/30 bg-destructive/10')
->toContain('setDeleteClusterError(null);');
});
it('defines a ghost variant for compact v5 select triggers', function () {
$select = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
expect($select)
->toContain('@base-ui/react/select')
->not->toContain('radix-ui')
->toContain("variant = 'default'")
->toContain("variant === 'default'")
->toContain("variant === 'ghost'")
->toContain('border-transparent')
->toContain('h-auto')
->toContain('text-sm')
->toContain("position === 'popper' ? false : true");
});
it('provides reusable v5 form field primitives with reserved validation error space', function () {
$fieldPath = resource_path('js/v5/components/ui/field.tsx');
$inputPath = resource_path('js/v5/components/ui/input.tsx');
$textareaPath = resource_path('js/v5/components/ui/textarea.tsx');
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect(file_exists($fieldPath))->toBeTrue()
->and(file_exists($inputPath))->toBeTrue()
->and(file_exists($textareaPath))->toBeTrue();
$field = file_get_contents($fieldPath);
$input = file_get_contents($inputPath);
$textarea = file_get_contents($textareaPath);
expect($field)
->toContain('function Field(')
->toContain('function FieldLabel(')
->toContain('function FieldError(')
->toContain('min-h-4')
->toContain('aria-live="polite"')
->toContain('message ? undefined : true')
->toContain('export { Field, FieldError, FieldLabel }');
expect($input)
->toContain('function Input(')
->toContain('aria-invalid:border-destructive')
->toContain('export { Input };');
expect($textarea)
->toContain('function Textarea(')
->toContain('aria-invalid:border-destructive')
->toContain('export { Textarea };');
expect($clustersPage)
->toContain("import { Field, FieldError, FieldLabel } from '@/components/ui/field';")
->toContain("import { Input } from '@/components/ui/input';")
->toContain("import { Textarea } from '@/components/ui/textarea';")
->toContain('<FieldError message={errors.name?.[0]} />')
->toContain('<FieldError message={serverErrors.private_key_uuid?.[0]} />')
->not->toContain('{errors.name ? <span className="text-xs text-destructive">{errors.name[0]}</span> : null}')
->not->toContain('{serverErrors.name ? (');
});
it('uses the requested shadcn preset configuration for v5', function () {
$components = json_decode(file_get_contents(base_path('components.json')), true);
$css = file_get_contents(resource_path('css/v5/app.css'));
expect($components['style'])
->toBe('base-lyra')
->and($components['tsx'])->toBeTrue()
->and($components['iconLibrary'])->toBe('phosphor')
->and($components['tailwind']['css'])->toBe('resources/css/v5/app.css')
->and($components['tailwind']['baseColor'])->toBe('zinc')
->and($css)->toContain('@import "@fontsource-variable/geist";')
->and($css)->toContain('--foreground: oklch(0.141 0.005 285.823);')
->and($css)->toContain('--background: #101010;')
->and($css)->toContain('button:not(:disabled)');
});
it('sizes the v5 app root with the dynamic mobile viewport', function () {
$css = file_get_contents(resource_path('css/v5/app.css'));
expect($css)
->toContain('min-height: 100dvh;')
->not->toContain('min-height: 100vh;');
});
it('does not include coolify version controls on the v5 dashboard page', function () {
$dashboardPage = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($dashboardPage)
->not->toContain('Check coolify version')
->not->toContain('/v5/coolify/version')
->not->toContain('Installed version:');
});
it('does not render flux status in the v5 navbar', function () {
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.tsx'));
expect($navbar)
->not->toContain('Flux: {flux?.label ??')
->not->toContain('title={flux?.socket ?? flux?.message ?? undefined}')
->not->toContain('{clusters.length} clusters')
->not->toContain('<h2 id="flux-status-heading">Flux status</h2>')
->not->toContain('<p>{flux.message}</p>')
->not->toContain('Socket: {flux.socket}');
});
it('hardens v5 dashboard fetches and websocket merges against failures', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$canvasApiSource = file_get_contents(resource_path('js/v5/lib/canvas-api.ts'));
$statusBadgeSource = file_get_contents(resource_path('js/v5/components/canvas/status-badge.ts'));
expect($canvasApiSource)->toContain('AbortSignal.timeout(30_000)');
expect($statusBadgeSource)->toContain("'starting'");
expect($dashboardSource)->toContain('Could not save the card position.');
$addNginxSource = substr($dashboardSource, strpos($dashboardSource, 'addNginx'));
expect(strpos($addNginxSource, '!response.ok'))->not->toBeFalse();
});
it('preserves all v5 resource connection firewall directions when editing ports', function () {
$connectionsHook = file_get_contents(resource_path('js/v5/lib/use-canvas-connections.ts'));
$updateDirectionSource = substr(
$connectionsHook,
strpos($connectionsHook, 'const updateConnectionDirection'),
strpos($connectionsHook, 'const setConnectionPortDraft') - strpos($connectionsHook, 'const updateConnectionDirection'),
);
expect($connectionsHook)
->toContain('function connectionPortsPayload(connection: CanvasConnection)')
->toContain('body: { ports_by_direction: connectionPortsPayload(updatedConnection) }')
->toContain('preserveActiveDirection(nextConnection, updatedConnection)')
->and($updateDirectionSource)
->toContain('replaceConnection(updatedConnection)')
->not->toContain('persistConnectionPorts(updatedConnection, connection)');
});
-343
View File
@@ -1,343 +0,0 @@
<?php
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
resetV5DashboardTestState();
});
it('reuses existing projects instead of creating v5 projects', function () {
expect(file_exists(database_path('migrations-v5/2026_06_04_050157_v5_create_projects_table.php')))->toBeFalse()
->and(file_exists(app_path('Models/V5/Project.php')))->toBeFalse();
});
it('creates v5 cluster tables and lets each server belong to one cluster', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
expect(Schema::hasTable('v5_clusters'))->toBeTrue()
->and(Schema::hasColumns('v5_clusters', [
'id',
'team_id',
'created_by_user_id',
'name',
'description',
'wireguard_interface',
'wireguard_management_pool',
'wireguard_listen_port',
'container_network_pool',
'container_network_prefix',
'namespaces',
'default_deny_containers',
'coold_version',
'corrosion_version',
'corrosion_gossip_port',
'corrosion_api_port',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'builder_memory_max',
'builder_timeout_secs',
'last_cli_action',
'last_cli_status',
'last_cli_summary',
'last_cli_ran_at',
'created_at',
'updated_at',
]))->toBeTrue()
->and(Schema::hasColumn('v5_servers', 'cluster_id'))->toBeTrue();
});
it('creates v5 server tables in the shared database', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$migration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$migration->up();
expect(Schema::hasTable('v5_servers'))->toBeTrue()
->and(Schema::hasColumns('v5_servers', [
'id',
'team_id',
'cluster_id',
'created_by_user_id',
'private_key_id',
'name',
'host',
'ssh_user',
'ssh_port',
'status',
'capabilities',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'node_address',
'wireguard_listen_port_override',
'wireguard_endpoint_override',
'uuid',
'wireguard_management_ip',
'wireguard_public_key',
'container_subnets',
'last_bootstrapped_at',
'last_bootstrap_action',
'last_bootstrap_status',
'last_bootstrap_output',
'last_bootstrap_ran_at',
'last_status_check',
'last_status_output',
'last_status_checked_at',
'created_at',
'updated_at',
]))->toBeTrue();
});
it('creates v5 server canvas columns for movable caddy ingress nodes', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
expect(Schema::hasColumns('v5_servers', [
'canvas_x',
'canvas_y',
]))->toBeTrue();
});
it('creates v5 server ingress columns', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
expect(Schema::hasColumns('v5_servers', [
'ingress_type',
'ingress_status',
]))->toBeTrue();
});
it('creates v5 application tables for dashboard canvas nodes', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_application_domains');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$applicationMigration = include database_path('migrations-v5/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration->up();
expect(Schema::hasTable('v5_applications'))->toBeTrue()
->and(Schema::hasColumns('v5_applications', [
'id',
'team_id',
'project_id',
'environment_id',
'server_id',
'created_by_user_id',
'name',
'image',
'container_name',
'status',
'status_message',
'runtime_container_id',
'mesh_namespace',
'ingress_enabled',
'internal_port',
'canvas_x',
'canvas_y',
'created_at',
'updated_at',
]))->toBeTrue();
});
it('creates v5 application domain tables for zero or more inbound routes', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_application_domains');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$applicationMigration = include database_path('migrations-v5/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration->up();
expect(Schema::hasTable('v5_application_domains'))->toBeTrue()
->and(Schema::hasColumns('v5_application_domains', [
'id',
'application_id',
'domain',
'created_at',
'updated_at',
]))->toBeTrue()
->and(Schema::hasColumns('v5_applications', [
'ingress_enabled',
'internal_port',
]))->toBeTrue();
});
it('creates generic v5 resource connection tables', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_resource_connection_rules');
Schema::dropIfExists('v5_resource_connections');
Schema::dropIfExists('v5_application_domains');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$applicationMigration = include database_path('migrations-v5/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration->up();
$connectionMigration = include database_path('migrations-v5/2026_06_19_142000_v5_create_resource_connections_table.php');
$connectionMigration->up();
expect(Schema::hasTable('v5_resource_connections'))->toBeTrue()
->and(Schema::hasColumns('v5_resource_connections', [
'id',
'team_id',
'project_id',
'environment_id',
'resource_one_type',
'resource_one_id',
'resource_two_type',
'resource_two_id',
'resource_pair_key',
'created_by_user_id',
'created_at',
'updated_at',
]))->toBeTrue()
->and(Schema::hasTable('v5_resource_connection_rules'))->toBeTrue()
->and(Schema::hasColumns('v5_resource_connection_rules', [
'id',
'connection_id',
'source_resource_type',
'source_resource_id',
'target_resource_type',
'target_resource_id',
'protocol',
'port',
'created_at',
'updated_at',
]))->toBeTrue();
});
it('keeps v5 server fields in the initial migration', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
expect(Schema::hasColumns('v5_servers', [
'uuid',
'ingress_type',
'ingress_status',
'builder_cpu_quota',
'node_address',
'wireguard_management_ip',
'container_subnets',
'canvas_x',
'canvas_y',
'last_bootstrap_output',
'last_status_output',
]))->toBeTrue();
});
it('includes v5 tables in the dev testing schema', function () {
$schema = file_get_contents(database_path('schema/testing-schema.sql'));
expect($schema)->toContain('"team_id" INTEGER NOT NULL')
->and($schema)->toContain('"created_by_user_id" INTEGER NOT NULL')
->and($schema)->not->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"')
->and($schema)->not->toContain('2026_06_04_050157_v5_create_projects_table')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_servers"')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_container_statuses"')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_applications"')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_application_domains"')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_resource_connections"')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_resource_connection_rules"')
->and($schema)->toContain('"domain" TEXT NOT NULL')
->and($schema)->toContain('"ingress_enabled" INTEGER DEFAULT false NOT NULL')
->and($schema)->toContain('"internal_port" INTEGER')
->and($schema)->toContain('"cluster_id" INTEGER')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_clusters"')
->and($schema)->toContain('"wireguard_interface" TEXT DEFAULT \'wg0\' NOT NULL')
->and($schema)->toContain('"wireguard_management_pool" TEXT DEFAULT \'100.64.0.0/16\' NOT NULL')
->and($schema)->toContain('"container_network_pool" TEXT DEFAULT \'10.210.0.0/16\' NOT NULL')
->and($schema)->toContain('"builder_timeout_secs" INTEGER NOT NULL DEFAULT \'1800\'')
->and($schema)->toContain('"private_key_id" INTEGER')
->and($schema)->toContain('"ingress_type" TEXT')
->and($schema)->toContain('"ingress_status" TEXT')
->and($schema)->toContain('"builder_cpu_quota" TEXT DEFAULT \'200%\' NOT NULL')
->and($schema)->toContain('"uuid" TEXT')
->and($schema)->toContain('"wireguard_management_ip" TEXT')
->and($schema)->toContain('"container_subnets" JSON')
->and($schema)->toContain('"canvas_x" INTEGER')
->and($schema)->toContain('"canvas_y" INTEGER')
->and($schema)->toContain('"last_bootstrap_output" TEXT')
->and($schema)->toContain('"last_status_output" TEXT')
->and($schema)->toContain('2026_06_16_130650_v5_create_servers_table')
->and($schema)->toContain('2026_06_19_140000_v5_create_applications_table')
->and($schema)->toContain('2026_06_19_142000_v5_create_resource_connections_table')
->and($schema)->toContain('2026_06_19_182231_create_container_statuses_table')
->and($schema)->not->toContain('2026_06_19_141231_add_canvas_position_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_19_173933_add_ingress_status_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_20_072818_v5_add_ingress_routing_to_applications_table')
->and($schema)->not->toContain('2026_06_19_150000_add_mesh_namespace_to_v5_applications_table')
->and($schema)->toContain('2026_06_16_130649_v5_create_clusters_table')
->and($schema)->not->toContain('2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers')
->and($schema)->not->toContain('2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table')
->and($schema)->not->toContain('2026_06_17_172845_add_status_check_fields_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_19_064539_add_bootstrap_log_fields_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_19_090217_add_uuid_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_16_131229_add_cluster_id_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_16_132000_make_v5_server_private_key_nullable')
->and($schema)->not->toContain('v5_hosts');
});
@@ -1,47 +0,0 @@
<?php
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection;
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
});
it('does not consider projects or environments with v5 applications empty', function () {
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'created_by_user_id' => $user->id,
'name' => 'nginx',
'image' => 'nginx:alpine',
'container_name' => 'v5-nginx',
]);
expect($project->isEmpty())->toBeFalse()
->and($environment->isEmpty())->toBeFalse();
});
it('does not consider projects or environments with v5 resource connections empty', function () {
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
ResourceConnection::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'resource_one_type' => V5Application::class,
'resource_one_id' => 1,
'resource_two_type' => V5Application::class,
'resource_two_id' => 2,
'resource_pair_key' => 'application:1|application:2',
'created_by_user_id' => $user->id,
]);
expect($project->isEmpty())->toBeFalse()
->and($environment->isEmpty())->toBeFalse();
});
@@ -1,94 +0,0 @@
<?php
use App\Http\Kernel;
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\V5\EnsureCurrentTeam;
use App\Http\Middleware\V5\HandleInertiaRequests;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Route;
beforeEach(function () {
resetV5DashboardTestState();
});
it('registers the v5 dashboard route', function () {
expect(Route::has('v5.dashboard'))->toBeTrue()
->and(Route::has('v5.home'))->toBeFalse()
->and(Route::has('v5.selection.update'))->toBeTrue()
->and(Route::has('v5.clusters.index'))->toBeTrue()
->and(Route::has('v5.clusters.show'))->toBeTrue()
->and(Route::has('v5.clusters.store'))->toBeTrue()
->and(Route::has('v5.clusters.destroy'))->toBeTrue()
->and(Route::has('v5.clusters.servers.store'))->toBeTrue()
->and(Route::has('v5.clusters.servers.update'))->toBeTrue()
->and(Route::has('v5.clusters.servers.check'))->toBeTrue()
->and(Route::has('v5.clusters.servers.coold-logs'))->toBeTrue()
->and(Route::has('v5.clusters.servers.corrosion-tables'))->toBeTrue()
->and(Route::has('v5.clusters.servers.bootstrap'))->toBeTrue()
->and(Route::has('v5.clusters.servers.destroy'))->toBeTrue()
->and(Route::has('v5.applications.nginx'))->toBeTrue()
->and(Route::has('v5.applications.refresh'))->toBeTrue()
->and(Route::has('v5.applications.position'))->toBeTrue()
->and(Route::has('v5.applications.ingress'))->toBeTrue()
->and(Route::has('v5.caddy-ingresses.position'))->toBeTrue()
->and(Route::has('v5.applications.destroy'))->toBeTrue()
->and(Route::has('v5.resource-connections.store'))->toBeTrue()
->and(Route::has('v5.resource-connections.update'))->toBeTrue()
->and(Route::has('v5.resource-connections.destroy'))->toBeTrue()
->and(Route::has('v5.realtime-test'))->toBeTrue()
->and(Route::has('v5.realtime-test.broadcast'))->toBeTrue()
->and(Route::has('v5.coolify.version'))->toBeFalse()
->and(Route::has('v5.coolify.bootstrap'))->toBeFalse();
});
it('uses separated v5 middleware groups', function () {
$kernel = app(Kernel::class);
$reflection = new ReflectionClass($kernel);
$property = $reflection->getProperty('middlewareGroups');
$property->setAccessible(true);
$groups = $property->getValue($kernel);
expect($groups)->toHaveKey('v5.web')
->and($groups)->toHaveKey('v5.authenticated')
->and($groups['v5.web'])->toContain(HandleInertiaRequests::class)
->and($groups['v5.web'])->not->toContain(CheckForcePasswordReset::class)
->and($groups['v5.web'])->not->toContain(DecideWhatToDoWithUser::class)
->and($groups['v5.authenticated'])->toContain('auth')
->and($groups['v5.authenticated'])->toContain('verified')
->and($groups['v5.authenticated'])->toContain(EnsureCurrentTeam::class);
});
it('redirects guests to the shared login', function () {
$this->get('/v5')
->assertRedirect('/login');
});
it('selects a shared team when the session has no current team', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Grace Hopper',
'email' => 'grace@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Auto Selected Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'admin']);
$this
->actingAs($user)
->get('/v5')
->assertSuccessful()
->assertSessionHas('currentTeam')
->assertDontSee('Auto Selected Team')
->assertDontSee('admin');
});
-329
View File
@@ -1,329 +0,0 @@
<?php
use App\Jobs\V5TeardownTeamJob;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Application as V5Application;
use App\Models\V5\RevokedAgentToken;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\FluxClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->owner = User::factory()->create();
$this->team = Team::factory()->create(['name' => 'Teardown Team']);
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
});
/**
* @param array<string, mixed> $attributes
*/
function createTeardownServer(Team $team, User $owner, PrivateKey $privateKey, array $attributes = []): V5Server
{
return V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $owner->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-'.fake()->unique()->numberBetween(1, 9999),
'host' => fake()->unique()->ipv4(),
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
...$attributes,
]);
}
function createTeardownApplication(Team $team, User $owner, V5Server $server): V5Application
{
$project = Project::query()->create(['name' => fake()->unique()->company(), 'team_id' => $team->id]);
// Project creation auto-provisions a default environment; reuse it rather
// than colliding with the (name, project_id) unique index.
$environment = $project->environments()->first()
?? Environment::query()->create(['name' => 'production', 'project_id' => $project->id]);
// The checked-in testing-schema.sql dump predates the v5_applications.uuid
// column, so skip the V5Model uuid-generating event (matching how the other
// V5 suites hand-roll their schema) to insert against the dump.
return V5Application::withoutEvents(fn () => V5Application::query()->forceCreate([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $owner->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-'.fake()->unique()->numerify('#######'),
'status' => 'running',
]));
}
/**
* @param array<string, mixed> $overrides
* @param array<int, array<string, mixed>> $applications
* @return array<string, mixed>
*/
function teardownServerPayload(array $overrides = [], array $applications = []): array
{
return [
'id' => 1,
'uuid' => 'teardown-server-uuid',
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'node_address' => '203.0.113.10',
'wireguard_management_ip' => '100.64.0.10',
'is_ingress' => false,
'ingress_type' => null,
'status' => 'installed',
'last_bootstrapped_at' => now()->toISOString(),
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
'applications' => $applications,
...$overrides,
];
}
it('dispatches the v5 teardown job for the team servers when the team is deleted', function () {
Queue::fake();
$serverOne = createTeardownServer($this->team, $this->owner, $this->privateKey, [
'host' => '203.0.113.10',
'is_ingress' => true,
'ingress_type' => 'caddy',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
createTeardownApplication($this->team, $this->owner, $serverOne);
$serverTwo = createTeardownServer($this->team, $this->owner, $this->privateKey, [
'host' => '203.0.113.11',
'last_bootstrapped_at' => now(),
]);
$this->team->delete();
// The team deletion completes (the teardown hook never blocks it). The
// per-row cascade is a database-level guarantee from the v5 migrations'
// cascadeOnDelete() foreign keys; the in-memory SQLite test dump does not
// enforce it, so it is not asserted here.
expect(Team::find($this->team->id))->toBeNull();
Queue::assertPushed(V5TeardownTeamJob::class, function (V5TeardownTeamJob $job): bool {
if ($job->teamId !== $this->team->id || count($job->servers) !== 2) {
return false;
}
$ingressServer = collect($job->servers)->firstWhere('host', '203.0.113.10');
return $ingressServer !== null
&& $ingressServer['is_ingress'] === true
&& str_contains((string) $ingressServer['private_key'], 'BEGIN OPENSSH PRIVATE KEY')
&& count($ingressServer['applications']) === 1
&& str_starts_with((string) $ingressServer['applications'][0]['container_name'], 'coolify-v5-nginx-');
});
});
it('does not dispatch teardown when the team owns no v5 servers', function () {
Queue::fake();
$this->team->delete();
Queue::assertNotPushed(V5TeardownTeamJob::class);
});
it('runs the full per-server teardown sequence over ssh and flux', function () {
Process::fake(['*' => Process::result(output: '')]);
$fluxClient = Mockery::mock(FluxClient::class, function (MockInterface $mock): void {
$mock->shouldReceive('revokeFirewallRule')->andReturn('Firewall rule revoked.');
$mock->shouldReceive('stopIngress')
->once()
->with(Mockery::type('string'), 'caddy')
->andReturn('Caddy ingress stopped.');
});
app()->instance(FluxClient::class, $fluxClient);
$payload = teardownServerPayload([
'is_ingress' => true,
'ingress_type' => 'caddy',
], [
['id' => 5, 'container_name' => 'coolify-v5-nginx-abc', 'runtime_container_id' => 'runtime-1'],
]);
(new V5TeardownTeamJob($this->team->id, [$payload]))->handle();
// Application container removal ran over SSH.
Process::assertRan(fn ($process) => is_array($process->command)
&& ($process->command[0] ?? null) === 'ssh'
&& str_contains((string) end($process->command), "rm -f 'coolify-v5-nginx-abc'"));
// On-host bootstrap identity removal ran over SSH.
Process::assertRan(fn ($process) => is_array($process->command)
&& ($process->command[0] ?? null) === 'ssh'
&& str_contains((string) end($process->command), '/etc/coolify/v5-node.json'));
});
it('keeps tearing down other servers when one host is unreachable', function () {
Process::fake(['*' => Process::result(output: '')]);
// The ingress server's Flux stop throws (unreachable host); teardown of the
// rest must still proceed and the job must not fail.
$fluxClient = Mockery::mock(FluxClient::class, function (MockInterface $mock): void {
$mock->shouldReceive('revokeFirewallRule')->andReturn('Firewall rule revoked.');
$mock->shouldReceive('stopIngress')
->with(Mockery::type('string'), 'caddy')
->andThrow(new RuntimeException('flux socket unreachable'));
});
app()->instance(FluxClient::class, $fluxClient);
$unreachableIngress = teardownServerPayload([
'id' => 1,
'host' => '203.0.113.10',
'wireguard_management_ip' => '100.64.0.10',
'is_ingress' => true,
'ingress_type' => 'caddy',
]);
$healthyServer = teardownServerPayload([
'id' => 2,
'host' => '203.0.113.11',
'wireguard_management_ip' => '100.64.0.11',
]);
(new V5TeardownTeamJob($this->team->id, [$unreachableIngress, $healthyServer]))->handle();
// The healthy server was still cleaned up despite the first host failing.
Process::assertRan(fn ($process) => is_array($process->command)
&& in_array('root@203.0.113.11', $process->command, true)
&& str_contains((string) end($process->command), '/etc/coolify/v5-node.json'));
// The unreachable ingress server's marker removal was still attempted.
Process::assertRan(fn ($process) => is_array($process->command)
&& in_array('root@203.0.113.10', $process->command, true)
&& str_contains((string) end($process->command), '/etc/coolify/v5-node.json'));
});
it('captures each server agent token jti and expiry in the dispatched payload', function () {
Queue::fake();
$server = createTeardownServer($this->team, $this->owner, $this->privateKey, [
'host' => '203.0.113.42',
'agent_token_jti' => 'jti-captured',
'agent_token_expires_at' => now()->addDay(),
'last_bootstrapped_at' => now(),
]);
$this->team->delete();
Queue::assertPushed(V5TeardownTeamJob::class, function (V5TeardownTeamJob $job): bool {
$payload = collect($job->servers)->firstWhere('host', '203.0.113.42');
return $payload !== null
&& $payload['agent_token_jti'] === 'jti-captured'
&& $payload['agent_token_expires_at'] !== null;
});
});
it('revokes each server host token during teardown', function () {
Process::fake(['*' => Process::result(output: '')]);
$expiresAtOne = now()->addDay();
$expiresAtTwo = now()->addDays(2);
$fluxClient = Mockery::mock(FluxClient::class, function (MockInterface $mock) use ($expiresAtOne, $expiresAtTwo): void {
$mock->shouldReceive('revokeFirewallRule')->andReturn('Firewall rule revoked.');
$mock->shouldReceive('revokeToken')->once()->with('jti-1', $expiresAtOne->timestamp);
$mock->shouldReceive('revokeToken')->once()->with('jti-2', $expiresAtTwo->timestamp);
});
app()->instance(FluxClient::class, $fluxClient);
// last_bootstrapped_at null so only the explicit token revocation runs (no
// SSH marker removal, which would revoke a second time).
$serverOne = teardownServerPayload([
'id' => 1,
'host' => '203.0.113.10',
'last_bootstrapped_at' => null,
'agent_token_jti' => 'jti-1',
'agent_token_expires_at' => $expiresAtOne->toISOString(),
]);
$serverTwo = teardownServerPayload([
'id' => 2,
'host' => '203.0.113.11',
'last_bootstrapped_at' => null,
'agent_token_jti' => 'jti-2',
'agent_token_expires_at' => $expiresAtTwo->toISOString(),
]);
(new V5TeardownTeamJob($this->team->id, [$serverOne, $serverTwo]))->handle();
expect(RevokedAgentToken::query()->where('jti', 'jti-1')->exists())->toBeTrue()
->and(RevokedAgentToken::query()->where('jti', 'jti-2')->exists())->toBeTrue();
});
it('logs the incomplete-teardown error naming hosts that could not be torn down', function () {
Process::fake(['*' => Process::result(output: '')]);
Log::spy();
// The ingress host's Flux stop throws (unreachable) so it is reported
// incomplete; the healthy host tears down cleanly.
$fluxClient = Mockery::mock(FluxClient::class, function (MockInterface $mock): void {
$mock->shouldReceive('revokeFirewallRule')->andReturn('Firewall rule revoked.');
$mock->shouldReceive('revokeToken')->andReturnNull();
$mock->shouldReceive('stopIngress')
->with(Mockery::type('string'), 'caddy')
->andThrow(new RuntimeException('flux socket unreachable'));
});
app()->instance(FluxClient::class, $fluxClient);
$unreachableIngress = teardownServerPayload([
'id' => 1,
'host' => '203.0.113.10',
'wireguard_management_ip' => '100.64.0.10',
'is_ingress' => true,
'ingress_type' => 'caddy',
]);
$healthyServer = teardownServerPayload([
'id' => 2,
'host' => '203.0.113.11',
'wireguard_management_ip' => '100.64.0.11',
]);
(new V5TeardownTeamJob($this->team->id, [$unreachableIngress, $healthyServer]))->handle();
// The healthy host was still cleaned up despite the ingress host failing.
Process::assertRan(fn ($process) => is_array($process->command)
&& in_array('root@203.0.113.11', $process->command, true)
&& str_contains((string) end($process->command), '/etc/coolify/v5-node.json'));
// The operator-facing signal names only the unreachable host.
Log::shouldHaveReceived('error')->withArgs(
fn (string $message, array $context = []) => str_contains($message, 'v5 team teardown incomplete')
&& ($context['team_id'] ?? null) === $this->team->id
&& collect($context['hosts'] ?? [])->contains(fn ($host) => ($host['host'] ?? null) === '203.0.113.10')
&& collect($context['hosts'] ?? [])->doesntContain(fn ($host) => ($host['host'] ?? null) === '203.0.113.11')
)->once();
});
it('does not remove the bootstrap marker for servers that were never bootstrapped', function () {
Process::fake(['*' => Process::result(output: '')]);
$payload = teardownServerPayload([
'last_bootstrapped_at' => null,
'is_ingress' => false,
]);
(new V5TeardownTeamJob($this->team->id, [$payload]))->handle();
Process::assertNothingRan();
});
+37
View File
@@ -0,0 +1,37 @@
<?php
use Illuminate\Support\Facades\Route;
it('does not register the archived v5 interface', function () {
expect(Route::has('v5.dashboard'))->toBeFalse()
->and(collect(Route::getRoutes())->contains(fn ($route) => str_starts_with($route->uri(), 'v5')))->toBeFalse();
});
it('keeps v5 prototypes outside executable application paths', function () {
expect(is_dir(app_path('Support/V5')))->toBeFalse()
->and(is_dir(resource_path('js/v5')))->toBeFalse()
->and(is_dir(resource_path('css/v5')))->toBeFalse()
->and(is_dir(resource_path('views/v5')))->toBeFalse()
->and(file_exists(base_path('routes/v5.php')))->toBeFalse()
->and(file_exists(config_path('v5.php')))->toBeFalse();
});
it('archives the previous v5 migrations and ui as documentation', function () {
expect(glob(base_path('docs/v5/migrations/*.php.txt')))->toHaveCount(15)
->and(glob(base_path('docs/v5/ui/resources/js/v5/**/*.txt')))->not->toBeEmpty()
->and(file_exists(base_path('docs/v5/ui/resources/views/v5/app.blade.php.txt')))->toBeTrue()
->and(file_exists(base_path('docs/v5/archive/app/Support/V5/V5Feature.php.txt')))->toBeTrue()
->and(file_exists(base_path('docs/v5/archive/routes/v5.php.txt')))->toBeTrue()
->and(file_exists(base_path('docs/v5/archive/tests/v5/Browser/DashboardSmokeTest.php.txt')))->toBeTrue();
});
it('documents removed v5 packages and the v4 animation dependency', function () {
$packageDocumentation = file_get_contents(base_path('docs/v5/package.md'));
expect($packageDocumentation)
->toContain('`inertiajs/inertia-laravel`')
->toContain('`react`')
->toContain('`@vitejs/plugin-react`')
->toContain('`tw-animate-css`')
->toContain('must not be removed');
});
@@ -1,82 +0,0 @@
<?php
use Illuminate\Support\Facades\Artisan;
use Symfony\Component\Process\Process;
function inspectV5RegistrationForEnvironment(string $environment): array
{
$script = <<<'PHP'
$environment = $argv[1];
putenv("APP_ENV={$environment}");
$_ENV['APP_ENV'] = $environment;
$_SERVER['APP_ENV'] = $environment;
require 'vendor/autoload.php';
$app = require 'bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
echo json_encode([
'routes' => collect($app['router']->getRoutes()->getRoutes())
->map(fn ($route) => $route->uri())
->values()
->all(),
'migration_paths' => $app->make('migrator')->paths(),
'horizon_supervisors' => array_keys(config("horizon.environments.{$environment}", [])),
], JSON_THROW_ON_ERROR);
PHP;
$process = new Process([PHP_BINARY, '-r', $script, $environment], base_path());
$process->mustRun();
return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
}
it('registers v5 routes, migrations, and workers in development environments', function (string $environment) {
$registration = inspectV5RegistrationForEnvironment($environment);
expect($registration['routes'])
->toContain('v5')
->toContain('api/v1/internal/flux/resource-status')
->and($registration['migration_paths'])->toContain(database_path('migrations-v5'))
->and($registration['horizon_supervisors'])->toContain('v5reconcile');
})->with(['local', 'development', 'dev', 'testing']);
it('does not register v5 routes, migrations, or workers outside development', function (string $environment) {
$registration = inspectV5RegistrationForEnvironment($environment);
expect($registration['routes'])
->not->toContain('v5')
->not->toContain('api/v1/internal/flux/resource-status')
->and($registration['migration_paths'])->not->toContain(database_path('migrations-v5'))
->and($registration['horizon_supervisors'])->not->toContain('v5reconcile');
})->with(['production', 'staging']);
it('keeps v5 schema changes out of the default migration path', function () {
$v5MigrationFiles = collect(glob(database_path('migrations/*.php')))
->filter(fn (string $path) => str_contains((string) file_get_contents($path), "'v5_"));
expect($v5MigrationFiles)->toBeEmpty();
});
it('does not ship the v5 Flux runtime in the production container', function () {
$productionCompose = file_get_contents(base_path('docker-compose.prod.yml'));
$productionDockerfile = file_get_contents(base_path('docker/production/Dockerfile'));
expect($productionCompose)
->not->toContain('COOLIFY_FLUX')
->not->toContain('/data/coolify/flux')
->and($productionDockerfile)
->not->toContain('COOLIFY_FLUX_VERSION')
->not->toContain('/usr/local/bin/flux');
});
it('blocks v5 console commands when v5 is disabled', function (string $command) {
config()->set('v5.enabled', false);
expect(Artisan::call($command))->toBe(1)
->and(Artisan::output())->toContain('V5 is only available in development environments.');
})->with([
'flux:dev',
'v5:flux-generate-keys',
'v5:sync-dev-lima-servers',
]);
@@ -1,22 +0,0 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Support\V5TestSchema;
uses(RefreshDatabase::class);
it('keeps v4 project and environment checks working without the v5 schema', function () {
config()->set('v5.enabled', false);
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
V5TestSchema::dropAllTables();
expect($project->isEmpty())->toBeTrue()
->and($environment->isEmpty())->toBeTrue();
});
-1
View File
@@ -25,7 +25,6 @@ uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser', 'v5/Browser');
|
*/
require_once __DIR__.'/Support/V5TestHelpers.php';
require_once __DIR__.'/Support/BrowserTestHelpers.php';
/*
+1 -1
View File
@@ -5,7 +5,7 @@
| Shared Browser Test Helpers
|--------------------------------------------------------------------------
|
| Helpers for Pest browser tests under tests/v4/Browser and tests/v5/Browser.
| Helpers for Pest browser tests under tests/v4/Browser.
| Loaded from tests/Pest.php.
|
*/
-246
View File
@@ -1,246 +0,0 @@
<?php
/*
|--------------------------------------------------------------------------
| Shared V5 Test Helpers
|--------------------------------------------------------------------------
|
| Global Pest helper functions shared by the tests/Feature/V5 and
| tests/Unit/V5 suites. Loaded from tests/Pest.php. The v5_* table
| definitions themselves live in Tests\Support\V5TestSchema so they cannot
| drift from the migrations per test file.
|
*/
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use App\Services\Flux\AgentTokenIssuer;
use App\Services\Flux\FluxClient;
use App\Services\Flux\FluxHealth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Schema;
use Mockery\MockInterface;
use Tests\Support\V5TestSchema;
/**
* The shared per-test reset used by the split V5 dashboard feature tests
* (previously the DashboardTest beforeEach).
*/
function resetV5DashboardTestState(): void
{
Config::set('app.maintenance.store', 'array');
Config::set('broadcasting.default', 'log');
Config::set('cache.default', 'array');
Config::set('flux.bootstrap_host_connection_timeout_seconds', 0);
V5TestSchema::dropAllTables();
Schema::dropIfExists('private_keys');
Schema::dropIfExists('team_user');
Schema::dropIfExists('teams');
Schema::dropIfExists('users');
}
function fakeFluxHealth(bool $available = true, string $message = 'Flux is running.'): void
{
app()->instance(FluxHealth::class, Mockery::mock(FluxHealth::class, function (MockInterface $mock) use ($available, $message) {
$mock->shouldReceive('check')
->once()
->andReturn([
'available' => $available,
'label' => $available ? 'Running' : 'Unavailable',
'message' => $message,
'socket' => '/run/coolify/flux.sock',
]);
}));
}
function fakeAgentTokenIssuer(string $token = 'signed-host-jwt'): void
{
$issuer = Mockery::mock(AgentTokenIssuer::class);
$issuer->shouldReceive('issueForServer')->andReturn($token);
app()->instance(AgentTokenIssuer::class, $issuer);
}
function fakeSuccessfulNginxFluxDeployment(string $image = 'docker.io/library/nginx:alpine'): void
{
$mock = Mockery::mock(FluxClient::class, function (MockInterface $mock) use ($image): void {
$mock->shouldReceive('pullImage')
->once()
->with(Mockery::type('string'), $image)
->andReturn('Image pulled.');
$mock->shouldReceive('createContainer')
->once()
->with(Mockery::type('string'), Mockery::on(fn (array $spec): bool => ($spec['image'] ?? null) === $image
&& ($spec['networks'] ?? []) === ['coolify-default-mesh']
&& ($spec['dns_search'] ?? []) === ['default.coolify.internal']
&& in_array($spec['name'] ?? '', $spec['network_aliases'] ?? [], true)))
->andReturn('nginx-container-id');
$mock->shouldReceive('startContainer')
->once()
->with(Mockery::type('string'), 'nginx-container-id')
->andReturn('Container started.');
$mock->shouldReceive('inspectContainer')
->once()
->with(Mockery::type('string'), 'nginx-container-id')
->andReturn(['State' => ['Running' => true]]);
});
app()->instance(FluxClient::class, $mock);
}
function expectCaddyIngressFirewallRule(mixed $fluxClient): void
{
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with(Mockery::type('string'), [
'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.');
$fluxClient
->shouldNotReceive('applyFirewallRule')
->with(Mockery::type('string'), Mockery::on(fn (array $rule): bool => ($rule['port'] ?? null) === 443));
}
function createSharedUserAndTeamTables(): 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('private_keys', function ($table) {
$table->id();
$table->string('uuid')->unique();
$table->string('name');
$table->string('description')->nullable();
$table->longText('private_key');
$table->string('fingerprint')->nullable();
$table->boolean('is_git_related')->default(false);
$table->foreignId('team_id');
$table->timestamps();
});
Schema::create('projects', function ($table) {
$table->id();
$table->string('uuid');
$table->string('name');
$table->text('description')->nullable();
$table->foreignId('team_id');
$table->timestamps();
});
Schema::create('environments', function ($table) {
$table->id();
$table->string('name');
$table->foreignId('project_id');
$table->timestamps();
$table->text('description')->nullable();
$table->string('uuid');
});
V5TestSchema::createAllTables();
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']);
});
}
/**
* @param array<int, string> $command
*/
function cliFlagValue(array $command, string $flag): ?string
{
$index = array_search($flag, $command, true);
if ($index === false || ! isset($command[$index + 1])) {
return null;
}
return $command[$index + 1];
}
/**
* @return array{0: Project, 1: Environment}
*/
function createV5ProjectWithEnvironment(Team $team, string $projectName, string $environmentName): array
{
$project = Project::withoutEvents(fn () => Project::query()->forceCreate([
'uuid' => str($projectName)->slug().'-uuid',
'name' => $projectName,
'description' => null,
'team_id' => $team->id,
]));
$environment = Environment::withoutEvents(fn () => Environment::query()->forceCreate([
'uuid' => str($environmentName)->slug().'-uuid',
'name' => $environmentName,
'description' => null,
'project_id' => $project->id,
]));
return [$project, $environment];
}
function createV5PrivateKey(Team $team, string $name): PrivateKey
{
return PrivateKey::withoutEvents(fn () => PrivateKey::query()->forceCreate([
'uuid' => str($name)->slug().'-uuid',
'name' => $name,
'description' => null,
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
'fingerprint' => str($name)->slug()->toString(),
'is_git_related' => false,
'team_id' => $team->id,
]));
}
/**
* @return array{0: User, 1: Team}
*/
function createV5UserWithTeam(string $email = 'margaret@example.com'): array
{
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Margaret Hamilton',
'email' => $email,
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'V5 Tooling Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
return [$user, $team];
}
-230
View File
@@ -1,230 +0,0 @@
<?php
namespace Tests\Support;
use Illuminate\Support\Facades\Schema;
/**
* Single source of truth for the v5_* table definitions used by tests that
* build their schema by hand instead of running the full migration set.
*
* The column definitions must exactly mirror the final state produced by the
* database/migrations-v5 files (including the 2026_07_05/2026_07_06
* additions: status_observed_at, coold_version, has_coold/is_ingress booleans
* replacing the dropped capabilities json, and NOT NULL server uuids). Update
* this class in the same change as any v5 migration.
*
* Cross-table foreign key constraints and the composite unique indexes on
* v5_servers (team_id/host/ssh_port) and v5_clusters (team_id/name) are
* intentionally omitted: tests seed sparse rows (team_id => 1 without a teams
* row) and rely on application-level validation, matching the previous
* hand-rolled schemas. Unique indexes that encode behaviour are kept.
*/
class V5TestSchema
{
public static function createAllTables(): void
{
self::createClustersTable();
self::createServersTable();
self::createContainerStatusesTable();
self::createApplicationsTable();
self::createApplicationDomainsTable();
self::createResourceConnectionsTable();
self::createResourceConnectionRulesTable();
self::createRevokedAgentTokensTable();
}
public static function dropAllTables(): void
{
Schema::dropIfExists('v5_revoked_agent_tokens');
Schema::dropIfExists('v5_resource_connection_rules');
Schema::dropIfExists('v5_resource_connections');
Schema::dropIfExists('v5_application_domains');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_container_statuses');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
}
public static function createClustersTable(): void
{
Schema::create('v5_clusters', function ($table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('team_id');
$table->foreignId('created_by_user_id');
$table->string('name');
$table->text('description')->nullable();
$table->string('wireguard_interface')->default('wg0');
$table->string('wireguard_management_pool')->default('100.64.0.0/16');
$table->unsignedInteger('wireguard_listen_port')->default(51820);
$table->string('container_network_pool')->default('10.210.0.0/16');
$table->unsignedTinyInteger('container_network_prefix')->default(24);
$table->json('namespaces')->nullable();
$table->boolean('default_deny_containers')->default(true);
$table->string('coold_version')->default('nightly');
$table->string('corrosion_version')->default('v1.0.0');
$table->unsignedInteger('corrosion_gossip_port')->default(8787);
$table->unsignedInteger('corrosion_api_port')->default(8080);
$table->boolean('builder_enabled')->default(true);
$table->unsignedInteger('builder_capacity')->default(2);
$table->string('builder_cpu_quota')->default('200%');
$table->string('builder_memory_max')->default('2G');
$table->unsignedInteger('builder_timeout_secs')->default(1800);
$table->string('last_cli_action')->nullable();
$table->string('last_cli_status')->nullable();
$table->text('last_cli_summary')->nullable();
$table->timestamp('last_cli_ran_at')->nullable();
$table->timestamps();
});
}
public static function createServersTable(): void
{
Schema::create('v5_servers', function ($table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('team_id');
$table->foreignId('cluster_id')->nullable();
$table->foreignId('created_by_user_id');
$table->foreignId('private_key_id')->nullable();
$table->string('name');
$table->string('host');
$table->string('ssh_user');
$table->unsignedInteger('ssh_port')->default(22);
$table->string('status')->default('installed');
$table->timestamp('status_observed_at')->nullable();
$table->string('ingress_type')->nullable();
$table->string('ingress_status')->nullable();
$table->boolean('has_coold')->default(false)->index();
$table->boolean('is_ingress')->default(false)->index();
$table->boolean('builder_enabled')->default(false);
$table->unsignedInteger('builder_capacity')->default(0);
$table->string('builder_cpu_quota')->default('200%');
$table->string('node_address')->nullable();
$table->unsignedInteger('wireguard_listen_port_override')->nullable();
$table->string('wireguard_endpoint_override')->nullable();
$table->string('wireguard_management_ip')->nullable();
$table->string('wireguard_public_key')->nullable();
$table->string('coold_version')->nullable();
$table->string('agent_token_jti')->nullable();
$table->timestamp('agent_token_expires_at')->nullable();
$table->json('container_subnets')->nullable();
$table->integer('canvas_x')->nullable();
$table->integer('canvas_y')->nullable();
$table->timestamp('last_bootstrapped_at')->nullable();
$table->string('last_bootstrap_action')->nullable();
$table->string('last_bootstrap_status')->nullable();
$table->text('last_bootstrap_output')->nullable();
$table->timestamp('last_bootstrap_ran_at')->nullable();
$table->string('last_status_check')->nullable();
$table->text('last_status_output')->nullable();
$table->timestamp('last_status_checked_at')->nullable();
$table->timestamps();
});
}
public static function createContainerStatusesTable(): void
{
Schema::create('v5_container_statuses', function ($table) {
$table->id();
$table->foreignId('team_id');
$table->foreignId('server_id');
$table->string('container_id');
$table->string('container_name')->nullable();
$table->string('image')->nullable();
$table->string('status')->default('unknown');
$table->text('status_message')->nullable();
$table->timestamp('status_observed_at')->nullable();
$table->timestamp('last_seen_at')->nullable()->index();
$table->timestamps();
$table->unique(['server_id', 'container_id']);
});
}
public static function createApplicationsTable(): void
{
Schema::create('v5_applications', function ($table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('team_id');
$table->foreignId('project_id');
$table->foreignId('environment_id');
$table->foreignId('server_id')->nullable();
$table->foreignId('created_by_user_id');
$table->string('name');
$table->string('image');
$table->string('container_name')->unique();
$table->string('status')->default('creating');
$table->text('status_message')->nullable();
$table->timestamp('status_observed_at')->nullable();
$table->string('runtime_container_id')->nullable();
$table->string('mesh_namespace')->default('default');
$table->boolean('ingress_enabled')->default(false);
$table->unsignedSmallInteger('internal_port')->nullable();
$table->integer('canvas_x')->default(0);
$table->integer('canvas_y')->default(0);
$table->timestamps();
});
}
public static function createApplicationDomainsTable(): void
{
Schema::create('v5_application_domains', function ($table) {
$table->id();
$table->foreignId('application_id');
$table->string('domain');
$table->timestamps();
$table->unique(['application_id', 'domain']);
});
}
public static function createResourceConnectionsTable(): void
{
Schema::create('v5_resource_connections', function ($table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('team_id');
$table->foreignId('project_id');
$table->foreignId('environment_id');
$table->string('resource_one_type');
$table->unsignedBigInteger('resource_one_id');
$table->string('resource_two_type');
$table->unsignedBigInteger('resource_two_id');
$table->string('resource_pair_key');
$table->foreignId('created_by_user_id');
$table->timestamps();
$table->unique(['team_id', 'resource_pair_key']);
});
}
public static function createRevokedAgentTokensTable(): void
{
Schema::create('v5_revoked_agent_tokens', function ($table) {
$table->id();
$table->string('jti')->unique();
$table->foreignId('server_id')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
public static function createResourceConnectionRulesTable(): void
{
Schema::create('v5_resource_connection_rules', function ($table) {
$table->id();
$table->foreignId('connection_id');
$table->string('source_resource_type');
$table->unsignedBigInteger('source_resource_id');
$table->string('target_resource_type');
$table->unsignedBigInteger('target_resource_id');
$table->string('protocol')->default('tcp');
$table->unsignedSmallInteger('port');
$table->timestamps();
});
}
}
+1 -4
View File
@@ -4,7 +4,4 @@ test('app layouts always reserve space for the vertical scrollbar', function (st
$contents = file_get_contents(dirname(__DIR__, 2).'/'.$layout);
expect($contents)->toMatch('/<body(?:\s+[^>]*)?class="[^"]*overflow-y-scroll[^"]*"[^>]*>/');
})->with([
'current interface' => 'resources/views/layouts/base.blade.php',
'v5 interface' => 'resources/views/v5/app.blade.php',
]);
})->with(['resources/views/layouts/base.blade.php']);
+1 -6
View File
@@ -2,11 +2,6 @@
it('uses a thin warning yellow focus ring in dark mode', function () {
$appStyles = file_get_contents(dirname(__DIR__, 2).'/resources/css/app.css');
$v5Styles = file_get_contents(dirname(__DIR__, 2).'/resources/css/v5/app.css');
expect($appStyles)
->toContain('ring-2 ring-coollabs dark:ring-1 dark:ring-warning')
->and($v5Styles)
->toMatch('/\.dark\s*\{[^}]*--ring:\s*var\(--warning\);/s')
->toContain('ring-2 dark:ring-1 ring-ring');
->toContain('ring-2 ring-coollabs dark:ring-1 dark:ring-warning');
});
+2 -8
View File
@@ -13,16 +13,10 @@ it('uses at least sixteen pixel form controls on mobile to prevent input focus z
->and($css)->toContain(':root textarea,')
->and($css)->toContain(':root select')
->and($css)->toContain('font-size: 16px !important;');
})->with([
'current interface' => 'resources/css/app.css',
'v5 interface' => 'resources/css/v5/app.css',
]);
})->with(['resources/css/app.css']);
it('keeps user zoom enabled in the viewport meta tags', function () {
$layouts = [
'resources/views/layouts/base.blade.php',
'resources/views/v5/app.blade.php',
];
$layouts = ['resources/views/layouts/base.blade.php'];
foreach ($layouts as $layout) {
expect(file_get_contents(dirname(__DIR__, 2).'/'.$layout))
-23
View File
@@ -44,29 +44,6 @@ it('downloads postgres upgrade script during install and upgrade without auto-ru
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('does not provision a flux laravel api token before v5 production activation', function (string $path) {
$source = file_get_contents(getcwd().'/'.$path);
expect($source)->not->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN');
})->with([
'production env' => '.env.production',
'stable install' => 'scripts/install.sh',
'nightly install' => 'other/nightly/install.sh',
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('does not provision flux storage before v5 production activation', function (string $path) {
$source = file_get_contents(getcwd().'/'.$path);
expect($source)->not->toContain('/data/coolify/flux');
})->with([
'stable install' => 'scripts/install.sh',
'nightly install' => 'other/nightly/install.sh',
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses the selected registry url when extracting upgrade images', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
-151
View File
@@ -1,151 +0,0 @@
<?php
use App\Models\V5\Server as V5Server;
use App\Services\Flux\AgentTokenIssuer;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
/**
* @return array{0: string, 1: string}
*/
function createAgentTokenIssuerKeypair(): array
{
$directory = storage_path('framework/testing/agent-token-keys-'.bin2hex(random_bytes(4)));
mkdir($directory, 0777, true);
$privateKeyPath = $directory.'/jwt.priv';
$publicKeyPath = $directory.'/jwt.pub';
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
expect($code)->toBe(0);
chmod($privateKeyPath, 0600);
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
expect($code)->toBe(0);
return [$privateKeyPath, $publicKeyPath];
}
function decodeAgentToken(string $token, string $publicKeyPath, ?stdClass &$headers = null): stdClass
{
// Firebase's JWT::decode only populates $headers when it is non-null on
// entry (it opts out of clobbering by default), so seed a placeholder.
if (func_num_args() >= 3) {
$headers = new stdClass;
}
return JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'), $headers);
}
beforeEach(function () {
[$this->privateKeyPath, $this->publicKeyPath] = createAgentTokenIssuerKeypair();
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
});
it('mints the explicit advertised capability list by default, not the wildcard profile', function () {
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
$claims = decodeAgentToken($token, $this->publicKeyPath);
expect((array) $claims->caps)->toBe(config('flux.host_capabilities'))
->and((array) $claims->caps)->toContain('containers.create')
->and((array) $claims->caps)->toContain('ingress.apply')
->and((array) $claims->caps)->toContain('host.jwt.set')
->and((array) $claims->caps)->not->toContain('host-agent:default')
->and((array) $claims->caps)->not->toContain('host-agent:dev')
->and((array) $claims->caps)->not->toContain('*');
});
it('mints the escape-hatch capability profile when configured', function () {
Config::set('flux.host_capability_profile', 'host-agent:default');
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
$claims = decodeAgentToken($token, $this->publicKeyPath);
expect((array) $claims->caps)->toBe(['host-agent:default']);
});
it('includes a unique jti on every minted token', function () {
$issuer = app(AgentTokenIssuer::class);
$first = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
$second = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
expect($first->jti)->toBeString()->not->toBe('')
->and($second->jti)->toBeString()->not->toBe('')
->and($first->jti)->not->toBe($second->jti);
});
it('derives the token lifetime from config', function () {
Config::set('flux.host_token_ttl', 1234);
$before = time();
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
expect($claims->exp - $claims->iat)->toBe(1234)
->and($claims->exp)->toBeGreaterThanOrEqual($before + 1234);
});
it('defaults host token lifetime to the flux max token lifetime', function () {
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
expect($claims->exp - $claims->iat)->toBe(3600)
->and(config('flux.host_token_refresh_threshold'))->toBeLessThan(config('flux.host_token_ttl'));
});
it('clamps a below-floor ttl to 60 seconds', function () {
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10', null, 5), $this->publicKeyPath);
expect($claims->exp - $claims->iat)->toBe(60);
});
it('sets the configured kid header for future key rotation', function () {
Config::set('flux.jwt_kid', 'flux-2026');
$headers = null;
decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath, $headers);
expect($headers->kid)->toBe('flux-2026')
->and($headers->alg)->toBe('ES256');
});
it('refuses to mint a host token before the server has a stable Flux host id', function () {
$server = new V5Server([
'node_address' => '203.0.113.10',
'wireguard_management_ip' => '100.64.0.10',
]);
expect(fn () => app(AgentTokenIssuer::class)->issueForServer($server))
->toThrow(RuntimeException::class, 'Server is missing a valid Flux host id.');
});
it('mints server host tokens for the stable server uuid, not the WireGuard management IP', function () {
$server = new V5Server([
'uuid' => 'server_abc123',
'team_id' => 12,
'cluster_id' => 34,
'node_address' => '203.0.113.10',
'wireguard_management_ip' => '100.64.0.10',
]);
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issueForServer($server), $this->publicKeyPath);
expect($claims->sub)->toBe('server_abc123')
->and($claims->server_id)->toBe('server_abc123')
->and($claims->wireguard_management_ip)->toBe('100.64.0.10');
});
it('warns when the private key file has insecure permissions', function () {
chmod($this->privateKeyPath, 0644);
Log::shouldReceive('warning')
->once()
->withArgs(fn (string $message, array $context) => str_contains($message, 'insecure permissions')
&& $context['expected'] === '0600');
app(AgentTokenIssuer::class)->issue('100.64.0.10');
});
-100
View File
@@ -1,100 +0,0 @@
<?php
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Server as V5Server;
use App\Support\V5\CanvasResourceSerializer;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
});
/**
* @param array<string, mixed> $attributes
*/
function createBroadcastPayloadServer(array $attributes = []): V5Server
{
[$user, $team] = createV5UserWithTeam();
return V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.10',
...$attributes,
]);
}
it('serializes a null cluster payload when the cluster is gone before the queued broadcast runs', function () {
[, $team] = createV5UserWithTeam();
$event = new V5ClusterUpdated($team->id, 999);
expect($event->broadcastWith())->toBe(['cluster' => null]);
});
it('omits the caddy ingress payload for a non-ingress server', function () {
$server = createBroadcastPayloadServer(['is_ingress' => false]);
$event = new V5CanvasResourceUpdated($server->team_id, null, $server->id);
expect($event->broadcastWith()['caddyIngress'])->toBeNull();
});
it('serializes the caddy ingress payload for an ingress server', function () {
$server = createBroadcastPayloadServer([
'is_ingress' => true,
'ingress_type' => 'caddy',
'ingress_status' => 'running',
]);
$payload = (new V5CanvasResourceUpdated($server->team_id, null, $server->id))->broadcastWith();
expect($payload['caddyIngress'])->toBe(
app(CanvasResourceSerializer::class)->serializeCaddyIngress($server->fresh()),
)->and($payload['caddyIngress']['id'])->toBe($server->uuid);
});
it('broadcasts the same application shape as the initial-load canvas serializer', function () {
$server = createBroadcastPayloadServer();
$team = $server->team;
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Payload Parity', 'production');
$application = V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $server->created_by_user_id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-parity',
'status' => 'running',
'status_message' => 'Container started.',
'runtime_container_id' => 'container-123',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->domains()->create(['domain' => 'parity.example.com']);
$payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith();
$expected = app(CanvasResourceSerializer::class)->serializeApplication(
V5Application::query()->with(['server', 'domains'])->findOrFail($application->id),
);
expect($payload['application'])->toBe($expected)
->and($payload['application']['id'])->toBe($application->uuid)
->and($payload['application']['domains'])->toBe(['parity.example.com'])
->and($payload['application']['projectUuid'])->toBe($project->uuid)
->and($payload['application']['environmentUuid'])->toBe($environment->uuid);
});
@@ -1,744 +0,0 @@
<?php
use App\Actions\V5\Proxy\GenerateCaddyIngressConfiguration;
use App\Actions\V5\Proxy\StartCaddyIngress;
use App\Actions\V5\Proxy\StopCaddyIngress;
use App\Models\V5\Application;
use App\Models\V5\ApplicationDomain;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
uses(TestCase::class);
it('generates a caddy ingress compose file with health endpoint and application routes', function () {
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain([
'domain' => 'nginx.example.com',
]),
new ApplicationDomain([
'domain' => 'www.nginx.example.com',
]),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['compose'])->toContain('container_name: coolify-v5-caddy')
->and($configuration['compose'])->toContain("image: 'docker.io/library/caddy:2-alpine'")
->and($configuration['compose'])->toContain('80:80')
->and($configuration['compose'])->not->toContain('443:443')
->and($configuration['compose'])->toContain('./Caddyfile:/etc/caddy/Caddyfile:ro')
->and($configuration['compose'])->toContain('./apps:/etc/caddy/apps:ro')
->and($configuration['caddyfile'])->toContain('respond /coolify-health 200')
->and($configuration['caddyfile'])->toContain('respond 404')
->and($configuration['caddyfile'])->toContain('import apps/*.caddy')
->and($configuration['apps'])->toHaveCount(1)
->and($configuration['apps'][0]['caddyfile'])->toContain('http://nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->toContain('http://www.nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->not->toContain('https://')
->and($configuration['apps'][0]['caddyfile'])->toContain('reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080');
});
it('generates byte-identical caddy routes for valid domains', function () {
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'nginx.example.com']),
new ApplicationDomain(['domain' => 'www.nginx.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
'http://nginx.example.com {',
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
'}',
'',
'http://www.nginx.example.com {',
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
'}',
]));
});
it('never emits domains with caddyfile injection payloads into the configuration', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => "evil.com {\n} http://x"]),
new ApplicationDomain(['domain' => "evil\n.com"]),
new ApplicationDomain(['domain' => 'safe.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toHaveCount(1)
->and($configuration['apps'][0]['caddyfile'])->not->toContain('evil')
->and($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
'http://safe.example.com {',
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
'}',
]));
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
->twice();
});
it('skips whole applications when every domain is malicious', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'evil.com { respond "pwned" }']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
->once();
});
it('never emits unsafe container names or namespaces into the configuration', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => "coolify {\n} injected",
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'app.example.com']),
]));
$namespaceApplication = new Application([
'name' => 'nginx-test-2',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => "default {\n}",
'ingress_enabled' => true,
'internal_port' => 8080,
]);
$namespaceApplication->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'other.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application, $namespaceApplication]));
expect($configuration['apps'])->toBe([]);
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an unsafe container name or namespace.', Mockery::type('array'))
->twice();
});
it('never emits out-of-range internal ports into the configuration', function () {
Log::spy();
$application = new Application([
'name' => 'nginx-test',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
]);
$application->internal_port = 70000;
$application->setRelation('domains', new Collection([
new ApplicationDomain(['domain' => 'app.example.com']),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
Log::shouldHaveReceived('warning')
->with('Skipping a caddy ingress route with an out-of-range internal port.', Mockery::type('array'))
->once();
});
it('does not generate app routes for applications without domains', function () {
$application = new Application([
'name' => 'private-app',
'container_name' => 'coolify-v5-private',
'mesh_namespace' => 'default',
]);
$application->setRelation('domains', new Collection);
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['caddyfile'])
->toContain('respond /coolify-health 200')
->and($configuration['apps'])->toBe([]);
});
it('does not generate app routes when ingress is disabled even with domains', function () {
$application = new Application([
'name' => 'private-app',
'container_name' => 'coolify-v5-private',
'mesh_namespace' => 'default',
'ingress_enabled' => false,
'internal_port' => 8080,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain([
'domain' => 'private.example.com',
]),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
});
it('does not generate app routes when the internal port is missing', function () {
$application = new Application([
'name' => 'needs-port',
'container_name' => 'coolify-v5-needs-port',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => null,
]);
$application->setRelation('domains', new Collection([
new ApplicationDomain([
'domain' => 'needs-port.example.com',
]),
]));
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
expect($configuration['apps'])->toBe([]);
});
it('applies caddy ingress configuration through flux instead of ssh', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '10.0.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyIngress')
->once()
->with(
Mockery::type('string'),
'caddy',
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'respond /coolify-health 200')),
[]
)
->andReturn('Caddy ingress applied.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with(Mockery::type('string'), [
'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.');
$fluxClient
->shouldNotReceive('applyFirewallRule')
->with(Mockery::type('string'), Mockery::on(fn (array $rule): bool => ($rule['port'] ?? null) === 443));
app()->instance(FluxClient::class, $fluxClient);
$result = StartCaddyIngress::run($server);
expect($result)->toBe('Caddy ingress applied.');
});
it('does not start caddy ingress for non-ingress servers', function () {
$server = new Server([
'capabilities' => [],
]);
$result = StartCaddyIngress::run($server);
expect($result)->toBe('Server is not an ingress server.');
});
it('stops caddy ingress through flux instead of ssh', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '10.0.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('stopIngress')
->once()
->with(Mockery::type('string'), 'caddy')
->andReturn('Caddy ingress stopped.');
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
->andReturn('Firewall rule removed.');
$fluxClient
->shouldNotReceive('revokeFirewallRule')
->with(Mockery::type('string'), 'v5-caddy-ingress:443');
app()->instance(FluxClient::class, $fluxClient);
$result = StopCaddyIngress::run($server);
expect($result)->toBe('Caddy ingress stopped.');
});
it('does not stop caddy ingress for servers that never had ingress', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => [],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldNotReceive('stopIngress');
$fluxClient->shouldNotReceive('revokeFirewallRule');
app()->instance(FluxClient::class, $fluxClient);
expect(StopCaddyIngress::run($server))->toBe('Server is not an ingress server.');
});
it('revokes the caddy firewall rule before stopping the ingress container', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
->ordered()
->andReturn('Firewall rule removed.');
$fluxClient
->shouldReceive('stopIngress')
->once()
->with(Mockery::type('string'), 'caddy')
->ordered()
->andThrow(new RuntimeException('podman rm failed'));
app()->instance(FluxClient::class, $fluxClient);
expect(fn () => StopCaddyIngress::run($server))->toThrow(RuntimeException::class, 'podman rm failed');
});
it('tolerates already-revoked caddy firewall rules when stopping ingress', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->andThrow(new RuntimeException('firewall rule not found'));
$fluxClient
->shouldReceive('stopIngress')
->once()
->andReturn('Caddy ingress stopped.');
app()->instance(FluxClient::class, $fluxClient);
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
});
it('reports an unknown ingress status until coold confirms one', function () {
$installed = new Server(['capabilities' => ['ingress']]);
$installed->status = 'installed';
expect($installed->ingressStatus())->toBe('unknown');
$running = new Server(['capabilities' => ['ingress'], 'ingress_status' => 'running']);
expect($running->ingressStatus())->toBe('running');
});
it('includes flux error response details when dispatch returns a non success status', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 500,
'message' => 'start Caddy ingress: podman exited with status 125',
], JSON_THROW_ON_ERROR);
withFakeFluxSocket(
"HTTP/1.1 500 Internal Server Error\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
fn () => (new FluxClient)->applyIngress('100.64.0.10', 'caddy', 'example.com { respond "ok" }')
);
})->throws(RuntimeException::class, 'start Caddy ingress: podman exited with status 125');
it('uses a friendly message when flux returns an invalid http response', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
withFakeFluxSocket(
'',
fn () => (new FluxClient)->applyIngress('100.64.0.10', 'caddy', 'example.com { respond "ok" }')
);
})->throws(RuntimeException::class, 'Flux did not return a response before the timeout.');
it('uses separate flux timeouts for health checks and command dispatches', function () {
expect(config('flux.health_timeout_seconds'))->toBe(1.0)
->and(config('flux.connection_timeout_seconds'))->toBe(1.0)
->and(config('flux.dispatch_timeout_seconds'))->toBe(35.0);
});
function withFakeFluxSocket(string $response, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$connection = stream_socket_accept($server, 5);
if ($connection !== false) {
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
fwrite($connection, $response);
fclose($connection);
}
fclose($server);
exit(0);
}
fclose($server);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.health_timeout_seconds', 1.0);
Config::set('flux.connection_timeout_seconds', 1.0);
Config::set('flux.dispatch_timeout_seconds', 1.0);
try {
$callback();
} finally {
pcntl_waitpid($pid, $status);
@unlink($socketPath);
}
}
it('dispatches container inventory through the containers list 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' => [],
], 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)->listContainers('100.64.0.10')
);
$request = file_get_contents($requestPath) ?: '';
@unlink($requestPath);
expect($request)->toContain('"type":"containers.list"')
->not->toContain('list_containers');
});
it('dispatches image pull and container lifecycle primitives for v5 apps', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$responses = [
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Image pulled.']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['id' => 'container-123']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container started.']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['State' => ['Running' => true]]],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container stopped.']],
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container removed.']],
];
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
withFakeFluxSocketCapturingRequests($responses, $requestPath, function (): void {
$fluxClient = new FluxClient;
expect($fluxClient->pullImage('100.64.0.10', 'docker.io/library/nginx:alpine'))->toBe('Image pulled.')
->and($fluxClient->createContainer('100.64.0.10', [
'name' => 'coolify-v5-nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'networks' => ['coolify-default-mesh'],
'network_aliases' => ['coolify-v5-nginx-test'],
'dns' => ['10.210.0.1'],
'dns_search' => ['default.coolify.internal'],
'restart_policy' => 'unless-stopped',
]))->toBe('container-123')
->and($fluxClient->startContainer('100.64.0.10', 'container-123'))->toBe('Container started.')
->and($fluxClient->inspectContainer('100.64.0.10', 'container-123'))->toBe(['State' => ['Running' => true]])
->and($fluxClient->stopContainer('100.64.0.10', 'container-123'))->toBe('Container stopped.')
->and($fluxClient->removeContainer('100.64.0.10', 'container-123', true))->toBe('Container removed.');
});
$request = file_get_contents($requestPath) ?: '';
@unlink($requestPath);
expect($request)->toContain('"type":"images.pull"')
->toContain('"type":"containers.create"')
->toContain('"network_aliases":["coolify-v5-nginx-test"]')
->toContain('"dns_search":["default.coolify.internal"]')
->toContain('"type":"containers.start"')
->toContain('"type":"containers.inspect"')
->toContain('"type":"containers.stop"')
->toContain('"type":"containers.delete"')
->toContain('"force":true');
});
it('dispatches firewall allow through the firewall allow primitive', function () {
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 withFakeFluxSocketCapturingRequests(array $responsePayloads, string $requestPath, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$capturedRequests = '';
foreach ($responsePayloads as $payload) {
$connection = stream_socket_accept($server, 5);
if ($connection === false) {
continue;
}
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
if (preg_match('/Content-Length: (\d+)/i', $request, $matches) === 1) {
$remaining = (int) $matches[1] - strlen(substr($request, strpos($request, "\r\n\r\n") + 4));
while ($remaining > 0 && ! feof($connection)) {
$chunk = fread($connection, $remaining);
$request .= $chunk;
$remaining -= strlen($chunk);
}
}
$capturedRequests .= $request."\n---REQUEST---\n";
$body = json_encode($payload, JSON_THROW_ON_ERROR);
fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n{$body}");
fclose($connection);
}
file_put_contents($requestPath, $capturedRequests);
fclose($server);
exit(0);
}
fclose($server);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.health_timeout_seconds', 1.0);
Config::set('flux.connection_timeout_seconds', 1.0);
Config::set('flux.dispatch_timeout_seconds', 1.0);
try {
$callback();
} finally {
pcntl_waitpid($pid, $status);
@unlink($socketPath);
}
}
function withFakeFluxSocketCapturingRequest(string $response, string $requestPath, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$connection = stream_socket_accept($server, 5);
if ($connection !== false) {
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
if (preg_match('/Content-Length: (\d+)/i', $request, $matches) === 1) {
$remaining = (int) $matches[1] - strlen(substr($request, strpos($request, "\r\n\r\n") + 4));
while ($remaining > 0 && ! feof($connection)) {
$chunk = fread($connection, $remaining);
$request .= $chunk;
$remaining -= strlen($chunk);
}
}
file_put_contents($requestPath, $request);
fwrite($connection, $response);
fclose($connection);
}
fclose($server);
exit(0);
}
fclose($server);
try {
config(['flux.unix_socket_path' => $socketPath]);
$callback();
pcntl_waitpid($pid, $status);
} finally {
@unlink($socketPath);
}
}
-314
View File
@@ -1,314 +0,0 @@
<?php
use App\Actions\V5\Proxy\StartCaddyIngress;
use App\Actions\V5\Proxy\StopCaddyIngress;
use App\Exceptions\V5\UnsupportedCooldVerb;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use App\Support\V5\ConnectionFirewallSync;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
use Tests\TestCase;
uses(TestCase::class);
it('detects unsupported coold verbs from flux http 501 responses', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
// Flux rejects verbs the node's coold did not advertise with HTTP 501 and
// "primitive <verb> is not supported by host" (coold repo:
// flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245).
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 501,
'message' => 'primitive firewall.allow is not supported by host',
], JSON_THROW_ON_ERROR);
$caught = null;
withCooldVerbContractFluxSocket(
"HTTP/1.1 501 Not Implemented\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
function () use (&$caught): void {
try {
(new FluxClient)->applyFirewallRule('100.64.0.10', [
'id' => 'rule-1',
'namespace' => 'default',
'src' => '0.0.0.0/0',
'dst' => 'coolify-v5-caddy',
'proto' => 'tcp',
'port' => 80,
]);
} catch (RuntimeException $exception) {
$caught = $exception;
}
}
);
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
->and($caught->verb)->toBe('firewall.allow')
->and($caught->getMessage())->toBe('primitive firewall.allow is not supported by host');
});
it('detects unsupported coold verbs from the flux message even without a 501 status', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 502,
'message' => 'primitive coold.logs is not supported by host',
], JSON_THROW_ON_ERROR);
$caught = null;
withCooldVerbContractFluxSocket(
"HTTP/1.1 502 Bad Gateway\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
function () use (&$caught): void {
try {
(new FluxClient)->cooldLogs('100.64.0.10');
} catch (RuntimeException $exception) {
$caught = $exception;
}
}
);
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
->and($caught->verb)->toBe('coold.logs');
});
it('keeps generic flux dispatch failures as plain runtime exceptions', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 500,
'message' => 'apply firewall rule: nft exited with status 1',
], JSON_THROW_ON_ERROR);
$caught = null;
withCooldVerbContractFluxSocket(
"HTTP/1.1 500 Internal Server Error\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
function () use (&$caught): void {
try {
(new FluxClient)->applyFirewallRule('100.64.0.10', [
'id' => 'rule-1',
'namespace' => 'default',
'src' => '0.0.0.0/0',
'dst' => 'coolify-v5-caddy',
'proto' => 'tcp',
'port' => 80,
]);
} catch (RuntimeException $exception) {
$caught = $exception;
}
}
);
expect($caught)->toBeInstanceOf(RuntimeException::class)
->not->toBeInstanceOf(UnsupportedCooldVerb::class)
->and($caught->getMessage())->toBe('apply firewall rule: nft exited with status 1');
});
it('marks caddy ingress running with a warning when the node coold lacks firewall support', function () {
Event::fake();
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
V5TestSchema::createServersTable();
V5TestSchema::createApplicationsTable();
$server = Server::query()->create([
'uuid' => 'cooldverb-ingress-server-uuid',
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'ingress-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'capabilities' => ['ingress'],
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyIngress')
->once()
->andReturn('Caddy ingress applied.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
app()->instance(FluxClient::class, $fluxClient);
$result = StartCaddyIngress::run($server);
$server->refresh();
expect($result)->toBe('Caddy ingress applied.')
->and($server->ingress_type)->toBe('caddy')
->and($server->ingress_status)->toBe('running')
->and($server->last_status_check)->toBe('flux')
->and($server->last_status_output)->toContain('does not support firewall.allow');
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
});
it('still surfaces supported-but-failed firewall errors when starting caddy ingress', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyIngress')
->once()
->andReturn('Caddy ingress applied.');
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
app()->instance(FluxClient::class, $fluxClient);
expect(fn () => StartCaddyIngress::run($server))
->toThrow(RuntimeException::class, 'apply firewall rule: nft exited with status 1');
});
it('stops caddy ingress even when the node coold lacks firewall support', function () {
$server = new Server([
'uuid' => 'test-server-uuid',
'wireguard_management_ip' => '100.64.0.10',
'capabilities' => ['ingress'],
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
$fluxClient
->shouldReceive('stopIngress')
->once()
->with(Mockery::type('string'), 'caddy')
->andReturn('Caddy ingress stopped.');
app()->instance(FluxClient::class, $fluxClient);
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
});
it('skips resource connection firewall sync rules when the node coold lacks firewall support', function () {
$sync = new ConnectionFirewallSync;
$oldRule = [
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
'hostId' => '100.64.0.11',
'rule' => [
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
'namespace' => 'default',
'src' => 'coolify-v5-api',
'dst' => 'coolify-v5-postgres',
'proto' => 'tcp',
'port' => 5432,
],
];
$newRule = [
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
'hostId' => '100.64.0.10',
'rule' => [
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
'namespace' => 'default',
'src' => 'coolify-v5-api',
'dst' => 'coolify-v5-redis',
'proto' => 'tcp',
'port' => 6379,
],
];
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('revokeFirewallRule')
->once()
->with(Mockery::type('string'), $oldRule['id'])
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
$fluxClient
->shouldReceive('applyFirewallRule')
->once()
->with(Mockery::type('string'), $newRule['rule'])
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
$sync->sync($fluxClient, collect([$oldRule]), collect([$newRule]));
expect(true)->toBeTrue();
});
function withCooldVerbContractFluxSocket(string $response, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$socketServer = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($socketServer)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$connection = stream_socket_accept($socketServer, 5);
if ($connection !== false) {
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
fwrite($connection, $response);
fclose($connection);
}
fclose($socketServer);
exit(0);
}
fclose($socketServer);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.connection_timeout_seconds', 1.0);
Config::set('flux.dispatch_timeout_seconds', 1.0);
try {
$callback();
} finally {
pcntl_waitpid($pid, $status);
@unlink($socketPath);
}
}
@@ -1,57 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { resolveCanvasNodeLayout, resolveCanvasNodePosition } from '../../../../resources/js/v5/lib/canvas-collision.ts';
test('moves a dragged canvas node to the closest non-overlapping side with a gap', () => {
const settledPosition = resolveCanvasNodePosition(
{ id: 'dragged', x: 110, y: 10, width: 320, height: 136 },
[{ id: 'existing', x: 0, y: 0, width: 320, height: 136 }],
16,
);
assert.deepEqual(settledPosition, { x: 110, y: 152 });
});
test('keeps moving until the closest side is clear', () => {
const settledPosition = resolveCanvasNodePosition(
{ id: 'dragged', x: 110, y: 10, width: 320, height: 136 },
[
{ id: 'left-blocker', x: 0, y: 0, width: 320, height: 136 },
{ id: 'bottom-blocker', x: 110, y: 152, width: 320, height: 136 },
{ id: 'top-blocker', x: 110, y: -152, width: 320, height: 136 },
],
16,
);
assert.deepEqual(settledPosition, { x: -336, y: 10 });
});
test('ignores the dragged node when comparing canvas collisions', () => {
const settledPosition = resolveCanvasNodePosition(
{ id: 'app-1', x: 24, y: 32, width: 320, height: 136 },
[{ id: 'app-1', x: 24, y: 32, width: 320, height: 136 }],
16,
);
assert.deepEqual(settledPosition, { x: 24, y: 32 });
});
test('spreads an overlapping canvas layout in order', () => {
const settledNodes = resolveCanvasNodeLayout(
[
{ id: 'first', x: 0, y: 0, width: 320, height: 136 },
{ id: 'second', x: 110, y: 10, width: 320, height: 136 },
],
16,
);
assert.deepEqual(
settledNodes.map((node) => ({ id: node.id, x: node.x, y: node.y })),
[
{ id: 'first', x: 0, y: 0 },
{ id: 'second', x: 110, y: 152 },
],
);
});
@@ -1,242 +0,0 @@
<?php
use App\Actions\V5\Application\DeployNginxApplication;
use App\Models\V5\Application;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Tests\Support\V5TestSchema;
use Tests\TestCase;
uses(TestCase::class);
it('builds an nginx container spec for the coold mesh runtime', function () {
$application = new Application([
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'status' => 'creating',
]);
$action = new DeployNginxApplication(Mockery::mock(FluxClient::class));
$method = new ReflectionMethod($action, 'containerSpec');
$method->setAccessible(true);
$spec = $method->invoke($action, $application);
expect($spec)
->toMatchArray([
'name' => 'coolify-v5-nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'networks' => ['coolify-default-mesh'],
'network_aliases' => ['coolify-v5-nginx-test'],
'dns_search' => ['default.coolify.internal'],
'restart_policy' => 'unless-stopped',
])
->not->toHaveKey('command')
->not->toHaveKey('privileged');
});
it('stops and removes the created container when starting it fails', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient
->shouldReceive('startContainer')
->once()
->with(Mockery::type('string'), 'container-123')
->andThrow(new RuntimeException('podman start exited with status 125'));
$fluxClient
->shouldReceive('stopContainer')
->once()
->with(Mockery::type('string'), 'container-123')
->andReturn('Container stopped.');
$fluxClient
->shouldReceive('removeContainer')
->once()
->with(Mockery::type('string'), 'container-123', true)
->andReturn('Container removed.');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('podman start exited with status 125')
->and($result->runtime_container_id)->toBeNull();
});
it('does not clean up any container when creating the container fails', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient
->shouldReceive('createContainer')
->once()
->andThrow(new RuntimeException('Flux did not return a container id.'));
$fluxClient->shouldNotReceive('startContainer');
$fluxClient->shouldNotReceive('stopContainer');
$fluxClient->shouldNotReceive('removeContainer');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('Flux did not return a container id.')
->and($result->runtime_container_id)->toBeNull();
});
it('keeps the original deploy failure and logs a warning when cleanup itself fails', function () {
Event::fake();
Log::spy();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient
->shouldReceive('startContainer')
->once()
->andThrow(new RuntimeException('original deploy failure'));
$fluxClient
->shouldReceive('stopContainer')
->once()
->andThrow(new RuntimeException('stop failed'));
$fluxClient
->shouldReceive('removeContainer')
->once()
->andThrow(new RuntimeException('remove failed'));
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('original deploy failure')
->and($result->runtime_container_id)->toBeNull();
Log::shouldHaveReceived('warning')
->with('Could not stop the container created by a failed v5 deploy.', Mockery::type('array'))
->once();
Log::shouldHaveReceived('warning')
->with('Could not remove the container created by a failed v5 deploy.', Mockery::type('array'))
->once();
});
it('cleans up the container when it does not stay running after start', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
$fluxClient
->shouldReceive('inspectContainer')
->once()
->andReturn(['State' => ['Running' => false]]);
$fluxClient
->shouldReceive('stopContainer')
->once()
->with(Mockery::type('string'), 'container-123')
->andReturn('Container stopped.');
$fluxClient
->shouldReceive('removeContainer')
->once()
->with(Mockery::type('string'), 'container-123', true)
->andReturn('Container removed.');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('failed')
->and($result->status_message)->toBe('Container did not stay running.')
->and($result->runtime_container_id)->toBeNull();
});
it('persists the container id only for a successful deploy', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
$fluxClient
->shouldReceive('inspectContainer')
->once()
->andReturn(['State' => ['Running' => true]]);
$fluxClient->shouldNotReceive('stopContainer');
$fluxClient->shouldNotReceive('removeContainer');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($result->status)->toBe('running')
->and($result->runtime_container_id)->toBe('container-123');
});
it('persists the runtime container id immediately after create, before start', function () {
Event::fake();
$application = createNginxDeploymentApplication();
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
$persistedBeforeStart = null;
$fluxClient
->shouldReceive('startContainer')
->once()
->andReturnUsing(function () use ($application, &$persistedBeforeStart) {
// Read the row straight from the database to prove the id is durable
// before start runs, not just held in the in-memory model.
$persistedBeforeStart = Application::query()->find($application->getKey())?->runtime_container_id;
throw new RuntimeException('podman start exited with status 125');
});
$fluxClient->shouldReceive('stopContainer')->once()->andReturn('Container stopped.');
$fluxClient->shouldReceive('removeContainer')->once()->andReturn('Container removed.');
$result = (new DeployNginxApplication($fluxClient))->handle($application);
expect($persistedBeforeStart)->toBe('container-123')
->and($result->status)->toBe('failed')
->and($result->runtime_container_id)->toBeNull();
});
function createNginxDeploymentApplication(): Application
{
Schema::dropIfExists('v5_applications');
Schema::dropIfExists('v5_servers');
V5TestSchema::createServersTable();
V5TestSchema::createApplicationsTable();
$server = Server::query()->forceCreate([
'uuid' => 'nginx-deployment-server-uuid',
'team_id' => 1,
'created_by_user_id' => 1,
'name' => 'node-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
return Application::query()->forceCreate([
'uuid' => 'nginx-deployment-application-uuid',
'team_id' => 1,
'project_id' => 1,
'environment_id' => 1,
'created_by_user_id' => 1,
'server_id' => $server->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'status' => 'creating',
'mesh_namespace' => 'default',
]);
}
-265
View File
@@ -1,265 +0,0 @@
<?php
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Cluster;
use App\Models\V5\ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Policies\V5\ApplicationPolicy;
use App\Policies\V5\ClusterPolicy;
use App\Policies\V5\ResourceConnectionPolicy;
use App\Policies\V5\ServerPolicy;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Tests\TestCase;
uses(TestCase::class);
/**
* Build a user whose team membership carries the given role, so the policies'
* $user->isAdminOfTeam() role gate can be exercised without touching the DB.
*/
function v5PolicyUser(string $role = 'owner', int $teamId = 10): User
{
$user = (new User)->forceFill(['id' => 1]);
$team = (new Team)->forceFill(['id' => $teamId]);
$pivot = new Pivot;
$pivot->role = $role;
$team->setRelation('pivot', $pivot);
$user->setRelation('teams', collect([$team]));
return $user;
}
function v5PolicyMemberUser(int $teamId = 10): User
{
return v5PolicyUser('member', $teamId);
}
function v5PolicyTeam(int $id = 10): Team
{
return (new Team)->forceFill(['id' => $id]);
}
it('allows adding a server to a cluster of the current team', function () {
$team = v5PolicyTeam();
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $cluster);
expect($response->allowed())->toBeTrue();
});
it('denies adding a server to another teams cluster as forbidden instead of not found', function () {
$team = v5PolicyTeam(10);
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $foreignCluster);
// A plain deny carries no status override, so the framework renders the
// historical 403 instead of a 404.
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
});
it('denies server update and delete outside the current team as not found', function () {
$team = v5PolicyTeam(10);
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
$foreignServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'cluster_id' => 5]);
$policy = new ServerPolicy;
$update = $policy->update(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
$delete = $policy->delete(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
expect($update->denied())->toBeTrue()
->and($update->status())->toBe(404)
->and($delete->denied())->toBeTrue()
->and($delete->status())->toBe(404);
});
it('denies server update for a server outside the addressed cluster as not found', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$serverInOtherCluster = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 6]);
$response = (new ServerPolicy)->update(v5PolicyUser(), $serverInOtherCluster, $team, $cluster);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
});
it('allows moving the caddy ingress canvas card only for ingress servers of the current team', function () {
$team = v5PolicyTeam(10);
$policy = new ServerPolicy;
$ingressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => true]);
expect($policy->updateCanvasPosition(v5PolicyUser(), $ingressServer, $team)->allowed())->toBeTrue();
});
it('denies moving the canvas card of a non-ingress server as not found', function () {
$team = v5PolicyTeam(10);
$nonIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => false]);
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $nonIngressServer, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
});
it('denies moving another teams ingress canvas card as not found', function () {
$team = v5PolicyTeam(10);
$foreignIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'is_ingress' => true]);
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $foreignIngressServer, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
});
it('scopes cluster viewing and deletion to the current team with not-found denials', function () {
$team = v5PolicyTeam(10);
$policy = new ClusterPolicy;
$ownCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$foreignCluster = (new Cluster)->forceFill(['id' => 6, 'team_id' => 99]);
expect($policy->view(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue()
->and($policy->delete(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue();
$view = $policy->view(v5PolicyUser(), $foreignCluster, $team);
$delete = $policy->delete(v5PolicyUser(), $foreignCluster, $team);
expect($view->denied())->toBeTrue()
->and($view->status())->toBe(404)
->and($delete->denied())->toBeTrue()
->and($delete->status())->toBe(404);
});
it('scopes application mutations to the current team with not-found denials', function () {
$team = v5PolicyTeam(10);
$policy = new ApplicationPolicy;
$ownApplication = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
$foreignApplication = (new V5Application)->forceFill(['id' => 4, 'team_id' => 99]);
expect($policy->update(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
->and($policy->updateIngress(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
->and($policy->delete(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue();
foreach (['update', 'updateIngress', 'delete'] as $ability) {
$response = $policy->{$ability}(v5PolicyUser(), $foreignApplication, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
}
});
it('scopes resource connection mutations to the current team with not-found denials', function () {
$team = v5PolicyTeam(10);
$policy = new ResourceConnectionPolicy;
$ownConnection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
$foreignConnection = (new ResourceConnection)->forceFill(['id' => 4, 'team_id' => 99]);
expect($policy->update(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue()
->and($policy->delete(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue();
foreach (['update', 'delete'] as $ability) {
$response = $policy->{$ability}(v5PolicyUser(), $foreignConnection, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBe(404);
}
});
it('denies server mutations for a member of the current team as forbidden not not-found', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5, 'is_ingress' => true]);
$member = v5PolicyMemberUser();
$policy = new ServerPolicy;
expect($policy->create($member, $team, $cluster)->denied())->toBeTrue()
->and($policy->create($member, $team, $cluster)->status())->toBeNull();
foreach (['update', 'delete', 'check', 'bootstrap'] as $ability) {
$response = $policy->{$ability}($member, $server, $team, $cluster);
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
}
expect($policy->updateCanvasPosition($member, $server, $team)->denied())->toBeTrue()
->and($policy->updateCanvasPosition($member, $server, $team)->status())->toBeNull();
});
it('still lets a member read server diagnostics of the current team', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5]);
expect((new ServerPolicy)->viewDiagnostics(v5PolicyMemberUser(), $server, $team, $cluster)->allowed())->toBeTrue();
});
it('denies cluster create and delete for a member of the current team as forbidden', function () {
$team = v5PolicyTeam(10);
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
$member = v5PolicyMemberUser();
$policy = new ClusterPolicy;
expect($policy->create($member, $team)->denied())->toBeTrue()
->and($policy->create($member, $team)->status())->toBeNull()
->and($policy->delete($member, $cluster, $team)->denied())->toBeTrue()
->and($policy->delete($member, $cluster, $team)->status())->toBeNull();
// Members may still read.
expect($policy->view($member, $cluster, $team)->allowed())->toBeTrue();
});
it('allows an admin to create a cluster in the current team', function () {
$team = v5PolicyTeam(10);
expect((new ClusterPolicy)->create(v5PolicyUser('admin'), $team)->allowed())->toBeTrue();
});
it('denies application mutations for a member of the current team as forbidden', function () {
$team = v5PolicyTeam(10);
$application = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
$member = v5PolicyMemberUser();
$policy = new ApplicationPolicy;
expect($policy->create($member, $team)->denied())->toBeTrue();
foreach (['update', 'updateIngress', 'delete'] as $ability) {
$response = $policy->{$ability}($member, $application, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
}
});
it('denies resource connection mutations for a member of the current team as forbidden', function () {
$team = v5PolicyTeam(10);
$connection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
$member = v5PolicyMemberUser();
$policy = new ResourceConnectionPolicy;
expect($policy->create($member, $team)->denied())->toBeTrue();
foreach (['update', 'delete'] as $ability) {
$response = $policy->{$ability}($member, $connection, $team);
expect($response->denied())->toBeTrue()
->and($response->status())->toBeNull();
}
});
it('allows admins to create applications and resource connections', function () {
$team = v5PolicyTeam(10);
$admin = v5PolicyUser('admin');
expect((new ApplicationPolicy)->create($admin, $team)->allowed())->toBeTrue()
->and((new ResourceConnectionPolicy)->create($admin, $team)->allowed())->toBeTrue();
});
@@ -1,14 +0,0 @@
<?php
it('includes v5 applications in the v4 resource index and links them to v5', function () {
$component = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($component)
->toContain('V5Application')
->toContain("route('v5.dashboard'")
->toContain("? 'v5' : 'v4'")
->and($view)
->toContain("item.version === 'v5'")
->toContain('V5');
});
-73
View File
@@ -1,73 +0,0 @@
<?php
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Events\V5RealtimeTestEvent;
use App\Jobs\V5BootstrapServerJob;
use App\Jobs\V5DeployApplicationJob;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
uses(TestCase::class);
it('locks v5 deploy jobs per application for the job timeout plus a margin', function () {
$job = new V5DeployApplicationJob(42);
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
->and($job->uniqueId())->toBe('42')
->and($job->uniqueFor)->toBe($job->timeout + 60);
});
it('locks v5 bootstrap jobs per server aligned with the controller bootstrap claim window', function () {
$job = new V5BootstrapServerJob(7, 13);
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
->and($job->uniqueId())->toBe('13')
->and($job->uniqueFor)->toBe(V5BootstrapServerJob::TIMEOUT_SECONDS + 300);
});
it('does not queue a second v5 deploy job while one is pending for the same application', function () {
Config::set('cache.default', 'array');
Queue::fake();
V5DeployApplicationJob::dispatch(101);
V5DeployApplicationJob::dispatch(101);
V5DeployApplicationJob::dispatch(202);
Queue::assertPushed(V5DeployApplicationJob::class, 2);
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 101);
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 202);
});
it('does not queue a second v5 bootstrap job while one is pending for the same server', function () {
Config::set('cache.default', 'array');
Queue::fake();
V5BootstrapServerJob::dispatch(1, 55);
V5BootstrapServerJob::dispatch(2, 55);
V5BootstrapServerJob::dispatch(1, 66);
Queue::assertPushed(V5BootstrapServerJob::class, 2);
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 55);
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 66);
});
it('queues v5 canvas and cluster broadcasts after commit instead of pushing on the request thread', function () {
$canvasEvent = new V5CanvasResourceUpdated(1, 2);
$clusterEvent = new V5ClusterUpdated(1, 2);
expect($canvasEvent)->toBeInstanceOf(ShouldBroadcast::class)
->not->toBeInstanceOf(ShouldBroadcastNow::class)
->and($canvasEvent->afterCommit)->toBeTrue()
->and($clusterEvent)->toBeInstanceOf(ShouldBroadcast::class)
->not->toBeInstanceOf(ShouldBroadcastNow::class)
->and($clusterEvent->afterCommit)->toBeTrue();
});
it('keeps the manual v5 realtime test ping broadcasting immediately', function () {
expect(new V5RealtimeTestEvent(1, 'ping'))->toBeInstanceOf(ShouldBroadcastNow::class);
});
-164
View File
@@ -1,164 +0,0 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\User;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
use App\Models\V5\Server as V5Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('broadcasting.default', 'log');
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
$this->user = User::factory()->create([
'name' => 'Root User',
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$this->team = $this->user->teams()->firstOrFail();
$this->project = Project::create([
'name' => 'Canvas Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->firstOrFail();
});
function createCanvasV5Server(array $attributes = []): V5Server
{
return V5Server::query()->create([
'team_id' => test()->team->id,
'created_by_user_id' => test()->user->id,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'last_bootstrapped_at' => now(),
...$attributes,
]);
}
function createCanvasV5Application(V5Server $server, array $attributes = []): V5Application
{
return V5Application::query()->create([
'team_id' => test()->team->id,
'project_id' => test()->project->id,
'environment_id' => test()->environment->id,
'server_id' => $server->id,
'created_by_user_id' => test()->user->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()),
'status' => 'running',
'mesh_namespace' => 'default',
'canvas_x' => 0,
'canvas_y' => 0,
...$attributes,
]);
}
it('renders seeded applications on the canvas', function () {
$server = createCanvasV5Server();
createCanvasV5Application($server);
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('nginx-test')
->assertSee('docker.io/library/nginx:alpine')
->assertSee('edge-01')
->assertSee('Configure')
->assertSee('1 apps')
->assertDontSee('No applications on this canvas yet.')
->assertNoJavaScriptErrors()
->screenshot();
});
it('shows the selected project and environment in the navbar', function () {
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('Canvas Project')
->assertSee('production')
->assertNoJavaScriptErrors()
->screenshot();
});
it('deploys an nginx container from the toolbar', function () {
createCanvasV5Server();
fakeSuccessfulNginxFluxDeployment();
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('No applications on this canvas yet.')
->click('Deploy')
->assertSee('docker.io/library/nginx:alpine')
->assertDontSee('No applications on this canvas yet.')
->assertNoJavaScriptErrors()
->screenshot();
$application = V5Application::query()->sole();
expect($application->image)->toBe('docker.io/library/nginx:alpine')
->and($application->status)->toBe('running');
});
it('disables app ingress from the inspector sheet', function () {
// Server ingress on, but not "installed", so the Caddy config sync is skipped.
$server = createCanvasV5Server(['is_ingress' => true, 'status' => 'installing']);
$application = createCanvasV5Application($server, ['ingress_enabled' => true, 'internal_port' => 8080]);
V5ApplicationDomain::query()->create([
'application_id' => $application->id,
'domain' => 'app.example.com',
]);
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('nginx-test')
->click('Configure')
->assertSee('App configuration')
->click('Networking')
->assertSee('Public ingress')
->click('[data-slot="sheet-content"] button:has-text("Disable")')
->assertSee('Private')
->assertNoJavaScriptErrors()
->screenshot();
expect($application->refresh()->ingress_enabled)->toBeFalse();
});
it('enables app ingress through the ingress dialog', function () {
$server = createCanvasV5Server(['is_ingress' => true, 'status' => 'installing']);
$application = createCanvasV5Application($server, ['ingress_enabled' => false]);
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('nginx-test')
->click('button:has-text("Enable")')
->assertSee('Enable app ingress')
->fill('[placeholder="example.com, www.example.com"]', 'app.example.com')
->fill('[placeholder="3000"]', '8080')
->click('button:has-text("Enable ingress")')
->assertDontSee('Ingress update failed')
->assertNoJavaScriptErrors()
->screenshot();
expect($application->refresh()->ingress_enabled)->toBeTrue()
->and($application->internal_port)->toBe(8080)
->and($application->domains()->pluck('domain')->all())->toBe(['app.example.com']);
});
-72
View File
@@ -1,72 +0,0 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use App\Models\V5\Cluster as V5Cluster;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('broadcasting.default', 'log');
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
$this->user = User::factory()->create([
'name' => 'Root User',
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$this->team = $this->user->teams()->firstOrFail();
});
it('lists existing clusters', function () {
V5Cluster::query()->create([
'team_id' => $this->team->id,
'created_by_user_id' => $this->user->id,
'name' => 'Existing Mesh',
]);
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->assertSee('Clusters')
->assertSee('Existing Mesh')
->assertNoJavaScriptErrors()
->screenshot();
});
it('creates a cluster from the clusters page', function () {
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->assertSee('Clusters')
->click('button[aria-label="Create cluster"]')
->assertSee('Create cluster')
->fill('[placeholder="Production Mesh"]', 'Test Mesh')
->click('button:has-text("Create cluster")')
->assertSee('Test Mesh')
->assertNoJavaScriptErrors()
->screenshot();
$cluster = V5Cluster::query()->sole();
expect($cluster->name)->toBe('Test Mesh')
->and($cluster->team_id)->toBe($this->team->id);
});
it('shows a validation error when the cluster name is missing', function () {
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->click('button[aria-label="Create cluster"]')
->click('button:has-text("Create cluster")')
->assertSee('The name field is required.')
->screenshot();
expect(V5Cluster::query()->count())->toBe(0);
});
-47
View File
@@ -1,47 +0,0 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
$this->user = User::factory()->create([
'id' => 0,
'name' => 'Root User',
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
});
it('redirects guests to login', function () {
$page = visit('/v5');
$page->assertPathIs('/login')
->screenshot();
});
it('shows the dashboard canvas for an authenticated user', function () {
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('Deploy')
->assertSee('No applications on this canvas yet.')
->assertNoJavaScriptErrors()
->screenshot();
});
it('shows the clusters page for an authenticated user', function () {
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->assertSee('Clusters')
->assertNoJavaScriptErrors()
->screenshot();
});