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
parent
249efa0553
commit
8bcb5ec268
|
|
@ -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();
|
||||
if ($dc === null) {
|
||||
return;
|
||||
}
|
||||
// Deactivate first so HostCreate's `exists:…,active,1` rule blocks any
|
||||
// new host for this code while we finish deleting.
|
||||
$dc->update(['active' => false]);
|
||||
$dc = Datacenter::query()->where('uuid', $this->uuid)->first();
|
||||
if ($dc === null) {
|
||||
return $this->redirectRoute('admin.datacenters', navigate: true);
|
||||
}
|
||||
|
||||
if (Host::query()->where('datacenter', $dc->code)->exists()) {
|
||||
return; // still has hosts — leave it (deactivated)
|
||||
}
|
||||
// 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 $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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue