Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,27 @@ request for minutes. A status older than three job intervals (at least six
hours), or background jobs that do not run from system cron, count as a
warning, so an old all-clear is never shown as current. "Check now" on the page
queues a check for the next cron run and shows the result when it is done;
only one check runs at a time. Admins can also run:
only one check runs at a time.

The hourly job asks the package manager for pending updates only when
something they depend on changed: it compares the modification times of the
installed-package database, the repository metadata and the package manager's
sources, pinning and lock configuration with those of the last run. If none
changed, it keeps the last list of pending updates, held packages and last
upgrade, and checks everything else (reboot, services, time sync) again. The
package manager is still asked at least once a day, and whenever a file cannot
be checked, the last query failed or was incomplete, more than 500 updates are
pending, or the updates came from the root helper. On Arch this only applies
with `os_allow_network` off, because `checkupdates` and `arch-audit` answer
from the network. "Check now" and `--refresh` below always ask the package
manager. Admins can also run:

```
occ serverinfo:os-status [--refresh] [--output=json]
```

The command shows how old the status is and exits with 0, 1, 2 or 3 for ok,
The command shows how old the status is and when the pending updates were
last checked (`updatesCheckedAt`), and exits with 0, 1, 2 or 3 for ok,
warning, error and unknown, so it can be used as a monitoring check. Unknown means the status has not been
collected yet, or nothing is wrong but the pending updates could not be
determined. The same verdict appears as setup checks on Settings → Overview
Expand Down
1 change: 1 addition & 0 deletions lib/Commands/ShowOsStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public function __invoke(
'packageManager' => $status->packageManager ?? 'none',
'securityUpdates' => $securityUpdates === null ? 'unknown' : count($securityUpdates),
'updates' => $status->updates === null ? 'unknown' : count($status->updates) + $status->updatesOmitted,
'updatesCheckedAt' => $status->updatesCheckedAt === null ? 'unknown' : date(DATE_ATOM, $status->updatesCheckedAt),
'updatesPendingDays' => is_int($pendingDays) ? $pendingDays : 'none',
'rebootRequired' => $status->rebootReasons === null ? 'unknown' : ($status->rebootReasons !== [] ? 'yes' : 'no'),
'helper' => $status->helper->state,
Expand Down
2 changes: 1 addition & 1 deletion lib/Jobs/UpdateOsStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ protected function run($argument): void {
return;
}

if ($this->collector->collectAndStore() !== null) {
if ($this->collector->collectAndStore(reuseUnchangedUpdates: true) !== null) {
$this->notifications->sync();
}
}
Expand Down
32 changes: 32 additions & 0 deletions lib/OsStatus/OsStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ final class OsStatus {
* @param list<string>|null $failedUnits
* @param array{all: ?string, security: ?string} $upgradeCommands
* @param list<string> $diagnostics
* @param array<string, int>|null $updateInputs
*/
public function __construct(
public readonly string $state,
Expand Down Expand Up @@ -81,6 +82,12 @@ public function __construct(
public readonly HelperState $helper = new HelperState(HelperState::STATE_ABSENT, HelperReportReader::DEFAULT_PATH),
public readonly ?int $bootTime = null,
public readonly array $diagnostics = [],
/** When the pending updates were last asked of the package manager or written by the helper */
public readonly ?int $updatesCheckedAt = null,
/** Whether the pending updates were taken over from the previous run, because nothing they depend on changed */
public readonly bool $updatesReused = false,
/** Modification times of what the pending updates depend on, set when a later run may reuse them */
public readonly ?array $updateInputs = null,
) {
}

Expand Down Expand Up @@ -135,6 +142,9 @@ public function toArray(): array {
'helper' => $this->helper->toArray(),
'bootTime' => $this->bootTime,
'diagnostics' => $this->diagnostics,
'updatesCheckedAt' => $this->updatesCheckedAt,
'updatesReused' => $this->updatesReused,
'updateInputs' => $this->updateInputs,
];
}

Expand Down Expand Up @@ -186,9 +196,31 @@ public static function fromArray(mixed $data): ?self {
HelperState::fromArray($data['helper'] ?? null, HelperReportReader::DEFAULT_PATH),
Values::int($data['bootTime'] ?? null),
self::stringList($data['diagnostics'] ?? null) ?? [],
Values::int($data['updatesCheckedAt'] ?? null),
($data['updatesReused'] ?? false) === true,
self::modificationTimes($data['updateInputs'] ?? null),
);
}

/**
* @return array<string, int>|null null unless every entry is a path with a time
*/
private static function modificationTimes(mixed $items): ?array {
if (!is_array($items) || $items === []) {
return null;
}

$times = [];
foreach ($items as $path => $time) {
if (!is_string($path) || !is_int($time)) {
return null;
}
$times[$path] = $time;
}

return $times;
}

/**
* @template T
* @param callable(mixed): (T|null) $map
Expand Down
91 changes: 82 additions & 9 deletions lib/OsStatus/OsStatusCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use OCA\ServerInfo\OsStatus\Packages\PackageManagerFactory;
use OCA\ServerInfo\OsStatus\Packages\PendingUpdate;
use OCA\ServerInfo\OsStatus\Packages\ProbeException;
use OCA\ServerInfo\OsStatus\Packages\ProbeHelper;
use OCA\ServerInfo\OsStatus\Reboot\CoreLibraryProbe;
use OCA\ServerInfo\OsStatus\Reboot\KernelProbe;
use OCA\ServerInfo\OsStatus\Reboot\RebootReason;
Expand Down Expand Up @@ -53,6 +54,9 @@ class OsStatusCollector {
/** Held while a collection runs, so package managers never run twice at once. */
public const LOCK = 'serverinfo/os-status-collection';

/** Pending updates are asked of the package manager at least this often, even when nothing changed. */
public const REUSE_UPDATES_FOR = 86400;

public function __construct(
private IHost $host,
private IAppConfig $appConfig,
Expand All @@ -74,7 +78,13 @@ public function __construct(
) {
}

public function collect(): OsStatus {
/**
* With $reuseUnchangedUpdates, the pending updates, held packages and
* last upgrade of the previous run are kept when nothing they depend on
* changed, which spares a scheduled run the package manager's query.
* Everything else is asked again.
*/
public function collect(bool $reuseUnchangedUpdates = false): OsStatus {
$now = $this->timeFactory->getTime();

if ($this->appConfig->getAppValueString('restricted_mode', 'no') === 'yes') {
Expand Down Expand Up @@ -125,11 +135,26 @@ public function collect(): OsStatus {

$updates = null;
$updatesSource = null;
$updatesCheckedAt = null;
$needsRoot = null;
$inputs = null;
$reused = null;
if ($packageManager !== null) {
// Taken before the query, so that a change while it runs shows on the next run.
$inputs = $this->probe('What the pending updates depend on', fn (): ?array => $this->updateInputs($packageManager), null, $diagnostics);
if ($reuseUnchangedUpdates && $inputs !== null && $release !== null) {
$reused = $this->reusableUpdates($packageManager, $release, $inputs, $now);
}
}
if ($reused !== null) {
$updates = $reused->updates;
$updatesSource = OsStatus::SOURCE_SYSTEM;
$updatesCheckedAt = $reused->updatesCheckedAt;
} elseif ($packageManager !== null) {
try {
$updates = $packageManager->getPendingUpdates();
$updatesSource = OsStatus::SOURCE_SYSTEM;
$updatesCheckedAt = $now;
} catch (NeedsRootException $e) {
$needsRoot = $e->getMessage();
} catch (ProbeException $e) {
Expand All @@ -143,6 +168,7 @@ public function collect(): OsStatus {
if ($updates === null && $reportedUpdates !== null && $environment->managesPackages()) {
$updates = $reportedUpdates;
$updatesSource = OsStatus::SOURCE_HELPER;
$updatesCheckedAt = $report?->generatedAt;
}
if ($needsRoot !== null && $updatesSource !== OsStatus::SOURCE_HELPER) {
$diagnostics[] = $needsRoot;
Expand All @@ -156,6 +182,7 @@ public function collect(): OsStatus {
if ($updates === [] && $updatesSource === OsStatus::SOURCE_SYSTEM && $listsRefreshedAt === null && in_array($packageManager?->getId(), OsVerdict::LISTS_AGE_MATTERS, true)) {
$updates = null;
$updatesSource = null;
$updatesCheckedAt = null;
$diagnostics[] = 'The package lists could not be found, so pending updates are unknown until the lists are refreshed.';
}

Expand All @@ -164,7 +191,7 @@ public function collect(): OsStatus {
: null;
$restartServices = $environment->managesPackages() ? $report?->restartServices : null;

$classified = $packageManager?->classifiesSecurity() ?? ($updatesSource === OsStatus::SOURCE_HELPER);
$classified = $reused?->securityClassified ?? $packageManager?->classifiesSecurity() ?? ($updatesSource === OsStatus::SOURCE_HELPER);
[$updates, $rebootReasons, $restartServices] = $this->probe(
'How long the findings have been pending',
fn (): array => $this->applyFirstSeen($updates, $classified, $rebootReasons, $restartServices, $now),
Expand All @@ -179,9 +206,11 @@ public function collect(): OsStatus {
$updates = array_slice($updates, 0, self::MAX_LISTED_UPDATES);
}

if ($packageManager instanceof IReportsDiagnostics) {
array_push($diagnostics, ...$packageManager->getDiagnostics());
}
$packageDiagnostics = $reused === null && $packageManager instanceof IReportsDiagnostics ? $packageManager->getDiagnostics() : [];
array_push($diagnostics, ...$packageDiagnostics);

// Only a complete answer of the package manager is worth reusing.
$reusableInputs = $updatesSource === OsStatus::SOURCE_SYSTEM && $packageDiagnostics === [] && $omitted === 0 ? $inputs : null;

$timeSync = $this->probe('The time synchronisation', fn (): array => $this->timeSync->probe(), ['synchronized' => null, 'source' => null], $diagnostics);

Expand All @@ -198,9 +227,9 @@ public function collect(): OsStatus {
$updatesSource,
$classified,
$omitted,
$packageManager !== null ? $this->probe('The held packages', fn (): ?array => $packageManager->getHeldPackages(), null, $diagnostics) : null,
$reused?->held ?? ($packageManager !== null ? $this->probe('The held packages', fn (): ?array => $packageManager->getHeldPackages(), null, $diagnostics) : null),
$listsRefreshedAt,
$packageManager !== null ? $this->probe('The last upgrade', fn (): ?int => $packageManager->getLastUpgradeAt(), null, $diagnostics) : null,
$reused?->lastUpgradeAt ?? ($packageManager !== null ? $this->probe('The last upgrade', fn (): ?int => $packageManager->getLastUpgradeAt(), null, $diagnostics) : null),
$packageManager !== null
? $this->probe('The automatic updates', fn (): AutomaticUpdates => $packageManager->getAutomaticUpdates(), AutomaticUpdates::unknown(), $diagnostics)
: AutomaticUpdates::unknown(),
Expand All @@ -217,9 +246,51 @@ public function collect(): OsStatus {
$helperState,
$bootTime,
$diagnostics,
$updatesCheckedAt,
$reused !== null,
$reusableInputs,
);
}

/**
* @return array<string, int>|null null when any of it cannot be checked
*/
private function updateInputs(IPackageManager $packageManager): ?array {
$paths = $packageManager->getUpdateInputs();
$inputs = $paths === null ? null : ProbeHelper::fingerprint($this->host, $paths);

return $inputs === [] ? null : $inputs;
}

/**
* The previous status, when its pending updates still hold: the same
* package manager on the same release found them completely, less than
* REUSE_UPDATES_FOR ago, and nothing they depend on was touched since.
*
* @param array<string, int> $inputs
*/
private function reusableUpdates(IPackageManager $packageManager, OsRelease $release, array $inputs, int $now): ?OsStatus {
$previous = $this->store->load();
$checkedAt = $previous?->updatesCheckedAt;
if ($previous === null
|| $previous->updateInputs === null
|| $previous->updates === null
|| $previous->updatesSource !== OsStatus::SOURCE_SYSTEM
|| $previous->packageManager !== $packageManager->getId()
|| $previous->release?->toArray() !== $release->toArray()
|| $checkedAt === null
|| $checkedAt > $now
|| $now - $checkedAt >= self::REUSE_UPDATES_FOR) {
return null;
}

$stored = $previous->updateInputs;
ksort($stored);
ksort($inputs);

return $stored === $inputs ? $previous : null;
}

/**
* Runs one probe, so that a probe that throws costs its own finding and
* not the whole run.
Expand Down Expand Up @@ -247,8 +318,10 @@ private function probe(string $finding, callable $probe, mixed $fallback, array
* Collects and stores the result, logging rather than throwing so a
* failure does not take the background job queue down with it. Returns
* null without collecting while another collection runs.
*
* @param bool $reuseUnchangedUpdates as for collect()
*/
public function collectAndStore(): ?OsStatus {
public function collectAndStore(bool $reuseUnchangedUpdates = false): ?OsStatus {
try {
$this->locks->acquireLock(self::LOCK, ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) {
Expand All @@ -258,7 +331,7 @@ public function collectAndStore(): ?OsStatus {
}

try {
$status = $this->collect();
$status = $this->collect($reuseUnchangedUpdates);
$this->store->save($status);

return $status;
Expand Down
15 changes: 15 additions & 0 deletions lib/OsStatus/Packages/Apk.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,19 @@ public function getUpgradeCommands(): array {
public function getPackageDatabasePath(): ?string {
return '/lib/apk/db/installed';
}

/**
* The installed packages, the cached indexes, the repositories and the
* constraints in /etc/apk/world.
*/
#[\Override]
public function getUpdateInputs(): ?array {
return [
'/lib/apk/db/installed',
'/var/cache/apk',
'/var/cache/apk/APKINDEX.*.tar.gz',
'/etc/apk/repositories',
self::WORLD,
];
}
}
22 changes: 22 additions & 0 deletions lib/OsStatus/Packages/Apt.php
Original file line number Diff line number Diff line change
Expand Up @@ -330,4 +330,26 @@ public function getUpgradeCommands(): array {
public function getPackageDatabasePath(): ?string {
return self::DPKG_STATUS;
}

/**
* dpkg rewrites its status file, which also records holds, on every
* change, and apt update moves each changed list into the lists
* directory. Sources, pinning and apt's configuration choose among them.
*/
#[\Override]
public function getUpdateInputs(): ?array {
return [
self::DPKG_STATUS,
'/var/lib/dpkg/updates',
self::LISTS,
'/etc/apt/sources.list',
'/etc/apt/sources.list.d',
'/etc/apt/sources.list.d/*',
'/etc/apt/preferences',
'/etc/apt/preferences.d',
'/etc/apt/preferences.d/*',
'/etc/apt/apt.conf.d',
'/etc/apt/apt.conf.d/*',
];
}
}
25 changes: 25 additions & 0 deletions lib/OsStatus/Packages/Dnf.php
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,31 @@ public function getPackageDatabasePath(): ?string {
return $this->rpm->getDatabasePath();
}

/**
* The RPM database, the system cache that check-update and updateinfo
* read with --cacheonly, the repositories and the version locks.
*/
#[\Override]
public function getUpdateInputs(): ?array {
$database = $this->rpm->getUpdateInputs();
if ($database === null) {
return null;
}

return [
...$database,
'/var/cache/dnf',
'/var/cache/dnf/*/repodata/repomd.xml',
'/var/cache/libdnf5',
'/var/cache/libdnf5/*/repodata/repomd.xml',
'/etc/dnf/dnf.conf',
'/etc/dnf/plugins/versionlock.list',
'/etc/dnf/versionlock.toml',
'/etc/yum.repos.d',
'/etc/yum.repos.d/*.repo',
];
}

/**
* Told apart by the binaries rather than by running dnf, because running
* dnf 5 as the web server is what has to be avoided. Where both are
Expand Down
11 changes: 11 additions & 0 deletions lib/OsStatus/Packages/IPackageManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,15 @@ public function getUpgradeCommands(): array;
* tell that a cached status is out of date.
*/
public function getPackageDatabasePath(): ?string;

/**
* Files, directories and glob patterns whose modification times change
* whenever getPendingUpdates() or getHeldPackages() could answer
* differently: the installed packages, the repository metadata and the
* configuration that selects or holds back updates.
*
* @return list<string>|null null when the answer depends on more than
* local files, e.g. on the network
*/
public function getUpdateInputs(): ?array;
}
Loading
Loading