server creation wip

This commit is contained in:
2025-03-28 17:10:36 +00:00
parent 7d2bc3ca5e
commit 350cf6e240
16 changed files with 3180 additions and 30 deletions

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Actions;
class GenerateRandomSlug
{
public function execute($adjectiveCount = 1): string
{
$adjectives = explode("\n", file_get_contents(resource_path('text/english-adjectives.txt')));
$nouns = explode("\n", file_get_contents(resource_path('text/english-nouns.txt')));
$slug = '';
for ($i = 0; $i < $adjectiveCount; $i++) {
$slug .= $adjectives[array_rand($adjectives)] . '-';
}
$slug .= $nouns[array_rand($nouns)];
return $slug;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Actions;
use App\Services\ServerProviders\HetznerService;
use App\Services\ServerProviders\ServerProviderService;
class GetProviderService
{
public function execute(string $provider): ServerProviderService|null
{
return match ($provider) {
'hetzner' => new HetznerService(),
default => null,
};
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Data\ServerProviders;
class CreatedServer
{
public function __construct(
public string $name,
public string $rootPassword,
public string $id,
public string $status,
public string $ipv4,
public string $ipv6,
) {}
}

View File

@@ -7,7 +7,7 @@ class ServerType
/** /**
* @param string $name The name of the server type * @param string $name The name of the server type
* @param int $cores The number of cores * @param int $cores The number of cores
* @param int $memory The amount of memory in MB * @param int $memory The amount of memory in GB
* @param int $disk The amount of disk space in GB * @param int $disk The amount of disk space in GB
*/ */
public function __construct( public function __construct(

View File

@@ -2,16 +2,100 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\GenerateRandomSlug;
use App\Actions\GetProviderService;
use App\Enums\ServerProvider;
use App\Enums\ServerStatus;
use App\Models\Organisation; use App\Models\Organisation;
use App\Services\ServerProviders\HetznerService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use NunoMaduro\Collision\Provider;
class ServerController extends Controller class ServerController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$organisation = Organisation::findOrFail($request->route('organisation')); $organisation = Organisation::findOrFail($request->route('organisation'));
return inertia('servers/Index', [ return inertia('servers/Index', [
'servers' => $organisation->servers()->paginate(30), 'servers' => $organisation->servers()->paginate(30),
]); ]);
} }
public function create(Request $request)
{
$locations = null;
$serverTypes = null;
$images = null;
if ($request->has('provider')) {
$providerService = app(GetProviderService::class)->execute($request->provider);
if ($providerService) {
$locations = Cache::remember($request->provider . '.locations', now()->addHour(), function () use ($providerService) {
return $providerService->getLocations();
});
$serverTypes = Cache::remember($request->provider . '.serverTypes', now()->addHour(), function () use ($providerService) {
return $providerService->getServerTypes();
});
$images = Cache::remember($request->provider . '.images', now()->addHour(), function () use ($providerService) {
return $providerService->getImages();
});
}
}
return inertia('servers/Create', [
'locations' => $locations,
'serverTypes' => $serverTypes,
'images' => $images,
]);
}
public function store(Request $request)
{
$rootPassword = Str::random(32);
$providerService = app(GetProviderService::class)->execute($request->provider);
if (!$providerService) {
return back()->with('error', 'Invalid provider');
}
$createdServer = $providerService->createServer(
name: app(GenerateRandomSlug::class)->execute(), // @todo allow custom name
serverType: $request->server_type,
location: $request->location,
image: $request->image,
rootPassword: $rootPassword,
);
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->create([
'name' => $createdServer->name,
'provider' => ServerProvider::tryFrom($request->provider),
'provider_id' => $createdServer->id,
'ipv4' => $createdServer->ipv4,
'ipv6' => $createdServer->ipv6,
'provider_status' => $createdServer->status,
'status' => ServerStatus::PENDING,
'region' => $request->location,
'os' => $request->image,
'plan' => $request->server_type,
'user' => '',
]);
return redirect()->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id]);
}
public function show(Request $request)
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
return inertia('servers/Show', [
'server' => $server,
]);
}
} }

View File

