mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 08:42:38 -04:00
Require write and deploy abilities on the import check endpoint, and clarify the target token permissions in transfer messages and UI.
702 lines
32 KiB
PHP
702 lines
32 KiB
PHP
<?php
|
|
|
|
use App\Jobs\MigrateServerToInstanceJob;
|
|
use App\Jobs\ValidateAndInstallServerJob;
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\GithubApp;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\ServerTransfer\ServerTransferClaimer;
|
|
use App\Services\ServerTransfer\ServerTransferExporter;
|
|
use App\Services\ServerTransfer\ServerTransferMigrator;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Sleep;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
// A claim writes the owner marker on the host over SSH.
|
|
Process::fake();
|
|
Storage::fake('ssh-keys');
|
|
// An import validates the claimed server over SSH; the sync test queue would run it.
|
|
Queue::fake([ValidateAndInstallServerJob::class]);
|
|
Sleep::fake();
|
|
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => $this->privateKey->id,
|
|
'ip' => '10.88.0.10',
|
|
'name' => 'migrate-me',
|
|
]);
|
|
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
|
$project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Shop']);
|
|
$environment = $project->environments()->first()
|
|
?? Environment::factory()->create(['project_id' => $project->id]);
|
|
$this->environment = $environment;
|
|
Application::factory()->create([
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
'name' => 'app-on-server',
|
|
'ports_exposes' => '3000',
|
|
]);
|
|
});
|
|
|
|
test('migrate exports imports via http and completes locally', function () {
|
|
Http::fake([
|
|
'https://8.8.8.8/api/v1/servers/import' => Http::response([
|
|
'dry_run' => false,
|
|
'server_uuid' => $this->server->uuid,
|
|
'claimed' => true,
|
|
'export_id' => 'remote-export',
|
|
'created' => ['applications' => 1],
|
|
'warnings' => [],
|
|
], 201),
|
|
]);
|
|
|
|
$result = app(ServerTransferMigrator::class)->migrate(
|
|
server: $this->server,
|
|
targetUrl: 'https://8.8.8.8',
|
|
targetToken: 'target-token-xyz',
|
|
);
|
|
|
|
expect($result['server_uuid'])->toBe($this->server->uuid)
|
|
->and($result['target_url'])->toBe('https://8.8.8.8')
|
|
->and($result['import']['claimed'])->toBeTrue()
|
|
->and($result['message'])->toContain('migrated');
|
|
|
|
$this->server->refresh();
|
|
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('transferred')
|
|
->and((bool) $this->server->settings->force_disabled)->toBeTrue();
|
|
|
|
Http::assertSent(function ($request) {
|
|
return $request->url() === 'https://8.8.8.8/api/v1/servers/import'
|
|
&& $request->hasHeader('Authorization', 'Bearer target-token-xyz')
|
|
&& ! array_key_exists('write_remote', $request->data())
|
|
&& data_get($request->data(), 'bundle.server.uuid') === $this->server->uuid;
|
|
});
|
|
});
|
|
|
|
test('migrate rejects a private target before sending the bundle', function () {
|
|
config()->set('app.env', 'production');
|
|
Http::fake();
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
|
$this->server,
|
|
'https://127.0.0.1:8001',
|
|
'token',
|
|
))->toThrow(ValidationException::class);
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('migrate reaches a local peer instance in development', function () {
|
|
config()->set('app.env', 'local');
|
|
Http::fake(fn () => Http::response([
|
|
'dry_run' => false,
|
|
'server_uuid' => $this->server->uuid,
|
|
'claimed' => true,
|
|
'warnings' => [],
|
|
], 201));
|
|
|
|
app(ServerTransferMigrator::class)->migrate(
|
|
$this->server,
|
|
'http://localhost:8001',
|
|
'target-token',
|
|
);
|
|
|
|
$targetHost = file_exists('/.dockerenv') || is_file('/run/.containerenv')
|
|
? 'host.docker.internal'
|
|
: 'localhost';
|
|
Http::assertSent(fn ($request) => $request->url() === "http://{$targetHost}:8001/api/v1/servers/import");
|
|
});
|
|
|
|
test('migrate still rejects other private targets in development', function () {
|
|
config()->set('app.env', 'local');
|
|
Http::fake();
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
|
$this->server,
|
|
'https://10.0.0.5:8001',
|
|
'token',
|
|
))->toThrow(ValidationException::class);
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('migrate fails clearly when target is unreachable', function () {
|
|
Http::fake([
|
|
'https://8.8.4.4/*' => Http::failedConnection(),
|
|
]);
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
|
$this->server,
|
|
'https://8.8.4.4',
|
|
'token',
|
|
))->toThrow(RuntimeException::class, 'Could not reach target');
|
|
});
|
|
|
|
test('migrate fails when target returns error', function () {
|
|
Http::fake([
|
|
'https://8.8.8.8/api/v1/servers/import' => Http::response([
|
|
'message' => 'A server with IP/domain already exists',
|
|
], 422),
|
|
]);
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
|
$this->server,
|
|
'https://8.8.8.8',
|
|
'token',
|
|
))->toThrow(RuntimeException::class, 'Target import failed');
|
|
|
|
// Source must remain unmanaged-away only after successful remote import+complete.
|
|
$this->server->refresh();
|
|
expect(data_get($this->server->server_metadata, 'transfer.status'))->not->toBe('transferred')
|
|
->and((bool) $this->server->settings->force_disabled)->toBeFalse();
|
|
});
|
|
|
|
test('migrate surfaces recovery guidance when complete fails after successful remote import', function () {
|
|
Http::fake([
|
|
'https://8.8.8.8/api/v1/servers/import' => Http::response([
|
|
'dry_run' => false,
|
|
'server_uuid' => $this->server->uuid,
|
|
'claimed' => true,
|
|
'warnings' => [],
|
|
], 201),
|
|
]);
|
|
|
|
$claimer = Mockery::mock(ServerTransferClaimer::class)->makePartial();
|
|
$claimer->shouldReceive('markTransferred')
|
|
->once()
|
|
->andThrow(new RuntimeException('simulated complete failure'));
|
|
app()->instance(ServerTransferClaimer::class, $claimer);
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
|
$this->server,
|
|
'https://8.8.8.8',
|
|
'token',
|
|
))->toThrow(RuntimeException::class, 'transfer/complete on this instance');
|
|
});
|
|
|
|
test('migrate keeps managing the server here when the target could not take management', function () {
|
|
Http::fake([
|
|
'https://8.8.8.8/api/v1/servers/import' => Http::response([
|
|
'dry_run' => false,
|
|
'server_uuid' => $this->server->uuid,
|
|
'claimed' => false,
|
|
'warnings' => ['Server imported, but automatic claim failed: boom'],
|
|
], 201),
|
|
]);
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
|
$this->server,
|
|
'https://8.8.8.8',
|
|
'token',
|
|
))->toThrow(RuntimeException::class, 'could not take management');
|
|
|
|
$this->server->refresh();
|
|
expect($this->server->isTransferredAway())->toBeFalse()
|
|
->and((bool) $this->server->settings->force_disabled)->toBeFalse();
|
|
});
|
|
|
|
test('migrate rejects a target without https outside development', function () {
|
|
Http::fake();
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
|
$this->server,
|
|
'http://8.8.8.8',
|
|
'token',
|
|
))->toThrow(RuntimeException::class, 'must use https');
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('migrate tells the user to upgrade a target without server transfer', function () {
|
|
Http::fake([
|
|
'https://8.8.8.8/api/v1/servers/import' => Http::response(['message' => 'Not found.'], 404),
|
|
]);
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
|
$this->server,
|
|
'https://8.8.8.8',
|
|
'token',
|
|
))->toThrow(RuntimeException::class, 'does not support server transfer');
|
|
});
|
|
|
|
test('migrate completes when the import request times out but the target claimed the server', function () {
|
|
$sentExportId = null;
|
|
Http::fake(function ($request) use (&$sentExportId) {
|
|
if (str_ends_with($request->url(), '/api/v1/servers/import')) {
|
|
$sentExportId = data_get($request->data(), 'bundle.export_id');
|
|
|
|
return Http::response('Gateway Timeout', 504);
|
|
}
|
|
|
|
return Http::response([
|
|
'server_uuid' => $this->server->uuid,
|
|
'transfer' => ['status' => 'claimed', 'export_id' => $sentExportId],
|
|
]);
|
|
});
|
|
|
|
$result = app(ServerTransferMigrator::class)->migrate($this->server, 'https://8.8.8.8', 'token');
|
|
|
|
expect($result['import']['claimed'])->toBeTrue()
|
|
->and($this->server->fresh()->isTransferredAway())->toBeTrue();
|
|
Http::assertSent(fn ($request) => $request->url() === "https://8.8.8.8/api/v1/servers/{$this->server->uuid}/transfer");
|
|
});
|
|
|
|
test('migrate keeps the server here when the target never confirms a timed out import', function () {
|
|
Http::fake(function ($request) {
|
|
if (str_ends_with($request->url(), '/api/v1/servers/import')) {
|
|
return Http::response('Gateway Timeout', 504);
|
|
}
|
|
|
|
return Http::response(['message' => 'Server not found.'], 404);
|
|
});
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate($this->server, 'https://8.8.8.8', 'token'))
|
|
->toThrow(RuntimeException::class, 'Target import failed (HTTP 504)');
|
|
|
|
expect($this->server->fresh()->isTransferredAway())->toBeFalse();
|
|
});
|
|
|
|
test('start records the running transfer and queues the job once', function () {
|
|
Queue::fake();
|
|
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
|
|
$migrator = app(ServerTransferMigrator::class);
|
|
|
|
$migrator->start($this->server, 'https://8.8.8.8/', 'token');
|
|
|
|
expect(data_get($this->server->fresh()->server_metadata, 'migration.status'))->toBe('running')
|
|
->and(data_get($this->server->fresh()->server_metadata, 'migration.target_url'))->toBe('https://8.8.8.8');
|
|
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
|
|
|
|
expect(fn () => $migrator->start($this->server, 'https://8.8.8.8', 'token'))
|
|
->toThrow(RuntimeException::class, 'already running');
|
|
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
|
|
});
|
|
|
|
test('the migrate job records a failed transfer', function () {
|
|
Http::fake(['*' => Http::response(['message' => 'Unauthenticated.'], 401)]);
|
|
|
|
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
|
|
->handle(app(ServerTransferMigrator::class));
|
|
|
|
$migration = data_get($this->server->fresh()->server_metadata, 'migration');
|
|
expect($migration['status'])->toBe('failed')
|
|
->and($migration['error'])->toContain('HTTP 401');
|
|
});
|
|
|
|
test('the migrate job records a finished transfer', function () {
|
|
Http::fake([
|
|
'https://8.8.8.8/api/v1/servers/import' => Http::response([
|
|
'server_uuid' => $this->server->uuid,
|
|
'claimed' => true,
|
|
'warnings' => ['check webhooks'],
|
|
], 201),
|
|
]);
|
|
|
|
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
|
|
->handle(app(ServerTransferMigrator::class));
|
|
|
|
$server = $this->server->fresh();
|
|
expect(data_get($server->server_metadata, 'migration.status'))->toBe('succeeded')
|
|
->and(data_get($server->server_metadata, 'migration.warnings'))->toContain('check webhooks')
|
|
->and($server->isTransferredAway())->toBeTrue();
|
|
});
|
|
|
|
test('start replaces a running state that a stopped worker left behind', function () {
|
|
Queue::fake();
|
|
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
|
|
$this->server->forceFill(['server_metadata' => ['migration' => [
|
|
'status' => 'running',
|
|
'started_at' => now()->subHour()->toIso8601String(),
|
|
]]])->save();
|
|
|
|
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
|
|
|
|
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
|
|
});
|
|
|
|
test('start checks the target before it queues the transfer', function (int $status, string $message) {
|
|
Queue::fake();
|
|
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'A server with IP/domain 10.0.0.1 already exists on this instance.'], $status)]);
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'wrong-token'))
|
|
->toThrow(RuntimeException::class, $message);
|
|
|
|
Queue::assertNothingPushed();
|
|
$migration = data_get($this->server->fresh()->server_metadata, 'migration');
|
|
expect($migration['status'])->toBe('failed')
|
|
->and($migration['phase'])->toBe('checks')
|
|
->and(collect($migration['log'])->last())->toMatchArray(['level' => 'error'])
|
|
->and(collect($migration['log'])->where('level', 'error')->pluck('message')->implode("\n"))->toContain('FAILED: ')->toContain($message);
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'server_uuid='.$this->server->uuid)
|
|
&& $request->hasHeader('Authorization', 'Bearer wrong-token'));
|
|
})->with([
|
|
'invalid token' => [401, 'does not accept the API token'],
|
|
'token without the write and deploy permissions' => [403, 'write and deploy permissions'],
|
|
'conflict on the target' => [422, 'already exists on this instance'],
|
|
'target without server transfer' => [404, 'does not support server transfer'],
|
|
]);
|
|
|
|
test('start fails at once when the target cannot be reached', function () {
|
|
Queue::fake();
|
|
Http::fake(fn () => throw new ConnectionException('Connection refused'));
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token'))
|
|
->toThrow(RuntimeException::class, 'This instance cannot reach the target instance');
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('migrate stops waiting for a timed out import when the target rejects the token', function () {
|
|
Http::fake(function ($request) {
|
|
if (str_ends_with($request->url(), '/api/v1/servers/import')) {
|
|
return Http::response('Gateway Timeout', 504);
|
|
}
|
|
|
|
return Http::response(['message' => 'Unauthenticated.'], 401);
|
|
});
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->migrate($this->server, 'https://8.8.8.8', 'token'))
|
|
->toThrow(RuntimeException::class, 'Target import failed (HTTP 504)');
|
|
|
|
Sleep::assertSleptTimes(1);
|
|
expect($this->server->fresh()->isTransferredAway())->toBeFalse();
|
|
});
|
|
|
|
test('start logs every check as OK and the queued transfer', function () {
|
|
Queue::fake();
|
|
Http::fake(['*/api/v1/servers/import/check*' => Http::response([
|
|
'message' => 'ok',
|
|
'checks' => [
|
|
['label' => 'no server with the same IP exists', 'ok' => true, 'message' => null],
|
|
['label' => 'the target can reach the SSH port of the server (10.0.0.1:22)', 'ok' => true, 'message' => null],
|
|
],
|
|
])]);
|
|
|
|
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
|
|
|
|
$log = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message');
|
|
expect($log)->toContain('OK: the server is not the Coolify host.')
|
|
->toContain('OK: this instance manages the server.')
|
|
->toContain('OK: no deployment is queued or running on the server.')
|
|
->toContain('OK: this instance can connect to the server over SSH.')
|
|
->toContain('OK: the target instance at https://8.8.8.8 can be reached.')
|
|
->toContain('OK: the target instance accepts the API token.')
|
|
->toContain('OK: on the target: the target can reach the SSH port of the server (10.0.0.1:22).')
|
|
->and($log->last())->toBe('All checks passed. The transfer is queued.');
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'schema_version=') && str_contains($request->url(), 'port='));
|
|
});
|
|
|
|
test('start runs every check and logs which are OK and which failed', function () {
|
|
Queue::fake();
|
|
Process::fake(['*' => Process::result(errorOutput: 'Connection refused', exitCode: 255)]);
|
|
Http::fake(['*/api/v1/servers/import/check*' => Http::response([
|
|
'message' => 'A server with IP/domain 10.0.0.1 already exists on this instance.',
|
|
'checks' => [
|
|
['label' => 'the team can have one more server', 'ok' => true, 'message' => null],
|
|
['label' => 'no server with the same IP exists', 'ok' => false, 'message' => 'A server with IP/domain 10.0.0.1 already exists on this instance.'],
|
|
],
|
|
], 422)]);
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token'))
|
|
->toThrow(RuntimeException::class, '2 of 10 checks failed');
|
|
|
|
$log = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'));
|
|
expect($log->where('level', 'success')->pluck('message'))->toContain('OK: the target instance accepts the API token.')
|
|
->toContain('OK: on the target: the team can have one more server.')
|
|
->and($log->where('level', 'error')->pluck('message')->implode("\n"))
|
|
->toContain('FAILED: this instance can connect to the server over SSH.')
|
|
->toContain('FAILED: on the target: no server with the same IP exists. A server with IP/domain 10.0.0.1 already exists')
|
|
->and($log->last()['message'])->toContain('2 check(s) failed');
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('start reports the target checks as not checked when the token is rejected', function () {
|
|
Queue::fake();
|
|
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'Unauthenticated.'], 401)]);
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token'))
|
|
->toThrow(RuntimeException::class);
|
|
|
|
$log = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message');
|
|
expect($log)->toContain('OK: the target instance supports server transfer.')
|
|
->toContain('NOT CHECKED: the checks on the target instance. They did not run, because "the target instance accepts the API token" failed.');
|
|
});
|
|
|
|
test('the migrate job logs each step of a finished transfer', function () {
|
|
Http::fake([
|
|
'https://8.8.8.8/api/v1/servers/import' => Http::response(['server_uuid' => $this->server->uuid, 'claimed' => true], 201),
|
|
]);
|
|
|
|
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
|
|
->handle(app(ServerTransferMigrator::class));
|
|
|
|
$log = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'));
|
|
expect($log->pluck('message')->first())->toBe('Exporting the server configuration and its resources.')
|
|
->and($log->pluck('message')->implode("\n"))->toContain('Sending it to https://8.8.8.8')
|
|
->toContain('manages it now')
|
|
->and($log->last())->toMatchArray(['level' => 'success', 'message' => 'Transfer finished. The target instance manages the server now.']);
|
|
});
|
|
|
|
test('the migrate job logs each attempt to read the result of a timed out import', function () {
|
|
Http::fake(function ($request) {
|
|
if (str_ends_with($request->url(), '/api/v1/servers/import')) {
|
|
return Http::response('Gateway Timeout', 504);
|
|
}
|
|
|
|
return Http::response(['message' => 'Server not found.'], 404);
|
|
});
|
|
|
|
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
|
|
->handle(app(ServerTransferMigrator::class));
|
|
|
|
$messages = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message');
|
|
expect($messages->filter(fn ($message) => str_starts_with($message, 'Asking the target for the import result')))->toHaveCount(ServerTransferMigrator::STATUS_PROBE_ATTEMPTS)
|
|
->and($messages->last())->toStartWith('Transfer failed: Target import failed (HTTP 504)');
|
|
});
|
|
|
|
test('the migrate job stores the post-transfer checklist with the target URL', function () {
|
|
Http::fake([
|
|
'https://8.8.8.8/api/v1/servers/import' => Http::response(['server_uuid' => $this->server->uuid, 'claimed' => true], 201),
|
|
]);
|
|
$exporter = app(ServerTransferExporter::class);
|
|
$fakeExporter = Mockery::mock(ServerTransferExporter::class);
|
|
$fakeExporter->shouldReceive('export')->once()->andReturnUsing(fn (Server $server, bool $includeSensitive = true) => [
|
|
...$exporter->export($server, $includeSensitive),
|
|
'post_transfer_checklist' => [
|
|
'Set the webhook URL of GitHub App deploy-bot to {target_url}/webhooks/source/github/events.',
|
|
['not a step'],
|
|
'Point the DNS record of shop.example.com to the server.',
|
|
],
|
|
]);
|
|
app()->instance(ServerTransferExporter::class, $fakeExporter);
|
|
|
|
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
|
|
->handle(app(ServerTransferMigrator::class));
|
|
|
|
$migration = data_get($this->server->fresh()->server_metadata, 'migration');
|
|
expect($migration['status'])->toBe('succeeded')
|
|
->and($migration['checklist'])->toBe([
|
|
'Set the webhook URL of GitHub App deploy-bot to https://8.8.8.8/webhooks/source/github/events.',
|
|
'Point the DNS record of shop.example.com to the server.',
|
|
]);
|
|
});
|
|
|
|
test('the migrate job stores the checklist of the target, which has the public URL of the target', function () {
|
|
Http::fake([
|
|
'https://8.8.8.8/api/v1/servers/import' => Http::response([
|
|
'server_uuid' => $this->server->uuid,
|
|
'claimed' => true,
|
|
'checklist' => ['Set the webhook URL of GitHub App deploy-bot to https://coolify-b.example.com/webhooks/source/github/events.'],
|
|
], 201),
|
|
]);
|
|
|
|
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
|
|
->handle(app(ServerTransferMigrator::class));
|
|
|
|
expect(data_get($this->server->fresh()->server_metadata, 'migration.checklist'))
|
|
->toBe(['Set the webhook URL of GitHub App deploy-bot to https://coolify-b.example.com/webhooks/source/github/events.']);
|
|
});
|
|
|
|
test('start notes projects that also have resources on other servers', function (int $checkStatus, bool $queued) {
|
|
Queue::fake();
|
|
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []], $checkStatus)]);
|
|
$otherServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => '10.88.0.11']);
|
|
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
|
|
foreach (['web', 'worker'] as $name) {
|
|
Application::factory()->create([
|
|
'environment_id' => $this->environment->id,
|
|
'destination_id' => $otherDestination->id,
|
|
'destination_type' => $otherDestination->getMorphClass(),
|
|
'name' => $name,
|
|
]);
|
|
}
|
|
// A project with resources only on the other server is not affected by the transfer.
|
|
$otherProject = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Blog']);
|
|
Application::factory()->create([
|
|
'environment_id' => $otherProject->environments()->first()->id,
|
|
'destination_id' => $otherDestination->id,
|
|
'destination_type' => $otherDestination->getMorphClass(),
|
|
]);
|
|
|
|
try {
|
|
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
|
|
} catch (RuntimeException) {
|
|
}
|
|
|
|
$notes = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))
|
|
->filter(fn (array $entry) => str_starts_with($entry['message'], 'NOTE: '));
|
|
expect($notes->pluck('message')->all())->toBe([
|
|
"NOTE: project 'Shop' also has 2 resources on other servers. They stay on this instance; only the resources of this server are transferred.",
|
|
])
|
|
->and($notes->first()['level'])->toBe('info');
|
|
$queued ? Queue::assertPushed(MigrateServerToInstanceJob::class, 1) : Queue::assertNothingPushed();
|
|
})->with([
|
|
'checks pass' => [200, true],
|
|
'a check fails' => [401, false],
|
|
]);
|
|
|
|
test('start adds no note when the projects have no resources on other servers', function () {
|
|
Queue::fake();
|
|
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
|
|
|
|
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
|
|
|
|
$messages = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message');
|
|
expect($messages->filter(fn (string $message) => str_starts_with($message, 'NOTE: ')))->toBeEmpty();
|
|
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
|
|
});
|
|
|
|
test('the migrate job timeout covers the import request and every status probe', function () {
|
|
$worstCase = ServerTransferMigrator::IMPORT_TIMEOUT_SECONDS
|
|
+ ServerTransferMigrator::STATUS_PROBE_ATTEMPTS
|
|
* (ServerTransferMigrator::STATUS_PROBE_INTERVAL_SECONDS + ServerTransferMigrator::STATUS_REQUEST_TIMEOUT_SECONDS);
|
|
$job = new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token');
|
|
|
|
// The rest of the timeout is for the export and the database writes.
|
|
expect($job->timeout)->toBeGreaterThanOrEqual($worstCase + 300)
|
|
->and(ServerTransferMigrator::STALE_MIGRATION_SECONDS)->toBeGreaterThan($job->timeout);
|
|
});
|
|
|
|
function stopMigrateJobAfterExport(Server $server): void
|
|
{
|
|
$metadata = $server->fresh()->server_metadata ?? [];
|
|
$metadata['migration'] = ['status' => 'running', 'target_url' => 'https://8.8.8.8', 'export_id' => 'export-1', 'log' => []];
|
|
$server->forceFill(['server_metadata' => $metadata])->save();
|
|
|
|
(new MigrateServerToInstanceJob($server, 'https://8.8.8.8', 'token'))
|
|
->failed(new RuntimeException('The job has timed out.'));
|
|
}
|
|
|
|
test('a stopped migrate job completes the transfer when the target manages the server', function () {
|
|
Http::fake(['*' => Http::response(['transfer' => ['status' => 'claimed', 'export_id' => 'export-1']])]);
|
|
|
|
stopMigrateJobAfterExport($this->server);
|
|
|
|
$server = $this->server->fresh();
|
|
expect(data_get($server->server_metadata, 'migration.status'))->toBe('succeeded')
|
|
->and($server->isTransferredAway())->toBeTrue();
|
|
});
|
|
|
|
test('a stopped migrate job records an unknown result when the target cannot be asked', function () {
|
|
Http::fake(fn () => throw new ConnectionException('Connection timed out'));
|
|
|
|
stopMigrateJobAfterExport($this->server);
|
|
|
|
$server = $this->server->fresh();
|
|
expect(data_get($server->server_metadata, 'migration.status'))->toBe('unknown')
|
|
->and(data_get($server->server_metadata, 'migration.error'))->toContain("/api/v1/servers/{$server->uuid}/transfer/complete")
|
|
->and($server->isTransferredAway())->toBeFalse();
|
|
});
|
|
|
|
test('a stopped migrate job records a failure when the target does not have the export', function (array $body, int $status) {
|
|
Http::fake(['*' => Http::response($body, $status)]);
|
|
|
|
stopMigrateJobAfterExport($this->server);
|
|
|
|
$server = $this->server->fresh();
|
|
expect(data_get($server->server_metadata, 'migration.status'))->toBe('failed')
|
|
->and($server->isTransferredAway())->toBeFalse();
|
|
})->with([
|
|
'not found' => [['message' => 'Server not found.'], 404],
|
|
'other export' => [['transfer' => ['status' => 'claimed', 'export_id' => 'export-0']], 200],
|
|
]);
|
|
|
|
test('a migrate job stopped before the export records a failure without asking the target', function () {
|
|
Http::fake();
|
|
|
|
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
|
|
->failed(new RuntimeException('The job has timed out.'));
|
|
|
|
expect(data_get($this->server->fresh()->server_metadata, 'migration.status'))->toBe('failed');
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('migrate saves the export ID before it sends the bundle', function () {
|
|
$savedExportId = null;
|
|
Http::fake(function ($request) use (&$savedExportId) {
|
|
$savedExportId = data_get($this->server->fresh()->server_metadata, 'migration.export_id');
|
|
|
|
return Http::response(['server_uuid' => $this->server->uuid, 'claimed' => true], 201);
|
|
});
|
|
|
|
$result = app(ServerTransferMigrator::class)->migrate($this->server, 'https://8.8.8.8', 'token');
|
|
|
|
expect($savedExportId)->toBe($result['export_id']);
|
|
});
|
|
|
|
test('transfer back asks the managing instance to transfer the archive here with a short-lived token of its team', function () {
|
|
$user = User::factory()->create();
|
|
$this->team->members()->attach($user->id, ['role' => 'owner']);
|
|
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8');
|
|
Http::fake(['https://8.8.8.8/api/v1/servers/*/migrate' => Http::response(['migration' => ['status' => 'running']], 202)]);
|
|
|
|
app(ServerTransferMigrator::class)->requestTransferBack($this->server->fresh(), $user, 'https://8.8.8.8', 'token-of-b', 'https://coolify-a.test');
|
|
|
|
$token = $user->tokens()->sole();
|
|
expect((int) $token->team_id)->toBe($this->team->id)
|
|
->and($token->abilities)->toBe(['read', 'write'])
|
|
->and($token->expires_at->isFuture())->toBeTrue()
|
|
->and(data_get($this->server->fresh()->server_metadata, 'transfer.transfer_back.from'))->toBe('https://8.8.8.8');
|
|
Http::assertSent(fn ($request) => $request->url() === "https://8.8.8.8/api/v1/servers/{$this->server->uuid}/migrate"
|
|
&& $request->hasHeader('Authorization', 'Bearer token-of-b')
|
|
&& $request['target_url'] === 'https://coolify-a.test'
|
|
&& str_starts_with((string) $request['target_token'], $token->id.'|'));
|
|
});
|
|
|
|
test('a refused transfer back deletes the token and shows the reason of the other instance', function () {
|
|
$user = User::factory()->create();
|
|
$this->team->members()->attach($user->id, ['role' => 'owner']);
|
|
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8');
|
|
Http::fake(['*' => Http::response(['message' => '1 of 13 checks failed: no server with the same IP exists.'], 422)]);
|
|
|
|
expect(fn () => app(ServerTransferMigrator::class)->requestTransferBack($this->server->fresh(), $user, 'https://8.8.8.8', 'token-of-b', 'https://coolify-a.test'))
|
|
->toThrow(RuntimeException::class, 'no server with the same IP exists');
|
|
|
|
expect($user->tokens()->count())->toBe(0)
|
|
->and(data_get($this->server->fresh()->server_metadata, 'transfer.transfer_back'))->toBeNull();
|
|
});
|
|
|
|
test('start notes system-wide git sources that the target does not have', function (bool $noted) {
|
|
Queue::fake();
|
|
$systemApp = GithubApp::create([
|
|
'name' => 'Coolify GitHub', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com',
|
|
'team_id' => 0, 'is_system_wide' => true, 'is_public' => false,
|
|
]);
|
|
Application::where('name', 'app-on-server')->update(['source_type' => GithubApp::class, 'source_id' => $systemApp->id]);
|
|
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => [], 'missing_system_sources' => ['github' => $noted ? [$systemApp->uuid] : [], 'gitlab' => []]])]);
|
|
|
|
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
|
|
|
|
$notes = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message')
|
|
->filter(fn (string $message) => str_contains($message, 'system-wide'));
|
|
expect($notes->isNotEmpty())->toBe($noted);
|
|
if ($noted) {
|
|
expect($notes->first())->toBe("NOTE: the system-wide GitHub App 'Coolify GitHub' of this instance is not transferred, and the target has no matching one. These applications need a new git source on the target: app-on-server. The finish list on the target shows them.");
|
|
}
|
|
Http::assertSent(fn ($request) => str_contains(urldecode($request->url()), "system_github_apps[0]={$systemApp->uuid}"));
|
|
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
|
|
})->with([
|
|
'the target has no matching App' => [true],
|
|
'the target has the App' => [false],
|
|
]);
|