feat(engine): core provisioning data model + hosts

hosts, provisioning_runs (polymorphic), append-only provisioning_step_events,
run_resources (idempotency breadcrumbs). Models with encrypted api_token_ref,
json context helpers, UUID routing (R11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 09:49:43 +02:00
parent 7ae81d8127
commit 11cdcdd4da
12 changed files with 475 additions and 0 deletions

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Str;
/**
* Assigns a random UUID on creation. Records that expose a `uuid` column use it
* as their route key (R11: URLs address records by UUID, not integer PK).
*/
trait HasUuid
{
protected static function bootHasUuid(): void
{
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

51
app/Models/Host.php Normal file
View File

@ -0,0 +1,51 @@
<?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\MorphMany;
class Host extends Model
{
/** @use HasFactory<\Database\Factories\HostFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey',
'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores',
'cpu_weight', 'reserve_pct', 'pve_version', 'status', 'last_seen_at',
];
protected $hidden = ['api_token_ref'];
protected function casts(): array
{
return [
'api_token_ref' => 'encrypted',
'last_seen_at' => 'datetime',
'total_gb' => 'integer',
'total_ram_mb' => 'integer',
'cpu_cores' => 'integer',
'cpu_weight' => 'integer',
'reserve_pct' => 'integer',
];
}
/** Placement runs (polymorphic subject). */
public function runs(): MorphMany
{
return $this->morphMany(ProvisioningRun::class, 'subject');
}
/** Free committable storage: total minus reserve. Instance quotas subtract in B. */
public function freeGb(): int
{
if ($this->total_gb === null) {
return 0;
}
return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100);
}
}

View File

@ -0,0 +1,78 @@
<?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;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ProvisioningRun extends Model
{
/** @use HasFactory<\Database\Factories\ProvisioningRunFactory> */
use HasFactory, HasUuid;
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'running';
public const STATUS_WAITING = 'waiting';
public const STATUS_PAUSED = 'paused';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'subject_type', 'subject_id', 'pipeline', 'current_step', 'status',
'attempt', 'max_attempts', 'next_attempt_at', 'started_at', 'finished_at',
'error', 'context',
];
protected function casts(): array
{
return [
'context' => 'array',
'next_attempt_at' => 'datetime',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'current_step' => 'integer',
'attempt' => 'integer',
'max_attempts' => 'integer',
];
}
public function subject(): MorphTo
{
return $this->morphTo();
}
public function events(): HasMany
{
return $this->hasMany(ProvisioningStepEvent::class, 'run_id');
}
public function resources(): HasMany
{
return $this->hasMany(RunResource::class, 'run_id');
}
/** Read a single key from the json context. */
public function context(string $key, mixed $default = null): mixed
{
return data_get($this->context, $key, $default);
}
/** Merge values into the json context and persist. */
public function mergeContext(array $values): void
{
$this->context = array_merge($this->context ?? [], $values);
$this->save();
}
/** Remove a key from the json context and persist (e.g. scrubbing a secret). */
public function forgetContext(string $key): void
{
$context = $this->context ?? [];
unset($context[$key]);
$this->context = $context;
$this->save();
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Append-only run event. Eloquent manages created_at only (no updated_at).
*/
class ProvisioningStepEvent extends Model
{
public const UPDATED_AT = null;
protected $fillable = ['run_id', 'step', 'attempt', 'outcome', 'message', 'external_ref'];
public function run(): BelongsTo
{
return $this->belongsTo(ProvisioningRun::class, 'run_id');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Idempotency breadcrumb: an external resource created during a run (wg peer,
* pve token, vmid ). Persisted before the creating step advances.
*/
class RunResource extends Model
{
public const UPDATED_AT = null;
protected $fillable = ['run_id', 'host_id', 'kind', 'external_id'];
public function run(): BelongsTo
{
return $this->belongsTo(ProvisioningRun::class, 'run_id');
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class, 'host_id');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use App\Models\Host;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Host> */
class HostFactory extends Factory
{
protected $model = Host::class;
public function definition(): array
{
return [
'name' => 'pve-test-'.$this->faker->unique()->numberBetween(1, 99999),
'datacenter' => 'fsn',
'public_ip' => $this->faker->ipv4(),
'status' => 'pending',
];
}
public function active(): static
{
return $this->state(fn () => [
'status' => 'active',
'wg_ip' => '10.66.0.'.$this->faker->numberBetween(2, 250),
'total_gb' => 1000,
'total_ram_mb' => 65536,
'cpu_cores' => 16,
'cpu_weight' => 16,
]);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Models\Host;
use App\Models\ProvisioningRun;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<ProvisioningRun> */
class ProvisioningRunFactory extends Factory
{
protected $model = ProvisioningRun::class;
public function definition(): array
{
return [
'subject_type' => Host::class,
'subject_id' => Host::factory(),
'pipeline' => 'host',
'current_step' => 0,
'status' => ProvisioningRun::STATUS_PENDING,
'attempt' => 0,
'max_attempts' => 5,
'context' => [],
];
}
public function forHost(Host $host): static
{
return $this->state(fn () => [
'subject_type' => Host::class,
'subject_id' => $host->id,
]);
}
}

View File

@ -0,0 +1,38 @@
<?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('hosts', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('name');
$table->string('datacenter'); // placement filter, e.g. fsn, hel
$table->string('cluster')->nullable(); // v1.0 always null (growth goal)
$table->string('public_ip'); // first SSH login
$table->string('wg_ip')->nullable(); // management address (Proxmox API)
$table->string('wg_pubkey')->nullable(); // host WG peer public key
$table->text('ssh_host_key')->nullable(); // fingerprint pinning
$table->text('api_token_ref')->nullable(); // encrypted: automation@pve!<id>=<secret>
$table->unsignedBigInteger('total_gb')->nullable();
$table->unsignedBigInteger('total_ram_mb')->nullable();
$table->unsignedInteger('cpu_cores')->nullable();
$table->unsignedInteger('cpu_weight')->nullable();
$table->unsignedInteger('reserve_pct')->default(15);
$table->string('pve_version')->nullable();
$table->string('status')->default('pending'); // pending, onboarding, active, error, disabled
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('hosts');
}
};

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('provisioning_runs', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('subject_type'); // polymorphic subject (Host | Order …)
$table->unsignedBigInteger('subject_id');
$table->string('pipeline'); // host | customer
$table->unsignedInteger('current_step')->default(0);
$table->string('status')->default('pending'); // pending running waiting paused completed failed
$table->unsignedInteger('attempt')->default(0);
$table->unsignedInteger('max_attempts')->default(5);
$table->timestamp('next_attempt_at')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->text('error')->nullable();
$table->json('context')->nullable();
$table->timestamps();
$table->index(['subject_type', 'subject_id']);
$table->index(['status', 'next_attempt_at']);
});
}
public function down(): void
{
Schema::dropIfExists('provisioning_runs');
}
};

View File

@ -0,0 +1,30 @@
<?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
{
// Append-only log: created_at only, no updated_at.
Schema::create('provisioning_step_events', function (Blueprint $table) {
$table->id();
$table->foreignId('run_id')->constrained('provisioning_runs')->cascadeOnDelete();
$table->string('step');
$table->unsignedInteger('attempt')->default(0);
$table->string('outcome'); // advanced | retry | failed | info
$table->text('message')->nullable();
$table->string('external_ref')->nullable();
$table->timestamp('created_at')->nullable();
$table->index('run_id');
});
}
public function down(): void
{
Schema::dropIfExists('provisioning_step_events');
}
};

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
{
// Idempotency breadcrumbs: external IDs persisted before a step advances.
// One resource per (run, kind) — enforces "created exactly once" on re-run.
Schema::create('run_resources', function (Blueprint $table) {
$table->id();
$table->foreignId('run_id')->constrained('provisioning_runs')->cascadeOnDelete();
$table->foreignId('host_id')->nullable()->constrained('hosts')->nullOnDelete();
$table->string('kind'); // wg_peer | pve_token | vmid | …
$table->string('external_id');
$table->timestamp('created_at')->nullable();
$table->unique(['run_id', 'kind']);
});
}
public function down(): void
{
Schema::dropIfExists('run_resources');
}
};

View File

@ -0,0 +1,69 @@
<?php
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Models\RunResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
it('auto-assigns a uuid and routes by it', function () {
$host = Host::factory()->create();
expect($host->uuid)->not->toBeNull()
->and($host->getRouteKeyName())->toBe('uuid');
});
it('encrypts api_token_ref at rest but reads it back in clear', function () {
$host = Host::factory()->create(['api_token_ref' => 'automation@pve!clupilot=s3cr3t']);
$raw = DB::table('hosts')->where('id', $host->id)->value('api_token_ref');
expect($raw)->not->toContain('s3cr3t')
->and($host->fresh()->api_token_ref)->toBe('automation@pve!clupilot=s3cr3t');
});
it('casts run context to an array and resolves the polymorphic subject', function () {
$host = Host::factory()->create();
$run = ProvisioningRun::factory()->forHost($host)->create(['context' => ['reboot_issued' => true]]);
expect($run->fresh()->context)->toBe(['reboot_issued' => true])
->and($run->fresh()->subject->is($host))->toBeTrue();
});
it('merges and forgets context keys', function () {
$run = ProvisioningRun::factory()->create(['context' => ['root_password' => 'x']]);
$run->mergeContext(['wg_ip' => '10.66.0.5']);
expect($run->fresh()->context)->toBe(['root_password' => 'x', 'wg_ip' => '10.66.0.5']);
$run->forgetContext('root_password');
expect($run->fresh()->context)->toBe(['wg_ip' => '10.66.0.5']);
});
it('logs append-only step events without an updated_at column', function () {
$run = ProvisioningRun::factory()->create();
$event = $run->events()->create(['step' => 'validate_host_input', 'attempt' => 0, 'outcome' => 'advanced']);
expect($event->created_at)->not->toBeNull()
->and(Schema::hasColumn('provisioning_step_events', 'updated_at'))->toBeFalse();
});
it('keeps exactly one run_resource per kind on re-run (idempotency)', function () {
$host = Host::factory()->create();
$run = ProvisioningRun::factory()->forHost($host)->create();
foreach (range(1, 2) as $_) {
RunResource::firstOrCreate(
['run_id' => $run->id, 'kind' => 'wg_peer'],
['host_id' => $host->id, 'external_id' => 'pubkeyAAA'],
);
}
expect(RunResource::where('run_id', $run->id)->where('kind', 'wg_peer')->count())->toBe(1);
});
it('computes free committable storage from total minus reserve', function () {
$host = Host::factory()->create(['total_gb' => 1000, 'reserve_pct' => 15]);
expect($host->freeGb())->toBe(850);
});