94 lines
3.8 KiB
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 [];
|
|
}
|
|
});
|
|
}
|
|
}
|