arrayable enums, only use base ubuntu images, server controller tests, server frontend page fixes

This commit is contained in:
2025-04-01 15:57:40 +00:00
parent d6a0fb3838
commit 4ff9b05cb4
20 changed files with 387 additions and 63 deletions

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Enums\Concerns;
trait Arrayable
{
public static function toArray(): array
{
$names = array_column(self::cases(), 'name');
$values = array_column(self::cases(), 'value');
return array_combine($names, $values);
}
}

View File

@@ -2,8 +2,12 @@
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum DeploymentStatus: string
{
use Arrayable;
case PENDING = 'pending';
case IN_PROGRESS = 'in-progress';
case COMPLETED = 'completed';

View File

@@ -2,8 +2,12 @@
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum FirewallRuleStatus: string
{
use Arrayable;
case NOT_APPLIED = 'not-applied';
case APPLIED = 'applied';
case FAILED = 'failed';

View File

@@ -2,8 +2,12 @@
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum OrganisationRole: string
{
use Arrayable;
case ADMIN = 'admin';
case MEMBER = 'member';
}

View File

@@ -2,7 +2,11 @@
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum RepositoryType: string
{
use Arrayable;
case GIT = 'git';
}

View File

@@ -2,8 +2,12 @@
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum ServerProvider: string
{
use Arrayable;
case HETZNER = 'hetzner';
case DIGITAL_OCEAN = 'digital-ocean';
}

View File

@@ -2,8 +2,12 @@
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum ServerStatus: string
{
use Arrayable;
case WAITING_FOR_PROVIDER = 'waiting-for-provider';
case PROVIDER_TIMEOUT = 'provider-timeout';
case UNPROVISIONED = 'unprovisioned';

View File

@@ -2,8 +2,12 @@
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum ServiceCategory: string
{
use Arrayable;
case DATABASE = 'database';
case APPLICATION = 'application';
case GATEWAY = 'gateway';

View File

@@ -2,8 +2,12 @@
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum ServiceStatus: string
{
use Arrayable;
case NOT_INSTALLED = 'not-installed';
case INSTALLING = 'installing';
case RUNNING = 'running';

View File

@@ -2,7 +2,11 @@
namespace App\Enums;
use App\Enums\Concerns\Arrayable;
enum ServiceType: string {
use Arrayable;
case FRANKENPHP = 'frankenphp';
case PHP_FPM = 'php-fpm';
case POSTGRES = 'postgres';

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -10,6 +11,9 @@ use Illuminate\Support\Str;
class Organisation extends Model
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory;
protected $guarded = [];
public function owner(): BelongsTo

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\ServerProvider;
use App\Enums\ServerStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -11,6 +12,9 @@ use Spatie\Ssh\Ssh;
class Server extends Model
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory;
protected $guarded = [];
protected function casts(): array

View File

@@ -104,6 +104,6 @@ class HetznerService extends ServerProviderService
osFlavor: $image['os_flavor'],
osVersion: $image['os_version'],
);
})->values();
})->where('osVersion', '!=', 'unknown')->values();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Organisation>
*/
class OrganisationFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = $this->faker->company();
$owner = User::inRandomOrder()->first() ?: User::factory()->create();
return [
'name' => $this->faker->company(),
'slug' => Organisation::createUniqueSlug($name),
'owner_id' => $owner->id,
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use App\Enums\ServerProvider;
use App\Enums\ServerStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Server>
*/
class ServerFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => $this->faker->word(),
'provider' => ServerProvider::HETZNER,
'provider_id' => $this->faker->uuid(),
'ipv4' => $this->faker->ipv4(),
'ipv6' => $this->faker->ipv6(),
'provider_status' => '',
'status' => $this->faker->randomElement(ServerStatus::toArray()),
'region' => '28',
'os' => 'ubuntu',
'plan' => '26',
'user' => 'keystone',
];
}
public function forOrganisation(int $organisationId): static
{
return $this->state(function (array $attributes) use ($organisationId) {
return [
'organisation_id' => $organisationId,
];
});
}
}

View File

