mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-22 06:23:28 +00:00
Move V5 source, migrations, UI, scripts, and tests into documentation, then remove V5 routes, models, jobs, configuration, dependencies, and application hooks.
330 lines
13 KiB
Plaintext
330 lines
13 KiB
Plaintext
<?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();
|
|
});
|