mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-20 20:03:18 +02:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user