190 lines
8.0 KiB
PHP
190 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Provisioning\Steps\Customer;
|
|
|
|
use App\Models\Instance;
|
|
use App\Models\ProvisioningRun;
|
|
use App\Provisioning\StepResult;
|
|
use App\Services\Billing\StorageAllowance;
|
|
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.
|
|
*
|
|
* Growing it is only the host's half. The guest sees a larger block device and
|
|
* nothing more — its partition and its filesystem still end where they did — so
|
|
* GrowGuestFilesystem runs immediately after this step in every pipeline that
|
|
* can enlarge a disk. Without it, an upgrade delivered a bigger image and not one
|
|
* usable byte.
|
|
*
|
|
* **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 customer is now entitled to, and say so out loud
|
|
* when that is less than the machine already has.
|
|
*
|
|
* The target is the customer's WHOLE allowance — package plus booked storage
|
|
* packs — with the package's own system overhead left on top of it. That
|
|
* overhead is read off the package and never invented: the catalogue's four
|
|
* packages ship 120/100, 540/500, 1050/1000 and 2100/2000 GB, which is 20,
|
|
* 40, 50 and 100 GB of room beside the customer's files and is a decision the
|
|
* owner makes per package, not a ratio anybody may derive. A pack of extra
|
|
* storage is that much more FOR THE CUSTOMER, so it is added to the
|
|
* allowance and the package's overhead rides along unchanged.
|
|
*
|
|
* With no packs booked the sum is exactly the package's own `disk_gb`, which
|
|
* is what this step has always used.
|
|
*/
|
|
private function growDisk(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void
|
|
{
|
|
$plan = $this->plan($run);
|
|
$planDisk = (int) ($plan['disk_gb'] ?? 0);
|
|
$planQuota = (int) ($plan['quota_gb'] ?? 0);
|
|
|
|
// 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 ($planDisk <= 0) {
|
|
return;
|
|
}
|
|
|
|
$allowance = StorageAllowance::for($instance);
|
|
|
|
$target = $allowance->totalGb() > 0
|
|
? $allowance->totalGb() + max(0, $planDisk - $planQuota)
|
|
: $planDisk;
|
|
$actual = (int) $instance->disk_gb;
|
|
|
|
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.',
|
|
]);
|
|
}
|
|
}
|