252 lines
10 KiB
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;
|
|
}
|
|
}
|