create network if doesn't already exist on server, wip test

This commit is contained in:
2025-04-07 18:24:33 +01:00
parent b2070e052d
commit a5854c7a04
13 changed files with 183 additions and 13 deletions

View File

@@ -11,5 +11,6 @@ class CreatedServer
public string $status, public string $status,
public string $ipv4, public string $ipv4,
public string $ipv6, public string $ipv6,
public string $networkId,
) {} ) {}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\GenerateRandomSlug; use App\Actions\GenerateRandomSlug;
use App\Enums\NetworkType;
use App\Enums\ServerStatus; use App\Enums\ServerStatus;
use App\Jobs\Servers\WaitForServerToConnect; use App\Jobs\Servers\WaitForServerToConnect;
use App\Models\Organisation; use App\Models\Organisation;
@@ -57,7 +58,15 @@ class ServerController extends Controller
public function store(Request $request) 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); $sudoPassword = Str::random(32);
/** @var Provider $provider */
$provider = Provider::findOrFail($request->provider); $provider = Provider::findOrFail($request->provider);
$providerService = $provider->service(); $providerService = $provider->service();
@@ -65,11 +74,27 @@ class ServerController extends Controller
return back()->with('error', 'Invalid provider'); 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( $createdServer = $providerService->createServer(
name: app(GenerateRandomSlug::class)->execute(), // @todo allow custom name name: app(GenerateRandomSlug::class)->execute(), // @todo allow custom name
serverType: $request->server_type, serverType: $request->server_type,
location: $request->location, location: $request->location,
image: $request->image, image: $request->image,
networkId: $network->external_id,
); );
$organisation = Organisation::findOrFail($request->route('organisation')); $organisation = Organisation::findOrFail($request->route('organisation'));

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Integrations\Requests\Hetzner\Servers; namespace App\Http\Integrations\Requests\Hetzner\Networks;
use Saloon\Contracts\Body\HasBody; use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method; use Saloon\Enums\Method;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Integrations\Requests\Hetzner\Servers; namespace App\Http\Integrations\Requests\Hetzner\Networks;
use Saloon\Contracts\Body\HasBody; use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method; use Saloon\Enums\Method;

View File

@@ -18,6 +18,7 @@ class CreateServerRequest extends Request implements HasBody
protected ?string $name = null, protected ?string $name = null,
protected ?string $serverType = null, protected ?string $serverType = null,
protected ?string $location = null, protected ?string $location = null,
protected ?array $networks = null,
) {} ) {}
protected function defaultBody(): array protected function defaultBody(): array
@@ -27,6 +28,7 @@ class CreateServerRequest extends Request implements HasBody
'name' => $this->name, 'name' => $this->name,
'server_type' => $this->serverType, 'server_type' => $this->serverType,
'location' => $this->location, 'location' => $this->location,
'networks' => $this->networks,
'user_data' => file_get_contents(resource_path('scripts/hetzner-cloudinit.yml')), 'user_data' => file_get_contents(resource_path('scripts/hetzner-cloudinit.yml')),
]; ];
} }

View File

@@ -45,6 +45,11 @@ class Organisation extends Model
return $this->hasMany(Provider::class); return $this->hasMany(Provider::class);
} }
public function networks(): HasMany
{
return $this->hasMany(Network::class);
}
public static function createUniqueSlug(string $name): string public static function createUniqueSlug(string $name): string
{ {
$slug = Str::slug($name); $slug = Str::slug($name);

View File

@@ -5,11 +5,15 @@ namespace App\Models;
use App\Enums\ProviderType; use App\Enums\ProviderType;
use App\Services\ServerProviders\HetznerService; use App\Services\ServerProviders\HetznerService;
use App\Services\ServerProviders\ServerProviderService; use App\Services\ServerProviders\ServerProviderService;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class Provider extends Model class Provider extends Model
{ {
/** @use HasFactory<\Database\Factories\ProviderFactory> */
use HasFactory;
protected $guarded = []; protected $guarded = [];
protected function casts(): array protected function casts(): array
@@ -33,7 +37,7 @@ class Provider extends Model
public function service(): ?ServerProviderService public function service(): ?ServerProviderService
{ {
return match ($this->type) { return match ($this->type) {
ProviderType::HETZNER => new HetznerService($this), ProviderType::HETZNER => app(HetznerService::class, ['provider' => $this]),
default => null, default => null,
}; };
} }

View File

@@ -10,8 +10,9 @@ use App\Data\ServerProviders\ServerType;
use App\Http\Integrations\Connectors\HetznerConnector; use App\Http\Integrations\Connectors\HetznerConnector;
use App\Http\Integrations\Requests\Hetzner\Images\GetImagesRequest; use App\Http\Integrations\Requests\Hetzner\Images\GetImagesRequest;
use App\Http\Integrations\Requests\Hetzner\Locations\GetLocationsRequest; 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\CreateServerRequest;
use App\Http\Integrations\Requests\Hetzner\Servers\GetNetworksRequest;
use App\Http\Integrations\Requests\Hetzner\ServerTypes\GetServerTypesRequest; use App\Http\Integrations\Requests\Hetzner\ServerTypes\GetServerTypesRequest;
use App\Models\Provider; use App\Models\Provider;
use Exception; use Exception;
@@ -29,12 +30,16 @@ class HetznerService extends ServerProviderService
string $serverType, string $serverType,
string $location, string $location,
string $image, string $image,
string $networkId,
): CreatedServer { ): CreatedServer {
$response = $this->connector->send(new CreateServerRequest( $response = $this->connector->send(new CreateServerRequest(
image: $image, image: $image,
name: $name, name: $name,
serverType: $serverType, serverType: $serverType,
location: $location, location: $location,
networks: [
$networkId,
],
)); ));
if ($response->status() !== 201) { if ($response->status() !== 201) {
@@ -48,6 +53,7 @@ class HetznerService extends ServerProviderService
status: $response->json('server.status'), status: $response->json('server.status'),
ipv4: $response->json('server.public_net.ipv4.ip'), ipv4: $response->json('server.public_net.ipv4.ip'),
ipv6: $response->json('server.public_net.ipv6.ip'), ipv6: $response->json('server.public_net.ipv6.ip'),
networkId: $networkId,
); );
} }
@@ -132,4 +138,21 @@ class HetznerService extends ServerProviderService
return null; 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'),
);
}
} }

