refactor(v5): remove Project model and consolidate v5 migrations

Drop v5_projects table, Project model, and related alter-table
migrations; fold cluster_id and nullable private_key_id into the
initial v5_servers migration; rename migration files to v5_ prefix;
strip cooldServers/privateKeys/teams props from HomeController and
Home page.
This commit is contained in:
Andras Bacsai 2026-06-16 16:57:52 +02:00
parent 5c23b9aaa5
commit 9566b259b2
10 changed files with 35 additions and 498 deletions

View file

@ -20,33 +20,11 @@ class HomeController extends Controller
{
public function __invoke(Request $request, FluxHealth $fluxHealth): Response
{
/** @var User $user */
$user = $request->user();
$currentTeam = $request->attributes->get('v5.currentTeam');
return Inertia::render('Home', [
'flux' => $fluxHealth->check(),
'clusters' => $this->clusters($currentTeam),
'cooldServers' => $this->cooldServers($currentTeam),
'privateKeys' => $this->privateKeys($currentTeam),
'currentTeam' => $currentTeam instanceof Team ? [
'id' => $currentTeam->id,
'name' => $currentTeam->name,
'description' => $currentTeam->description,
'role' => $currentTeam->pivot?->role ?? $user->roleInTeam($currentTeam->id),
'personal' => $currentTeam->personal_team,
] : null,
'teams' => $user->teams()
->select('teams.id', 'teams.name', 'teams.description', 'teams.personal_team')
->orderBy('teams.name')
->get()
->map(fn (Team $team) => [
'id' => $team->id,
'name' => $team->name,
'description' => $team->description,
'role' => $team->pivot->role,
'personal' => $team->personal_team,
]),
]);
}
@ -124,27 +102,6 @@ private function recordBootstrappedServer(User $user, Team $team, PrivateKey $pr
]);
}
/**
* @return array<int, array{uuid: string, name: string}>
*/
private function privateKeys(mixed $currentTeam): array
{
if (! $currentTeam instanceof Team) {
return [];
}
return PrivateKey::query()
->where('team_id', $currentTeam->id)
->select('uuid', 'name')
->orderBy('name')
->get()
->map(fn (PrivateKey $privateKey) => [
'uuid' => $privateKey->uuid,
'name' => $privateKey->name,
])
->all();
}
/**
* @return array<int, array{id: string, name: string, description: string|null, serversCount: int, servers: array<int, array{id: string, name: string, host: string, status: string, capabilities: array<int, string>}>}>
*/
@ -175,31 +132,4 @@ private function clusters(mixed $currentTeam): array
])
->all();
}
/**
* @return array<int, array{id: string, host: string, sshUser: string, sshPort: int, status: string, capabilities: array<int, string>, builderEnabled: bool, builderCapacity: int, lastBootstrappedAt: string|null}>
*/
private function cooldServers(mixed $currentTeam): array
{
if (! $currentTeam instanceof Team) {
return [];
}
return V5Server::query()
->where('team_id', $currentTeam->id)
->orderBy('name')
->get()
->map(fn (V5Server $server) => [
'id' => (string) $server->id,
'host' => $server->host,
'sshUser' => $server->ssh_user,
'sshPort' => $server->ssh_port,
'status' => $server->status,
'capabilities' => $server->capabilities ?? [],
'builderEnabled' => $server->builder_enabled,
'builderCapacity' => $server->builder_capacity,
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toISOString(),
])
->all();
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Models\V5;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Project extends V5Model
{
protected $table = 'v5_projects';
protected $fillable = [
'team_id',
'created_by_user_id',
'name',
'description',
];
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}

View file

@ -1,33 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('v5_projects', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained('teams')->cascadeOnDelete();
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
$table->index(['team_id', 'name']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v5_projects');
}
};

View file

@ -14,6 +14,7 @@ public function up(): void
Schema::create('v5_servers', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained('teams')->cascadeOnDelete();
$table->foreignId('cluster_id')->nullable()->constrained('v5_clusters')->nullOnDelete();
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('private_key_id')->nullable()->constrained('private_keys')->nullOnDelete();
$table->string('name');
@ -28,6 +29,7 @@ public function up(): void
$table->timestamps();
$table->unique(['team_id', 'host', 'ssh_port']);
$table->index(['team_id', 'cluster_id']);
$table->index(['team_id', 'status']);
});
}

