From a5854c7a04c2d5846d4de38d72e90d3faa7af3a7 Mon Sep 17 00:00:00 2001 From: "Harry (hjbdev)" Date: Mon, 7 Apr 2025 18:24:33 +0100 Subject: [PATCH] create network if doesn't already exist on server, wip test --- app/Data/ServerProviders/CreatedServer.php | 1 + app/Http/Controllers/ServerController.php | 25 ++++++ .../Hetzner/Networks/CreateNetworkRequest.php | 2 +- .../Hetzner/Networks/GetNetworksRequest.php | 2 +- .../Hetzner/Servers/CreateServerRequest.php | 2 + app/Models/Organisation.php | 5 ++ app/Models/Provider.php | 6 +- .../ServerProviders/HetznerService.php | 25 +++++- .../ServerProviders/ServerProviderService.php | 3 + database/factories/ProviderFactory.php | 35 ++++++++ database/factories/ServerFactory.php | 1 + ...2025_03_27_120552_create_servers_table.php | 2 +- tests/Feature/ServerControllerTest.php | 87 +++++++++++++++++-- 13 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 database/factories/ProviderFactory.php diff --git a/app/Data/ServerProviders/CreatedServer.php b/app/Data/ServerProviders/CreatedServer.php index 3d7d5fa..455cf39 100644 --- a/app/Data/ServerProviders/CreatedServer.php +++ b/app/Data/ServerProviders/CreatedServer.php @@ -11,5 +11,6 @@ class CreatedServer public string $status, public string $ipv4, public string $ipv6, + public string $networkId, ) {} } diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index 2c946a7..9dad04d 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Actions\GenerateRandomSlug; +use App\Enums\NetworkType; use App\Enums\ServerStatus; use App\Jobs\Servers\WaitForServerToConnect; use App\Models\Organisation; @@ -57,7 +58,15 @@ class ServerController extends Controller public function store(Request $request) { + $request->validate([ + 'provider' => ['required', 'exists:providers,id'], + 'server_type' => ['required', 'string'], + 'location' => ['required', 'string'], + 'image' => ['required', 'string'], + ]); + $sudoPassword = Str::random(32); + /** @var Provider $provider */ $provider = Provider::findOrFail($request->provider); $providerService = $provider->service(); @@ -65,11 +74,27 @@ class ServerController extends Controller return back()->with('error', 'Invalid provider'); } + if (! $network = $provider->networks()->first()) { + // we need a keystone network to create this server + $createdNetwork = $providerService->createNetwork( + name: 'keystone', + ); + + $network = $provider->networks()->create([ + 'organisation_id' => $provider->organisation_id, + 'external_id' => $createdNetwork->id, + 'type' => NetworkType::EXTERNAL, + 'name' => $createdNetwork->name, + 'ip_range' => $createdNetwork->ipRange, + ]); + } + $createdServer = $providerService->createServer( name: app(GenerateRandomSlug::class)->execute(), // @todo allow custom name serverType: $request->server_type, location: $request->location, image: $request->image, + networkId: $network->external_id, ); $organisation = Organisation::findOrFail($request->route('organisation')); diff --git a/app/Http/Integrations/Requests/Hetzner/Networks/CreateNetworkRequest.php b/app/Http/Integrations/Requests/Hetzner/Networks/CreateNetworkRequest.php index 2e5a287..fb4e5de 100644 --- a/app/Http/Integrations/Requests/Hetzner/Networks/CreateNetworkRequest.php +++ b/app/Http/Integrations/Requests/Hetzner/Networks/CreateNetworkRequest.php @@ -1,6 +1,6 @@ $this->name, 'server_type' => $this->serverType, 'location' => $this->location, + 'networks' => $this->networks, 'user_data' => file_get_contents(resource_path('scripts/hetzner-cloudinit.yml')), ]; } diff --git a/app/Models/Organisation.php b/app/Models/Organisation.php index 5be0714..eb3e032 100644 --- a/app/Models/Organisation.php +++ b/app/Models/Organisation.php @@ -45,6 +45,11 @@ class Organisation extends Model return $this->hasMany(Provider::class); } + public function networks(): HasMany + { + return $this->hasMany(Network::class); + } + public static function createUniqueSlug(string $name): string { $slug = Str::slug($name); diff --git a/app/Models/Provider.php b/app/Models/Provider.php index d7c1606..2f7ac88 100644 --- a/app/Models/Provider.php +++ b/app/Models/Provider.php @@ -5,11 +5,15 @@ namespace App\Models; use App\Enums\ProviderType; use App\Services\ServerProviders\HetznerService; use App\Services\ServerProviders\ServerProviderService; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class Provider extends Model { + /** @use HasFactory<\Database\Factories\ProviderFactory> */ + use HasFactory; + protected $guarded = []; protected function casts(): array @@ -33,7 +37,7 @@ class Provider extends Model public function service(): ?ServerProviderService { return match ($this->type) { - ProviderType::HETZNER => new HetznerService($this), + ProviderType::HETZNER => app(HetznerService::class, ['provider' => $this]), default => null, }; } diff --git a/app/Services/ServerProviders/HetznerService.php b/app/Services/ServerProviders/HetznerService.php index cdc4c42..73401a0 100644 --- a/app/Services/ServerProviders/HetznerService.php +++ b/app/Services/ServerProviders/HetznerService.php @@ -10,8 +10,9 @@ use App\Data\ServerProviders\ServerType; use App\Http\Integrations\Connectors\HetznerConnector; use App\Http\Integrations\Requests\Hetzner\Images\GetImagesRequest; use App\Http\Integrations\Requests\Hetzner\Locations\GetLocationsRequest; +use App\Http\Integrations\Requests\Hetzner\Networks\CreateNetworkRequest; +use App\Http\Integrations\Requests\Hetzner\Networks\GetNetworksRequest; use App\Http\Integrations\Requests\Hetzner\Servers\CreateServerRequest; -use App\Http\Integrations\Requests\Hetzner\Servers\GetNetworksRequest; use App\Http\Integrations\Requests\Hetzner\ServerTypes\GetServerTypesRequest; use App\Models\Provider; use Exception; @@ -29,12 +30,16 @@ class HetznerService extends ServerProviderService string $serverType, string $location, string $image, + string $networkId, ): CreatedServer { $response = $this->connector->send(new CreateServerRequest( image: $image, name: $name, serverType: $serverType, location: $location, + networks: [ + $networkId, + ], )); if ($response->status() !== 201) { @@ -48,6 +53,7 @@ class HetznerService extends ServerProviderService status: $response->json('server.status'), ipv4: $response->json('server.public_net.ipv4.ip'), ipv6: $response->json('server.public_net.ipv6.ip'), + networkId: $networkId, ); } @@ -132,4 +138,21 @@ class HetznerService extends ServerProviderService return null; } + + public function createNetwork(string $name): Network + { + $response = $this->connector->send(new CreateNetworkRequest( + name: $name, + )); + + if ($response->status() !== 201) { + throw new Exception('Failed to create network on Hetzner'); + } + + return new Network( + id: $response->json('network.id'), + name: $response->json('network.name'), + ipRange: $response->json('network.ip_range'), + ); + } } diff --git a/app/Services/ServerProviders/ServerProviderService.php b/app/Services/ServerProviders/ServerProviderService.php index 7c35ca5..92e7b2f 100644 --- a/app/Services/ServerProviders/ServerProviderService.php +++ b/app/Services/ServerProviders/ServerProviderService.php @@ -16,6 +16,7 @@ abstract class ServerProviderService string $serverType, string $location, string $image, + string $networkId, ): CreatedServer; abstract public function getServerTypes(): Collection; @@ -25,4 +26,6 @@ abstract class ServerProviderService abstract public function getImages(): Collection; abstract public function findNetwork(string $name): ?Network; + + abstract public function createNetwork(string $name): Network; } diff --git a/database/factories/ProviderFactory.php b/database/factories/ProviderFactory.php new file mode 100644 index 0000000..c3fd425 --- /dev/null +++ b/database/factories/ProviderFactory.php @@ -0,0 +1,35 @@ + + */ +class ProviderFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => 'keystone', + 'type' => $this->faker->randomElement([ + ProviderType::HETZNER, + ]), + 'token' => $this->faker->uuid(), + ]; + } + + public function forOrganisation($organisationId): static + { + return $this->state(fn (array $attributes) => [ + 'organisation_id' => $organisationId, + ]); + } +} diff --git a/database/factories/ServerFactory.php b/database/factories/ServerFactory.php index 387906b..4defda0 100644 --- a/database/factories/ServerFactory.php +++ b/database/factories/ServerFactory.php @@ -21,6 +21,7 @@ class ServerFactory extends Factory 'name' => $this->faker->word(), 'ipv4' => $this->faker->ipv4(), 'ipv6' => $this->faker->ipv6(), + 'private_ip' => $this->faker->ipv4(), 'provider_status' => '', 'status' => $this->faker->randomElement(ServerStatus::toArray()), 'region' => '28', diff --git a/database/migrations/2025_03_27_120552_create_servers_table.php b/database/migrations/2025_03_27_120552_create_servers_table.php index 7ae89d4..38bbddf 100644 --- a/database/migrations/2025_03_27_120552_create_servers_table.php +++ b/database/migrations/2025_03_27_120552_create_servers_table.php @@ -15,7 +15,7 @@ return new class extends Migration $table->id(); $table->foreignIdFor(Organisation::class); $table->foreignIdFor(Network::class, 'external_network_id'); - $table->foreignIdFor(Network::class, 'internal_network_id'); + $table->foreignIdFor(Network::class, 'internal_network_id')->nullable(); $table->foreignIdFor(Provider::class); $table->string('external_id')->nullable(); $table->string('name'); diff --git a/tests/Feature/ServerControllerTest.php b/tests/Feature/ServerControllerTest.php index a9b4a87..9133e73 100644 --- a/tests/Feature/ServerControllerTest.php +++ b/tests/Feature/ServerControllerTest.php @@ -1,9 +1,16 @@ user = User::factory()->create(); actingAs($this->user); }); test('index route displays servers for an organisation', function () { $organisation = Organisation::factory()->create(); - Server::factory()->count(2)->create(['organisation_id' => $organisation->id]); + $provider = Provider::factory()->forOrganisation($organisation->id)->create(); + $network = $organisation->networks()->create([ + 'type' => NetworkType::EXTERNAL, + 'name' => 'keystone', + 'external_id' => 'net-12345', + 'provider_id' => $provider->id, + 'ip_range' => fake()->ipv4() . '/24', + ]); + + Server::factory()->count(2)->create([ + 'provider_id' => $provider->id, + 'organisation_id' => $organisation->id, + 'external_network_id' => $network->id, + ]); $response = $this->get(route('servers.index', ['organisation' => $organisation->id])); $response->assertStatus(200); - $response->assertInertia(fn (AssertableInertia $page) => $page + $response->assertInertia(fn(AssertableInertia $page) => $page ->component('servers/Index')); }); @@ -30,7 +51,7 @@ test('create route returns inertia view', function () { $organisation = Organisation::factory()->create(); $response = $this->get(route('servers.create', ['organisation' => $organisation->id])); $response->assertStatus(200); - $response->assertInertia(fn (AssertableInertia $page) => $page + $response->assertInertia(fn(AssertableInertia $page) => $page ->component('servers/Create')); }); @@ -44,15 +65,56 @@ test('store route fails with invalid provider', function () { 'image' => 'ubuntu-20.04', ]); - $response->assertSessionHas('error', 'Invalid provider'); + $response->assertSessionHasErrors(['provider' => 'The selected provider is invalid.']); $response->assertStatus(302); // redirect back }); test('store route creates a server with valid data', function () { $organisation = Organisation::factory()->create(); + // Create a real provider first, then partially mock it + $provider = Provider::factory()->create([ + 'name' => 'hetzner', + 'type' => ProviderType::HETZNER, + 'token' => Str::uuid(), + 'organisation_id' => $organisation->id + ]); + + $network = $organisation->networks()->create([ + 'type' => NetworkType::EXTERNAL, + 'name' => 'keystone', + 'external_id' => 'net-12345', + 'provider_id' => $provider->id, + 'ip_range' => fake()->ipv4() . '/24', + ]); + + $this->mock(HetznerService::class, function (MockInterface $mock) use ($network) { + $mock->shouldReceive('createServer') + ->once() + ->with( + Mockery::on(function ($arg) { + return is_string($arg['name']) && strlen($arg['name']) > 0; + }), + 'cx11', + 'hel1', + 'ubuntu-20.04', + $network->external_id + ) + ->andReturn(new CreatedServer( + name: 'test-server-from-mock', + rootPassword: 'password123', + id: 'srv-12345', + status: 'running', + ipv4: '192.0.2.100', + ipv6: '2001:db8::100', + networkId: $network->external_id, + )); + + $mock->shouldReceive('createNetwork')->never(); + }); + $response = $this->post(route('servers.store', ['organisation' => $organisation->id]), [ - 'provider' => 'hetzner', + 'provider' => $provider->id, 'server_type' => 'cx11', 'location' => 'hel1', 'image' => 'ubuntu-20.04', @@ -61,15 +123,24 @@ test('store route creates a server with valid data', function () { $response->assertRedirectContains('/servers/'); $this->assertDatabaseHas('servers', [ 'organisation_id' => $organisation->id, - 'provider' => 'hetzner', + 'provider_id' => $provider->id, 'region' => 'hel1', 'os' => 'ubuntu-20.04', + 'external_network_id' => $network->id, ]); }); test('show route displays a single server', function () { $organisation = Organisation::factory()->create(); - $server = Server::factory()->create(['organisation_id' => $organisation->id]); + $network = $organisation->networks()->create([ + 'type' => NetworkType::EXTERNAL, + 'name' => 'keystone', + 'external_id' => 'net-12345', + ]); + $server = Server::factory()->create([ + 'organisation_id' => $organisation->id, + 'external_network_id' => $network->id, + ]); $response = $this->get(route('servers.show', [ 'organisation' => $organisation->id, @@ -77,6 +148,6 @@ test('show route displays a single server', function () { ])); $response->assertStatus(200); - $response->assertInertia(fn (AssertableInertia $page) => $page + $response->assertInertia(fn(AssertableInertia $page) => $page ->component('servers/Show')); });