236 lines
8.7 KiB
PHP
236 lines
8.7 KiB
PHP
<?php
|
|
|
|
use App\Models\Host;
|
|
use App\Models\ProvisioningRun;
|
|
use App\Models\RunResource;
|
|
use App\Provisioning\Steps\Host\CompleteHostOnboarding;
|
|
use App\Provisioning\Steps\Host\ConfigureWireguard;
|
|
use App\Provisioning\Steps\Host\CreateAutomationToken;
|
|
use App\Provisioning\Steps\Host\EstablishSshTrust;
|
|
use App\Provisioning\Steps\Host\InstallProxmoxVe;
|
|
use App\Provisioning\Steps\Host\PrepareBaseSystem;
|
|
use App\Provisioning\Steps\Host\RebootIntoPveKernel;
|
|
use App\Provisioning\Steps\Host\RegisterCapacity;
|
|
use App\Provisioning\Steps\Host\ValidateHostInput;
|
|
use App\Provisioning\Steps\Host\VerifyProxmoxApi;
|
|
use App\Services\Ssh\CommandResult;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
|
|
function hostRun(Host $host, array $context = []): ProvisioningRun
|
|
{
|
|
return ProvisioningRun::factory()->forHost($host)->create(['context' => $context]);
|
|
}
|
|
|
|
// --- ValidateHostInput ---
|
|
|
|
it('advances on valid host input and marks the host onboarding', function () {
|
|
$host = Host::factory()->create();
|
|
$run = hostRun($host, ['root_password' => Crypt::encryptString('pw')]);
|
|
|
|
expect(app(ValidateHostInput::class)->execute($run)->type)->toBe('advance')
|
|
->and($host->fresh()->status)->toBe('onboarding');
|
|
});
|
|
|
|
it('fails validation when the root password is missing', function () {
|
|
$run = hostRun(Host::factory()->create(), []);
|
|
|
|
expect(app(ValidateHostInput::class)->execute($run)->type)->toBe('fail');
|
|
});
|
|
|
|
// --- EstablishSshTrust ---
|
|
|
|
it('establishes ssh trust, pins the host key and scrubs the password', function () {
|
|
$s = fakeServices();
|
|
$host = Host::factory()->create(['public_ip' => '203.0.113.5']);
|
|
$run = hostRun($host, ['root_password' => Crypt::encryptString('rootpw')]);
|
|
|
|
expect(app(EstablishSshTrust::class)->execute($run)->type)->toBe('advance')
|
|
->and($host->fresh()->ssh_host_key)->not->toBeNull()
|
|
->and($run->fresh()->context('root_password'))->toBeNull()
|
|
->and($s['shell']->connectionsWith('password'))->toHaveCount(1)
|
|
->and($s['shell']->connectionsWith('key'))->toHaveCount(1);
|
|
});
|
|
|
|
it('skips ssh trust once the host key is already pinned', function () {
|
|
$s = fakeServices();
|
|
$host = Host::factory()->create(['ssh_host_key' => 'SHA256:pinned']);
|
|
$run = hostRun($host, []);
|
|
|
|
expect(app(EstablishSshTrust::class)->execute($run)->type)->toBe('advance')
|
|
->and($s['shell']->connectionsWith('password'))->toHaveCount(0);
|
|
});
|
|
|
|
it('throws when the host is unreachable (runner turns it into a retry)', function () {
|
|
$s = fakeServices();
|
|
$s['shell']->failConnect = true;
|
|
$run = hostRun(Host::factory()->create(), ['root_password' => Crypt::encryptString('pw')]);
|
|
|
|
expect(fn () => app(EstablishSshTrust::class)->execute($run))->toThrow(RuntimeException::class);
|
|
});
|
|
|
|
// --- PrepareBaseSystem ---
|
|
|
|
it('prepares the base system', function () {
|
|
$s = fakeServices();
|
|
$run = hostRun(Host::factory()->create());
|
|
|
|
expect(app(PrepareBaseSystem::class)->execute($run)->type)->toBe('advance')
|
|
->and($s['shell']->ran('apt-get update'))->toBeTrue();
|
|
});
|
|
|
|
it('retries base preparation on an apt failure', function () {
|
|
$s = fakeServices();
|
|
$s['shell']->script('apt-get update', CommandResult::failure(1, 'network down'));
|
|
$run = hostRun(Host::factory()->create());
|
|
|
|
expect(app(PrepareBaseSystem::class)->execute($run)->type)->toBe('retry');
|
|
});
|
|
|
|
// --- ConfigureWireguard ---
|
|
|
|
it('configures wireguard and registers the peer exactly once', function () {
|
|
$s = fakeServices();
|
|
$s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB='));
|
|
$host = Host::factory()->create();
|
|
$run = hostRun($host);
|
|
|
|
expect(app(ConfigureWireguard::class)->execute($run)->type)->toBe('advance');
|
|
$host->refresh();
|
|
expect($host->wg_ip)->not->toBeNull()
|
|
->and($host->wg_pubkey)->toBe('HOSTPUB=')
|
|
->and($s['hub']->peers())->toHaveCount(1);
|
|
|
|
// Re-run: idempotent short-circuit, still one peer resource.
|
|
app(ConfigureWireguard::class)->execute($run->fresh());
|
|
expect(RunResource::where('run_id', $run->id)->where('kind', 'wg_peer')->count())->toBe(1);
|
|
});
|
|
|
|
it('retries wireguard while the handshake is not up', function () {
|
|
$s = fakeServices();
|
|
$s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB='));
|
|
$s['shell']->script('ping', CommandResult::failure(1));
|
|
$run = hostRun(Host::factory()->create());
|
|
|
|
expect(app(ConfigureWireguard::class)->execute($run)->type)->toBe('retry');
|
|
});
|
|
|
|
// --- InstallProxmoxVe ---
|
|
|
|
it('skips the install when proxmox-ve is already present', function () {
|
|
$s = fakeServices();
|
|
$s['shell']->script('dpkg -l proxmox-ve', CommandResult::success('ii proxmox-ve'));
|
|
$run = hostRun(Host::factory()->create());
|
|
|
|
expect(app(InstallProxmoxVe::class)->execute($run)->type)->toBe('advance')
|
|
->and($s['shell']->ran('apt-get -y install proxmox-ve'))->toBeFalse();
|
|
});
|
|
|
|
it('installs proxmox-ve when it is absent', function () {
|
|
$s = fakeServices();
|
|
$s['shell']->script('dpkg -l proxmox-ve', CommandResult::failure(1));
|
|
$run = hostRun(Host::factory()->create());
|
|
|
|
expect(app(InstallProxmoxVe::class)->execute($run)->type)->toBe('advance')
|
|
->and($s['shell']->ran('apt-get -y install proxmox-ve'))->toBeTrue();
|
|
});
|
|
|
|
// --- RebootIntoPveKernel ---
|
|
|
|
it('issues the reboot on first execution', function () {
|
|
$s = fakeServices();
|
|
$run = hostRun(Host::factory()->create());
|
|
|
|
expect(app(RebootIntoPveKernel::class)->execute($run)->type)->toBe('retry')
|
|
->and($run->fresh()->context('reboot_issued'))->toBeTrue()
|
|
->and($s['shell']->ran('reboot'))->toBeTrue();
|
|
});
|
|
|
|
it('advances once the proxmox kernel is up', function () {
|
|
$s = fakeServices();
|
|
$s['shell']->script('uname -r', CommandResult::success('6.8.12-4-pve'));
|
|
$run = hostRun(Host::factory()->create(), [
|
|
'reboot_issued' => true,
|
|
'reboot_deadline' => now()->addMinutes(10)->toIso8601String(),
|
|
]);
|
|
|
|
expect(app(RebootIntoPveKernel::class)->execute($run)->type)->toBe('advance');
|
|
});
|
|
|
|
it('keeps waiting while the old kernel is still running', function () {
|
|
$s = fakeServices();
|
|
$s['shell']->script('uname -r', CommandResult::success('6.1.0-18-amd64'));
|
|
$run = hostRun(Host::factory()->create(), [
|
|
'reboot_issued' => true,
|
|
'reboot_deadline' => now()->addMinutes(10)->toIso8601String(),
|
|
]);
|
|
|
|
expect(app(RebootIntoPveKernel::class)->execute($run)->type)->toBe('retry');
|
|
});
|
|
|
|
it('fails when the host does not return before the reboot deadline', function () {
|
|
$s = fakeServices();
|
|
$s['shell']->script('uname -r', CommandResult::success('6.1.0-18-amd64'));
|
|
$run = hostRun(Host::factory()->create(), [
|
|
'reboot_issued' => true,
|
|
'reboot_deadline' => now()->subMinute()->toIso8601String(),
|
|
]);
|
|
|
|
expect(app(RebootIntoPveKernel::class)->execute($run)->type)->toBe('fail');
|
|
});
|
|
|
|
// --- CreateAutomationToken ---
|
|
|
|
it('creates and persists the automation token exactly once', function () {
|
|
$s = fakeServices();
|
|
$host = Host::factory()->create();
|
|
$run = hostRun($host);
|
|
|
|
expect(app(CreateAutomationToken::class)->execute($run)->type)->toBe('advance');
|
|
$host->refresh();
|
|
expect($host->api_token_ref)->toContain('automation@pve')
|
|
->and($host->api_token_ref)->toContain('fake-secret')
|
|
->and($s['pve']->tokenCalls)->toBe(1);
|
|
|
|
// Re-run: idempotent short-circuit, no second token call.
|
|
app(CreateAutomationToken::class)->execute($run->fresh());
|
|
expect($s['pve']->tokenCalls)->toBe(1)
|
|
->and(RunResource::where('run_id', $run->id)->where('kind', 'pve_token')->count())->toBe(1);
|
|
});
|
|
|
|
// --- VerifyProxmoxApi ---
|
|
|
|
it('advances when the api returns nodes', function () {
|
|
fakeServices();
|
|
expect(app(VerifyProxmoxApi::class)->execute(hostRun(Host::factory()->create()))->type)->toBe('advance');
|
|
});
|
|
|
|
it('retries when the api returns no nodes yet', function () {
|
|
$s = fakeServices();
|
|
$s['pve']->nodes = [];
|
|
expect(app(VerifyProxmoxApi::class)->execute(hostRun(Host::factory()->create()))->type)->toBe('retry');
|
|
});
|
|
|
|
// --- RegisterCapacity ---
|
|
|
|
it('registers node capacity on the host', function () {
|
|
fakeServices();
|
|
$host = Host::factory()->create();
|
|
$run = hostRun($host);
|
|
|
|
expect(app(RegisterCapacity::class)->execute($run)->type)->toBe('advance');
|
|
$host->refresh();
|
|
expect($host->cpu_cores)->toBe(16)
|
|
->and($host->total_ram_mb)->toBe(65536)
|
|
->and($host->total_gb)->toBe(1024)
|
|
->and($host->last_seen_at)->not->toBeNull();
|
|
});
|
|
|
|
// --- CompleteHostOnboarding ---
|
|
|
|
it('marks the host active on completion', function () {
|
|
$host = Host::factory()->create(['status' => 'onboarding']);
|
|
|
|
expect(app(CompleteHostOnboarding::class)->execute(hostRun($host))->type)->toBe('advance')
|
|
->and($host->fresh()->status)->toBe('active');
|
|
});
|