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
parent
7ae81d8127
commit
11cdcdd4da
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue