mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 06:16:04 +00:00
fix(servers): retain cloud instances awaiting IPs
Persist DigitalOcean, Hetzner, and Vultr servers before public IP assignment, then backfill placeholder addresses from provider state. Treat partial Sentinel snapshots as non-authoritative and document the destinations API with OpenAPI schemas.
This commit is contained in:
1 parent
d3fbb32c52
commit
e01b8a057e
29 files changed
+1275
-59
No files matched your search
@@ -102,6 +102,26 @@ describe('GET /api/v1/servers/{server_uuid}/destinations', function () {
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
|
||||
test('creates a standalone destination', function () {
|
||||
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
||||
'name' => 'API Standalone',
|
||||
'network' => 'api-standalone-network',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson([
|
||||
'name' => 'API Standalone',
|
||||
'network' => 'api-standalone-network',
|
||||
'type' => 'standalone',
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]);
|
||||
|
||||
expect(StandaloneDocker::where('server_id', $this->server->id)
|
||||
->where('network', 'api-standalone-network')
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('requires a write token', function () {
|
||||
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ use Illuminate\Support\Str;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['is_api_enabled' => true],
|
||||
));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByDigitalOcean;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -25,6 +29,88 @@ beforeEach(function () {
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->digitalOceanToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-digitalocean-token',
|
||||
'name' => 'Test DigitalOcean Token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
function submitDigitalOceanServer(): void
|
||||
{
|
||||
Livewire::test(ByDigitalOcean::class, ['selectedTokenUuid' => test()->digitalOceanToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->set('server_name', 'test-do-server')
|
||||
->set('selected_region', 'nyc1')
|
||||
->set('selected_size', 's-1vcpu-1gb')
|
||||
->set('selected_image', 'ubuntu-24-04-x64')
|
||||
->set('private_key_id', test()->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
}
|
||||
|
||||
it('persists the server with a placeholder IP when waiting for the droplet IP fails', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account/keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
], 200),
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||
'droplet' => ['id' => 555, 'status' => 'new'],
|
||||
], 202),
|
||||
'https://api.digitalocean.com/v2/droplets/555' => Http::response(['message' => 'server error'], 500),
|
||||
]);
|
||||
|
||||
submitDigitalOceanServer();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-do-server',
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'team_id' => $this->team->id,
|
||||
'cloud_provider_token_id' => $this->digitalOceanToken->id,
|
||||
'digitalocean_droplet_id' => '555',
|
||||
'digitalocean_droplet_status' => 'new',
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates the placeholder IP once the droplet reports one', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account/keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
], 200),
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||
'droplet' => ['id' => 555, 'status' => 'new'],
|
||||
], 202),
|
||||
'https://api.digitalocean.com/v2/droplets/555' => Http::response([
|
||||
'droplet' => [
|
||||
'id' => 555,
|
||||
'status' => 'active',
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['type' => 'public', 'ip_address' => '203.0.113.40'],
|
||||
],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
submitDigitalOceanServer();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-do-server',
|
||||
'ip' => '203.0.113.40',
|
||||
'digitalocean_droplet_id' => '555',
|
||||
'digitalocean_droplet_status' => 'active',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
|
||||
|
||||
@@ -100,6 +100,17 @@ test('MCP endpoint returns 403 when the token team has MCP disabled', function (
|
||||
$response->assertJson(['message' => 'MCP server is disabled for this team.']);
|
||||
});
|
||||
|
||||
test('MCP endpoint is enabled for teams by default', function () {
|
||||
$defaultTeam = Team::factory()->create();
|
||||
$this->user->teams()->attach($defaultTeam->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $defaultTeam]);
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpListTools($token);
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('MCP endpoint rejects unauthenticated requests', function () {
|
||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||
$response->assertStatus(401);
|
||||
|
||||
@@ -20,7 +20,10 @@ use Illuminate\Support\Facades\Log;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['is_api_enabled' => true],
|
||||
));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
@@ -126,3 +126,112 @@ function createPushUpdatePostgresql(Team $team, array $attributes = []): Standal
|
||||
|
||||
return $database;
|
||||
}
|
||||
|
||||
test('partial sentinel snapshots do not mark missing databases as exited', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'running:healthy',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
Queue::fake();
|
||||
|
||||
$data = [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => false,
|
||||
],
|
||||
'containers' => [
|
||||
[
|
||||
'name' => 'unrelated-container',
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('partial sentinel snapshots apply directly observed database status updates', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
Queue::fake();
|
||||
|
||||
$data = [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => false,
|
||||
],
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('partial sentinel snapshots do not trigger missing tcp proxy recovery', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'exited',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
Queue::fake();
|
||||
|
||||
$data = [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => false,
|
||||
],
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
@@ -196,3 +196,24 @@ it('rejects an invalid token without dispatching', function () use ($running) {
|
||||
|
||||
Queue::assertNotPushed(PushServerUpdateJob::class);
|
||||
});
|
||||
|
||||
it('dispatches a complete snapshot after an identical partial snapshot', function () use ($running) {
|
||||
$partialPayload = sentinelPayload($running()) + [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$completePayload = sentinelPayload($running()) + [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => true,
|
||||
],
|
||||
];
|
||||
|
||||
pushSentinel($this->token, $partialPayload)->assertOk();
|
||||
pushSentinel($this->token, $completePayload)->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 2);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->hetznerToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-token',
|
||||
'name' => 'Test Hetzner Token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
it('persists the server with a placeholder IP when Hetzner has not assigned one yet', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 777,
|
||||
'status' => 'initializing',
|
||||
'public_net' => [
|
||||
'ipv4' => null,
|
||||
'ipv6' => null,
|
||||
],
|
||||
],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->set('server_name', 'test-hetzner-server')
|
||||
->set('selected_location', 'fsn1')
|
||||
->set('selected_server_type', 'cx22')
|
||||
->set('selected_image', 114690387)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-hetzner-server',
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'team_id' => $this->team->id,
|
||||
'cloud_provider_token_id' => $this->hetznerToken->id,
|
||||
'hetzner_server_id' => '777',
|
||||
'hetzner_server_status' => 'initializing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates the server with the real IP when Hetzner assigns one immediately', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 777,
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '203.0.113.30'],
|
||||
],
|
||||
],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->set('server_name', 'test-hetzner-server')
|
||||
->set('selected_location', 'fsn1')
|
||||
->set('selected_server_type', 'cx22')
|
||||
->set('selected_image', 114690387)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-hetzner-server',
|
||||
'ip' => '203.0.113.30',
|
||||
'hetzner_server_id' => '777',
|
||||
'hetzner_server_status' => 'running',
|
||||
]);
|
||||
});
|
||||
@@ -15,8 +15,8 @@ function serviceExtraFieldsTestServiceWithApplicationImage(string $image): Servi
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create();
|
||||
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Livewire\Server\New\ByVultr;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -95,7 +96,7 @@ it('creates a Vultr server through the Livewire flow', function () {
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'label' => 'test-vultr-server',
|
||||
'main_ip' => '1.2.3.4',
|
||||
'main_ip' => '192.0.2.10',
|
||||
'v6_main_ip' => '2001:db8::1',
|
||||
'status' => 'pending',
|
||||
],
|
||||
@@ -115,7 +116,7 @@ it('creates a Vultr server through the Livewire flow', function () {
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-vultr-server',
|
||||
'ip' => '1.2.3.4',
|
||||
'ip' => '192.0.2.10',
|
||||
'team_id' => $this->team->id,
|
||||
'cloud_provider_token_id' => $this->vultrToken->id,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
@@ -132,6 +133,45 @@ it('creates a Vultr server through the Livewire flow', function () {
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the server with a placeholder IP when Vultr has not assigned one yet', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/ssh-keys' => Http::response([
|
||||
'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()],
|
||||
], 201),
|
||||
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response(['error' => 'temporary error'], 500),
|
||||
'https://api.vultr.com/v2/instances' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'label' => 'test-vultr-server',
|
||||
'main_ip' => '0.0.0.0',
|
||||
'v6_main_ip' => '::',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
|
||||
->set('server_name', 'test-vultr-server')
|
||||
->set('selected_region', 'ewr')
|
||||
->set('selected_plan', 'vc2-1c-1gb')
|
||||
->set('selected_os_id', 2284)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-vultr-server',
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'team_id' => $this->team->id,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
'vultr_instance_status' => 'pending',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires IPv6 when public IPv4 is disabled', function () {
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('selected_token_id', $this->vultrToken->id)
|
||||
|
||||
Reference in new issue
Block a user