clusev/app/Services/MaintenanceService.php

110 lines
3.9 KiB
PHP

<?php
namespace App\Services;
use App\Models\Server;
use App\Support\Os\OsDetector;
use App\Support\Os\PackageManager;
/**
* Package-maintenance helpers across apt/dnf/zypper (resolved per OS via
* OsDetector): counts pending package updates and applies an upgrade.
*
* Every reader/writer goes through FleetService, so quoting is base64-safe and
* privileged work runs as root. (fail2ban tuning + management lives in Fail2banService.)
*/
class MaintenanceService
{
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
/**
* NULL when Clusev can manage package updates on this host, otherwise a German
* reason (e.g. unsupported package manager) for the operator.
*/
public function updateSupport(Server $server): ?string
{
return $this->detector->detect($server)->supports('updates');
}
/**
* Number of pending package upgrades via a dry-run/metadata read (no root
* needed), or NULL when it could not run (unsupported manager / errored) — so
* the caller shows "unbekannt" rather than a misleading "0 / up to date". The
* remote script emits a CLUSEV_OK sentinel only when the query actually ran.
*/
public function pendingUpdates(Server $server): ?int
{
$os = $this->detector->detect($server);
if (! $os->managesPackages()) {
return null;
}
$res = $this->fleet->runPlain($server, PackageManager::for($os)->pendingScript());
if (! str_contains($res['output'], 'CLUSEV_OK')) {
return null;
}
return preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null;
}
/**
* Pending update count AND its security subset in one round-trip. Either value is NULL when the
* query couldn't run (unsupported manager / errored) so the caller shows "unbekannt" rather than
* a misleading 0. Security is best-effort per family; it is always ≤ pending.
*
* @return array{pending: ?int, security: ?int}
*/
public function updateCounts(Server $server): array
{
$os = $this->detector->detect($server);
if (! $os->managesPackages()) {
return ['pending' => null, 'security' => null];
}
$res = $this->fleet->runPlain($server, PackageManager::for($os)->countsScript());
if (! str_contains($res['output'], 'CLUSEV_OK')) {
return ['pending' => null, 'security' => null];
}
$pending = preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null;
$security = preg_match('/CLUSEV_SECURITY=(\d+)/', $res['output'], $s) ? (int) $s[1] : null;
// Security can never exceed total pending (guards a noisy grep).
if ($pending !== null && $security !== null) {
$security = min($security, $pending);
}
return ['pending' => $pending, 'security' => $security];
}
/**
* Refresh the index and apply all upgrades as root, using the host's package
* manager. Long-running, so a 900s timeout. Output trimmed to the last ~400
* chars for a compact result panel.
*
* @return array{ok: bool, output: string}
*/
public function applyUpgrades(Server $server): array
{
$os = $this->detector->detect($server);
if ($reason = $os->supports('updates')) {
return ['ok' => false, 'output' => $reason];
}
$res = $this->fleet->runPrivileged($server, PackageManager::for($os)->applyScript(), 900);
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)];
}
/** Keep only the last $max characters of a (multi-line) output. */
private function tail(string $out, int $max): string
{
$out = trim($out);
if (mb_strlen($out) <= $max) {
return $out;
}
return '…'.mb_substr($out, -$max);
}
}