201 lines
8.3 KiB
PHP
201 lines
8.3 KiB
PHP
<?php
|
|
|
|
use App\Models\Host;
|
|
use App\Provisioning\Jobs\RemoveWireguardPeer;
|
|
use App\Services\Dns\FileHostDnsDirectory;
|
|
use App\Services\Dns\HttpHetznerDnsClient;
|
|
use App\Services\Proxmox\FakeProxmoxClient;
|
|
use App\Services\Ssh\CommandResult;
|
|
use App\Services\Ssh\FakeRemoteShell;
|
|
use App\Services\Wireguard\FakeWireguardHub;
|
|
use App\Services\Wireguard\LocalWireguardHub;
|
|
use App\Support\ProvisioningSettings;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
it('scripts remote command output and records calls (FakeRemoteShell)', function () {
|
|
$shell = new FakeRemoteShell;
|
|
$shell->script('dpkg -l proxmox-ve', CommandResult::success('ii proxmox-ve 8.2'));
|
|
$shell->connectWithPassword('1.2.3.4', 'root', 'pw');
|
|
|
|
expect($shell->run('dpkg -l proxmox-ve')->stdout)->toContain('proxmox-ve')
|
|
->and($shell->run('whoami')->ok())->toBeTrue() // default success
|
|
->and($shell->ran('dpkg -l proxmox-ve'))->toBeTrue();
|
|
|
|
$shell->putFile('/etc/wireguard/wg0.conf', '[Interface]');
|
|
expect($shell->files()['/etc/wireguard/wg0.conf'])->toBe('[Interface]')
|
|
->and($shell->connectionsWith('password'))->toHaveCount(1);
|
|
});
|
|
|
|
it('removes a peer via the RemoveWireguardPeer job', function () {
|
|
$hub = new FakeWireguardHub;
|
|
$hub->addPeer('PK', '10.66.0.9');
|
|
|
|
(new RemoveWireguardPeer('PK'))->handle($hub);
|
|
|
|
expect($hub->peerIps())->toBe([]);
|
|
});
|
|
|
|
it('provides a deterministic VM lifecycle (FakeProxmoxClient)', function () {
|
|
$pve = new FakeProxmoxClient;
|
|
|
|
$a = $pve->nextVmid();
|
|
$b = $pve->nextVmid();
|
|
expect($b)->toBe($a + 1);
|
|
|
|
$upid = $pve->cloneVm('pve', 9000, $a, 'nc-test');
|
|
expect($upid)->toContain((string) $a)
|
|
->and($pve->clonedVmids)->toContain($a)
|
|
->and($pve->taskStatus('pve', $upid))->toBe(['status' => 'stopped', 'exitstatus' => 'OK'])
|
|
->and($pve->vmStatus('pve', $a)['status'])->toBe('stopped');
|
|
|
|
$pve->startVm('pve', $a);
|
|
expect($pve->vmStatus('pve', $a)['status'])->toBe('running')
|
|
->and($pve->guestAgentPing('pve', $a))->toBeTrue();
|
|
|
|
$pve->guestScript('occ status', 0, 'installed: true');
|
|
expect($pve->guestExec('pve', $a, 'occ status')['out-data'])->toContain('installed')
|
|
->and($pve->guestExec('pve', $a, 'whoami')['exitcode'])->toBe(0)
|
|
->and($pve->guestRan('occ status'))->toBeTrue();
|
|
});
|
|
|
|
it('can force a running task and a failed exit status (FakeProxmoxClient)', function () {
|
|
$pve = new FakeProxmoxClient;
|
|
|
|
$pve->forceTaskStatus = 'running';
|
|
expect($pve->taskStatus('pve', 'UPID')['status'])->toBe('running');
|
|
|
|
$pve->forceTaskStatus = null;
|
|
$pve->taskExitStatus = 'clone failed';
|
|
expect($pve->taskStatus('pve', 'UPID')['exitstatus'])->toBe('clone failed');
|
|
});
|
|
|
|
it('allocates ips and tracks peers (FakeWireguardHub)', function () {
|
|
$hub = new FakeWireguardHub;
|
|
|
|
$a = $hub->allocateIp();
|
|
$b = $hub->allocateIp();
|
|
expect($a)->not->toBe($b);
|
|
|
|
$hub->addPeer('PUBKEY', '10.66.0.9');
|
|
expect($hub->peerIps())->toBe(['PUBKEY' => '10.66.0.9']);
|
|
|
|
$hub->removePeer('PUBKEY');
|
|
expect($hub->peerIps())->toBe([]);
|
|
});
|
|
|
|
it('allocates addresses within a /24 subnet, skipping hub and used (LocalWireguardHub)', function () {
|
|
config()->set('provisioning.wireguard.subnet', '10.66.0.0/24');
|
|
config()->set('provisioning.wireguard.hub_ip', '10.66.0.1');
|
|
Host::factory()->create(['wg_ip' => '10.66.0.2']);
|
|
|
|
// .0 network, .1 hub, .2 used → first free is .3
|
|
expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.3');
|
|
});
|
|
|
|
it('respects a non-/24 subnet (LocalWireguardHub)', function () {
|
|
config()->set('provisioning.wireguard.subnet', '10.66.5.128/25');
|
|
config()->set('provisioning.wireguard.hub_ip', '10.66.5.129');
|
|
|
|
// network .128, hub .129 → first free is .130
|
|
expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.5.130');
|
|
});
|
|
|
|
it('reports node capacity for a host (FakeProxmoxClient)', function () {
|
|
$host = Host::factory()->create(['wg_ip' => '10.66.0.5']);
|
|
$client = (new FakeProxmoxClient)->forHost($host);
|
|
|
|
expect($client->listNodes())->not->toBeEmpty()
|
|
->and($client->nodeStatus('pve')['memory']['total'])->toBeGreaterThan(0)
|
|
->and($client->nodeStorage('pve')[0]['total'])->toBeGreaterThan(0);
|
|
});
|
|
|
|
it('treats an already-deleted DNS record as done', function () {
|
|
Http::fake([
|
|
'*' => Http::response(['error' => 'not found'], 404),
|
|
]);
|
|
|
|
// A lost response, or a retry after a later step failed, must not make the
|
|
// record impossible to clean up — and its host impossible to purge.
|
|
(new HttpHetznerDnsClient)->deleteRecord('rec-123');
|
|
})->throwsNoExceptions();
|
|
|
|
it('finds an existing DNS record past the first page instead of creating a second one', function () {
|
|
// Hetzner pages `GET /records` at a hundred entries, and this asked only for
|
|
// the first page. Past a hundred records the lookup missed an entry that was
|
|
// plainly there, fell through to POST, and Hetzner accepted a SECOND A record
|
|
// for the same name: two addresses round-robined for one instance, one of them
|
|
// a host the machine is not on, so the cloud was up about half the time. The
|
|
// `address` and `plan-change` pipelines upsert on every run, so it compounded.
|
|
$filler = fn (int $from) => collect(range($from, $from + 99))
|
|
->map(fn (int $i) => ['id' => 'rec-'.$i, 'name' => 'other-'.$i, 'type' => 'A'])
|
|
->all();
|
|
|
|
// Matched by reading the page out of the query rather than with a URL glob:
|
|
// `page=1` is a substring of `per_page=100`, so a pattern would answer page
|
|
// two with page one's records and the test would pass against the bug.
|
|
Http::fake(function ($request) use ($filler) {
|
|
parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);
|
|
|
|
if (str_contains($request->url(), '/zones')) {
|
|
return Http::response(['zones' => [['id' => 'zone-1']]]);
|
|
}
|
|
|
|
if ($request->method() === 'GET') {
|
|
$page = (int) ($query['page'] ?? 1);
|
|
|
|
return Http::response([
|
|
'records' => $page === 2
|
|
? array_merge($filler(101), [['id' => 'rec-berger', 'name' => 'berger', 'type' => 'A']])
|
|
: $filler(1),
|
|
'meta' => ['pagination' => ['page' => $page, 'per_page' => 100, 'last_page' => 2]],
|
|
]);
|
|
}
|
|
|
|
// A POST is the bug: the record exists and has to be updated.
|
|
return Http::response(['record' => ['id' => 'rec-new']], 201);
|
|
});
|
|
|
|
$id = (new HttpHetznerDnsClient)->upsertRecord(
|
|
'berger.'.ProvisioningSettings::dnsZone(), 'A', '203.0.113.9'
|
|
);
|
|
|
|
expect($id)->toBe('rec-berger');
|
|
Http::assertSent(fn ($request) => $request->method() === 'PUT'
|
|
&& str_contains($request->url(), '/records/rec-berger'));
|
|
Http::assertNotSent(fn ($request) => $request->method() === 'POST');
|
|
});
|
|
|
|
it('writes and removes a real hostsdir entry on disk (FileHostDnsDirectory)', function () {
|
|
$dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid();
|
|
config()->set('provisioning.dns.hosts_dir', $dir);
|
|
$writer = new FileHostDnsDirectory;
|
|
|
|
// The directory itself is created on first write — dnsmasq --hostsdir
|
|
// only needs it to exist, not to be pre-provisioned by anything else.
|
|
$writer->write('fsn-01', 'fsn-01.node.clupilot.com', '10.66.0.11');
|
|
$path = $dir.'/fsn-01.hosts';
|
|
|
|
expect(is_file($path))->toBeTrue()
|
|
->and(trim(file_get_contents($path)))->toBe('10.66.0.11 fsn-01.node.clupilot.com');
|
|
|
|
// Overwrite is the update path — RegisterHostDns calls write() again on
|
|
// retry with the same name, and that must replace, not append.
|
|
$writer->write('fsn-01', 'fsn-01.node.clupilot.com', '10.66.0.99');
|
|
expect(trim(file_get_contents($path)))->toBe('10.66.0.99 fsn-01.node.clupilot.com');
|
|
|
|
$writer->remove('fsn-01');
|
|
expect(is_file($path))->toBeFalse();
|
|
|
|
File::deleteDirectory($dir);
|
|
});
|
|
|
|
it('treats removing a name that was never written as done (FileHostDnsDirectory)', function () {
|
|
$dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid();
|
|
config()->set('provisioning.dns.hosts_dir', $dir);
|
|
|
|
// Mirrors PurgeHost's guard (filled($host->dns_name)) not always holding
|
|
// — a host that never got a name still has to purge cleanly.
|
|
(new FileHostDnsDirectory)->remove('never-written');
|
|
})->throwsNoExceptions();
|