98 lines
3.4 KiB
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;
|
|
}
|
|
}
|