CluPilotCloud/app/Services/Provisioning/DiskUsageProbe.php

98 lines
3.4 KiB
PHP

<?php
namespace App\Services\Provisioning;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Services\Proxmox\ProxmoxClient;
/**
* How full a customer's cloud actually is, read from inside it.
*
* Proxmox's own `disk` figure for a VM is the allocated image, not what is used
* within it — it would show a customer 500 GB from the first day. `df` in the
* guest is the number they recognise, and it is the number the downgrade check
* blocks on, so there must be exactly one way of taking it.
*
* There are two callers and they want it for opposite reasons.
* CollectInstanceTraffic takes it on its rounds, for the trend and for the
* nightly picture. The portal takes it ON DEMAND, because a customer who has
* just deleted forty gigabytes to fit into a smaller package must not be told to
* come back tomorrow — the whole value of the answer is that it is current.
* Both go through ProxmoxClient, so a test fakes it and nothing needs a real
* guest.
*/
final class DiskUsageProbe
{
public function __construct(private ProxmoxClient $pve) {}
/**
* A reading, or null when the guest could not give one.
*
* Null is not zero, and the difference matters everywhere this is used: a
* failed read charted as 0 GB tells a customer their data is gone, and a
* failed read taken as "empty" would wave a downgrade through that is not
* actually possible.
*
* @return array{used: int, total: int}|null
*/
public function read(Instance $instance): ?array
{
if ($instance->host === null || $instance->vmid === null) {
return null;
}
$node = $instance->host->node ?? 'pve';
$client = $this->pve->forHost($instance->host);
if (! $client->guestAgentPing($node, (int) $instance->vmid)) {
return null;
}
// -B1 so the numbers are bytes and no locale can reinterpret them.
$result = $client->guestExec($node, (int) $instance->vmid,
'df -B1 --output=size,used /var/www 2>/dev/null || df -B1 --output=size,used /');
$out = trim((string) ($result['out-data'] ?? $result['output'] ?? ''));
// Header line, then one line of two integers. Anything else is a shell
// that answered something we did not ask for, and is discarded rather
// than parsed hopefully.
$lines = preg_split('/\r?\n/', $out) ?: [];
$last = trim((string) end($lines));
if (preg_match('/^(\d+)\s+(\d+)$/', $last, $m) !== 1) {
return null;
}
return ['total' => (int) $m[1], 'used' => (int) $m[2]];
}
/**
* Take a reading and write it into today's row, so everything that reads the
* fill level sees it.
*
* The same row the sampler accumulates into, and the fill level is
* overwritten rather than added to — it is a state, not a flow. Returns null
* when there was nothing to record, so a caller can say "we could not look"
* instead of "you are fine".
*/
public function record(Instance $instance): ?InstanceMetric
{
$disk = $this->read($instance);
if ($disk === null) {
return null;
}
$metric = InstanceMetric::query()->firstOrCreate(
['instance_id' => $instance->id, 'day' => now()->toDateString()],
);
$metric->disk_used_bytes = $disk['used'];
$metric->disk_total_bytes = $disk['total'];
$metric->save();
return $metric;
}
}