feat(engine-b): customer domain models + migrations

customers, orders (ProvisioningSubject, stripe_event_id unique), instances
(subdomain unique, encrypted nc_admin_ref), dns_records, backups,
monitoring_targets, onboarding_tasks. Host capacity: committedGb/availableGb +
datacenter placement. 7 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 11:38:01 +02:00
parent b9742f61ee
commit 406251dfde
19 changed files with 595 additions and 1 deletions

21
app/Models/Backup.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Backup extends Model
{
protected $fillable = ['instance_id', 'external_job_id', 'schedule', 'last_ok_at', 'status'];
protected function casts(): array
{
return ['last_ok_at' => 'datetime'];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

26
app/Models/Customer.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Customer extends Model
{
/** @use HasFactory<\Database\Factories\CustomerFactory> */
use HasFactory, HasUuid;
protected $fillable = ['name', 'email', 'locale', 'stripe_customer_id', 'status'];
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function instances(): HasMany
{
return $this->hasMany(Instance::class);
}
}

16
app/Models/DnsRecord.php Normal file
View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DnsRecord extends Model
{
protected $fillable = ['instance_id', 'provider', 'record_id', 'fqdn', 'type', 'value'];
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

View File

@ -6,6 +6,7 @@ use App\Models\Concerns\HasUuid;
use App\Provisioning\Contracts\ProvisioningSubject; use App\Provisioning\Contracts\ProvisioningSubject;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
class Host extends Model implements ProvisioningSubject class Host extends Model implements ProvisioningSubject
@ -46,7 +47,12 @@ class Host extends Model implements ProvisioningSubject
$this->update(['status' => 'error']); $this->update(['status' => 'error']);
} }
/** Free committable storage: total minus reserve. Instance quotas subtract in B. */ public function instances(): HasMany
{
return $this->hasMany(Instance::class);
}
/** Free committable storage: total minus reserve. */
public function freeGb(): int public function freeGb(): int
{ {
if ($this->total_gb === null) { if ($this->total_gb === null) {
@ -55,4 +61,30 @@ class Host extends Model implements ProvisioningSubject
return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100); return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100);
} }
/** Storage already committed to non-failed instances. */
public function committedGb(): int
{
return (int) $this->instances()->where('status', '!=', 'failed')->sum('quota_gb');
}
/** Storage still available for new instances. */
public function availableGb(): int
{
return max(0, $this->freeGb() - $this->committedGb());
}
/**
* Placement (spec §1): first active host in the datacenter with enough free
* committable storage. Cluster is ignored in v1.0.
*/
public static function placeableIn(string $datacenter, int $quotaGb): ?self
{
return self::query()
->where('datacenter', $datacenter)
->where('status', 'active')
->orderBy('name')
->get()
->first(fn (self $host) => $host->availableGb() >= $quotaGb);
}
} }

72
app/Models/Instance.php Normal file
View File