View file

@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->foreignId('cluster_id')
->nullable()
->after('team_id')
->constrained('v5_clusters')
->nullOnDelete();
$table->index(['team_id', 'cluster_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->dropIndex(['team_id', 'cluster_id']);
$table->dropConstrainedForeignId('cluster_id');
});
}
};

View file

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->foreignId('private_key_id')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->foreignId('private_key_id')->nullable(false)->change();
});
}
};

View file

@ -1350,17 +1350,6 @@ CREATE TABLE IF NOT EXISTS "v5_servers" (
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_projects" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"cluster_id" INTEGER,
"created_by_user_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "webhook_notification_settings" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
@ -1792,8 +1781,5 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_1
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (315, '2026_06_04_050157_create_v5_projects_table', 315);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130650_create_v5_servers_table', 316);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130649_create_v5_clusters_table', 317);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_16_131229_add_cluster_id_to_v5_servers_table', 318);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_16_132000_make_v5_server_private_key_nullable', 319);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130650_v5_create_servers_table', 316);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130649_v5_create_clusters_table', 317);

View file

@ -1,75 +1,6 @@
import { Head } from '@inertiajs/react';
import { useState } from 'react';
export default function Home({ currentTeam, teams, flux, clusters, cooldServers, privateKeys }) {
const firstPrivateKey = privateKeys[0]?.uuid || '';
const [bootstrapResult, setBootstrapResult] = useState(null);
const [bootstrapping, setBootstrapping] = useState(false);
const [bootstrapForm, setBootstrapForm] = useState({
host: '',
ssh_user: 'root',
ssh_port: '22',
private_key_uuid: firstPrivateKey,
wg_listen_port: '',
wg_endpoint: '',
enable_builder: true,
builder_capacity: '2',
});
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
}
function updateBootstrapForm(field, value) {
setBootstrapForm((current) => ({
...current,
[field]: value,
}));
}
async function bootstrapCoolifyMesh(event) {
event.preventDefault();
setBootstrapping(true);
setBootstrapResult(null);
const payload = {
host: bootstrapForm.host,
ssh_user: bootstrapForm.ssh_user,
ssh_port: Number(bootstrapForm.ssh_port),
private_key_uuid: bootstrapForm.private_key_uuid,
enable_builder: bootstrapForm.enable_builder,
builder_capacity: bootstrapForm.builder_capacity === '' ? null : Number(bootstrapForm.builder_capacity),
wg_listen_port: bootstrapForm.wg_listen_port === '' ? null : Number(bootstrapForm.wg_listen_port),
wg_endpoint: bootstrapForm.wg_endpoint || null,
};
try {
const response = await fetch('/v5/coolify/bootstrap', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify(payload),
});
const result = await response.json();
setBootstrapResult(result);
} catch (error) {
setBootstrapResult({
successful: false,
label: 'Bootstrap failed',
message: 'Could not start the coolify bootstrap command.',
output: null,
errorOutput: null,
exitCode: null,
});
} finally {
setBootstrapping(false);
}
}
export default function Home({ flux, clusters }) {
return (
<>
<Head title="V5" />
@ -110,177 +41,6 @@ export default function Home({ currentTeam, teams, flux, clusters, cooldServers,
</ul>
)}
</section>
<section aria-labelledby="coold-server-heading">
<h2 id="coold-server-heading">coold servers</h2>
{cooldServers.length === 0 ? (
<p>No coold serverss have been added yet.</p>
) : (
<ul>
{cooldServers.map((host) => (
<li key={host.id}>
<strong>{host.host}</strong> ({host.status}) SSH {host.sshUser}@{host.host}:{host.sshPort};{' '}
{host.capabilities.join(', ')}; builder{' '}
{host.builderEnabled
? `enabled, capacity ${host.builderCapacity}`
: 'disabled'}
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="coolify-heading">
<h2 id="coolify-heading">coolify</h2>
<form onSubmit={bootstrapCoolifyMesh}>
<h3>Bootstrap server</h3>
<label>
Host/IP
<input
type="text"
value={bootstrapForm.host}
onChange={(event) => updateBootstrapForm('host', event.target.value)}
placeholder="203.0.113.10"
required
/>
</label>
<label>
SSH user
<input
type="text"
value={bootstrapForm.ssh_user}
onChange={(event) => updateBootstrapForm('ssh_user', event.target.value)}
required
/>
</label>
<label>
SSH port
<input
type="number"
min="1"
max="65535"
value={bootstrapForm.ssh_port}
onChange={(event) => updateBootstrapForm('ssh_port', event.target.value)}
required
/>
</label>
<label>
Private key
<select
value={bootstrapForm.private_key_uuid}
onChange={(event) => updateBootstrapForm('private_key_uuid', event.target.value)}
required
>
<option value="" disabled>Select a private key</option>
{privateKeys.map((privateKey) => (
<option key={privateKey.uuid} value={privateKey.uuid}>
{privateKey.name}
</option>
))}
</select>
</label>
<details>
<summary>Advanced mesh options</summary>
<label>
WireGuard listen port override
<input
type="number"
min="1"
max="65535"
value={bootstrapForm.wg_listen_port}
onChange={(event) => updateBootstrapForm('wg_listen_port', event.target.value)}
/>
</label>
<label>
WireGuard endpoint override
<input
type="text"
value={bootstrapForm.wg_endpoint}
onChange={(event) => updateBootstrapForm('wg_endpoint', event.target.value)}
placeholder="host.example:51821"
/>
</label>
<label>
<input
type="checkbox"
checked={bootstrapForm.enable_builder}
onChange={(event) => updateBootstrapForm('enable_builder', event.target.checked)}
/>
Enable builder
</label>
<label>
Builder capacity
<input
type="number"
min="0"
max="100"
value={bootstrapForm.builder_capacity}
onChange={(event) => updateBootstrapForm('builder_capacity', event.target.value)}
/>
</label>
</details>
<button type="submit" disabled={bootstrapping || privateKeys.length === 0}>
{bootstrapping ? 'Bootstrapping server...' : 'Bootstrap server'}
</button>
{privateKeys.length === 0 ? (
<p>Add a private key before bootstrapping a server.</p>
) : null}
</form>
{bootstrapResult ? (
<div>
<p>
<strong>{bootstrapResult.label}</strong>
</p>
<p>{bootstrapResult.message}</p>
{bootstrapResult.exitCode !== null ? (
<p>Exit code: {bootstrapResult.exitCode}</p>
) : null}
{bootstrapResult.output ? <pre>{bootstrapResult.output}</pre> : null}
{bootstrapResult.errorOutput ? <pre>{bootstrapResult.errorOutput}</pre> : null}
</div>
) : null}
</section>
<h2>Current team</h2>
{currentTeam ? (
<dl>
<dt>Name</dt>
<dd>{currentTeam.name}</dd>
<dt>Description</dt>
<dd>{currentTeam.description || 'No description'}</dd>
<dt>Your role</dt>
<dd>{currentTeam.role}</dd>
</dl>
) : (
<p>No team selected.</p>
)}
<h2>Your teams</h2>
<ul>
{teams.map((team) => (
<li key={team.id}>
{team.name} ({team.role})
</li>
))}
</ul>
</main>
</>
);

View file

@ -24,7 +24,6 @@
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
Schema::dropIfExists('v5_projects');
Schema::dropIfExists('private_keys');
Schema::dropIfExists('team_user');
Schema::dropIfExists('teams');
@ -52,22 +51,9 @@
->and($groups['v5.authenticated'])->toContain(EnsureCurrentTeam::class);
});
it('creates v5 project tables in the shared database', function () {
createSharedUserAndTeamTables();
$migration = include database_path('migrations/2026_06_04_050157_create_v5_projects_table.php');
$migration->up();
expect(Schema::hasTable('v5_projects'))->toBeTrue()
->and(Schema::hasColumns('v5_projects', [
'id',
'team_id',
'created_by_user_id',
'name',
'description',
'created_at',
'updated_at',
]))->toBeTrue();
it('reuses existing projects instead of creating v5 projects', function () {
expect(file_exists(database_path('migrations/2026_06_04_050157_v5_create_projects_table.php')))->toBeFalse()
->and(file_exists(app_path('Models/V5/Project.php')))->toBeFalse();
});
it('creates v5 cluster tables and lets each server belong to one cluster', function () {
@ -75,7 +61,7 @@
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_create_v5_clusters_table.php');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
expect(Schema::hasTable('v5_clusters'))->toBeTrue()
@ -89,14 +75,9 @@
'updated_at',
]))->toBeTrue();
Schema::dropIfExists('v5_servers');
$serverMigration = include database_path('migrations/2026_06_16_130650_create_v5_servers_table.php');
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$serverClusterMigration = include database_path('migrations/2026_06_16_131229_add_cluster_id_to_v5_servers_table.php');
$serverClusterMigration->up();
expect(Schema::hasColumn('v5_servers', 'cluster_id'))->toBeTrue();
});
@ -106,15 +87,12 @@
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_create_v5_clusters_table.php');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$migration = include database_path('migrations/2026_06_16_130650_create_v5_servers_table.php');
$migration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$migration->up();
$serverClusterMigration = include database_path('migrations/2026_06_16_131229_add_cluster_id_to_v5_servers_table.php');
$serverClusterMigration->up();
expect(Schema::hasTable('v5_servers'))->toBeTrue()
->and(Schema::hasColumns('v5_servers', [
'id',
@ -136,20 +114,21 @@
]))->toBeTrue();
});
it('includes v5 project tables in the dev testing schema', function () {
it('includes v5 tables in the dev testing schema', function () {
$schema = file_get_contents(database_path('schema/testing-schema.sql'));
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"')
->and($schema)->toContain('"team_id" INTEGER NOT NULL')
expect($schema)->toContain('"team_id" INTEGER NOT NULL')
->and($schema)->toContain('"created_by_user_id" INTEGER NOT NULL')
->and($schema)->toContain('2026_06_04_050157_create_v5_projects_table')
->and($schema)->not->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"')
->and($schema)->not->toContain('2026_06_04_050157_v5_create_projects_table')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_servers"')
->and($schema)->toContain('"cluster_id" INTEGER')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_clusters"')
->and($schema)->toContain('"private_key_id" INTEGER')
->and($schema)->toContain('2026_06_16_130650_create_v5_servers_table')
->and($schema)->toContain('2026_06_16_130649_create_v5_clusters_table')
->and($schema)->toContain('2026_06_16_131229_add_cluster_id_to_v5_servers_table')
->and($schema)->toContain('2026_06_16_130650_v5_create_servers_table')
->and($schema)->toContain('2026_06_16_130649_v5_create_clusters_table')
->and($schema)->not->toContain('2026_06_16_131229_add_cluster_id_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_16_132000_make_v5_server_private_key_nullable')
->and($schema)->not->toContain('v5_hosts');
});
@ -186,16 +165,20 @@
->assertSee('Home', false)
->assertDontSee('v5-ready', false)
->assertDontSee('This page is served from Laravel through Inertia and React')
->assertDontSee('Bootstrap server')
->assertDontSee('privateKeys', false)
->assertSee('Running')
->assertSee('Flux is running.')
->assertSee('"clusters":[]', false)
->assertSee('"cooldServers":[]', false)
->assertDontSee('cooldServers', false)
->assertDontSee('coold-dev')
->assertDontSee('100.64.0.1')
->assertSee('V5 Shared Team')
->assertSee('Shared team details')
->assertSee('owner')
->assertSee($user->email);
->assertDontSee('Current team')
->assertDontSee('Your teams')
->assertDontSee('currentTeam', false)
->assertDontSee('teams', false)
->assertDontSee('V5 Shared Team')
->assertDontSee('Shared team details');
});
it('shows v5 clusters with their servers on the inertia shell', function () {
@ -263,8 +246,8 @@
->get('/v5')
->assertSuccessful()
->assertSessionHas('currentTeam')
->assertSee('Auto Selected Team')
->assertSee('admin');
->assertDontSee('Auto Selected Team')
->assertDontSee('admin');
});
it('shows when flux is unavailable', function () {
@ -430,9 +413,10 @@
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('"host":"192.0.2.10"', false)
->assertSee('"status":"installed"', false)
->assertSee('"capabilities":["coold","builder"]', false);
->assertDontSee('"host":"192.0.2.10"', false)
->assertDontSee('"capabilities":["coold","builder"]', false);
expect(V5Server::query()->where('host', '192.0.2.10')->where('status', 'installed')->exists())->toBeTrue();
$sshKeyPath = null;