CluPilotCloud/app/Services/Provisioning/ServerMarket.php

94 lines
3.8 KiB
PHP

<?php
namespace App\Services\Provisioning;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;
/**
* What a machine costs today, from the provider's own live list.
*
* So the question "what would clearing this queue cost me" is answered on the
* page that asks it, rather than in another tab against a list that has to be
* read and filtered by hand every time.
*
* Read-only and cached. This orders nothing: buying a server is a contract, and
* a bug in a capacity calculation must not be able to sign one.
*/
class ServerMarket
{
/** Hetzner's published live feed for the server auction. */
private const FEED = 'https://www.hetzner.com/_resources/app/data/app/live_data_sb_EUR.json';
/**
* Cached for a quarter of an hour.
*
* The list changes as machines sell, but not by the second, and an operator
* refreshing a page must not put a request on somebody else's server every
* time.
*/
private const TTL_MINUTES = 15;
/**
* Offers that could actually take the work, cheapest first.
*
* @param int $needsGb the largest single package that has to fit
* @return array<int, array{cpu: string, ram: int, disks: string, flash_gb: int, ecc: bool, datacenter: string, price: float}>
*/
public function offers(int $needsGb, int $limit = 6): array
{
$usable = (int) ceil($needsGb * 1.25); // RAID 1 headroom plus the host's own footprint
return collect($this->feed())
->map(function (array $s) {
$disks = $s['serverDiskData'] ?? [];
$flash = array_sum($disks['nvme'] ?? []) + array_sum($disks['sata'] ?? []);
return [
'cpu' => (string) ($s['cpu'] ?? ''),
'ram' => (int) ($s['ram_size'] ?? 0),
// The feed sends this as a LIST of disk descriptions
// ("2x SSD M.2 NVMe 512 GB"), not a string. Casting an
// array to string is a warning and the word "Array".
'disks' => trim(implode(', ', array_map(
fn ($d) => is_scalar($d) ? (string) $d : '',
(array) ($s['hdd_hr'] ?? [])
))),
// Half, because the pair is a mirror. A machine's usable
// space is not the sum of its disks on a platform that
// sells "your data survives a disk dying".
'flash_gb' => (int) floor($flash / 2),
'ecc' => (bool) ($s['is_ecc'] ?? false),
'datacenter' => (string) ($s['datacenter'] ?? ''),
'price' => (float) ($s['price'] ?? 0),
];
})
// All-flash only, and ECC only. Both are product decisions, not
// preferences: one VM per customer means every database lives on
// the same disks, and ECC is part of what the backups promise.
->filter(fn (array $s) => $s['flash_gb'] >= $usable && $s['ecc'] && $s['ram'] >= 32)
->sortBy('price')
->take($limit)
->values()
->all();
}
/** @return array<int, array<string, mixed>> */
private function feed(): array
{
return Cache::remember('server-market', now()->addMinutes(self::TTL_MINUTES), function () {
try {
$response = Http::timeout(8)->get(self::FEED);
return $response->successful() ? ($response->json('server') ?? []) : [];
} catch (Throwable) {
// An outside list being unreachable is not a reason for the
// capacity page to fail. It shows the queue either way; the
// prices are the convenience.
return [];
}
});
}
}