clusev/app/Support/Os/PackageManager.php

95 lines
3.8 KiB
PHP

<?php
namespace App\Support\Os;
/**
* Builds SHELL COMMAND STRINGS for package operations, per manager. It never runs
* anything — FleetService is the single executor (base64-wrapped, sudo + LC_ALL=C
* handled there). Only apt/dnf/zypper are driven this release; package names passed
* in are Clusev constants (fail2ban, ufw, …), never operator input.
*
* Note: the dnf/zypper command strings are constructed from documented syntax but
* are exercised live only on the families a tester actually runs; the apt path is
* the one verified against the live fleet today.
*/
final class PackageManager
{
/** @param 'apt'|'dnf'|'zypper'|'none' $manager */
public function __construct(
private string $manager,
private bool $useYum = false, // dnf family lacking the dnf binary -> yum
) {}
public static function for(OsProfile $os): self
{
$useYum = $os->packageManager === 'dnf' && ! $os->has('dnf') && $os->has('yum');
return new self($os->packageManager, $useYum);
}
private function dnf(): string
{
return $this->useYum ? 'yum' : 'dnf';
}
/**
* A command that prints `CLUSEV_OK` (only when the query actually ran) and
* `CLUSEV_PENDING=<n>`. Runs unprivileged (simulation / metadata read only).
*/
public function pendingScript(): string
{
return match ($this->manager) {
'apt' => 'out=$(apt-get -s upgrade 2>/dev/null) && echo CLUSEV_OK; '
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -c '^Inst ')\"",
'dnf' => 'out=$('.$this->dnf().' -q check-update 2>/dev/null); rc=$?; '
.'if [ "$rc" = 0 ] || [ "$rc" = 100 ]; then echo CLUSEV_OK; fi; '
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^[[:graph:]]+[[:space:]]+[[:graph:]]+[[:space:]]+[[:graph:]]+')\"",
'zypper' => 'out=$(zypper --non-interactive list-updates 2>/dev/null); rc=$?; '
.'[ "$rc" = 0 ] && echo CLUSEV_OK; '
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^v \\|')\"",
default => 'echo CLUSEV_PENDING=0',
};
}
/** Refresh index + apply all upgrades (privileged, long-running). */
public function applyScript(): string
{
return match ($this->manager) {
'apt' => 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade',
'dnf' => $this->dnf().' -y upgrade',
// zypper exits 102 (reboot required) / 103 (restart zypper) on SUCCESS —
// normalize those to 0 so a successful upgrade is not reported as failed.
'zypper' => 'zypper --non-interactive refresh && { zypper --non-interactive update; rc=$?; '
.'[ "$rc" = 0 ] || [ "$rc" = 102 ] || [ "$rc" = 103 ]; }',
default => 'false',
};
}
/** Install one package (privileged). */
public function installScript(string $pkg): string
{
return match ($this->manager) {
'apt' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y '.$pkg,
'dnf' => $this->dnf().' install -y '.$pkg,
'zypper' => 'zypper --non-interactive install '.$pkg,
default => 'false',
};
}
/** A shell CONDITION that exits 0 when $pkg is installed (for use with && / ||). */
public function isInstalledTest(string $pkg): string
{
return match ($this->manager) {
'apt' => "dpkg -l {$pkg} 2>/dev/null | grep -q '^ii'",
'dnf', 'zypper' => "rpm -q {$pkg} >/dev/null 2>&1",
default => 'false',
};
}
/** Install $pkg only if missing (idempotent). */
public function ensureInstalledScript(string $pkg): string
{
return '('.$this->isInstalledTest($pkg).' || '.$this->installScript($pkg).')';
}
}