mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 22:14:53 +00:00
feat(digitalocean): add droplet provisioning support
This commit is contained in:
1 parent
5936874ffd
commit
c303c34cfd
32 files changed
+2796
-19
No files matched your search
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\DeleteServer;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('deletes the DigitalOcean droplet when requested', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204),
|
||||
]);
|
||||
|
||||
$team = Team::create([
|
||||
'name' => 'Test Team',
|
||||
'personal_team' => false,
|
||||
]);
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-digitalocean-token',
|
||||
]);
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'digitalocean_droplet_id' => 987,
|
||||
]);
|
||||
|
||||
DeleteServer::run(
|
||||
serverId: $server->id,
|
||||
deleteFromDigitalOcean: true,
|
||||
digitalOceanDropletId: 987,
|
||||
cloudProviderTokenId: $token->id,
|
||||
teamId: $team->id,
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/987');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
it('renders the official DigitalOcean logo path', function () {
|
||||
$contents = file_get_contents(__DIR__.'/../../resources/views/components/digital-ocean-icon.blade.php');
|
||||
|
||||
expect($contents)
|
||||
->toContain('viewBox="0 0 24 24"')
|
||||
->toContain('M12.04 0C5.408-.02.005 5.37.005 11.992h4.638')
|
||||
->toContain('DigitalOcean');
|
||||
});
|
||||
|
||||
it('does not use the old custom DigitalOcean icon in Blade views', function () {
|
||||
$oldIconPaths = [
|
||||
'M100 42c31.5 0 57 25.5 57 57s-25.5 57-57 57H72v-36h28c11.6 0 21-9.4 21-21s-9.4-21-21-21-21 9.4-21 21H43c0-31.5 25.5-57 57-57z',
|
||||
'M43 120h36v36H43zM79 156h28v28H79zM43 156h28v28H43z',
|
||||
];
|
||||
|
||||
$bladeFiles = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator(__DIR__.'/../../resources/views')
|
||||
);
|
||||
|
||||
foreach ($bladeFiles as $bladeFile) {
|
||||
if (! $bladeFile->isFile() || $bladeFile->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($bladeFile->getPathname());
|
||||
|
||||
foreach ($oldIconPaths as $oldIconPath) {
|
||||
expect($contents)
|
||||
->not->toContain($oldIconPath, $bladeFile->getPathname().' contains the old DigitalOcean icon.');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function createDigitalOceanServerForStateTest(string $status = 'active'): Server
|
||||
{
|
||||
$team = Team::create([
|
||||
'name' => 'Test Team',
|
||||
'personal_team' => false,
|
||||
]);
|
||||
$token = CloudProviderToken::create([
|
||||
'team_id' => $team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-digitalocean-token',
|
||||
'name' => 'DigitalOcean',
|
||||
]);
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
return Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'digitalocean_droplet_id' => 987,
|
||||
'digitalocean_droplet_status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
it('marks a DigitalOcean droplet as deleted when the provider returns 404', function () {
|
||||
$server = createDigitalOceanServerForStateTest();
|
||||
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response(['message' => 'not found'], 404),
|
||||
]);
|
||||
|
||||
expect($server->refreshDigitalOceanState())->toBe('deleted');
|
||||
|
||||
expect($server->fresh()->digitalocean_droplet_status)->toBe('deleted');
|
||||
});
|
||||
|
||||
it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () {
|
||||
$server = createDigitalOceanServerForStateTest();
|
||||
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response(['message' => 'server error'], 500),
|
||||
]);
|
||||
|
||||
expect(fn () => $server->refreshDigitalOceanState())
|
||||
->toThrow(Exception::class);
|
||||
|
||||
expect($server->fresh()->digitalocean_droplet_status)->toBe('active');
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use App\Services\DigitalOceanService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('fetches paginated regions from DigitalOcean', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([
|
||||
'regions' => [
|
||||
['slug' => 'nyc1', 'name' => 'New York 1', 'available' => true],
|
||||
],
|
||||
'links' => ['pages' => ['next' => 'https://api.digitalocean.com/v2/regions?page=2&per_page=50']],
|
||||
]),
|
||||
'https://api.digitalocean.com/v2/regions?page=2&per_page=50' => Http::response([
|
||||
'regions' => [
|
||||
['slug' => 'ams3', 'name' => 'Amsterdam 3', 'available' => true],
|
||||
],
|
||||
'links' => ['pages' => []],
|
||||
]),
|
||||
]);
|
||||
|
||||
$regions = (new DigitalOceanService('test-token'))->getRegions();
|
||||
|
||||
expect($regions)->toHaveCount(2)
|
||||
->and($regions[0]['slug'])->toBe('nyc1')
|
||||
->and($regions[1]['slug'])->toBe('ams3');
|
||||
});
|
||||
|
||||
it('creates a droplet with the selected SSH keys and options', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||
'droplet' => [
|
||||
'id' => 987,
|
||||
'name' => 'coolify-test',
|
||||
'status' => 'new',
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
$droplet = (new DigitalOceanService('test-token'))->createDroplet([
|
||||
'name' => 'coolify-test',
|
||||
'region' => 'nyc1',
|
||||
'size' => 's-1vcpu-1gb',
|
||||
'image' => 'ubuntu-24-04-x64',
|
||||
'ssh_keys' => [123],
|
||||
'ipv6' => true,
|
||||
'monitoring' => true,
|
||||
'user_data' => '#cloud-config',
|
||||
]);
|
||||
|
||||
expect($droplet['id'])->toBe(987);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets'
|
||||
&& $request['ssh_keys'] === [123]
|
||||
&& $request['ipv6'] === true
|
||||
&& $request['monitoring'] === true
|
||||
&& $request['user_data'] === '#cloud-config');
|
||||
});
|
||||
|
||||
it('extracts a public IPv4 address before falling back to IPv6', function () {
|
||||
$droplet = [
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['ip_address' => '10.0.0.2', 'type' => 'private'],
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
'v6' => [
|
||||
['ip_address' => '2001:db8::10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
|
||||
expect($service->getPublicIpAddress($droplet, true, true))->toBe('203.0.113.10')
|
||||
->and($service->getPublicIpAddress($droplet, false, true))->toBe('2001:db8::10');
|
||||
});
|
||||
|
||||
it('waits for DigitalOcean to assign a public IP', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
|
||||
'droplet' => [
|
||||
'id' => 987,
|
||||
'status' => 'active',
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
$droplet = $service->waitForPublicIp([
|
||||
'id' => 987,
|
||||
'status' => 'new',
|
||||
'networks' => ['v4' => []],
|
||||
], sleepMilliseconds: 0);
|
||||
|
||||
expect($service->getPublicIpAddress($droplet))->toBe('203.0.113.10');
|
||||
});
|
||||
|
||||
it('keeps polling when DigitalOcean assigns a public IP slowly', function () {
|
||||
$polls = 0;
|
||||
|
||||
Http::fake(function () use (&$polls) {
|
||||
$polls++;
|
||||
|
||||
return Http::response([
|
||||
'droplet' => [
|
||||
'id' => 987,
|
||||
'status' => $polls < 10 ? 'new' : 'active',
|
||||
'networks' => [
|
||||
'v4' => $polls < 10 ? [] : [
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
$droplet = $service->waitForPublicIp([
|
||||
'id' => 987,
|
||||
'status' => 'new',
|
||||
'networks' => ['v4' => []],
|
||||
], sleepMilliseconds: 0);
|
||||
|
||||
expect($service->getPublicIpAddress($droplet))->toBe('203.0.113.10')
|
||||
->and($polls)->toBe(10);
|
||||
});
|
||||
|
||||
it('finds a droplet by public IP', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets*' => Http::response([
|
||||
'droplets' => [
|
||||
[
|
||||
'id' => 987,
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
'v6' => [
|
||||
['ip_address' => '2001:db8::10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'links' => ['pages' => []],
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
|
||||
expect($service->findDropletByIp('203.0.113.10')['id'])->toBe(987)
|
||||
->and($service->findDropletByIp('2001:db8::10')['id'])->toBe(987)
|
||||
->and($service->findDropletByIp('198.51.100.1'))->toBeNull();
|
||||
});
|
||||
|
||||
it('powers on and deletes a DigitalOcean droplet', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987/actions' => Http::response([
|
||||
'action' => ['id' => 111, 'type' => 'power_on'],
|
||||
], 201),
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204),
|
||||
]);
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
|
||||
expect($service->powerOnDroplet(987)['type'])->toBe('power_on');
|
||||
$service->deleteDroplet(987);
|
||||
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
Reference in new issue
Block a user