@@ -5,6 +5,7 @@ namespace Database\Seeders;
use App\Enums\OrganisationRole;
use App\Enums\RepositoryType;
use App\Models\Organisation;
use App\Models\Server;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -31,6 +32,10 @@ class DatabaseSeeder extends Seeder
'owner_id' => 1,
]);
$servers = Server::factory(40)->forOrganisation($organisation->id)->create();
$organisation->servers()->saveMany($servers);
$organisation->members()->attach($user, ['role' => OrganisationRole::ADMIN]);
$application = $organisation->applications()->create([

View File

@@ -33,11 +33,22 @@ const serverProviders = [
watch(
() => form.provider,
(provider) => {
console.log(provider);
loadLocations();
},
);
if (form.provider && !props.locations) {
watch (
() => form.location,
(location) => {
loadServerTypes();
}
)
loadLocations();
loadServerTypes();
function loadLocations() {
if (form.provider && !props.locations) {
router.reload({
only: ['locations'],
data: {
@@ -45,13 +56,39 @@ if (form.provider && !props.locations) {
},
async: true,
});
}
}
function loadServerTypes() {
if (form.location && !props.serverTypes) {
router.reload({
only: ['serverTypes', 'images'],
data: {
provider: form.provider,
location: form.location,
},
async: true,
});
}
}
</script>
<template>
<Head title="Create Server" />
<AppLayout>
<AppLayout
:breadcrumbs="[
{
title: 'Servers',
href: route('servers.index', {
organisation: $page.props.organisation.id,
}),
},
{
title: 'Create',
}
]"
>
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<div class="flex flex-wrap gap-2">
<RadioButton
@@ -65,17 +102,11 @@ if (form.provider && !props.locations) {
</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"
>
<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">
<div v-if="form.location" class="grid gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<RadioButton
v-for="serverType in serverTypes?.sort((a, b) => a.cores - b.cores) ?? []"
v-model="form.server_type"
@@ -84,21 +115,17 @@ if (form.provider && !props.locations) {
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>
<p class="text-sm opacity-60">
{{ serverType.cores }} cores &bull; {{ serverType.memory }} GB RAM &bull; {{ serverType.disk }} GB disk
</p>
</RadioButton>
</div>
<div v-if="form.server_type" class="flex gap-2 flex-wrap">
<RadioButton
v-for="image in images"
v-model="form.image"
:value="image.id"
:disabled="image.disabled"
name="image"
>
<div v-if="form.server_type" class="flex flex-wrap gap-2">
<RadioButton v-for="image in images" v-model="form.image" :value="image.id" :disabled="image.disabled" name="image">
<h5 class="text-lg font-semibold tracking-tight">{{ image.name }}</h5>
</RadioButton>
</div>
<div class="flex justify-end items-center">
<div class="flex items-center justify-end">
<Button @click="form.post(route('servers.store', { organisation: $page.props.organisation.id }))">Submit</Button>
</div>
</div>

View File

@@ -1,11 +1,13 @@
<script setup>
import { Badge } from '@/components/ui/badge';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
const props = defineProps({
servers: {
type: [Array, null],
type: [Object, null],
required: true,
},
});
@@ -14,27 +16,42 @@ const props = defineProps({
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="[
<AppLayout
:breadcrumbs="[
{
title: 'Servers',
href: route('servers.index', {
organisation: $page.props.organisation.id,
}),
},
]">
]"
>
<div class="flex justify-between items-center gap-3 p-4">
<h2 class="text-3xl font-bold tracking-tight">Servers</h2>
<div>
<Button :as="Link" :href="route('servers.create', {
organisation: $page.props.organisation.id,
})">Create</Button>
</div>
</div>
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="server in servers.data" :key="`server{$servers.id}`" class="w-full relative">
<Card v-for="server in servers.data" :key="`server{$servers.id}`" class="relative w-full">
<CardHeader>
<CardTitle>{{ server.name }}</CardTitle>
<CardDescription
><span class="inline-block rounded-md bg-green-200 px-2 text-xs uppercase text-green-800">{{ server.status }}</span> &bull;
<CardDescription>
<Badge :variant="server.status === 'active' ? 'success' : 'secondary'">{{ server.status.replace('-', ' ') }}</Badge> &bull;
{{ server.ipv4 || server.ipv6 }}</CardDescription
>
</CardHeader>
<Link :href="route('servers.show', {
<Link
:href="
route('servers.show', {
organisation: $page.props.organisation.id,
server: server.id,
})" class="absolute inset-0"></Link>
})
"
class="absolute inset-0"
></Link>
</Card>
<div>@todo pagination</div>

View File

@@ -3,7 +3,9 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { Head } from '@inertiajs/vue3';
import { DatabaseIcon, Layers2Icon } from 'lucide-vue-next';
import { useCycleList, useInterval } from '@vueuse/core';
import { DatabaseIcon, Layers2Icon, LoaderCircleIcon } from 'lucide-vue-next';
import { watch } from 'vue';
const props = defineProps({
server: {
@@ -11,6 +13,20 @@ const props = defineProps({
required: true,
},
});
const { state: provisionMessage, next } = useCycleList([
'Provisioning your server...',
'Updating dependencies...',
'Tightening security...',
'Installing packages...',
'Configuring ssh...',
'Installing docker...',
]);
const { counter, reset, pause, resume } = useInterval(5000, { controls: true });
watch(counter, () => {
next();
});
</script>
<template>
@@ -42,15 +58,18 @@ const props = defineProps({
<div class="leading-none opacity-40">{{ server.ipv4 }} &bull; {{ server.ipv6 }}</div>
</div>
<template v-if="server.status === 'active'">
<div>
<h3 class="text-2xl font-semibold tracking-tight mb-3">Services</h3>
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Services</h3>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="service in server.services" :key="service.id">
<CardHeader>
<div class="flex items-center gap-2">
<DatabaseIcon v-if="service.category === 'database'" class="size-5 opacity-50" />
<CardTitle>{{ service.name }}</CardTitle>
<Badge :variant="service.status === 'active' ? 'success' : 'secondary'">{{ service.status.replace('-', ' ') }}</Badge>
<Badge :variant="service.status === 'active' ? 'success' : 'secondary'">{{
service.status.replace('-', ' ')
}}</Badge>
</div>
<CardDescription>
<span class="capitalize">{{ service.type }}</span> {{ service.version }} &bull;
@@ -60,6 +79,27 @@ const props = defineProps({
</Card>
</div>
</div>
</template>
<template v-else-if="server.status === 'provisioning'">
<div class="flex items-center gap-4 py-6">
<div class="flex-0 flex-shrink">
<LoaderCircleIcon class="size-8 animate-spin" />
</div>
<div class="relative flex-grow">
<Transition
enter-active-class="transition duration-500 ease-in-out"
enter-from-class="opacity-0 -translate-x-4"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition absolute left-0 duration-500 ease-in-out"
leave-from-class="opacity-100 translate-x-0"
leave-to-class="opacity-0 translate-x-4"
>
<div :key="provisionMessage">{{ provisionMessage }}</div>
</Transition>
</div>
</div>
</template>
<template> Something else </template>
<div v-if="$page.props.flash?.server_credentials" class="p-5">
<div class="mb-4 text-sm font-medium text-gray-900 dark:text-white">

View File

@@ -0,0 +1,104 @@
<?php
use App\Actions\GetProviderService;
use App\Data\ServerProviders\CreatedServer;
use App\Models\Organisation;
use App\Models\Server;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use function Pest\Laravel\actingAs;
beforeEach(function () {
// If you have database migrations or any setup, include it here
// For example, using Laravel's RefreshDatabase trait
// use Illuminate\Foundation\Testing\RefreshDatabase;
$this->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]);
$response = $this->get(route('servers.index', ['organisation' => $organisation->id]));
$response->assertStatus(200);
$response->assertInertia(fn(AssertableInertia $page) => $page
->component('servers/Index'));
});
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
->component('servers/Create'));
});
test('store route fails with invalid provider', function () {
$organisation = Organisation::factory()->create();
$response = $this->post(route('servers.store', ['organisation' => $organisation->id]), [
'provider' => 'invalid_provider',
'server_type' => 'cx11',
'location' => 'hel1',
'image' => 'ubuntu-20.04',
]);
$response->assertSessionHas('error', 'Invalid provider');
$response->assertStatus(302); // redirect back
});
test('store route creates a server with valid data', function () {
$organisation = Organisation::factory()->create();
$this->mock(GetProviderService::class, function ($mock) {
$providerMock = \Mockery::mock(\App\Services\ServerProviders\ServerProviderService::class);
$providerMock->shouldReceive('createServer')->andReturn(
new CreatedServer(
name: 'test-server',
id: 123,
ipv4: '127.0.0.1',
ipv6: '::1',
status: 'running',
rootPassword: Str::random(16),
)
);
$mock->shouldReceive('execute')->andReturn($providerMock);
});
$response = $this->post(route('servers.store', ['organisation' => $organisation->id]), [
'provider' => 'hetzner',
'server_type' => 'cx11',
'location' => 'hel1',
'image' => 'ubuntu-20.04',
]);
$response->assertRedirectContains('/servers/');
$this->assertDatabaseHas('servers', [
'organisation_id' => $organisation->id,
'provider' => 'hetzner',
'region' => 'hel1',
'os' => 'ubuntu-20.04',
]);
});
test('show route displays a single server', function () {
$organisation = Organisation::factory()->create();
$server = Server::factory()->create(['organisation_id' => $organisation->id]);
$response = $this->get(route('servers.show', [
'organisation' => $organisation->id,
'server' => $server->id
]));
$response->assertStatus(200);
$response->assertInertia(fn(AssertableInertia $page) => $page
->component('servers/Show'));
});