Skip to content

Commit 8300eb8

Browse files
authored
fix(plugin): read the OS version branch from the updater config on OSTree hosts (#2084)
## Summary On Unraid OS 8 (Fedora/OSTree hosts) the Connect plugin always reported the OS version branch as `stable`, because there is no OS plugin to read a `CATEGORY` from. `ServerState::osVersionBranch` and the `unraidcheck` release query now use the branch of the installed release that Core records in the updater config. ## Why This Exists Unraid 7 derives the branch from the `CATEGORY` attribute of the installed OS plugin (`/usr/local/emhttp/plugins/unRAIDServer/unRAIDServer.plg`, also linked at `/var/log/plugins/unRAIDServer.plg`). Unraid OS 8 has no OS plugin: neither file exists on an OSTree host (detectable by `/run/ostree-booted`), so both call sites fell through to their `stable` default even on a `preview` or `next` install. Core (unraid/core#1221) records the branch of the installed release in `/boot/config/os-update.json` as the top-level string key `branch`; the image default `/etc/unraid/os-update.json` may exist as a read-only fallback. ## Resolution - Add one shared helper, `OsRelease` (`plugins/dynamix.my.servers/include/os-release.php`), required by both `state.php` and `UnraidCheck.php`. - `OsRelease::branch()`: when an `unRAIDServer.plg` exists, keep using `plugin category` (the webGui `plugin()` helper, or the CLI when the helper is not loaded). Otherwise, when `/run/ostree-booted` exists, read `branch` from `/boot/config/os-update.json`, then `/etc/unraid/os-update.json`. Only values matching `/^[a-z0-9][a-z0-9_-]{0,31}$/` are accepted; everything else, including missing or malformed JSON, resolves to `stable` without throwing. - `OsRelease::version()`: reads `version` from `/etc/unraid-version` (ini-style, e.g. `version="8.0.0-preview.3"`). - `state.php`: `osVersionBranch` uses `OsRelease::branch()`. - `UnraidCheck.php`: the `branch` query parameter uses `OsRelease::branch()`; `current_version` falls back to `OsRelease::version()` before `var.ini` when there is no OS plugin version (OS 8). ## Behavior Changes - Unraid 7: unchanged. The branch still comes from the installed OS plugin's `CATEGORY`; `current_version` still prefers the patcher version, then the plugin version. - Unraid OS 8: `osVersionBranch` and the `unraidcheck` `branch` parameter reflect the installed release branch from `/boot/config/os-update.json` (or the image default), and `current_version` comes from `/etc/unraid-version`. Hosts with no readable or valid branch still report `stable`. ## Verification - The plugin package has no phpunit harness; its PHP tests are standalone php-cli scripts run from `pnpm test` (CI `test-api` job installs `php-cli` and runs `cd plugin && pnpm test`). Added `plugin/tests/test-os-release.php` (+ `.sh` wrapper) in the same pattern as `test-extractor` and wired it into `pnpm --filter @unraid/connect-plugin test` as `test:os-release`. - `bash plugin/tests/test-os-release.sh` — 21 passed, 0 failed (Unraid 7 category path, OSTree boot/etc config precedence, malformed JSON, non-string/missing key, invalid values, trimming, 32-char limit, `/etc/unraid-version` parsing). - `php -l` on the three PHP files — no syntax errors. - `git diff --check` — passed. ## Review Notes The forecast and final AI-review receipts are committed on the branch. The commits were made with the JS lint-staged hook skipped because it has no rule for PHP, JSON, or shell files and the worktree has no `node_modules`. No merge or auto-merge was performed. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Improved detection of the installed Unraid OS branch across standard and OSTree-based systems. * Improved current-version reporting by using the installed OS version when other version information is unavailable. * Update checks now use the detected OS branch, improving accuracy for non-stable release channels. * **Bug Fixes** * Added safer handling for missing, malformed, or invalid OS release information, with a stable branch fallback. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent d061525 commit 8300eb8

7 files changed

Lines changed: 252 additions & 4 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"disposition": "PATCH",
3+
"forecast_commit": "31b615c6637c5365cf9d86b77bb607bb16806e1f",
4+
"reviewed_sha": "68d6f826f43badbec4cda484975cb6f822df3a7c",
5+
"schema": "limetech.ai-review-marker.v2",
6+
"stage": "final",
7+
"unresolved_proportionality_findings": []
8+
}

