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

163 lines
6.6 KiB
PHP

<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
/**
* The hardware half of a plan change: CPU, memory and disk.
*
* Two things here are not symmetrical, and both are deliberate.
*
* **A disk only grows.** Proxmox resizes a virtual disk upwards on a running
* guest and has no way back down — there is no shrink, online or offline, and
* asking for one is an error, not a slow operation. So a downgrade does not try.
* What shrinks instead is the customer's QUOTA, which Nextcloud enforces and
* which is the thing that was actually sold; the guest keeps the disk it already
* has, and `instances.disk_gb` keeps saying so because that column is what the
* machine HAS and the host's storage accounting reads it. What the smaller
* package includes is on the contract, where it belongs — nothing reads a disk
* size as proof of what was sold.
*
* **CPU and memory are applied but not made true.** The configuration PUT lands
* immediately and a running guest takes the new size at its next cold boot;
* nothing in this codebase turns Proxmox's CPU or memory hotplug on, so there is
* no live path. Rebooting a customer's cloud as a side effect of a purchase is
* not something to do quietly, and applying a value that only becomes real
* months later with nobody knowing is not either — so the machine is left
* running and the gap is recorded on the instance, where the console's instance
* list and the customer's own cloud page both show it.
*/
class ResizeVirtualMachine extends CustomerStep
{
public function __construct(private ProxmoxClient $pve) {}
public function key(): string
{
return 'resize_vm';
}
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);
// setCloudInit() is the generic `PUT /nodes/{node}/qemu/{vmid}/config`
// despite its name — it writes whatever parameters it is given, and
// cloud-init keys are simply what the first caller happened to need.
// cores and memory are ordinary VM configuration and go the same way.
// Idempotent: writing the same values again changes nothing.
$pve->setCloudInit($node, $vmid, [
'cores' => (int) $instance->cores,
'memory' => (int) $instance->ram_mb,
]);
$this->growDisk($run, $pve, $instance, $node, $vmid);
$this->settlePendingRestart($run, $pve, $instance, $node, $vmid);
return StepResult::advance();
}
/**
* Grow the disk to what the contract now includes, and say so out loud when
* the contract asks for less than the machine already has.
*/
private function growDisk(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void
{
$target = (int) ($this->plan($run)['disk_gb'] ?? 0);
$actual = (int) $instance->disk_gb;
// No contract behind this run, or a package without a size: nothing to
// grow towards, and guessing at one would resize somebody's disk from a
// default.
if ($target <= 0) {
return;
}
if ($target > $actual) {
// Absolute target, not '+…', so a retry cannot grow it twice.
$pve->resizeDisk($node, $vmid, 'scsi0', $target.'G');
$instance->update(['disk_gb' => $target]);
return;
}
if ($target < $actual) {
$run->events()->create([
'step' => $this->key(),
'attempt' => $run->attempt,
'outcome' => 'info',
'message' => "Festplatte bleibt bei {$actual} GB: ein kleineres Paket ({$target} GB) verkleinert "
.'keine Platte — das Kontingent des Kunden sinkt stattdessen.',
]);
}
}
/**
* Record — or clear — the restart this change is waiting on.
*
* Keyed on whether the machine is RUNNING rather than on reading back what
* Proxmox says the guest currently has. Without hotplug the answer is the
* same either way and this one needs no second API shape to be faked in
* tests; and where the two could differ, being pessimistic is the safe
* direction. A "restart pending" that turns out to have been unnecessary is
* a visible nuisance, while a resize that silently never happened is exactly
* the failure this step exists to stop.
*/
private function settlePendingRestart(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void
{
$fromCores = (int) $run->context('plan_change.from_cores', $instance->cores);
$fromRam = (int) $run->context('plan_change.from_ram_mb', $instance->ram_mb);
// Nothing about CPU or memory moved, so this change is waiting on
// nothing. An older pending restart is left exactly where it is — it is
// still pending, and clearing it here would hide it.
if ($fromCores === (int) $instance->cores && $fromRam === (int) $instance->ram_mb) {
return;
}
$running = ($pve->vmStatus($node, $vmid)['status'] ?? '') === 'running';
if (! $running) {
// A stopped machine reads its configuration when it starts, so the
// new size is already true for it — and anything pending from an
// earlier change is settled by the same start.
if ($instance->restartIsPending()) {
$instance->update(['restart_required_since' => null]);
}
return;
}
// First pending change keeps its timestamp: how long somebody has been
// paying for cores they do not have is the interesting number, not when
// the most recent change was applied.
if (! $instance->restartIsPending()) {
$instance->update(['restart_required_since' => now()]);
}
$run->events()->create([
'step' => $this->key(),
'attempt' => $run->attempt,
'outcome' => 'info',
'message' => "Neue Ausstattung ({$instance->cores} vCPU, {$instance->ram_mb} MB RAM) ist in der VM-Konfiguration "
.'hinterlegt und wird beim nächsten Neustart wirksam. Die laufende Maschine wurde nicht neu gestartet.',
]);
}
}