mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 14:17:18 +00:00
feat(v5): add server reconciliation and canvas APIs
Split V5 dashboard behavior into domain controllers and policies, add agent token rotation/revocation, status reconciliation jobs, ingress firewall syncing, and canvas connection APIs. Add migrations for V5 status tracking, server capabilities, resource connection aliases, and revoked agent tokens.
This commit is contained in:
1 parent
df854feee8
commit
6ae45684f9
147 files changed
+22444
-10407
No files matched your search
@@ -0,0 +1,246 @@
|
||||
<?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];
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user