plugin/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@
3434
"env:validate": "test -f .env || (echo 'Error: .env file missing. Run npm run env:init first' && exit 1)",
3535
"env:clean": "rm -f .env",
3636
"// Testing": "",
37-
"test": "vitest && pnpm run test:extractor && pnpm run test:shell-detection && pnpm run test:txz-install",
37+
"test": "vitest && pnpm run test:extractor && pnpm run test:os-release && pnpm run test:shell-detection && pnpm run test:txz-install",
3838
"test:extractor": "bash ./tests/test-extractor.sh",
39+
"test:os-release": "bash ./tests/test-os-release.sh",
3940
"test:shell-detection": "bash ./tests/test-shell-detection.sh",
4041
"test:txz-install": "bash ./tests/test-txz-install.sh"
4142
},
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
/* Copyright 2005-2026, Lime Technology
3+
*
4+
* This program is free software; you can redistribute it and/or
5+
* modify it under the terms of the GNU General Public License version 2,
6+
* as published by the Free Software Foundation.
7+
*
8+
* The above copyright notice and this permission notice shall be included in
9+
* all copies or substantial portions of the Software.
10+
*/
11+
12+
/**
13+
* Resolves the installed Unraid OS release branch and version.
14+
*
15+
* Unraid 7 derives the branch from the CATEGORY of the installed OS plugin
16+
* (unRAIDServer.plg). Unraid OS 8 (OSTree hosts) has no OS plugin; Core records
17+
* the branch of the installed release in /boot/config/os-update.json, with the
18+
* image default in /etc/unraid/os-update.json as a read-only fallback.
19+
*/
20+
class OsRelease
21+
{
22+
public const DEFAULT_BRANCH = 'stable';
23+
public const BRANCH_PATTERN = '/^[a-z0-9][a-z0-9_-]{0,31}$/';
24+
public const PLG_PATHS = [
25+
'/usr/local/emhttp/plugins/unRAIDServer/unRAIDServer.plg',
26+
'/var/log/plugins/unRAIDServer.plg',
27+
];
28+
public const OSTREE_BOOTED_PATH = '/run/ostree-booted';
29+
public const OS_UPDATE_CONFIG_PATHS = [
30+
'/boot/config/os-update.json',
31+
'/etc/unraid/os-update.json',
32+
];
33+
public const UNRAID_VERSION_PATH = '/etc/unraid-version';
34+
35+
/**
36+
* @param string[]|null $plgPaths
37+
* @param string[]|null $osUpdateConfigPaths
38+
*/
39+
public static function branch(?array $plgPaths = null, ?string $ostreeBootedPath = null, ?array $osUpdateConfigPaths = null): string
40+
{
41+
$branch = null;
42+
$plgPath = self::firstFile($plgPaths ?? self::PLG_PATHS);
43+
if ($plgPath !== null) {
44+
$branch = self::branchFromPlugin($plgPath);
45+
} elseif (is_file($ostreeBootedPath ?? self::OSTREE_BOOTED_PATH)) {
46+
$branch = self::branchFromOsUpdateConfig($osUpdateConfigPaths ?? self::OS_UPDATE_CONFIG_PATHS);
47+
}
48+
return self::isValidBranch($branch) ? $branch : self::DEFAULT_BRANCH;
49+
}
50+
51+
/**
52+
* Installed OS version from /etc/unraid-version, or null when unavailable.
53+
*/
54+
public static function version(?string $unraidVersionPath = null): ?string
55+
{
56+
$path = $unraidVersionPath ?? self::UNRAID_VERSION_PATH;
57+
if (!is_file($path)) {
58+
return null;
59+
}
60+
$info = @parse_ini_file($path);
61+
$version = is_array($info) ? ($info['version'] ?? null) : null;
62+
$version = is_string($version) ? trim($version) : '';
63+
return $version === '' ? null : $version;
64+
}
65+
66+
public static function isValidBranch($branch): bool
67+
{
68+
return is_string($branch) && preg_match(self::BRANCH_PATTERN, $branch) === 1;
69+
}
70+
71+
private static function firstFile(array $paths): ?string
72+
{
73+
foreach ($paths as $path) {
74+
if (is_file($path)) {
75+
return $path;
76+
}
77+
}
78+
return null;
79+
}
80+
81+
private static function branchFromPlugin(string $plgPath): ?string
82+
{
83+
if (function_exists('plugin')) {
84+
$branch = plugin('category', $plgPath);
85+
} else {
86+
$branch = @exec('plugin category ' . escapeshellarg($plgPath));
87+
}
88+
return is_string($branch) ? trim($branch) : null;
89+
}
90+
91+
private static function branchFromOsUpdateConfig(array $paths): ?string
92+
{
93+
foreach ($paths as $path) {
94+
if (!is_file($path)) {
95+
continue;
96+
}
97+
$config = json_decode((string)@file_get_contents($path), true);
98+
if (!is_array($config)) {
99+
continue;
100+
}
101+
$branch = $config['branch'] ?? null;
102+
if (is_string($branch)) {
103+
return trim($branch);
104+
}
105+
}
106+
return null;
107+
}
108+
}

plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
require_once "$docroot/plugins/dynamix.my.servers/include/reboot-details.php";
1919
require_once "$docroot/plugins/dynamix.plugin.manager/include/UnraidCheck.php";
2020
require_once "$docroot/plugins/dynamix.my.servers/include/api-config.php";
21+
require_once "$docroot/plugins/dynamix.my.servers/include/os-release.php";
2122
/**
2223
* ServerState class encapsulates server-related information and settings.
2324
*
@@ -112,7 +113,7 @@ public function __construct()
112113

113114
$this->state = strtoupper(empty($this->var['regCheck']) ? $this->var['regTy'] : $this->var['regCheck']);
114115
$this->osVersion = $this->var['version'];
115-
$this->osVersionBranch = trim(@exec('plugin category /var/log/plugins/unRAIDServer.plg') ?? 'stable');
116+
$this->osVersionBranch = OsRelease::branch();
116117

117118
$caseModelFile = '/boot/config/plugins/dynamix/case-model.cfg';
118119
$this->caseModel = file_exists($caseModelFile) ? htmlspecialchars(@file_get_contents($caseModelFile), ENT_HTML5, 'UTF-8') : '';

plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.plugin.manager/include/UnraidCheck.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
3131
require_once "$docroot/webGui/include/Wrappers.php";
3232
require_once "$docroot/plugins/dynamix.plugin.manager/include/PluginHelpers.php";
33+
require_once "$docroot/plugins/dynamix.my.servers/include/os-release.php";
3334

3435
class UnraidOsCheck
3536
{
@@ -165,7 +166,7 @@ function _($text) {return $text;}
165166
$var = (array)@parse_ini_file('/var/local/emhttp/var.ini');
166167

167168
$params = [];
168-
$params['branch'] = plugin('category', self::PLG_PATH, 'stable');
169+
$params['branch'] = OsRelease::branch();
169170
// Get current version from patches.json if it exists, otherwise fall back to plugin version or var.ini
170171
$patcherVersion = null;
171172
if (file_exists('/tmp/Patcher/patches.json')) {
@@ -176,7 +177,7 @@ function _($text) {return $text;}
176177
}
177178
}
178179

179-
$params['current_version'] = $patcherVersion ?: plugin('version', self::PLG_PATH) ?: _var($var, 'version');
180+
$params['current_version'] = $patcherVersion ?: plugin('version', self::PLG_PATH) ?: OsRelease::version() ?: _var($var, 'version');
180181
if (_var($var,'regExp')) $params['update_exp'] = date('Y-m-d', _var($var,'regExp')*1);
181182
$defaultUrl = self::BASE_RELEASES_URL;
182183
// pass a param of altUrl to use the provided url instead of the default

plugin/tests/test-os-release.php

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env php
2+
<?php
3+
/**
4+
* OsRelease Test Suite
5+
*
6+
* Exit codes:
7+
* 0 - All tests passed
8+
* 1 - One or more tests failed
9+
*/
10+
11+
// Stand-in for the webGui PluginHelpers plugin() function used on Unraid 7.
12+
function plugin($method, $plugin_file, $default = false) {
13+
return $method === 'category' ? "next\n" : $default;
14+
}
15+
16+
require_once __DIR__ . '/../source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/os-release.php';
17+
18+
class OsReleaseTest {
19+
private $dir;
20+
private $passed = 0;
21+
private $failed = 0;
22+
23+
const RED = "\033[0;31m";
24+
const GREEN = "\033[0;32m";
25+
const NC = "\033[0m";
26+
27+
public function run() {
28+
$this->dir = sys_get_temp_dir() . '/os-release-test-' . uniqid();
29+
mkdir($this->dir, 0755, true);
30+
try {
31+
$this->runTests();
32+
} finally {
33+
$this->cleanup($this->dir);
34+
}
35+
echo "\nResults: {$this->passed} passed, {$this->failed} failed\n";
36+
return $this->failed === 0 ? 0 : 1;
37+
}
38+
39+
private function path($name) {
40+
return $this->dir . '/' . $name;
41+
}
42+
43+
private function write($name, $contents) {
44+
file_put_contents($this->path($name), $contents);
45+
return $this->path($name);
46+
}
47+
48+
private function branch($plg, $ostree, array $configs) {
49+
return OsRelease::branch([$this->path($plg)], $this->path($ostree), array_map([$this, 'path'], $configs));
50+
}
51+
52+
private function assertSame($expected, $actual, $label) {
53+
if ($expected === $actual) {
54+
$this->passed++;
55+
echo self::GREEN . "PASS" . self::NC . " $label\n";
56+
} else {
57+
$this->failed++;
58+
echo self::RED . "FAIL" . self::NC . " $label: expected " . var_export($expected, true) . ", got " . var_export($actual, true) . "\n";
59+
}
60+
}
61+
62+
private function runTests() {
63+
$this->write('unRAIDServer.plg', '<PLUGIN category="next"></PLUGIN>');
64+
$this->assertSame('next', $this->branch('unRAIDServer.plg', 'missing-ostree', []), 'Unraid 7 uses the OS plugin category');
65+
66+
$this->write('os-update.json', json_encode(['branch' => 'preview', 'other' => 1]));
67+
$this->assertSame('next', $this->branch('unRAIDServer.plg', 'ostree-booted', ['os-update.json']), 'OS plugin category wins over the updater config');
68+
69+
$this->assertSame('stable', $this->branch('missing.plg', 'missing-ostree', ['os-update.json']), 'Non-OSTree host without an OS plugin defaults to stable');
70+
71+
$this->write('ostree-booted', '');
72+
$this->assertSame('preview', $this->branch('missing.plg', 'ostree-booted', ['os-update.json']), 'OSTree host reads branch from os-update.json');
73+
74+
$this->write('etc-os-update.json', json_encode(['branch' => 'next']));
75+
$this->assertSame('next', $this->branch('missing.plg', 'ostree-booted', ['missing.json', 'etc-os-update.json']), 'OSTree host falls back to the image default config');
76+
77+
$this->assertSame('stable', $this->branch('missing.plg', 'ostree-booted', ['missing.json']), 'OSTree host without any updater config defaults to stable');
78+
79+
$this->write('malformed.json', '{"branch": "preview"');
80+
$this->assertSame('preview', $this->branch('missing.plg', 'ostree-booted', ['malformed.json', 'os-update.json']), 'Malformed config is skipped without throwing');
81+
$this->assertSame('stable', $this->branch('missing.plg', 'ostree-booted', ['malformed.json']), 'Malformed config alone defaults to stable');
82+
83+
$this->write('no-branch.json', json_encode(['channel' => 'preview']));
84+
$this->assertSame('next', $this->branch('missing.plg', 'ostree-booted', ['no-branch.json', 'etc-os-update.json']), 'Config without a branch key falls through');
85+
86+
$this->write('non-string.json', json_encode(['branch' => ['preview']]));
87+
$this->assertSame('next', $this->branch('missing.plg', 'ostree-booted', ['non-string.json', 'etc-os-update.json']), 'Non-string branch falls through');
88+
89+
foreach (['Stable', '../etc', '-next', 'a b', str_repeat('a', 33), ''] as $bad) {
90+
$this->write('bad.json', json_encode(['branch' => $bad]));
91+
$this->assertSame('stable', $this->branch('missing.plg', 'ostree-booted', ['bad.json']), 'Invalid branch ' . var_export($bad, true) . ' defaults to stable');
92+
}
93+
94+
$this->write('trimmed.json', json_encode(['branch' => " preview \n"]));
95+
$this->assertSame('preview', $this->branch('missing.plg', 'ostree-booted', ['trimmed.json']), 'Branch value is trimmed');
96+
97+
$this->write('long.json', json_encode(['branch' => 'a' . str_repeat('b', 31)]));
98+
$this->assertSame('a' . str_repeat('b', 31), $this->branch('missing.plg', 'ostree-booted', ['long.json']), 'Branch of 32 characters is accepted');
99+
100+
$this->write('unraid-version', "version=\"8.0.0-preview.3\"\n");
101+
$this->assertSame('8.0.0-preview.3', OsRelease::version($this->path('unraid-version')), 'Version is read from /etc/unraid-version');
102+
$this->assertSame(null, OsRelease::version($this->path('missing-version')), 'Missing version file yields null');
103+
$this->write('empty-version', "name=\"Unraid\"\n");
104+
$this->assertSame(null, OsRelease::version($this->path('empty-version')), 'Version file without version yields null');
105+
}
106+
107+
private function cleanup($dir) {
108+
foreach (glob($dir . '/*') ?: [] as $file) {
109+
unlink($file);
110+
}
111+
rmdir($dir);
112+
}
113+
}
114+
115+
exit((new OsReleaseTest())->run());

plugin/tests/test-os-release.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
3+
# OsRelease helper test
4+
#
5+
# Runs the PHP test suite for the shared OS release branch/version helper.
6+
# Exit codes:
7+
# 0 - All tests passed
8+
# 1 - One or more tests failed
9+
10+
set -e
11+
12+
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
13+
14+
exec php "$SCRIPT_DIR/test-os-release.php" "$@"

0 commit comments

Comments
 (0)