feat(digitalocean): add droplet provisioning support

This commit is contained in:
Andras Bacsai committed 2026-07-08 10:42:58 +02:00
1 parent 5936874ffd
commit c303c34cfd
32 files changed
+2796 -19

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings->id = 0;
$settings->save();
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->digitalOceanToken = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'digitalocean',
'token' => 'test-digitalocean-api-token',
]);
$this->privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
});
describe('GET /api/v1/digitalocean/regions', function () {
test('gets DigitalOcean regions', function () {
Http::fake([
'https://api.digitalocean.com/v2/regions*' => Http::response([
'regions' => [
['slug' => 'nyc1', 'name' => 'New York 1', 'available' => true],
['slug' => 'ams3', 'name' => 'Amsterdam 3', 'available' => true],
],
'links' => ['pages' => []],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/digitalocean/regions?cloud_provider_token_id='.$this->digitalOceanToken->uuid);
$response->assertSuccessful();
$response->assertJsonCount(2);
$response->assertJsonFragment(['slug' => 'nyc1']);
});
test('requires cloud_provider_token_id parameter', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/digitalocean/regions');
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['cloud_provider_token_id']);
});
});
describe('POST /api/v1/servers/digitalocean', function () {
test('creates a DigitalOcean droplet server', function () {
Http::fake([
'https://api.digitalocean.com/v2/account/keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
'ssh_keys' => [],
'links' => ['pages' => []],
], 200),
'https://api.digitalocean.com/v2/droplets' => Http::response([
'droplet' => [
'id' => 987,
'name' => 'test-server',
'status' => 'new',
'networks' => [
'v4' => [
['ip_address' => '203.0.113.10', 'type' => 'public'],
],
'v6' => [
['ip_address' => '2001:db8::10', 'type' => 'public'],
],
],
],
], 202),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/digitalocean', [
'cloud_provider_token_id' => $this->digitalOceanToken->uuid,
'region' => 'nyc1',
'size' => 's-1vcpu-1gb',
'image' => 'ubuntu-24-04-x64',
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv6' => true,
'monitoring' => true,
'cloud_init_script' => '#cloud-config',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'digitalocean_droplet_id', 'ip']);
$response->assertJsonFragment(['digitalocean_droplet_id' => 987, 'ip' => '203.0.113.10']);
$this->assertDatabaseHas('servers', [
'name' => 'test-server',
'ip' => '203.0.113.10',
'team_id' => $this->team->id,
'digitalocean_droplet_id' => 987,
]);
Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets'
&& $request['region'] === 'nyc1'
&& $request['size'] === 's-1vcpu-1gb'
&& $request['image'] === 'ubuntu-24-04-x64'
&& $request['ssh_keys'] === [123]
&& $request['ipv6'] === true
&& $request['monitoring'] === true
&& $request['user_data'] === '#cloud-config');
});
test('validates required fields', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/digitalocean', ['name' => 'missing-required-fields']);
$response->assertUnprocessable();
$response->assertJsonValidationErrors([
'cloud_provider_token_id',
'region',
'size',
'image',
'private_key_uuid',
]);
});
test('returns 404 for a non-DigitalOcean token', function () {
$hetznerToken = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'token' => 'test-hetzner-api-token',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/digitalocean', [
'cloud_provider_token_id' => $hetznerToken->uuid,
'region' => 'nyc1',
'size' => 's-1vcpu-1gb',
'image' => 'ubuntu-24-04-x64',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertNotFound();
$response->assertJson(['message' => 'DigitalOcean cloud provider token not found.']);
});
});
@@ -84,6 +84,8 @@ it('creates Server with all fillable attributes', function () {
'team_id' => $this->team->id,
'hetzner_server_id' => 'htz-12345',
'hetzner_server_status' => 'running',
'digitalocean_droplet_id' => 987654,
'digitalocean_droplet_status' => 'active',
'is_validating' => false,
'detected_traefik_version' => 'v2.10.0',
'traefik_outdated_info' => 'Up to date',
@@ -101,6 +103,8 @@ it('creates Server with all fillable attributes', function () {
expect($server->cloud_provider_token_id)->toBe($cloudToken->id);
expect($server->hetzner_server_id)->toBe('htz-12345');
expect($server->hetzner_server_status)->toBe('running');
expect($server->digitalocean_droplet_id)->toBe(987654);
expect($server->digitalocean_droplet_status)->toBe('active');
expect($server->ip_previous)->toBe('10.0.0.1');
});
@@ -0,0 +1,54 @@
<?php
use App\Livewire\Security\CloudProviderTokenForm;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->whereKey(0)->exists()) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
Log::spy();
});
test('adding a digitalocean token from a modal closes the modal and refreshes digitalocean token selectors', function () {
Http::fake([
'https://api.digitalocean.com/v2/account' => Http::response([], 200),
]);
$component = Livewire::test(CloudProviderTokenForm::class, [
'modal_mode' => true,
'provider' => 'digitalocean',
])
->set('name', 'Production DigitalOcean')
->set('token', 'digitalocean-token')
->call('addToken')
->assertDispatched('close-modal');
$dispatches = collect(data_get($component->effects, 'dispatches', []));
expect($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded.digitalocean'
&& data_get($dispatch, 'to') === 'server.new.by-digital-ocean'))->toBeTrue()
->and($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded'
&& data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue();
});
@@ -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');
});
+34
View File
@@ -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');
});
+183
View File
@@ -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);
});