feat(modules): resolve module update sources + weekly update-check cron

ModuleUpdateChecker resolves the latest available version per module by source:
git (via GitHubReleases), url (version.json), platform (best-effort), bundled
(no-op). ModuleUpdatesCronJob (cron:module_updates) records available_version
into config/modules.php; migration 008 registers it to run weekly. Network-free
unit tests cover the routing.
This commit is contained in:
Divarion-D
2026-07-03 20:10:41 +03:00
parent 6de8ab7d9f
commit cb4dfe04b5
4 changed files with 246 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace XcVm\Cli\CronJobs;
use XcVm\Cli\CommandInterface;
use XcVm\Core\Container\ServiceContainer;
use XcVm\Core\Module\ModuleManager;
use XcVm\Core\Module\ModuleUpdateChecker;
/**
* ModuleUpdatesCronJob — weekly check of module update availability.
*
* For every INSTALLED module it resolves the latest version from the module's
* declared source (the `update` manifest block: git/url/platform; `bundled`
* compares the on-disk version) via ModuleUpdateChecker, and records a
* `available_version` in config/modules.php when it is strictly newer than the
* installed version (otherwise clears it). listModules()/the admin panel read
* that flag to show the "Update to X" button only when an update actually exists.
*
* Read-only w.r.t. the modules themselves — it never downloads or applies files
* (that is ModuleManager::updateModuleFromSource, a later phase).
*
* Scheduled via the `crontab` table (migration 008_add_module_updates_cron.sql).
*
* @package XC_VM_CLI_CronJobs
* @author Divarion_D <https://github.com/Divarion-D>
* @copyright 2025-2026 Vateron Media
* @link https://github.com/Vateron-Media/XC_VM
* @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html
*/
class ModuleUpdatesCronJob implements CommandInterface {
public function getName(): string {
return 'cron:module_updates';
}
public function getDescription(): string {
return 'Cron: check module update availability from their declared sources';
}
public function execute(array $rArgs): int {
register_shutdown_function(function () {
global $db;
if (is_object($db)) {
$db->close_mysql();
}
});
$rManager = new ModuleManager(container: ServiceContainer::getInstance());
$rChecker = new ModuleUpdateChecker();
foreach ($rManager->listModules() as $rModule) {
// Only installed modules — the check compares against installed_version.
if (($rModule['installed_version'] ?? '') === '') {
continue;
}
$rInstalled = (string) $rModule['installed_version'];
$rLatest = $rChecker->latestAvailable($rModule);
if ($rLatest !== null && version_compare($rLatest, $rInstalled, '>')) {
$rManager->recordAvailableVersion($rModule['name'], $rLatest);
echo '[UPDATE] ' . $rModule['name'] . ': ' . $rInstalled . ' -> ' . $rLatest . "\n";
} else {
// Nothing newer / not checkable — clear any stale flag.
$rManager->recordAvailableVersion($rModule['name'], null);
}
}
return 0;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace XcVm\Core\Module;
use XcVm\Core\Updates\GitHubReleases;
/**
* ModuleUpdateChecker — resolve the latest available version of a module from its
* declared update source (the `update` manifest block, see ModuleLoader).
*
* Pure read-only resolution — no files are changed here (that is P4's
* updateModuleFromSource). Returns the latest known version string, or null when
* there is nothing newer or the source cannot be checked. Every failure is
* swallowed (logged) and returns null so an update check never breaks the caller.
*
* Sources:
* - bundled : files ship with the panel → the on-disk manifest version is authoritative
* - git : GitHub releases of `update.repository` (reuses GitHubReleases)
* - url : a `version.json` (`{"version":"…"}`) at `update.url` (https only)
* - platform : the SaaS store via the xcvm_core extension (best-effort; skipped if absent)
*
* @package XC_VM_Core_Module
* @author Divarion_D <https://github.com/Divarion-D>
* @copyright 2025-2026 Vateron Media
* @link https://github.com/Vateron-Media/XC_VM
* @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html
*/
class ModuleUpdateChecker {
/**
* Latest available version for a module (a listModules() row), or null.
*
* @param array $module Module row with keys `update`, `version`, `installed_version`.
* @return string|null Version string, or null if nothing newer / not checkable.
*/
public function latestAvailable(array $module): ?string {
$update = is_array($module['update'] ?? null) ? $module['update'] : [];
$source = (string) ($update['source'] ?? 'bundled');
$installed = (string) ($module['installed_version'] ?? '');
return match ($source) {
'git' => $this->fromGit($update, $installed),
'url' => $this->fromUrl($update),
'platform' => $this->fromPlatform($update),
default => ((string) ($module['version'] ?? '')) ?: null, // bundled
};
}
/** GitHub releases of update.repository, newest newer-than-installed (or null). */
private function fromGit(array $update, string $installed): ?string {
$repo = (string) ($update['repository'] ?? '');
// https://github.com/OWNER/REPO(.git) | git@github.com:OWNER/REPO.git
if (!preg_match('~github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$~i', $repo, $m)) {
return null;
}
// Map the manifest channel onto GitHubReleases' stable/unstable.
$channel = in_array((string) ($update['channel'] ?? 'stable'), ['beta', 'unstable'], true)
? 'unstable'
: 'stable';
try {
$gh = new GitHubReleases($m[1], $m[2], $channel);
return $gh->getLatestVersion($installed !== '' ? $installed : '0.0.0');
} catch (\Throwable $e) {
error_log('ModuleUpdateChecker(git): ' . $e->getMessage());
return null;
}
}
/** version.json at a self-hosted https URL: {"version":"1.2.3"}. */
private function fromUrl(array $update): ?string {
$url = (string) ($update['url'] ?? '');
if ($url === '' || stripos($url, 'https://') !== 0) {
return null; // https only — SSRF/downgrade guard
}
$data = json_decode($this->httpGet($url), true);
$ver = is_array($data) ? trim((string) ($data['version'] ?? '')) : '';
return $ver !== '' ? $ver : null;
}
/** SaaS store latest version — best-effort; skipped if the extension has no such API. */
private function fromPlatform(array $update): ?string {
if (!class_exists('XC_VM') || !method_exists('XC_VM', 'module_latest')) {
return null; // store resolves "latest approved" at install time
}
try {
$r = \XC_VM::module_latest((string) ($update['slug'] ?? ''));
return is_array($r) && !empty($r['version']) ? (string) $r['version'] : null;
} catch (\Throwable $e) {
return null;
}
}
/** cURL GET (file_get_contents over https does not work under PHP-FPM here). */
private function httpGet(string $url): string {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_USERAGENT, 'XC_VM-ModuleUpdateChecker');
$body = curl_exec($ch);
curl_close($ch);
return is_string($body) ? $body : '';
}
}
@@ -0,0 +1,8 @@
-- Weekly check for module update availability (sources: git / url / platform).
-- Regenerated into the xc_vm crontab as `console.php cron:module_updates`
-- (the crontab table maps `filename` → `cron:<filename>`). Read-only: it only
-- records `available_version` in config/modules.php, never downloads/applies files.
-- Runs Tuesdays 04:30 (just after the MaxMind GeoIP job at 04:00).
INSERT INTO `crontab` (`filename`, `time`, `enabled`)
SELECT 'module_updates', '30 4 * * 2', 1
WHERE NOT EXISTS (SELECT 1 FROM `crontab` WHERE `filename` = 'module_updates');
+61
View File
@@ -0,0 +1,61 @@
<?php
use XcVm\Core\Module\ModuleUpdateChecker;
use PHPUnit\Framework\TestCase;
/**
* Network-free coverage of the source routing + guards. The actual git/url
* fetches are integration concerns (they hit the network) and are not exercised
* here — only the branches that resolve without any HTTP call.
*/
final class ModuleUpdateCheckerTest extends TestCase {
private ModuleUpdateChecker $checker;
protected function setUp(): void {
$this->checker = new ModuleUpdateChecker();
}
public function testBundledReturnsOnDiskVersion(): void {
$result = $this->checker->latestAvailable([
'update' => ['source' => 'bundled'],
'version' => '1.2.0',
'installed_version' => '1.0.0',
]);
$this->assertSame('1.2.0', $result);
}
public function testAbsentUpdateBlockTreatedAsBundled(): void {
$result = $this->checker->latestAvailable([
'version' => '2.0.0',
'installed_version' => '1.0.0',
]);
$this->assertSame('2.0.0', $result);
}
public function testUrlSourceRejectsNonHttps(): void {
$result = $this->checker->latestAvailable([
'update' => ['source' => 'url', 'url' => 'http://example.com/version.json'],
'installed_version' => '1.0.0',
]);
$this->assertNull($result); // https-only guard, no network
}
public function testGitSourceWithInvalidRepoReturnsNull(): void {
$result = $this->checker->latestAvailable([
'update' => ['source' => 'git', 'repository' => 'not-a-github-url'],
'installed_version' => '1.0.0',
]);
$this->assertNull($result); // regex fails before any network call
}
public function testPlatformWithoutExtensionReturnsNull(): void {
// The xcvm_core extension is not loaded in the test runtime, so the
// platform branch short-circuits to null.
$result = $this->checker->latestAvailable([
'update' => ['source' => 'platform', 'slug' => 'watch'],
'installed_version' => '1.0.0',
]);
$this->assertNull($result);
}
}