mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-31 19:03:43 +01:00
209 lines
10 KiB
PHP
209 lines
10 KiB
PHP
<?php
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use XcVm\Cli\Commands\ServerEnrolCommand;
|
|
use XcVm\Core\Config\SettingsManager;
|
|
use XcVm\Domain\Cluster\ClusterClock;
|
|
use XcVm\Domain\Cluster\NodeRegistry;
|
|
use XcVm\Infrastructure\Database\DatabaseFactory;
|
|
use XcVm\Tests\Support\FakeClusterCrypto;
|
|
use XcVm\Tests\Support\FakeSshFleet;
|
|
|
|
/**
|
|
* ServerEnrolCommand::enrol() — server:enrol's path for one node, which
|
|
* cluster:reenrol runs node by node. The SSH layer is faked (FakeSshFleet).
|
|
* The host key is checked before the password is sent and never trusted on
|
|
* first use; a node that fails keeps its identity; the reason comes back to
|
|
* the caller; the session is always closed.
|
|
*/
|
|
final class ServerEnrolCommandTest extends TestCase {
|
|
private TestDb $rDb;
|
|
|
|
private FakeClusterCrypto $rCrypto;
|
|
|
|
private FakeSshFleet $rSsh;
|
|
|
|
public static function setUpBeforeClass(): void {
|
|
foreach (['SERVER_ID' => 1, 'TMP_PATH' => sys_get_temp_dir() . '/xcvm-test-tmp/', 'CONFIG_PATH' => sys_get_temp_dir() . '/xcvm-test-config/'] as $rName => $rValue) {
|
|
if (!defined($rName)) {
|
|
define($rName, $rValue);
|
|
}
|
|
}
|
|
@mkdir(TMP_PATH, 0700, true);
|
|
}
|
|
|
|
protected function setUp(): void {
|
|
$this->rDb = new TestDb();
|
|
foreach (['029_create_cluster_nodes', '032_create_cluster_audit'] as $rName) {
|
|
$rSql = (string) file_get_contents(MAIN_HOME . 'migrations/database/up/' . $rName . '.sql');
|
|
$this->rDb->exec($rSql);
|
|
}
|
|
$this->rDb->exec('ALTER TABLE `cluster_node_epochs` ADD COLUMN `agent_eph_pub` binary(32) DEFAULT NULL');
|
|
$this->rDb->exec('CREATE TABLE `servers` (`id` INTEGER PRIMARY KEY AUTO_INCREMENT, `status` int NOT NULL DEFAULT 1, `ssh_hostkey_sha1` char(40) DEFAULT NULL)');
|
|
$this->rDb->exec("INSERT INTO `servers` (`id`, `ssh_hostkey_sha1`) VALUES (7, '" . sha1('host-7') . "')");
|
|
DatabaseFactory::set($this->rDb);
|
|
SettingsManager::set(['cluster_api_enabled' => 1, 'lb_token_rotation_min' => 60, 'lb_new_node_mode' => 'legacy']);
|
|
ClusterClock::fix(null);
|
|
$this->rCrypto = new FakeClusterCrypto();
|
|
$this->rSsh = new FakeSshFleet();
|
|
$this->rSsh->rNodes['10.0.0.7'] = ['hostkey' => sha1('host-7'), 'password' => 'pw'];
|
|
}
|
|
|
|
protected function tearDown(): void {
|
|
DatabaseFactory::reset();
|
|
SettingsManager::set([]);
|
|
}
|
|
|
|
/** MAIN #1, the LB #7 (host key stored at its install) and the proxy #4. */
|
|
private function servers(?string $rStored = null): array {
|
|
return [
|
|
SERVER_ID => ['id' => SERVER_ID, 'is_main' => 1, 'server_type' => 0, 'server_ip' => '10.0.0.1', 'http_broadcast_port' => 25461, 'enable_https' => 0],
|
|
4 => ['id' => 4, 'is_main' => 0, 'server_type' => 1, 'server_ip' => '10.0.0.4'],
|
|
7 => ['id' => 7, 'is_main' => 0, 'server_type' => 0, 'server_ip' => '10.0.0.7', 'ssh_hostkey_sha1' => $rStored ?? sha1('host-7')],
|
|
];
|
|
}
|
|
|
|
/** @return array{0: string|null, 1: string} [enrol()'s answer, what it printed] */
|
|
private function enrol(array $rServers, int $rServerID = 7, string $rPassword = 'pw', ?string $rExpected = null): array {
|
|
ob_start();
|
|
try {
|
|
$rWhy = ServerEnrolCommand::enrol($rServers, $rServerID, 22, ['username' => 'root', 'password' => $rPassword], $rExpected, $this->rCrypto, $this->rSsh);
|
|
} finally {
|
|
$rOut = (string) ob_get_clean();
|
|
}
|
|
return [$rWhy, $rOut];
|
|
}
|
|
|
|
private function storedHostKey(): ?string {
|
|
$this->rDb->query('SELECT `ssh_hostkey_sha1` FROM `servers` WHERE `id` = 7');
|
|
return $this->rDb->get_row()['ssh_hostkey_sha1'];
|
|
}
|
|
|
|
public function testEnrolsTheNodeOverSsh(): void {
|
|
[$rWhy, $rOut] = $this->enrol($this->servers());
|
|
$this->assertNull($rWhy, $rOut);
|
|
$this->assertSame('enrolling', NodeRegistry::byServer(7)['state']);
|
|
$this->assertSame(['connect 10.0.0.7:22', 'login 10.0.0.7 root'], array_slice($this->rSsh->rLog, 0, 2), 'the host key is checked before the password is sent');
|
|
$rCommands = $this->rSsh->commands('10.0.0.7');
|
|
$this->assertStringContainsString('echo READY', $rCommands[0], 'the release is checked first');
|
|
$this->assertStringContainsString('bin/xc_agent/run.sh', end($rCommands), 'the agent is started last');
|
|
$this->assertSame('close 10.0.0.7', end($this->rSsh->rLog));
|
|
$this->assertSame(1, substr_count($rOut, 'Node enrolled (uuid '), 'the flow prints once');
|
|
}
|
|
|
|
public function testEveryFileTestOnTheNodeRunsAsRoot(): void {
|
|
// bin/ is 0750 again after an update, config/ always: a sudoer who is not root sees into neither.
|
|
[$rWhy, $rOut] = $this->enrol($this->servers());
|
|
$this->assertNull($rWhy, $rOut);
|
|
foreach ($this->rSsh->commands('10.0.0.7') as $rCommand) {
|
|
$this->assertDoesNotMatchRegularExpression('/(?<!sudo )\btest -[a-z] |\[ !? ?-[a-z] /', $rCommand);
|
|
}
|
|
}
|
|
|
|
public function testTheHostKeyIsNeverTrustedOnFirstUse(): void {
|
|
$rServers = $this->servers();
|
|
$rServers[7]['ssh_hostkey_sha1'] = null;
|
|
[$rWhy, $rOut] = $this->enrol($rServers);
|
|
$this->assertNotNull($rWhy);
|
|
$this->assertStringContainsString('Trust on first use is refused for enrolment. Exiting', $rOut);
|
|
$this->assertSame([], $this->rSsh->rLog, 'nothing is contacted');
|
|
|
|
// A key the admin read on the node is checked, then stored.
|
|
$this->rDb->query('UPDATE `servers` SET `ssh_hostkey_sha1` = NULL WHERE `id` = 7');
|
|
[$rWhy, $rOut] = $this->enrol($rServers, 7, 'pw', sha1('host-7'));
|
|
$this->assertNull($rWhy, $rOut);
|
|
$this->assertSame(sha1('host-7'), $this->storedHostKey());
|
|
}
|
|
|
|
public function testAChangedHostKeyRunsNothing(): void {
|
|
$this->rSsh->rNodes['10.0.0.7']['hostkey'] = sha1('rebuilt');
|
|
[$rWhy, $rOut] = $this->enrol($this->servers());
|
|
$this->assertStringStartsWith('SSH host key changed since this node was installed', (string) $rWhy);
|
|
$this->assertStringContainsString($rWhy . ". Exiting\n", $rOut);
|
|
$this->assertSame(['connect 10.0.0.7:22', 'close 10.0.0.7'], $this->rSsh->rLog, 'no password is sent and no command runs');
|
|
$this->assertSame(sha1('host-7'), $this->storedHostKey());
|
|
|
|
[$rWhy] = $this->enrol($this->servers(), 7, 'pw', sha1('other'));
|
|
$this->assertSame('SSH host key mismatch: expected ' . sha1('other') . ', got ' . sha1('rebuilt'), $rWhy);
|
|
}
|
|
|
|
public function testRefusalsBeforeTheFlow(): void {
|
|
foreach ([1 => 'Server 1 is not a load balancer', 4 => 'Server 4 is not a load balancer', 99 => 'Server 99 is not a load balancer'] as $rID => $rExpected) {
|
|
[$rWhy] = $this->enrol($this->servers(), $rID);
|
|
$this->assertSame($rExpected, $rWhy);
|
|
}
|
|
$this->assertSame([], $this->rSsh->rLog);
|
|
|
|
[$rWhy] = $this->enrol($this->servers(), 7, 'wrong');
|
|
$this->assertSame('Failed to authenticate over SSH', $rWhy);
|
|
$this->assertSame([], $this->rSsh->commands('10.0.0.7'));
|
|
|
|
$this->rSsh->rNodes['10.0.0.7']['ready'] = false;
|
|
[$rWhy] = $this->enrol($this->servers());
|
|
$this->assertSame('The node does not run this panel release yet (no bin/xc_agent/run.sh). Update it first, then retry', $rWhy);
|
|
$this->assertCount(1, $this->rSsh->commands('10.0.0.7'), 'only the release check ran');
|
|
|
|
unset($this->rSsh->rNodes['10.0.0.7']);
|
|
[$rWhy] = $this->enrol($this->servers());
|
|
$this->assertSame('Failed to connect to server', $rWhy);
|
|
$this->assertNull(NodeRegistry::byServer(7));
|
|
}
|
|
|
|
public function testTheFlowsFailureIsTheReason(): void {
|
|
$this->rSsh->rNodes['10.0.0.7']['probe'] = false;
|
|
[$rWhy, $rOut] = $this->enrol($this->servers());
|
|
$this->assertSame("The node cannot reach MAIN's cluster API (http://10.0.0.1:25461/cluster/v1/): xc_agent probe: connection refused Open the cluster API port from the LB to MAIN, then reinstall", $rWhy);
|
|
$this->assertStringContainsString("Enrolling the node in the cluster API\nThe node cannot reach MAIN's cluster API", $rOut, 'printed as it happened');
|
|
$this->assertSame(1, substr_count($rOut, "The node cannot reach MAIN's cluster API"), 'and only once');
|
|
$this->assertNull(NodeRegistry::byServer(7), 'no token was minted');
|
|
$this->rDb->query('SELECT `status` FROM `servers` WHERE `id` = 7');
|
|
$this->assertSame(1, (int) $this->rDb->get_row()['status'], 'a live node is never marked failed');
|
|
|
|
// The API switched off: the flow leaves the node alone, and says so.
|
|
$this->rSsh->rNodes['10.0.0.7']['probe'] = true;
|
|
SettingsManager::set(['cluster_api_enabled' => 0]);
|
|
[$rWhy] = $this->enrol($this->servers());
|
|
$this->assertSame('The cluster API is disabled; the node was not enrolled and stays legacy', $rWhy);
|
|
}
|
|
|
|
public function testAFlowThatEnrolsNothingIsAFailure(): void {
|
|
// No xc_agent on the node (it could not take one from its release):
|
|
// provisionCluster returns true and leaves the node as it was. Its
|
|
// reason is kept, whatever the clock.
|
|
ClusterClock::fix(1_700_000_000_000);
|
|
$this->rSsh->rNodes['10.0.0.7']['agent'] = false;
|
|
[$rWhy, $rOut] = $this->enrol($this->servers());
|
|
$this->assertSame('No xc_agent on the node (Failed to download xc_agent-linux-amd64); the node was not enrolled and stays legacy', $rWhy, $rOut);
|
|
$this->assertNull(NodeRegistry::byServer(7));
|
|
|
|
// An enrolled node keeps its identity, even one enrolled in the same second.
|
|
$this->rSsh->rNodes['10.0.0.7']['agent'] = true;
|
|
$this->assertNull($this->enrol($this->servers())[0]);
|
|
$rOld = NodeRegistry::byServer(7);
|
|
$this->rSsh->rNodes['10.0.0.7']['agent'] = false;
|
|
[$rWhy] = $this->enrol($this->servers());
|
|
$this->assertSame('No xc_agent on the node (Failed to download xc_agent-linux-amd64); the node was not re-enrolled and keeps its previous identity', $rWhy);
|
|
$this->rSsh->rNodes['10.0.0.7']['agent'] = true;
|
|
$this->assertSame($rOld['node_uuid'], NodeRegistry::byServer(7)['node_uuid']);
|
|
|
|
// Enrolled again in the same second: a new identity is what counts.
|
|
$this->assertNull($this->enrol($this->servers())[0]);
|
|
$this->assertNotSame($rOld['node_uuid'], NodeRegistry::byServer(7)['node_uuid']);
|
|
}
|
|
|
|
public function testOneEnrolmentOfANodeAtATime(): void {
|
|
$rLock = fopen(ServerEnrolCommand::lockPath(7), 'c');
|
|
$this->assertTrue(flock($rLock, LOCK_EX | LOCK_NB));
|
|
try {
|
|
[$rWhy] = $this->enrol($this->servers());
|
|
$this->assertSame('Another enrolment of server 7 is running', $rWhy);
|
|
$this->assertSame([], $this->rSsh->rLog, 'nothing is contacted');
|
|
} finally {
|
|
flock($rLock, LOCK_UN);
|
|
fclose($rLock);
|
|
}
|
|
$this->assertNull($this->enrol($this->servers())[0], 'the lock is free once the other run ends');
|
|
$this->assertNull($this->enrol($this->servers())[0], 'and an enrolment releases it');
|
|
}
|
|
}
|