@@ -6,7 +6,7 @@ use Saloon\Enums\Method;
use Saloon\Http\Request; use Saloon\Http\Request;
use Saloon\Traits\Body\HasJsonBody; use Saloon\Traits\Body\HasJsonBody;
class ListImagesRequest extends Request class GetImagesRequest extends Request
{ {
protected Method $method = Method::GET; protected Method $method = Method::GET;

View File

@@ -5,7 +5,7 @@ namespace App\Http\Integrations\Requests\Hetzner\Locations;
use Saloon\Enums\Method; use Saloon\Enums\Method;
use Saloon\Http\Request; use Saloon\Http\Request;
class ListLocationsRequest extends Request class GetLocationsRequest extends Request
{ {
protected Method $method = Method::GET; protected Method $method = Method::GET;

View File

@@ -5,7 +5,7 @@ namespace App\Http\Integrations\Requests\Hetzner\ServerTypes;
use Saloon\Enums\Method; use Saloon\Enums\Method;
use Saloon\Http\Request; use Saloon\Http\Request;
class ListServerTypesRequest extends Request class GetServerTypesRequest extends Request
{ {
protected Method $method = Method::GET; protected Method $method = Method::GET;

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 ?string $rootPassword = 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,
'root_password' => $this->rootPassword,
]; ];
} }

View File

@@ -2,17 +2,19 @@
namespace App\Services\ServerProviders; namespace App\Services\ServerProviders;
use App\Data\ServerProviders\CreatedServer;
use App\Data\ServerProviders\Image; use App\Data\ServerProviders\Image;
use App\Data\ServerProviders\Location; use App\Data\ServerProviders\Location;
use App\Data\ServerProviders\ServerType; use App\Data\ServerProviders\ServerType;
use App\Http\Integrations\Connectors\HetznerConnector; use App\Http\Integrations\Connectors\HetznerConnector;
use App\Http\Integrations\Requests\Hetzner\Images\ListImagesRequest; use App\Http\Integrations\Requests\Hetzner\Images\GetImagesRequest;
use App\Http\Integrations\Requests\Hetzner\Locations\ListLocationsRequest; use App\Http\Integrations\Requests\Hetzner\Locations\GetLocationsRequest;
use App\Http\Integrations\Requests\Hetzner\ServerTypes\ListServerTypesRequest; use App\Http\Integrations\Requests\Hetzner\Servers\CreateServerRequest;
use App\Http\Integrations\Requests\Hetzner\ServerTypes\GetServerTypesRequest;
use Exception; use Exception;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class HetznerService implements ServerProviderService class HetznerService extends ServerProviderService
{ {
public function __construct() public function __construct()
{ {
@@ -24,13 +26,33 @@ class HetznerService implements ServerProviderService
string $serverType, string $serverType,
string $location, string $location,
string $image, string $image,
): bool { string $rootPassword,
return false; ): CreatedServer {
$response = $this->connector->send(new CreateServerRequest(
image: $image,
name: $name,
serverType: $serverType,
location: $location,
rootPassword: $rootPassword,
));
if ($response->status() !== 201) {
throw new Exception('Failed to create server on Hetzner');
} }
public function listServerTypes(): Collection return new CreatedServer(
id: $response->json('server.id'),
name: $name,
rootPassword: $rootPassword,
status: $response->json('server.status')['status'],
ipv4: $response->json('server.public_net.ipv4.ip'),
ipv6: $response->json('server.public_net.ipv6.ip'),
);
}
public function getServerTypes(): Collection
{ {
$response = $this->connector->send(new ListServerTypesRequest); $response = $this->connector->send(new GetServerTypesRequest);
if ($response->status() !== 200) { if ($response->status() !== 200) {
throw new Exception('Failed to fetch server types from Hetzner'); throw new Exception('Failed to fetch server types from Hetzner');
@@ -41,17 +63,17 @@ class HetznerService implements ServerProviderService
id: $serverType['id'], id: $serverType['id'],
name: $serverType['name'], name: $serverType['name'],
cores: $serverType['cores'], cores: $serverType['cores'],
memory: $serverType['memory'] * 1024, memory: $serverType['memory'],
disk: $serverType['disk'], disk: $serverType['disk'],
priceMonthly: $serverType['prices'][0]['monthly']['gross'] ?? 0, priceMonthly: $serverType['prices'][0]['monthly']['gross'] ?? 0,
priceHourly: $serverType['prices'][0]['hourly']['gross'] ?? 0, priceHourly: $serverType['prices'][0]['hourly']['gross'] ?? 0,
); );
}); })->values();
} }
public function listLocations(): Collection public function getLocations(): Collection
{ {
$response = $this->connector->send(new ListLocationsRequest); $response = $this->connector->send(new GetLocationsRequest);
if ($response->status() !== 200) { if ($response->status() !== 200) {
throw new Exception('Failed to fetch locations from Hetzner'); throw new Exception('Failed to fetch locations from Hetzner');
@@ -64,12 +86,12 @@ class HetznerService implements ServerProviderService
country: $location['country'], country: $location['country'],
city: $location['city'], city: $location['city'],
); );
}); })->values();
} }
public function listImages(): Collection public function getImages(): Collection
{ {
$response = $this->connector->send(new ListImagesRequest( $response = $this->connector->send(new GetImagesRequest(
architecture: 'x86', architecture: 'x86',
)); ));
@@ -84,6 +106,6 @@ class HetznerService implements ServerProviderService
osFlavor: $image['os_flavor'], osFlavor: $image['os_flavor'],
osVersion: $image['os_version'], osVersion: $image['os_version'],
); );
}); })->values();
} }
} }