@ -0,0 +1,72 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Instance extends Model
{
/** @use HasFactory<\Database\Factories\InstanceFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'customer_id', 'order_id', 'host_id', 'vmid', 'plan', 'quota_gb', 'disk_gb',
'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref',
'route_written', 'cert_ok', 'status',
];
protected $hidden = ['nc_admin_ref'];
protected function casts(): array
{
return [
'nc_admin_ref' => 'encrypted',
'route_written' => 'boolean',
'cert_ok' => 'boolean',
'vmid' => 'integer',
'quota_gb' => 'integer',
'disk_gb' => 'integer',
'ram_mb' => 'integer',
'cores' => 'integer',
];
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class);
}
public function dnsRecords(): HasMany
{
return $this->hasMany(DnsRecord::class);
}
public function backups(): HasMany
{
return $this->hasMany(Backup::class);
}
public function monitoringTargets(): HasMany
{
return $this->hasMany(MonitoringTarget::class);
}
public function onboardingTasks(): HasMany
{
return $this->hasMany(OnboardingTask::class);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MonitoringTarget extends Model
{
protected $fillable = ['instance_id', 'external_id', 'url', 'status'];
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OnboardingTask extends Model
{
protected $fillable = ['instance_id', 'key', 'done', 'done_at'];
protected function casts(): array
{
return ['done' => 'boolean', 'done_at' => 'datetime'];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

48
app/Models/Order.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Provisioning\Contracts\ProvisioningSubject;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Order extends Model implements ProvisioningSubject
{
/** @use HasFactory<\Database\Factories\OrderFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'customer_id', 'plan', 'amount_cents', 'currency', 'datacenter',
'stripe_event_id', 'status',
];
protected function casts(): array
{
return ['amount_cents' => 'integer'];
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function instance(): HasOne
{
return $this->hasOne(Instance::class);
}
public function runs(): MorphMany
{
return $this->morphMany(ProvisioningRun::class, 'subject');
}
/** Runner hook: a failed provisioning run marks the order failed (no auto-refund). */
public function onProvisioningFailed(): void
{
$this->update(['status' => 'failed']);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\Customer;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Customer> */
class CustomerFactory extends Factory
{
protected $model = Customer::class;
public function definition(): array
{
return [
'name' => $this->faker->company(),
'email' => $this->faker->unique()->safeEmail(),
'locale' => 'de',
'status' => 'active',
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Instance> */
class InstanceFactory extends Factory
{
protected $model = Instance::class;
public function definition(): array
{
return [
'customer_id' => Customer::factory(),
'order_id' => Order::factory(),
'plan' => 'start',
'quota_gb' => 100,
'disk_gb' => 120,
'ram_mb' => 4096,
'cores' => 2,
'subdomain' => 'nc-'.$this->faker->unique()->numberBetween(1, 999999),
'status' => 'reserving',
];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\Customer;
use App\Models\Order;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Order> */
class OrderFactory extends Factory
{
protected $model = Order::class;
public function definition(): array
{
return [
'customer_id' => Customer::factory(),
'plan' => 'start',
'amount_cents' => 1900,
'currency' => 'EUR',
'datacenter' => 'fsn',
'status' => 'paid',
];
}
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('name');
$table->string('email');
$table->string('locale', 5)->default('de');
$table->string('stripe_customer_id')->nullable();
$table->string('status')->default('active');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('customers');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
$table->string('plan'); // start|team|business|enterprise
$table->unsignedInteger('amount_cents')->default(0);
$table->string('currency', 3)->default('EUR');
$table->string('datacenter'); // target region
$table->string('stripe_event_id')->nullable()->unique(); // idempotency
$table->string('status')->default('paid'); // paid|provisioning|active|failed
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('instances', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
$table->foreignId('order_id')->constrained('orders')->cascadeOnDelete();
$table->foreignId('host_id')->nullable()->constrained('hosts')->nullOnDelete();
$table->unsignedInteger('vmid')->nullable();
$table->string('plan');
$table->unsignedBigInteger('quota_gb');
$table->unsignedBigInteger('disk_gb');
$table->unsignedInteger('ram_mb');
$table->unsignedInteger('cores');
$table->string('subdomain')->unique();
$table->string('custom_domain')->nullable();
$table->text('nc_admin_ref')->nullable(); // encrypted username/ref — never password
$table->boolean('route_written')->default(false);
$table->boolean('cert_ok')->default(false);
$table->string('status')->default('reserving'); // reserving|provisioning|active|suspended|failed
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('instances');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dns_records', function (Blueprint $table) {
$table->id();
$table->foreignId('instance_id')->constrained('instances')->cascadeOnDelete();
$table->string('provider');
$table->string('record_id');
$table->string('fqdn');
$table->string('type', 10);
$table->string('value');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('dns_records');
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('backups', function (Blueprint $table) {
$table->id();
$table->foreignId('instance_id')->constrained('instances')->cascadeOnDelete();
$table->string('external_job_id');
$table->string('schedule');
$table->timestamp('last_ok_at')->nullable();
$table->string('status')->default('scheduled');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('backups');
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('monitoring_targets', function (Blueprint $table) {
$table->id();
$table->foreignId('instance_id')->constrained('instances')->cascadeOnDelete();
$table->string('external_id');
$table->string('url');
$table->string('status')->default('up');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('monitoring_targets');
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('onboarding_tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('instance_id')->constrained('instances')->cascadeOnDelete();
$table->string('key');
$table->boolean('done')->default(false);
$table->timestamp('done_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('onboarding_tasks');
}
};

View File

@ -0,0 +1,70 @@
<?php
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use Illuminate\Database\QueryException;
it('resolves an Order as the polymorphic run subject', function () {
$order = Order::factory()->create();
$run = ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
]);
expect($run->fresh()->subject->is($order))->toBeTrue()
->and($order->getRouteKeyName())->toBe('uuid');
});
it('marks the order failed via the ProvisioningSubject hook', function () {
$order = Order::factory()->create(['status' => 'provisioning']);
$order->onProvisioningFailed();
expect($order->fresh()->status)->toBe('failed');
});
it('enforces a unique stripe_event_id (idempotency)', function () {
Order::factory()->create(['stripe_event_id' => 'evt_123']);
Order::factory()->create(['stripe_event_id' => 'evt_123']);
})->throws(QueryException::class);
it('enforces a unique subdomain', function () {
Instance::factory()->create(['subdomain' => 'kanzlei-berger']);
Instance::factory()->create(['subdomain' => 'kanzlei-berger']);
})->throws(QueryException::class);
it('encrypts nc_admin_ref at rest', function () {
$instance = Instance::factory()->create(['nc_admin_ref' => 'admin']);
$raw = \DB::table('instances')->where('id', $instance->id)->value('nc_admin_ref');
expect($raw)->not->toBe('admin')
->and($instance->fresh()->nc_admin_ref)->toBe('admin');
});
it('computes host capacity from committed instance quotas', function () {
$host = Host::factory()->active()->create(['total_gb' => 1000, 'reserve_pct' => 15]);
// freeGb = 850
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 100, 'status' => 'active']);
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 200, 'status' => 'provisioning']);
Instance::factory()->create(['host_id' => $host->id, 'quota_gb' => 500, 'status' => 'failed']); // excluded
expect($host->committedGb())->toBe(300)
->and($host->availableGb())->toBe(550);
});
it('places an instance on an active host in the datacenter with room', function () {
$full = Host::factory()->active()->create(['datacenter' => 'fsn', 'name' => 'pve-fsn-a', 'total_gb' => 100, 'reserve_pct' => 15]);
Instance::factory()->create(['host_id' => $full->id, 'quota_gb' => 80, 'status' => 'active']);
$room = Host::factory()->active()->create(['datacenter' => 'fsn', 'name' => 'pve-fsn-b', 'total_gb' => 1000, 'reserve_pct' => 15]);
Host::factory()->active()->create(['datacenter' => 'hel', 'name' => 'pve-hel-a', 'total_gb' => 1000]);
$picked = Host::placeableIn('fsn', 100);
expect($picked?->is($room))->toBeTrue()
->and(Host::placeableIn('nowhere', 100))->toBeNull();
});