View File

@@ -16,6 +16,7 @@ abstract class ServerProviderService
string $serverType, string $serverType,
string $location, string $location,
string $image, string $image,
string $networkId,
): CreatedServer; ): CreatedServer;
abstract public function getServerTypes(): Collection; abstract public function getServerTypes(): Collection;
@@ -25,4 +26,6 @@ abstract class ServerProviderService
abstract public function getImages(): Collection; abstract public function getImages(): Collection;
abstract public function findNetwork(string $name): ?Network; abstract public function findNetwork(string $name): ?Network;
abstract public function createNetwork(string $name): Network;
} }

View File

@@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Enums\ProviderType;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Provider>
*/
class ProviderFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
]);
}
}

View File

@@ -21,6 +21,7 @@ class ServerFactory extends Factory
'name' => $this->faker->word(), 'name' => $this->faker->word(),
'ipv4' => $this->faker->ipv4(), 'ipv4' => $this->faker->ipv4(),
'ipv6' => $this->faker->ipv6(), 'ipv6' => $this->faker->ipv6(),
'private_ip' => $this->faker->ipv4(),
'provider_status' => '', 'provider_status' => '',
'status' => $this->faker->randomElement(ServerStatus::toArray()), 'status' => $this->faker->randomElement(ServerStatus::toArray()),
'region' => '28', 'region' => '28',

View File

@@ -15,7 +15,7 @@ return new class extends Migration
$table->id(); $table->id();
$table->foreignIdFor(Organisation::class); $table->foreignIdFor(Organisation::class);
$table->foreignIdFor(Network::class, 'external_network_id'); $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->foreignIdFor(Provider::class);
$table->string('external_id')->nullable(); $table->string('external_id')->nullable();
$table->string('name'); $table->string('name');

View File

@@ -1,9 +1,16 @@
<?php <?php
use App\Data\ServerProviders\CreatedServer;
use App\Enums\NetworkType;
use App\Enums\ProviderType;
use App\Models\Organisation; use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Server; use App\Models\Server;
use App\Models\User; use App\Models\User;
use App\Services\ServerProviders\HetznerService;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia; use Inertia\Testing\AssertableInertia;
use Mockery\MockInterface;
use function Pest\Laravel\actingAs; use function Pest\Laravel\actingAs;
@@ -12,13 +19,27 @@ beforeEach(function () {
// For example, using Laravel's RefreshDatabase trait // For example, using Laravel's RefreshDatabase trait
// use Illuminate\Foundation\Testing\RefreshDatabase; // use Illuminate\Foundation\Testing\RefreshDatabase;
/** @var User $user */
$this->user = User::factory()->create(); $this->user = User::factory()->create();
actingAs($this->user); actingAs($this->user);
}); });
test('index route displays servers for an organisation', function () { test('index route displays servers for an organisation', function () {
$organisation = Organisation::factory()->create(); $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 = $this->get(route('servers.index', ['organisation' => $organisation->id]));
$response->assertStatus(200); $response->assertStatus(200);
@@ -44,15 +65,56 @@ test('store route fails with invalid provider', function () {
'image' => 'ubuntu-20.04', 'image' => 'ubuntu-20.04',
]); ]);
$response->assertSessionHas('error', 'Invalid provider'); $response->assertSessionHasErrors(['provider' => 'The selected provider is invalid.']);
$response->assertStatus(302); // redirect back $response->assertStatus(302); // redirect back
}); });
test('store route creates a server with valid data', function () { test('store route creates a server with valid data', function () {
$organisation = Organisation::factory()->create(); $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]), [ $response = $this->post(route('servers.store', ['organisation' => $organisation->id]), [
'provider' => 'hetzner', 'provider' => $provider->id,
'server_type' => 'cx11', 'server_type' => 'cx11',
'location' => 'hel1', 'location' => 'hel1',
'image' => 'ubuntu-20.04', 'image' => 'ubuntu-20.04',
@@ -61,15 +123,24 @@ test('store route creates a server with valid data', function () {
$response->assertRedirectContains('/servers/'); $response->assertRedirectContains('/servers/');
$this->assertDatabaseHas('servers', [ $this->assertDatabaseHas('servers', [
'organisation_id' => $organisation->id, 'organisation_id' => $organisation->id,
'provider' => 'hetzner', 'provider_id' => $provider->id,
'region' => 'hel1', 'region' => 'hel1',
'os' => 'ubuntu-20.04', 'os' => 'ubuntu-20.04',
'external_network_id' => $network->id,
]); ]);
}); });
test('show route displays a single server', function () { test('show route displays a single server', function () {
$organisation = Organisation::factory()->create(); $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', [ $response = $this->get(route('servers.show', [
'organisation' => $organisation->id, 'organisation' => $organisation->id,