CluPilotCloud/app/Provisioning/Steps/Customer/GrowGuestFilesystem.php

252 lines
10 KiB
PHP

<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
use RuntimeException;
/**
* Make the bigger disk visible INSIDE the guest.
*
* Growing storage is four things and the product only ever did the first.
* Proxmox enlarges the image; the guest kernel has to notice; the partition has
* to be stretched over the new space; and the filesystem has to be stretched
* over the partition. Without the last three, a customer who bought a hundred
* gigabytes got a larger image on the host and a Nextcloud that could not write
* one byte more than before — which is precisely what happened to every plan
* upgrade this product has ever carried out.
*
* **No cold boot is involved, and that is not luck.** The data disk is attached
* as `scsi0` (see CloneVirtualMachine and ConfigureCloudInit, which sizes that
* exact device), and Proxmox's resize on a running guest is a qemu block_resize
* on it: the new capacity reaches the guest's SCSI layer while it runs. Current
* kernels pick it up by themselves and older ones want to be told to look, which
* is what the rescan below is for. The cold boot ResizeVirtualMachine talks
* about is about CPU and memory, which have no hotplug configured here — a disk
* is not in that sentence, and the `restart` pipeline is not needed to deliver
* one.
*
* Idempotent from end to end, because a run is retried and this step sits in
* more than one pipeline: the rescan is a read, growpart says NOCHANGE when the
* partition already fills the disk, and every filesystem tool here exits 0 when
* there is nothing left to grow. Running it when nothing changed visits the
* guest and changes nothing.
*/
class GrowGuestFilesystem extends CustomerStep
{
/**
* How each filesystem is grown, and what its tool wants pointed at it.
*
* Detected, never assumed. The images are ext4 today, and a step that
* hard-coded resize2fs would go quietly wrong the day one is not: the
* command would fail on a filesystem it cannot read, the run would burn its
* retries, and the report would say nothing about why. `resize2fs` takes the
* block device; xfs and btrfs are grown through their MOUNT POINT, which is
* why the target is declared per filesystem instead of guessed at.
*
* @var array<string, array{0: string, 1: 'source'|'target'}>
*/
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;
}
}