From 11cdcdd4dafc49175c6112a865bcdafcd6d26b5b Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 09:49:43 +0200 Subject: [PATCH] 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 --- app/Models/Concerns/HasUuid.php | 26 +++++++ app/Models/Host.php | 51 ++++++++++++ app/Models/ProvisioningRun.php | 78 +++++++++++++++++++ app/Models/ProvisioningStepEvent.php | 21 +++++ app/Models/RunResource.php | 27 +++++++ database/factories/HostFactory.php | 34 ++++++++ database/factories/ProvisioningRunFactory.php | 35 +++++++++ .../2026_07_25_070001_create_hosts_table.php | 38 +++++++++ ..._070002_create_provisioning_runs_table.php | 37 +++++++++ ..._create_provisioning_step_events_table.php | 30 +++++++ ...7_25_070004_create_run_resources_table.php | 29 +++++++ tests/Feature/Provisioning/DataModelTest.php | 69 ++++++++++++++++ 12 files changed, 475 insertions(+) create mode 100644 app/Models/Concerns/HasUuid.php create mode 100644 app/Models/Host.php create mode 100644 app/Models/ProvisioningRun.php create mode 100644 app/Models/ProvisioningStepEvent.php create mode 100644 app/Models/RunResource.php create mode 100644 database/factories/HostFactory.php create mode 100644 database/factories/ProvisioningRunFactory.php create mode 100644 database/migrations/2026_07_25_070001_create_hosts_table.php create mode 100644 database/migrations/2026_07_25_070002_create_provisioning_runs_table.php create mode 100644 database/migrations/2026_07_25_070003_create_provisioning_step_events_table.php create mode 100644 database/migrations/2026_07_25_070004_create_run_resources_table.php create mode 100644 tests/Feature/Provisioning/DataModelTest.php diff --git a/app/Models/Concerns/HasUuid.php b/app/Models/Concerns/HasUuid.php new file mode 100644 index 0000000..70c3613 --- /dev/null +++ b/app/Models/Concerns/HasUuid.php @@ -0,0 +1,26 @@ +uuid)) { + $model->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } +} diff --git a/app/Models/Host.php b/app/Models/Host.php new file mode 100644 index 0000000..f288b0d --- /dev/null +++ b/app/Models/Host.php @@ -0,0 +1,51 @@ + */ + 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); + } +} diff --git a/app/Models/ProvisioningRun.php b/app/Models/ProvisioningRun.php new file mode 100644 index 0000000..a5f53fc --- /dev/null +++ b/app/Models/ProvisioningRun.php @@ -0,0 +1,78 @@ + */ + 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(); + } +} diff --git a/app/Models/ProvisioningStepEvent.php b/app/Models/ProvisioningStepEvent.php new file mode 100644 index 0000000..788ae60 --- /dev/null +++ b/app/Models/ProvisioningStepEvent.php @@ -0,0 +1,21 @@ +belongsTo(ProvisioningRun::class, 'run_id'); + } +} diff --git a/app/Models/RunResource.php b/app/Models/RunResource.php new file mode 100644 index 0000000..afdf7c1 --- /dev/null +++ b/app/Models/RunResource.php @@ -0,0 +1,27 @@ +belongsTo(ProvisioningRun::class, 'run_id'); + } + + public function host(): BelongsTo + { + return $this->belongsTo(Host::class, 'host_id'); + } +} diff --git a/database/factories/HostFactory.php b/database/factories/HostFactory.php new file mode 100644 index 0000000..f58ea12 --- /dev/null +++ b/database/factories/HostFactory.php @@ -0,0 +1,34 @@ + */ +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, + ]); + } +} diff --git a/database/factories/ProvisioningRunFactory.php b/database/factories/ProvisioningRunFactory.php new file mode 100644 index 0000000..9a272c9 --- /dev/null +++ b/database/factories/ProvisioningRunFactory.php @@ -0,0 +1,35 @@ + */ +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, + ]); + } +} diff --git a/database/migrations/2026_07_25_070001_create_hosts_table.php b/database/migrations/2026_07_25_070001_create_hosts_table.php new file mode 100644 index 0000000..3ddf29e --- /dev/null +++ b/database/migrations/2026_07_25_070001_create_hosts_table.php @@ -0,0 +1,38 @@ +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!= + $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'); + } +}; diff --git a/database/migrations/2026_07_25_070002_create_provisioning_runs_table.php b/database/migrations/2026_07_25_070002_create_provisioning_runs_table.php new file mode 100644 index 0000000..27ca642 --- /dev/null +++ b/database/migrations/2026_07_25_070002_create_provisioning_runs_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_25_070003_create_provisioning_step_events_table.php b/database/migrations/2026_07_25_070003_create_provisioning_step_events_table.php new file mode 100644 index 0000000..3df1717 --- /dev/null +++ b/database/migrations/2026_07_25_070003_create_provisioning_step_events_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_25_070004_create_run_resources_table.php b/database/migrations/2026_07_25_070004_create_run_resources_table.php new file mode 100644 index 0000000..d13d4a0 --- /dev/null +++ b/database/migrations/2026_07_25_070004_create_run_resources_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/tests/Feature/Provisioning/DataModelTest.php b/tests/Feature/Provisioning/DataModelTest.php new file mode 100644 index 0000000..140a2c9 --- /dev/null +++ b/tests/Feature/Provisioning/DataModelTest.php @@ -0,0 +1,69 @@ +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); +});