feat(patch): fleet patch view + security-update awareness (feature 6/8)
Fleet-wide package-update visibility: pending + SECURITY update counts per server, one-click patch.
Extends MaintenanceService (single-server pending/apply already existed) with the security subset
and a fleet aggregation.
- MaintenanceService::updateCounts(Server) → { pending, security } in ONE round-trip via a new
PackageManager::countsScript() (apt/dnf/zypper). Security is best-effort per family (apt path solid;
dnf/zypper degrade to 0 when the metadata isn't there) and clamped ≤ pending. Either value is null
when the query can't run (shows "unknown", never a misleading 0).
- Patch\Index page (route /patch, `operate`-gated: route + mount + per-method). Lazy per-server scan,
each guarded (an unreachable host → error row). Fleet KPIs (total pending / total security). Applying
upgrades is a stateful action → signed ConfirmToken + R5 modal (modal audits patch.apply from the
sealed token; handler does not re-audit) resolving a client uuid → the sealed server id; a forged
token is a no-op. Reuses the existing applyUpgrades; the result output is mb_scrub'd before it lands
in a Livewire prop.
- countsScript strings are STATIC per manager (no operator input) — no injection surface.
- lang/{de,en}/patch.php + audit.php patch.apply + shell.nav_patch (de/en parity). No emoji.
11 new tests: MaintenanceService (parse pending+security, clamp security≤pending, missing-sentinel /
unsupported-manager → unknown), component (route gating, scan shows counts + survives an unreachable
server, patch opens confirm, applyPatch runs upgrades over the SEALED server, forged token no-op).
720 tests green, Pint, lang parity, self-reviewed (RBAC, ConfirmToken seal, static scripts, scrub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
parent
31852fc04c
commit
79862ab014
|
|
@ -0,0 +1,130 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Patch;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\MaintenanceService;
|
||||||
|
use App\Support\Confirm\ConfirmToken;
|
||||||
|
use App\Support\Confirm\InvalidConfirmToken;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fleet patch view: pending + security update counts per server, one-click patch. `operate`-gated
|
||||||
|
* (route + mount + per-method). Counts scan is lazy + per-server guarded. Applying upgrades is a
|
||||||
|
* stateful action → a signed ConfirmToken + R5 modal (the modal audits patch.apply from the sealed
|
||||||
|
* token; the handler does not re-audit). Targets resolve a client uuid → the sealed server id.
|
||||||
|
*/
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
/** @var array<string, array{pending:?int, security:?int}|array{error:string}> uuid → counts */
|
||||||
|
public array $counts = [];
|
||||||
|
|
||||||
|
/** @var array<string, array{ok:bool, output:string}> uuid → last patch result */
|
||||||
|
public array $results = [];
|
||||||
|
|
||||||
|
public bool $ready = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()?->can('operate'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return __('patch.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function gate(): void
|
||||||
|
{
|
||||||
|
abort_unless(Auth::user()?->can('operate'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scan(MaintenanceService $maint): void
|
||||||
|
{
|
||||||
|
$this->gate();
|
||||||
|
$this->counts = [];
|
||||||
|
|
||||||
|
foreach (Server::withActiveCredential()->orderBy('name')->get() as $server) {
|
||||||
|
try {
|
||||||
|
$this->counts[$server->uuid] = $maint->updateCounts($server);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->counts[$server->uuid] = ['error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patch(string $uuid): void
|
||||||
|
{
|
||||||
|
$this->gate();
|
||||||
|
$server = Server::withActiveCredential()->where('uuid', $uuid)->firstOrFail();
|
||||||
|
$c = $this->counts[$uuid] ?? [];
|
||||||
|
$pending = $c['pending'] ?? '?';
|
||||||
|
|
||||||
|
$this->dispatch('openModal',
|
||||||
|
component: 'modals.confirm-action',
|
||||||
|
arguments: [
|
||||||
|
'heading' => __('patch.confirm_heading'),
|
||||||
|
'body' => __('patch.confirm_body', ['server' => $server->name, 'count' => $pending]),
|
||||||
|
'confirmLabel' => __('patch.apply'),
|
||||||
|
'danger' => true,
|
||||||
|
'icon' => 'rotate',
|
||||||
|
'notify' => '', // the handler reports the outcome
|
||||||
|
'token' => ConfirmToken::issue('patchApply', ['uuid' => $server->uuid], 'patch.apply', $server->name, $server->id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('patchApply')]
|
||||||
|
public function applyPatch(string $confirmToken, MaintenanceService $maint): void
|
||||||
|
{
|
||||||
|
$this->gate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = ConfirmToken::consume($confirmToken, 'patchApply');
|
||||||
|
} catch (InvalidConfirmToken) {
|
||||||
|
return; // forged / replayed — the modal already audited patch.apply
|
||||||
|
}
|
||||||
|
|
||||||
|
$server = Server::where('uuid', $payload['params']['uuid'])->first();
|
||||||
|
if (! $server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = $maint->applyUpgrades($server);
|
||||||
|
$res['output'] = mb_scrub($res['output'], 'UTF-8'); // remote output into a Livewire prop → scrub invalid bytes
|
||||||
|
$this->results[$server->uuid] = $res;
|
||||||
|
|
||||||
|
// Refresh this server's counts so the badge reflects the post-patch state.
|
||||||
|
try {
|
||||||
|
$this->counts[$server->uuid] = $maint->updateCounts($server);
|
||||||
|
} catch (Throwable) {
|
||||||
|
// keep the old counts if the re-scan fails
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dispatch('notify', message: $res['ok']
|
||||||
|
? __('patch.patched', ['server' => $server->name])
|
||||||
|
: __('patch.patch_failed', ['error' => $res['output']]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
// Fleet roll-up (only over successfully-scanned servers).
|
||||||
|
$scored = array_filter($this->counts, fn ($c) => isset($c['pending']) && $c['pending'] !== null);
|
||||||
|
$totalPending = array_sum(array_map(fn ($c) => $c['pending'], $scored));
|
||||||
|
$totalSecurity = array_sum(array_map(fn ($c) => $c['security'] ?? 0, $scored));
|
||||||
|
|
||||||
|
return view('livewire.patch.index', [
|
||||||
|
'servers' => Server::withActiveCredential()->orderBy('name')->get(['uuid', 'name']),
|
||||||
|
'totalPending' => (int) $totalPending,
|
||||||
|
'totalSecurity' => (int) $totalSecurity,
|
||||||
|
])->title($this->title());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,36 @@ class MaintenanceService
|
||||||
return preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : 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
|
* 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
|
* manager. Long-running, so a 900s timeout. Output trimmed to the last ~400
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ class ConfirmToken
|
||||||
'alertRuleDeleted',
|
'alertRuleDeleted',
|
||||||
'commandRun',
|
'commandRun',
|
||||||
'runbookDeleted',
|
'runbookDeleted',
|
||||||
|
'patchApply',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,29 @@ final class PackageManager
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like pendingScript, but ALSO emits `CLUSEV_SECURITY=<m>` — the subset of pending upgrades that
|
||||||
|
* come from a security source. One round-trip for both counts. Best-effort per family (the apt
|
||||||
|
* path is solid; dnf/zypper security queries degrade to 0 when the metadata isn't present).
|
||||||
|
*/
|
||||||
|
public function countsScript(): 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 ')\"; "
|
||||||
|
."echo \"CLUSEV_SECURITY=\$(printf '%s\\n' \"\$out\" | grep '^Inst ' | grep -ic 'securit')\"",
|
||||||
|
'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:]]+')\"; "
|
||||||
|
.'echo "CLUSEV_SECURITY=$('.$this->dnf().' -q --security check-update 2>/dev/null | 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 \\|')\"; "
|
||||||
|
.'echo "CLUSEV_SECURITY=$(zypper --non-interactive list-patches --category security 2>/dev/null | grep -cE \'^[[:alnum:]]\')"',
|
||||||
|
default => 'echo CLUSEV_PENDING=0; echo CLUSEV_SECURITY=0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Refresh index + apply all upgrades (privileged, long-running). */
|
/** Refresh index + apply all upgrades (privileged, long-running). */
|
||||||
public function applyScript(): string
|
public function applyScript(): string
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ return [
|
||||||
'group.members' => 'Gruppen-Zuordnung geändert',
|
'group.members' => 'Gruppen-Zuordnung geändert',
|
||||||
'mail.configure' => 'E-Mail (SMTP) konfiguriert',
|
'mail.configure' => 'E-Mail (SMTP) konfiguriert',
|
||||||
'password.change' => 'Passwort geändert',
|
'password.change' => 'Passwort geändert',
|
||||||
|
'patch.apply' => 'Updates eingespielt',
|
||||||
'password.reset' => 'Passwort zurückgesetzt',
|
'password.reset' => 'Passwort zurückgesetzt',
|
||||||
'profile.update' => 'Profil aktualisiert',
|
'profile.update' => 'Profil aktualisiert',
|
||||||
// Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone
|
// Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'eyebrow' => 'Wartung',
|
||||||
|
'title' => 'Updates',
|
||||||
|
'subtitle' => 'Ausstehende Paket-Updates je Server + Sicherheits-Updates',
|
||||||
|
|
||||||
|
'scan_hint' => 'Prüfe Updates …',
|
||||||
|
'total_pending' => 'Ausstehend',
|
||||||
|
'total_security' => 'Sicherheit',
|
||||||
|
'no_servers_title' => 'Keine Server',
|
||||||
|
'no_servers' => 'Keine Server mit aktivem Credential.',
|
||||||
|
|
||||||
|
'up_to_date' => 'Aktuell',
|
||||||
|
'pending_label' => ':count Update(s)',
|
||||||
|
'security_label' => ':count Sicherheit',
|
||||||
|
'unknown' => 'unbekannt',
|
||||||
|
'scan_error' => 'Prüfung fehlgeschlagen',
|
||||||
|
|
||||||
|
'apply' => 'Patchen',
|
||||||
|
'patched' => ':server gepatcht.',
|
||||||
|
'patch_failed' => 'Patch fehlgeschlagen: :error',
|
||||||
|
'result_title' => 'Ergebnis',
|
||||||
|
|
||||||
|
'confirm_heading' => 'Updates einspielen',
|
||||||
|
'confirm_body' => 'Alle Updates auf :server einspielen (:count ausstehend)?',
|
||||||
|
];
|
||||||
|
|
@ -26,6 +26,7 @@ return [
|
||||||
'nav_commands' => 'Befehle',
|
'nav_commands' => 'Befehle',
|
||||||
'nav_alerts' => 'Alarme',
|
'nav_alerts' => 'Alarme',
|
||||||
'nav_posture' => 'Sicherheitslage',
|
'nav_posture' => 'Sicherheitslage',
|
||||||
|
'nav_patch' => 'Updates',
|
||||||
'nav_threats' => 'Bedrohungen',
|
'nav_threats' => 'Bedrohungen',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
'nav_help' => 'Hilfe',
|
'nav_help' => 'Hilfe',
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ return [
|
||||||
'group.members' => 'Group membership changed',
|
'group.members' => 'Group membership changed',
|
||||||
'mail.configure' => 'E-mail (SMTP) configured',
|
'mail.configure' => 'E-mail (SMTP) configured',
|
||||||
'password.change' => 'Password changed',
|
'password.change' => 'Password changed',
|
||||||
|
'patch.apply' => 'Updates applied',
|
||||||
'password.reset' => 'Password reset',
|
'password.reset' => 'Password reset',
|
||||||
'profile.update' => 'Profile updated',
|
'profile.update' => 'Profile updated',
|
||||||
// Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone
|
// Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'eyebrow' => 'Maintenance',
|
||||||
|
'title' => 'Updates',
|
||||||
|
'subtitle' => 'Pending package updates per server + security updates',
|
||||||
|
|
||||||
|
'scan_hint' => 'Scanning updates …',
|
||||||
|
'total_pending' => 'Pending',
|
||||||
|
'total_security' => 'Security',
|
||||||
|
'no_servers_title' => 'No servers',
|
||||||
|
'no_servers' => 'No servers with an active credential.',
|
||||||
|
|
||||||
|
'up_to_date' => 'Up to date',
|
||||||
|
'pending_label' => ':count update(s)',
|
||||||
|
'security_label' => ':count security',
|
||||||
|
'unknown' => 'unknown',
|
||||||
|
'scan_error' => 'Scan failed',
|
||||||
|
|
||||||
|
'apply' => 'Patch',
|
||||||
|
'patched' => ':server patched.',
|
||||||
|
'patch_failed' => 'Patch failed: :error',
|
||||||
|
'result_title' => 'Result',
|
||||||
|
|
||||||
|
'confirm_heading' => 'Apply updates',
|
||||||
|
'confirm_body' => 'Apply all updates on :server (:count pending)?',
|
||||||
|
];
|
||||||
|
|
@ -26,6 +26,7 @@ return [
|
||||||
'nav_commands' => 'Commands',
|
'nav_commands' => 'Commands',
|
||||||
'nav_alerts' => 'Alerts',
|
'nav_alerts' => 'Alerts',
|
||||||
'nav_posture' => 'Security posture',
|
'nav_posture' => 'Security posture',
|
||||||
|
'nav_patch' => 'Updates',
|
||||||
'nav_threats' => 'Threats',
|
'nav_threats' => 'Threats',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
'nav_help' => 'Help',
|
'nav_help' => 'Help',
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@
|
||||||
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
|
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
|
||||||
@can('operate')
|
@can('operate')
|
||||||
<x-nav-item icon="shield-check" href="/posture" :active="request()->is('posture*')">{{ __('shell.nav_posture') }}</x-nav-item>
|
<x-nav-item icon="shield-check" href="/posture" :active="request()->is('posture*')">{{ __('shell.nav_posture') }}</x-nav-item>
|
||||||
|
<x-nav-item icon="rotate" href="/patch" :active="request()->is('patch*')">{{ __('shell.nav_patch') }}</x-nav-item>
|
||||||
@endcan
|
@endcan
|
||||||
@can('manage-panel')
|
@can('manage-panel')
|
||||||
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item>
|
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<div class="space-y-4" @if (! $ready) wire:init="scan" @endif>
|
||||||
|
{{-- Header --}}
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('patch.eyebrow') }}</p>
|
||||||
|
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('patch.title') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-ink-3">{{ __('patch.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Fleet KPIs --}}
|
||||||
|
@if ($ready)
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<x-kpi :label="__('patch.total_pending')" :value="$totalPending" icon="rotate" :tone="$totalPending > 0 ? 'warning' : 'online'" />
|
||||||
|
<x-kpi :label="__('patch.total_security')" :value="$totalSecurity" icon="shield-alert" :tone="$totalSecurity > 0 ? 'offline' : 'online'" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($servers->isEmpty())
|
||||||
|
<x-panel>
|
||||||
|
<div class="py-8 text-center">
|
||||||
|
<p class="font-display text-sm font-semibold text-ink">{{ __('patch.no_servers_title') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-ink-3">{{ __('patch.no_servers') }}</p>
|
||||||
|
</div>
|
||||||
|
</x-panel>
|
||||||
|
@elseif (! $ready)
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($servers as $s)
|
||||||
|
<div wire:key="pl-{{ $s->uuid }}" class="h-16 animate-pulse rounded-lg bg-raised/50"></div>
|
||||||
|
@endforeach
|
||||||
|
<p class="text-center font-mono text-[11px] text-ink-4">{{ __('patch.scan_hint') }}</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<x-panel :padded="false">
|
||||||
|
<div class="divide-y divide-line">
|
||||||
|
@foreach ($servers as $s)
|
||||||
|
@php($c = $counts[$s->uuid] ?? null)
|
||||||
|
@php($result = $results[$s->uuid] ?? null)
|
||||||
|
<div wire:key="pr-{{ $s->uuid }}" class="px-4 py-3 sm:px-5">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<a href="{{ route('servers.show', $s->uuid) }}" wire:navigate class="truncate font-mono text-sm text-ink hover:text-accent-text">{{ $s->name }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! $c || isset($c['error']))
|
||||||
|
<span class="font-mono text-[11px] text-offline">{{ __('patch.scan_error') }}</span>
|
||||||
|
@elseif (is_null($c['pending']))
|
||||||
|
<span class="font-mono text-[11px] text-ink-4">{{ __('patch.unknown') }}</span>
|
||||||
|
@elseif ($c['pending'] === 0)
|
||||||
|
<x-status-pill status="online">{{ __('patch.up_to_date') }}</x-status-pill>
|
||||||
|
@else
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<x-status-pill status="warning">{{ __('patch.pending_label', ['count' => $c['pending']]) }}</x-status-pill>
|
||||||
|
@if (($c['security'] ?? 0) > 0)
|
||||||
|
<x-status-pill status="offline">{{ __('patch.security_label', ['count' => $c['security']]) }}</x-status-pill>
|
||||||
|
@endif
|
||||||
|
@can('operate')
|
||||||
|
<x-modal-trigger variant="accent" action="patch('{{ $s->uuid }}')" wire:loading.attr="disabled">{{ __('patch.apply') }}</x-modal-trigger>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($result)
|
||||||
|
<pre class="mt-2 max-h-40 overflow-auto rounded-md border border-line bg-void p-3 font-mono text-[11px] leading-relaxed {{ $result['ok'] ? 'text-ink-2' : 'text-offline' }}">{{ $result['output'] }}</pre>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-panel>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -13,6 +13,7 @@ use App\Livewire\Dashboard;
|
||||||
use App\Livewire\Docker;
|
use App\Livewire\Docker;
|
||||||
use App\Livewire\Files;
|
use App\Livewire\Files;
|
||||||
use App\Livewire\Help;
|
use App\Livewire\Help;
|
||||||
|
use App\Livewire\Patch;
|
||||||
use App\Livewire\Posture;
|
use App\Livewire\Posture;
|
||||||
use App\Livewire\Release;
|
use App\Livewire\Release;
|
||||||
use App\Livewire\Servers;
|
use App\Livewire\Servers;
|
||||||
|
|
@ -239,6 +240,7 @@ Route::middleware('auth')->group(function () {
|
||||||
Route::get('/versions', Versions\Index::class)->name('versions');
|
Route::get('/versions', Versions\Index::class)->name('versions');
|
||||||
Route::get('/system', System\Index::class)->name('system');
|
Route::get('/system', System\Index::class)->name('system');
|
||||||
Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture');
|
Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture');
|
||||||
|
Route::get('/patch', Patch\Index::class)->middleware('can:operate')->name('patch');
|
||||||
Route::get('/help', Help\Index::class)->name('help');
|
Route::get('/help', Help\Index::class)->name('help');
|
||||||
Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard');
|
Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard');
|
||||||
Route::get('/terminal', Terminal\Index::class)->name('terminal');
|
Route::get('/terminal', Terminal\Index::class)->name('terminal');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\FleetService;
|
||||||
|
use App\Services\MaintenanceService;
|
||||||
|
use App\Support\Os\OsDetector;
|
||||||
|
use App\Support\Os\OsProfile;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MaintenanceUpdateCountsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function server(): Server
|
||||||
|
{
|
||||||
|
return Server::create(['name' => 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(string $runPlainOutput, OsProfile $os): MaintenanceService
|
||||||
|
{
|
||||||
|
$fleet = Mockery::mock(FleetService::class);
|
||||||
|
$fleet->shouldReceive('runPlain')->andReturn(['ok' => true, 'output' => $runPlainOutput]);
|
||||||
|
$detector = Mockery::mock(OsDetector::class);
|
||||||
|
$detector->shouldReceive('detect')->andReturn($os);
|
||||||
|
|
||||||
|
return new MaintenanceService($fleet, $detector);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apt(): OsProfile
|
||||||
|
{
|
||||||
|
return new OsProfile('debian', 'debian', 'Debian 13', 'apt', 'ufw', 'systemd', ['apt-get']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_parses_pending_and_security_counts(): void
|
||||||
|
{
|
||||||
|
$counts = $this->service("CLUSEV_OK\nCLUSEV_PENDING=12\nCLUSEV_SECURITY=3", $this->apt())->updateCounts($this->server());
|
||||||
|
|
||||||
|
$this->assertSame(12, $counts['pending']);
|
||||||
|
$this->assertSame(3, $counts['security']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_security_is_clamped_to_pending(): void
|
||||||
|
{
|
||||||
|
// A noisy grep could over-count security; it can never exceed the total.
|
||||||
|
$counts = $this->service("CLUSEV_OK\nCLUSEV_PENDING=2\nCLUSEV_SECURITY=9", $this->apt())->updateCounts($this->server());
|
||||||
|
|
||||||
|
$this->assertSame(2, $counts['pending']);
|
||||||
|
$this->assertSame(2, $counts['security']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_missing_sentinel_yields_unknown(): void
|
||||||
|
{
|
||||||
|
$counts = $this->service('some error, no sentinel', $this->apt())->updateCounts($this->server());
|
||||||
|
|
||||||
|
$this->assertNull($counts['pending']);
|
||||||
|
$this->assertNull($counts['security']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unsupported_manager_yields_unknown(): void
|
||||||
|
{
|
||||||
|
$alpine = new OsProfile('alpine', 'alpine', 'Alpine', 'apk', 'none', 'openrc', []);
|
||||||
|
$fleet = Mockery::mock(FleetService::class);
|
||||||
|
$fleet->shouldReceive('runPlain')->never(); // never even probes an unsupported manager
|
||||||
|
$detector = Mockery::mock(OsDetector::class);
|
||||||
|
$detector->shouldReceive('detect')->andReturn($alpine);
|
||||||
|
|
||||||
|
$counts = (new MaintenanceService($fleet, $detector))->updateCounts($this->server());
|
||||||
|
|
||||||
|
$this->assertNull($counts['pending']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Patch\Index;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\MaintenanceService;
|
||||||
|
use App\Support\Confirm\ConfirmToken;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PatchComponentTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function admin(): User
|
||||||
|
{
|
||||||
|
return User::factory()->create(['must_change_password' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operator(): User
|
||||||
|
{
|
||||||
|
return User::factory()->operator()->create(['must_change_password' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function viewer(): User
|
||||||
|
{
|
||||||
|
return User::factory()->viewer()->create(['must_change_password' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function server(string $name = 'box', string $ip = '10.0.0.1'): Server
|
||||||
|
{
|
||||||
|
$s = Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
|
||||||
|
$s->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
|
||||||
|
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_viewer_cannot_open_patch(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->viewer())->get('/patch')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_operator_can_open_patch(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operator())->get('/patch')->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scan_shows_pending_and_security_counts(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operator());
|
||||||
|
$this->server('web1');
|
||||||
|
|
||||||
|
$maint = Mockery::mock(MaintenanceService::class);
|
||||||
|
$maint->shouldReceive('updateCounts')->once()->andReturn(['pending' => 12, 'security' => 3]);
|
||||||
|
app()->instance(MaintenanceService::class, $maint);
|
||||||
|
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->call('scan')
|
||||||
|
->assertOk()
|
||||||
|
->assertSet('ready', true)
|
||||||
|
->assertSee('12')
|
||||||
|
->assertViewHas('totalSecurity', 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scan_survives_an_unreachable_server(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operator());
|
||||||
|
$this->server('down');
|
||||||
|
|
||||||
|
$maint = Mockery::mock(MaintenanceService::class);
|
||||||
|
$maint->shouldReceive('updateCounts')->once()->andThrow(new \RuntimeException('ssh fail'));
|
||||||
|
app()->instance(MaintenanceService::class, $maint);
|
||||||
|
|
||||||
|
Livewire::test(Index::class)->call('scan')->assertSet('ready', true)->assertSee(__('patch.scan_error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_opens_a_confirm(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operator());
|
||||||
|
$server = $this->server();
|
||||||
|
|
||||||
|
Livewire::test(Index::class)->call('patch', $server->uuid)->assertDispatched('openModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_apply_patch_runs_upgrades_over_the_sealed_server(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operator());
|
||||||
|
$server = $this->server();
|
||||||
|
|
||||||
|
$maint = Mockery::mock(MaintenanceService::class);
|
||||||
|
$maint->shouldReceive('applyUpgrades')->once()->with(Mockery::on(fn ($s) => $s->id === $server->id))->andReturn(['ok' => true, 'output' => 'upgraded 12 packages']);
|
||||||
|
$maint->shouldReceive('updateCounts')->andReturn(['pending' => 0, 'security' => 0]); // post-patch re-scan
|
||||||
|
app()->instance(MaintenanceService::class, $maint);
|
||||||
|
|
||||||
|
$token = ConfirmToken::issue('patchApply', ['uuid' => $server->uuid], 'patch.apply', $server->name, $server->id);
|
||||||
|
ConfirmToken::confirm($token);
|
||||||
|
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->call('applyPatch', $token)
|
||||||
|
->assertOk()
|
||||||
|
->assertSet("results.{$server->uuid}.output", 'upgraded 12 packages');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_forged_patch_token_runs_nothing(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->operator());
|
||||||
|
$maint = Mockery::mock(MaintenanceService::class);
|
||||||
|
$maint->shouldReceive('applyUpgrades')->never();
|
||||||
|
app()->instance(MaintenanceService::class, $maint);
|
||||||
|
|
||||||
|
Livewire::test(Index::class)->call('applyPatch', 'not-a-token')->assertSet('results', []);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue