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 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
*/
public function __construct(

View File

@@ -2,16 +2,100 @@
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\Services\ServerProviders\HetznerService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use NunoMaduro\Collision\Provider;
class ServerController extends Controller
{
public function index(Request $request)
{
$organisation = Organisation::findOrFail($request->route('organisation'));
return inertia('servers/Index', [
'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\Traits\Body\HasJsonBody;
class ListImagesRequest extends Request
class GetImagesRequest extends Request
{
protected Method $method = Method::GET;

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ class CreateServerRequest extends Request implements HasBody
protected ?string $name = null,
protected ?string $serverType = null,
protected ?string $location = null,
protected ?string $rootPassword = null,
) {}
protected function defaultBody(): array
@@ -27,6 +28,7 @@ class CreateServerRequest extends Request implements HasBody
'name' => $this->name,
'server_type' => $this->serverType,
'location' => $this->location,
'root_password' => $this->rootPassword,
];
}

View File

@@ -2,17 +2,19 @@
namespace App\Services\ServerProviders;
use App\Data\ServerProviders\CreatedServer;
use App\Data\ServerProviders\Image;
use App\Data\ServerProviders\Location;
use App\Data\ServerProviders\ServerType;
use App\Http\Integrations\Connectors\HetznerConnector;
use App\Http\Integrations\Requests\Hetzner\Images\ListImagesRequest;
use App\Http\Integrations\Requests\Hetzner\Locations\ListLocationsRequest;
use App\Http\Integrations\Requests\Hetzner\ServerTypes\ListServerTypesRequest;
use App\Http\Integrations\Requests\Hetzner\Images\GetImagesRequest;
use App\Http\Integrations\Requests\Hetzner\Locations\GetLocationsRequest;
use App\Http\Integrations\Requests\Hetzner\Servers\CreateServerRequest;
use App\Http\Integrations\Requests\Hetzner\ServerTypes\GetServerTypesRequest;
use Exception;
use Illuminate\Support\Collection;
class HetznerService implements ServerProviderService
class HetznerService extends ServerProviderService
{
public function __construct()
{
@@ -24,13 +26,33 @@ class HetznerService implements ServerProviderService
string $serverType,
string $location,
string $image,
): bool {
return false;
string $rootPassword,
): 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) {
throw new Exception('Failed to fetch server types from Hetzner');
@@ -41,17 +63,17 @@ class HetznerService implements ServerProviderService
id: $serverType['id'],
name: $serverType['name'],
cores: $serverType['cores'],
memory: $serverType['memory'] * 1024,
memory: $serverType['memory'],
disk: $serverType['disk'],
priceMonthly: $serverType['prices'][0]['monthly']['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) {
throw new Exception('Failed to fetch locations from Hetzner');
@@ -64,12 +86,12 @@ class HetznerService implements ServerProviderService
country: $location['country'],
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',
));
@@ -84,6 +106,6 @@ class HetznerService implements ServerProviderService
osFlavor: $image['os_flavor'],
osVersion: $image['os_version'],
);
});
})->values();
}
}

View File

@@ -2,23 +2,25 @@
namespace App\Services\ServerProviders;
use App\Data\ServerProviders\CreatedServer;
use Illuminate\Support\Collection;
use Saloon\Http\Connector;
interface ServerProviderService
abstract class ServerProviderService
{
protected Connector $connector;
public function createServer(
abstract public function createServer(
string $name,
string $serverType,
string $location,
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 { 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>
<template>
@@ -8,7 +53,43 @@ import { Head } from '@inertiajs/vue3';
<AppLayout>
<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>
</AppLayout>
</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::resource('servers', ServerController::class)
->only('index', 'create', 'store')
->only('index', 'show', 'create', 'store')
->name('index', 'servers.index')
->name('show', 'servers.show')
->name('create', 'servers.create')
->name('store', 'servers.store');