fix(admin): enforce hosts.datacenter FK (restrictOnDelete) — no deactivation side-effect

The DB now refuses to orphan a host; datacenter delete pre-checks for a friendly
message and catches the constraint as the race backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 15:00:38 +02:00
parent 249efa0553
commit 8bcb5ec268
2 changed files with 58 additions and 16 deletions

View File

@ -4,7 +4,7 @@ namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use LivewireUI\Modal\ModalComponent;
/**
@ -30,24 +30,23 @@ class ConfirmDeleteDatacenter extends ModalComponent
public function delete()
{
// Serialize the guard with the delete: lock the datacenter row and
// re-check host membership inside the same transaction so a host created
// concurrently can't be orphaned (hosts.datacenter is a bare code).
DB::transaction(function () {
$dc = Datacenter::query()->where('uuid', $this->uuid)->lockForUpdate()->first();
$dc = Datacenter::query()->where('uuid', $this->uuid)->first();
if ($dc === null) {
return;
return $this->redirectRoute('admin.datacenters', navigate: true);
}
// Deactivate first so HostCreate's `exists:…,active,1` rule blocks any
// new host for this code while we finish deleting.
$dc->update(['active' => false]);
// The hosts.datacenter → datacenters.code foreign key (restrictOnDelete)
// is the source of truth: the DB refuses to orphan a host. We pre-check
// for a friendly message and catch the constraint as the race backstop.
if (Host::query()->where('datacenter', $dc->code)->exists()) {
return; // still has hosts — leave it (deactivated)
return $this->redirectRoute('admin.datacenters', navigate: true);
}
try {
$dc->delete();
});
} catch (QueryException) {
// A host was created for this code concurrently — leave it intact.
}
return $this->redirectRoute('admin.datacenters', navigate: true);
}

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Enforce hosts.datacenter datacenters.code at the database level so a
* datacenter with hosts can never be deleted (restrictOnDelete), closing the
* check-then-delete race in the admin console. All existing host codes were
* backfilled into datacenters by 2026_07_25_090001.
*/
return new class extends Migration
{
public function up(): void
{
// Safety net: ensure every referenced code exists before adding the FK.
$now = now();
$missing = DB::table('hosts')
->whereNotNull('datacenter')
->whereNotIn('datacenter', fn ($q) => $q->from('datacenters')->select('code'))
->distinct()->pluck('datacenter');
foreach ($missing as $code) {
DB::table('datacenters')->insert([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'code' => $code, 'name' => \Illuminate\Support\Str::upper($code),
'active' => true, 'created_at' => $now, 'updated_at' => $now,
]);
}
Schema::table('hosts', function (Blueprint $table) {
$table->foreign('datacenter')->references('code')->on('datacenters')->restrictOnDelete();
});
}
public function down(): void
{
Schema::table('hosts', function (Blueprint $table) {
$table->dropForeign(['datacenter']);
});
}
};