diff --git a/app/Models/Backup.php b/app/Models/Backup.php new file mode 100644 index 0000000..863c020 --- /dev/null +++ b/app/Models/Backup.php @@ -0,0 +1,21 @@ + 'datetime']; + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 0000000..2a78f27 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,26 @@ + */ + 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); + } +} diff --git a/app/Models/DnsRecord.php b/app/Models/DnsRecord.php new file mode 100644 index 0000000..9749aeb --- /dev/null +++ b/app/Models/DnsRecord.php @@ -0,0 +1,16 @@ +belongsTo(Instance::class); + } +} diff --git a/app/Models/Host.php b/app/Models/Host.php index a5b5b4a..69719bc 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -6,6 +6,7 @@ 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\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; class Host extends Model implements ProvisioningSubject @@ -46,7 +47,12 @@ class Host extends Model implements ProvisioningSubject $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 { 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); } + + /** 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); + } } diff --git a/app/Models/Instance.php b/app/Models/Instance.php new file mode 100644 index 0000000..78b7ef9 --- /dev/null +++ b/app/Models/Instance.php @@ -0,0 +1,72 @@ + */ + 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); + } +} diff --git a/app/Models/MonitoringTarget.php b/app/Models/MonitoringTarget.php new file mode 100644 index 0000000..742bd75 --- /dev/null +++ b/app/Models/MonitoringTarget.php @@ -0,0 +1,16 @@ +belongsTo(Instance::class); + } +} diff --git a/app/Models/OnboardingTask.php b/app/Models/OnboardingTask.php new file mode 100644 index 0000000..9f8fd9c --- /dev/null +++ b/app/Models/OnboardingTask.php @@ -0,0 +1,21 @@ + 'boolean', 'done_at' => 'datetime']; + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..8151a7b --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,48 @@ + */ + 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']); + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 0000000..6a108ac --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,22 @@ + */ +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', + ]; + } +} diff --git a/database/factories/InstanceFactory.php b/database/factories/InstanceFactory.php new file mode 100644 index 0000000..11233c7 --- /dev/null +++ b/database/factories/InstanceFactory.php @@ -0,0 +1,29 @@ + */ +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', + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 0000000..8cd8ee4 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,25 @@ + */ +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', + ]; + } +} diff --git a/database/migrations/2026_07_25_080001_create_customers_table.php b/database/migrations/2026_07_25_080001_create_customers_table.php new file mode 100644 index 0000000..36d5a75 --- /dev/null +++ b/database/migrations/2026_07_25_080001_create_customers_table.php @@ -0,0 +1,27 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_25_080002_create_orders_table.php b/database/migrations/2026_07_25_080002_create_orders_table.php new file mode 100644 index 0000000..bc8d8ba --- /dev/null +++ b/database/migrations/2026_07_25_080002_create_orders_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_25_080003_create_instances_table.php b/database/migrations/2026_07_25_080003_create_instances_table.php new file mode 100644 index 0000000..e4fc1bd --- /dev/null +++ b/database/migrations/2026_07_25_080003_create_instances_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_25_080004_create_dns_records_table.php b/database/migrations/2026_07_25_080004_create_dns_records_table.php new file mode 100644 index 0000000..f6bc2fe --- /dev/null +++ b/database/migrations/2026_07_25_080004_create_dns_records_table.php @@ -0,0 +1,27 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_25_080005_create_backups_table.php b/database/migrations/2026_07_25_080005_create_backups_table.php new file mode 100644 index 0000000..a1b0f90 --- /dev/null +++ b/database/migrations/2026_07_25_080005_create_backups_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_25_080006_create_monitoring_targets_table.php b/database/migrations/2026_07_25_080006_create_monitoring_targets_table.php new file mode 100644 index 0000000..1abd459 --- /dev/null +++ b/database/migrations/2026_07_25_080006_create_monitoring_targets_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_25_080007_create_onboarding_tasks_table.php b/database/migrations/2026_07_25_080007_create_onboarding_tasks_table.php new file mode 100644 index 0000000..2360411 --- /dev/null +++ b/database/migrations/2026_07_25_080007_create_onboarding_tasks_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/tests/Feature/Provisioning/CustomerDataModelTest.php b/tests/Feature/Provisioning/CustomerDataModelTest.php new file mode 100644 index 0000000..51880ba --- /dev/null +++ b/tests/Feature/Provisioning/CustomerDataModelTest.php @@ -0,0 +1,70 @@ +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(); +});