*/ private const GROWERS = [ 'ext2' => ['resize2fs', 'source'], 'ext3' => ['resize2fs', 'source'], 'ext4' => ['resize2fs', 'source'], 'xfs' => ['xfs_growfs', 'target'], 'btrfs' => ['btrfs filesystem resize max', 'target'], ]; public function __construct(private ProxmoxClient $pve) {} public function key(): string { return 'grow_guest_filesystem'; } public function maxDuration(): int { return 300; } public function execute(ProvisioningRun $run): StepResult { $instance = $this->instance($run); if ($instance === null) { return StepResult::fail('no_instance'); } $node = (string) $run->context('node'); $vmid = (int) $run->context('vmid'); $pve = $this->pve->forHost($instance->host); // Nothing inside a machine can be grown from outside it. A guest that is // not answering is retried rather than failed on the spot — it may still // be coming up — and the run fails visibly once the budget is gone, // which is the outcome an operator can act on. if (! $pve->guestAgentPing($node, $vmid)) { return StepResult::retry(20, 'guest agent not answering yet'); } $mount = $this->dataFilesystem($pve, $run); if ($mount === null) { return StepResult::fail('no_data_filesystem'); } [$source, $type, $target] = $mount; $grower = self::GROWERS[$type] ?? null; // Loudly, and this is the whole point of detecting the type. A // filesystem nobody taught this step about must not be handed to // resize2fs on the chance that it works: at best the command fails, at // worst it writes ext4 structures over something else. A failed run says // which filesystem it was; a silent no-op would leave the customer // paying for space that never appeared. if ($grower === null) { return StepResult::fail('unsupported_filesystem: '.$type); } $this->rescanDisk($pve, $run, $source); $this->growPartition($pve, $run, $source); [$command, $argument] = $grower; $this->guest($pve, $run, $command.' '.escapeshellarg($argument === 'source' ? $source : $target)); return StepResult::advance(); } /** * The device, filesystem type and mount point that carry the customer's * files. * * `findmnt --target` answers for whichever filesystem actually holds the * path, so a `/var/www` that is only a directory on the root filesystem * resolves to the root filesystem — the correct answer, and the same one the * fill-level reading gets from `df`. The two paths are asked in the same * order for exactly that reason: what this step grows must be what the meter * measures, or a customer would be told they are full of a filesystem nobody * enlarged. * * @return array{0: string, 1: string, 2: string}|null */ private function dataFilesystem(ProxmoxClient $pve, ProvisioningRun $run): ?array { $out = $this->guest($pve, $run, 'findmnt -nro SOURCE,FSTYPE,TARGET --target /var/www 2>/dev/null ' .'|| findmnt -nro SOURCE,FSTYPE,TARGET --target /'); foreach (preg_split('/\r?\n/', trim($out)) ?: [] as $line) { $fields = preg_split('/\s+/', trim($line)) ?: []; // The first complete line. A bind mount can add more, and they // describe the same filesystem — growing it once is growing it. if (count($fields) >= 3 && str_starts_with($fields[0], '/dev/')) { return [$fields[0], $fields[1], $fields[2]]; } } return null; } /** * Ask the kernel to look at the disk again. * * A read, and it always exits 0: a machine whose sysfs has no such node — * another disk transport, an unusual image — is not a failed run, it is a * machine whose kernel noticed the new size without being asked. The * partition step immediately after is what actually proves whether the space * arrived. */ private function rescanDisk(ProxmoxClient $pve, ProvisioningRun $run, string $source): void { $disk = $this->diskName($source); if ($disk === null) { return; } $rescan = '/sys/class/block/'.$disk.'/device/rescan'; $this->guest($pve, $run, 'test -w '.escapeshellarg($rescan).' && echo 1 > '.escapeshellarg($rescan).'; ' .'udevadm settle >/dev/null 2>&1; true'); } /** * Stretch the partition over whatever space the disk has gained. * * growpart's own answer for "there was nothing to do" is the word NOCHANGE * on its output together with a NON-zero exit, and which non-zero code that * is has moved between releases of cloud-utils. So the word is what is read * and not the number — this step runs again on every retry and on every * further pack, and a second run finding the partition already at full size * is the ordinary case, not a failure. */ private function growPartition(ProxmoxClient $pve, ProvisioningRun $run, string $source): void { $partition = $this->partition($source); if ($partition === null) { // A filesystem sitting straight on a disk, an LVM volume, a mapper // device: there is no partition table entry to move, and guessing a // device name here would run growpart against the wrong disk. Said // out loud on the run, because the filesystem step below may then be // the only thing that grows — and if it grows nothing, this line is // the reason. $run->events()->create([ 'step' => $this->key(), 'attempt' => $run->attempt, 'outcome' => 'info', 'message' => "Keine erweiterbare Partition erkannt ({$source}) — die Partitionstabelle bleibt " .'unverändert, das Dateisystem wird trotzdem vergrößert.', ]); return; } [$disk, $number] = $partition; $result = $pve->guestExec( (string) $run->context('node'), (int) $run->context('vmid'), 'growpart '.escapeshellarg($disk).' '.escapeshellarg($number).' 2>&1', ); $out = trim((string) ($result['out-data'] ?? '')); if ((int) ($result['exitcode'] ?? 1) !== 0 && ! str_contains($out, 'NOCHANGE')) { throw new RuntimeException("growpart failed (exit {$result['exitcode']}): {$out}"); } } /** * The whole disk behind a partition, and the partition's number. * * `/dev/sda1` → `/dev/sda` and `1`; `/dev/nvme0n1p2` → `/dev/nvme0n1` and * `2`. Null for anything this cannot name with certainty. Deliberately * narrow: growpart takes a disk and a number, and a pattern loose enough to * match everything would eventually hand it the wrong disk. * * @return array{0: string, 1: string}|null */ private function partition(string $source): ?array { if (preg_match('#^(/dev/nvme\d+n\d+)p(\d+)$#', $source, $m) === 1 || preg_match('#^(/dev/[a-z]{2,4})(\d+)$#', $source, $m) === 1) { return [$m[1], $m[2]]; } return null; } /** The kernel's name for the disk itself — `sda` — or null if it is not one. */ private function diskName(string $source): ?string { $device = $this->partition($source)[0] ?? $source; return preg_match('#^/dev/([a-z0-9]+)$#', $device, $m) === 1 ? $m[1] : null; } }