View File

@@ -2,23 +2,25 @@
namespace App\Services\ServerProviders; namespace App\Services\ServerProviders;
use App\Data\ServerProviders\CreatedServer;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Saloon\Http\Connector; use Saloon\Http\Connector;
interface ServerProviderService abstract class ServerProviderService
{ {
protected Connector $connector; protected Connector $connector;
public function createServer( abstract public function createServer(
string $name, string $name,
string $serverType, string $serverType,
string $location, string $location,
string $image, string $image,
): bool; string $rootPassword,
): CreatedServer;
public function listServerTypes(): Collection; abstract public function getServerTypes(): Collection;
public function listLocations(): Collection; abstract public function getLocations(): Collection;
public function listImages(): Collection; abstract public function getImages(): Collection;
} }

View File

@@ -0,0 +1,32 @@
<script setup>
defineProps({
modelValue: String,
disabled: Boolean,
value: String,
name: String,
});
const emit = defineEmits(['update:modelValue']);
function onChange(event) {
console.log(event);
emit('update:modelValue', event.target.value)
}
</script>
<template>
<label
class="relative rounded-lg border-2 border-white/20 px-3 py-1 has-[:checked]:border-white has-[:disabled]:opacity-40"
>
<input
type="radio"
:name="name"
:value="value"
class="invisible absolute inset-0"
:disabled="disabled"
:checked="modelValue === value"
@change="onChange"
/>
<slot />
</label>
</template>

View File

@@ -1,6 +1,51 @@
<script setup lang="ts"> <script setup>
import RadioButton from '@/components/RadioButton.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { Head } from '@inertiajs/vue3'; import { Head, router, useForm } from '@inertiajs/vue3';
import { watch } from 'vue';
const props = defineProps({
locations: Array,
serverTypes: Array,
images: Array,
});
const form = useForm({
provider: 'hetzner',
location: null,
serverType: null,
image: null,
});
const serverProviders = [
{
name: 'Hetzner',
value: 'hetzner',
},
{
name: 'Digital Ocean',
value: 'digital-ocean',
disabled: true,
},
];
watch(
() => form.provider,
(provider) => {
console.log(provider);
},
);
if (form.provider && !props.locations) {
router.reload({
only: ['locations'],
data: {
provider: form.provider,
},
async: true,
});
}
</script> </script>
<template> <template>
@@ -8,7 +53,43 @@ import { Head } from '@inertiajs/vue3';
<AppLayout> <AppLayout>
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4"> <div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<div class="flex flex-wrap gap-2">
<RadioButton
v-for="serverProvider in serverProviders"
v-model="form.provider"
:value="serverProvider.value"
:disabled="serverProvider.disabled"
name="server-provider"
>
{{ serverProvider.name }}
</RadioButton>
</div>
<div v-if="form.provider" class="flex flex-wrap gap-2">
<RadioButton
v-for="location in locations"
v-model="form.location"
:value="location.id"
:disabled="location.disabled"
name="location"
>
{{ location.city }}
</RadioButton>
</div>
<div v-if="form.location" class="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
<RadioButton
v-for="serverType in serverTypes.sort((a, b) => a.cores - b.cores)"
v-model="form.serverType"
:value="serverType.id"
:disabled="serverType.disabled"
name="server-type"
>
<h5 class="text-lg font-semibold uppercase tracking-tight">{{ serverType.name }}</h5>
<p class="text-sm opacity-60">{{ serverType.cores }} cores &bull; {{ serverType.memory }} GB RAM &bull; {{ serverType.disk }} GB disk</p>
</RadioButton>
</div>
<div class="flex justify-end items-center">
<Button @click="form.post(route('servers.store', { organisation: $page.props.organisation.id }))">Submit</Button>
</div>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -18,8 +18,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/', [OrganisationController::class, 'show'])->name('organisations.show'); Route::get('/', [OrganisationController::class, 'show'])->name('organisations.show');
Route::resource('servers', ServerController::class) Route::resource('servers', ServerController::class)
->only('index', 'create', 'store') ->only('index', 'show', 'create', 'store')
->name('index', 'servers.index') ->name('index', 'servers.index')
->name('show', 'servers.show')
->name('create', 'servers.create') ->name('create', 'servers.create')
->name('store', 'servers.store'); ->name('store', 'servers.store');