Merge remote-tracking branch 'origin/develop' into HEAD

# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
This commit is contained in:
MacRimi
2026-10-16 23:25:01 +02:00
70 changed files with 3407 additions and 302 deletions
+9
View File
@@ -162,6 +162,15 @@ run_case stopped_restore RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_INITIA
[[ $(count 'pct start 101') -eq 1 ]] || fail "stopped CT was not started exactly once"
[[ $(count 'pct shutdown 101 --timeout 60') -eq 1 ]] || fail "stopped CT state was not restored"
run_case scheduled_stopped RUN_HELPER=0 UPDATE_COMMAND='update-custom' \
EXECUTION_MODE=scheduled SCHEDULED_STOPPED_MARKER='fixture-stopped-marker' MOCK_INITIAL_STATE=stopped
[[ $CASE_RC -eq 9 ]] || fail "scheduled_stopped returned $CASE_RC"
[[ $(count 'pct start 101') -eq 0 ]] || fail "scheduled update started a stopped CT"
[[ $(count 'pct exec') -eq 0 ]] || fail "scheduled update executed inside a stopped CT"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "scheduled update ran a custom updater"
grep -qF 'fixture-stopped-marker' "$TEST_ROOT/scheduled_stopped.out" \
|| fail "scheduled stopped result did not include its marker"
run_case locked RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_FLOCK_FAIL=1
[[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater"
+9 -3
View File
@@ -78,7 +78,7 @@ function walk(node) {
if (ts.isVariableDeclaration(node)) {
const name = node.name.getText(tree)
if (wanted.has(name)) definitions.push(`const ${node.getText(tree)};`)
if (["applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name))
if (["LEGACY_STOPPED_SCHEDULE_REASONS", "scheduleReasonCodeFromPersisted", "applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name))
preferenceDefinitions.push(`const ${node.getText(tree)};`)
if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
status.push(`const ${node.getText(tree)};`)
@@ -325,11 +325,11 @@ context.selectedVMRef.current = { vmid: 100 }
}
for (const name of ["ScheduleEnabled", "ScheduleConfigured", "ScheduleCron", "SchedulePreset",
"ScheduleTarget", "ScheduleTargets", "ScheduleReleaseDelayDays", "ApplyBackup", "ApplyBackupStorage",
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunLog",
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunReasonCode", "ScheduleLastRunLog",
"ScheduleLastRunRebootRequired", "ScheduleLastRunRebootPackages", "ExternalCron", "ScheduleError",
"ScheduleLoaded", "BulkError", "BulkLoaded", "BulkEditMode", "BulkConfigured", "BulkTargets", "BulkPersistedTargets"])
preferenceContext[`set${name}`] = value => { preferenceState[name] = value }
assert.equal(preferenceDefinitions.length, 3)
assert.equal(preferenceDefinitions.length, 5)
const preferences = compile(preferenceDefinitions.join("\n") + `
exports.apply = applySchedulePayload; exports.schedule = loadSchedule; exports.bulk = loadBulkUpdate;
`, preferenceContext)
@@ -340,6 +340,12 @@ context.selectedVMRef.current = { vmid: 100 }
assert.equal(preferenceState.ApplyRestart, false, "missing restart cannot inherit a reboot")
preferences.apply({ backup: false, backup_storage: "", restart: false })
assert.equal(preferenceState.ApplyBackupStorage, "")
preferences.apply({ last_run_reason_code: "stopped_container", last_run_reason: "raw stopped reason" })
assert.equal(preferenceState.ScheduleLastRunReasonCode, "stopped_container",
"schedule payload publishes its structured stopped reason")
preferences.apply({})
assert.equal(preferenceState.ScheduleLastRunReasonCode, null,
"a missing reason code cannot leak from the previously selected guest")
preferenceContext.vmModalCacheRef.current.schedule.set(213, { backup: true, backup_storage: "local", restart: true })
const previousSchedule = preferences.schedule(213)
assert.equal(preferenceState.ApplyBackup, true, "use only the selected guest's own cache")
@@ -0,0 +1,102 @@
// Execute the real scheduled-run detail JSX: a known backend reason code is
// localized, while an unknown/missing code keeps its raw diagnostic text.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const root = path.resolve(__dirname, '../../AppImage')
const ts = require(path.join(root, 'node_modules/typescript'))
const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let declaration
let legacyReasons
let reasonCodeHelper
function walk(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'renderScheduleRunDetails') declaration = node.getText(tree)
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'LEGACY_STOPPED_SCHEDULE_REASONS') legacyReasons = node.getText(tree)
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'scheduleReasonCodeFromPersisted') reasonCodeHelper = node.getText(tree)
ts.forEachChild(node, walk)
}
walk(tree)
assert(declaration, 'scheduled run detail renderer must exist')
assert(legacyReasons, 'legacy stopped-reason list must exist')
assert(reasonCodeHelper, 'persisted schedule reason-code helper must exist')
function compile(context) {
const result = ts.transpileModule(`const ${declaration}; exports.render = renderScheduleRunDetails;`, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
reportDiagnostics: true,
})
assert.equal(result.diagnostics.length, 0)
vm.runInNewContext(result.outputText, context)
return context.exports.render
}
function compileReasonCodeHelper() {
const result = ts.transpileModule(`const ${legacyReasons}; const ${reasonCodeHelper}; exports.reasonCode = scheduleReasonCodeFromPersisted;`, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
reportDiagnostics: true,
})
assert.equal(result.diagnostics.length, 0)
const context = { exports: {}, Set }
vm.runInNewContext(result.outputText, context)
return context.exports.reasonCode
}
function flattenText(node) {
if (Array.isArray(node)) return node.map(flattenText).join(' ')
if (!node || typeof node !== 'object') return typeof node === 'string' ? node : ''
return flattenText(node.props?.children)
}
const persistedReasonCode = compileReasonCodeHelper()
assert.equal(persistedReasonCode('stopped_container', 'anything'), 'stopped_container')
assert.equal(persistedReasonCode(null, 'Container is stopped; scheduled update was skipped'), 'stopped_container')
assert.equal(persistedReasonCode(null, 'container was stopped before scheduled update could begin'), 'stopped_container')
assert.equal(persistedReasonCode('future_reason', 'raw diagnostic'), 'future_reason')
assert.equal(persistedReasonCode(null, 'raw diagnostic'), null)
for (const locale of ['en', 'sk']) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`), 'utf8'))
const t = (key, variables = {}) => {
const message = key.split('.').reduce((value, part) => value?.[part], messages)
return String(message ?? key).replace(/\{([^}]+)\}/g, (_, name) => String(variables[name] ?? `{${name}}`))
}
const context = {
exports: {}, t, selectedVM: { vmid: 100, status: 'stopped' }, scheduleLastRunAt: '2026-10-16T05:10:02Z',
scheduleLastRunStatus: 'skipped', scheduleLastRunReasonCode: 'stopped_container',
scheduleLastRunReason: 'container is stopped; scheduled update was skipped',
scheduleLastRunRebootRequired: false, scheduleLastRunRebootPackages: [], scheduleLastRunLog: null,
openScheduleLog() {}, AlertTriangle: 'AlertTriangle', FileText: 'FileText',
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }), Fragment: 'Fragment' }),
}
const render = compile(context)
const translated = flattenText(render(true))
const expectedCurrentReason = t('vmLxc.scheduled.reasonStoppedContainer')
const expectedHistoricalReason = t('vmLxc.scheduled.reasonStoppedContainerHistorical')
const skippedLabel = t('vmLxc.scheduled.runSkippedStatus')
const expectedCurrentText = locale === 'sk'
? 'Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.'
: 'Container is stopped; scheduled update was skipped.'
const expectedHistoricalText = locale === 'sk'
? 'Kontajner bol v čase plánovanej aktualizácie vypnutý.'
: 'Container was stopped at the scheduled update time.'
assert.equal(expectedCurrentReason, expectedCurrentText,
`${locale}: stopped CT must describe its current stopped state`)
assert.equal(expectedHistoricalReason, expectedHistoricalText,
`${locale}: running CT must describe the stopped state at the prior scheduled run`)
assert(translated.includes(expectedCurrentReason), `${locale}: currently stopped CT must use the present-state catalog`)
assert(!translated.includes(expectedHistoricalReason), `${locale}: currently stopped CT must not use historical wording`)
assert(translated.includes(skippedLabel), `${locale}: skipped run must not claim that no updates are pending`)
assert(!translated.includes(context.scheduleLastRunReason), `${locale}: raw stopped reason must not leak into UI`)
context.selectedVM.status = 'running'
const historical = flattenText(render(true))
assert(historical.includes(expectedHistoricalReason), `${locale}: running CT must use historical wording for a past skipped run`)
assert(!historical.includes(expectedCurrentReason), `${locale}: running CT must not claim it is currently stopped`)
context.scheduleLastRunReasonCode = 'unknown_reason'
const fallback = flattenText(render(true))
assert(fallback.includes(context.scheduleLastRunReason), `${locale}: unknown code must retain the raw diagnostic`)
}
console.log('PASS scheduled reason i18n: EN/SK structured stopped reason and raw fallback')
@@ -119,7 +119,8 @@ raise SystemExit(h.main())
self.write("sleep", "#!/bin/sh\nexit 0\n")
self.write("flock", "#!/bin/sh\nexit 0\n")
names = {"_finalize_lxc_update", "_terminal_lxc_update_completed",
"api_lxc_updates_applied", "_run_scheduled_update"}
"api_lxc_updates_applied", "_run_scheduled_update",
"_scheduled_runner_reported_stopped"}
nodes = [n for n in ast.parse(observed.SERVER.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name in names]
self.scope = self.fixture.scope
@@ -134,6 +135,7 @@ raise SystemExit(h.main())
"_json_list": lambda value: json.loads(value or '[]'),
"_lxc_update_finalizations": {}, "_lxc_update_finalization_lock": threading.RLock(),
"_LXC_UPDATE_FINALIZATION_TTL": 3600,
"_SCHEDULED_STOPPED_EXIT": 9,
"_fast_guest_status": lambda *_: self.state.read_text(),
"_lxc_update_snapshot": lambda *_: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
@@ -315,6 +317,7 @@ raise SystemExit(h.main())
self.scope['notification_manager'].emit_event.assert_called_once()
def test_scheduled_path_executes_real_runner_and_real_finalization(self):
self.state.write_text("running")
def run(command, **kwargs):
kwargs['env'].update({key: value for key, value in self.env.items()
if key != 'RUN_ID'})
@@ -322,7 +325,7 @@ raise SystemExit(h.main())
self.scope['subprocess'] = SimpleNamespace(run=run, TimeoutExpired=subprocess.TimeoutExpired)
result = self.scope['_run_scheduled_update'](100, {'targets': ['os'], 'target': 'os'})
self.assertEqual(result['status'], 'success', result)
self.assertEqual(self.state.read_text(), 'stopped')
self.assertEqual(self.state.read_text(), 'running')
self.assertEqual(self.fixture.row()['update_check']['count'], 0)
def test_legacy_cli_without_run_id_preserves_update_and_original_state(self):
+156
View File
@@ -1,4 +1,7 @@
import ast
import json
import os
import re
import sys
import subprocess
import tempfile
@@ -312,6 +315,159 @@ class AppCacheWriteThroughContractTests(unittest.TestCase):
self.assertIn("before_snapshot=before", scheduler_block)
class ScheduledStoppedContainerTests(unittest.TestCase):
@staticmethod
def _worker(scope):
source = ast.parse((REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text())
node = next(item for item in source.body
if isinstance(item, ast.FunctionDef) and item.name == "_run_scheduled_update")
exec(compile(ast.Module(body=[node], type_ignores=[]), "scheduled-update", "exec"), scope)
return scope["_run_scheduled_update"]
@staticmethod
def _stopped_marker_reader(scope):
source = ast.parse((REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text())
node = next(item for item in source.body
if isinstance(item, ast.FunctionDef) and item.name == "_scheduled_runner_reported_stopped")
exec(compile(ast.Module(body=[node], type_ignores=[]), "scheduled-marker", "exec"), scope)
return scope["_scheduled_runner_reported_stopped"]
def test_stopped_container_skips_before_runner_or_any_guest_effect(self):
runner = MagicMock()
finalizer = MagicMock(return_value={"recorded": True})
append_log = MagicMock()
scope = {
"time": SimpleNamespace(monotonic=MagicMock(return_value=10.0)),
"uuid": SimpleNamespace(uuid4=lambda: SimpleNamespace(hex="a" * 32)),
"datetime": __import__("datetime").datetime,
"subprocess": SimpleNamespace(run=runner, STDOUT=object(), TimeoutExpired=subprocess.TimeoutExpired),
"_create_lxc_update_log": lambda _vmid, _run_id: ("fixture.log", "/tmp/fixture.log"),
"_fast_guest_status": MagicMock(return_value="stopped"),
"_normalise_schedule_targets": lambda _sched: ["os"],
"_lxc_update_snapshot": lambda _vmid: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
"_append_lxc_update_log": append_log,
"_prune_lxc_update_logs": MagicMock(),
"_finalize_lxc_update": finalizer,
}
outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["executed_targets"], [])
self.assertIn("container is stopped", outcome["reason"])
self.assertEqual(outcome["reason_code"], "stopped_container")
runner.assert_not_called()
self.assertEqual(finalizer.call_args.kwargs["status"], "skipped")
self.assertEqual(finalizer.call_args.kwargs["source"], "scheduled")
def test_stopped_marker_requires_the_scheduler_token_not_only_exit_code(self):
marker = "PROXMENUX_SCHEDULED_STOPPED:fixture"
reader = self._stopped_marker_reader({"_SCHEDULED_STOPPED_EXIT": 9, "os": os})
self.assertTrue(reader(SimpleNamespace(returncode=9, stdout=marker, stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=9, stdout="", stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=8, stdout=marker, stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=4, stdout=marker, stderr=""), None, marker))
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as log:
log.write("prefix\\n" + marker + "\\n")
log.flush()
self.assertTrue(reader(SimpleNamespace(returncode=9, stdout="", stderr=""), log.name, marker))
def test_runner_race_to_stopped_is_reported_as_skipped_without_reboot_probe(self):
runner = MagicMock(return_value=SimpleNamespace(returncode=9, stdout="", stderr=""))
finalizer = MagicMock(return_value={"recorded": True})
reboot_probe = MagicMock()
stopped_marker = MagicMock(return_value=True)
lxc = SimpleNamespace(
_read_sidecar=lambda _vmid: {"apps": []},
partition_scheduled_release_targets=lambda targets, _apps: (set(), targets),
)
scope = {
"time": SimpleNamespace(monotonic=MagicMock(return_value=10.0)),
"uuid": SimpleNamespace(uuid4=lambda: SimpleNamespace(hex="b" * 32)),
"datetime": __import__("datetime").datetime,
"subprocess": SimpleNamespace(run=runner, STDOUT=object(), TimeoutExpired=subprocess.TimeoutExpired),
"os": os,
"json": json,
"_APPLY_UPDATES_SCRIPT": "/bin/true",
"_SCHEDULED_STOPPED_EXIT": 9,
"_DOCKER_ENGINE_INTEGRATED_COMMAND": "integrated-docker",
"_create_lxc_update_log": lambda _vmid, _run_id: (None, None),
"_fast_guest_status": MagicMock(return_value="running"),
"_normalise_schedule_targets": lambda _sched: ["os"],
"_lxc_update_snapshot": lambda _vmid: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
"_append_lxc_update_log": MagicMock(),
"_prune_lxc_update_logs": MagicMock(),
"_finalize_lxc_update": finalizer,
"_scheduled_helper_enabled": lambda *_: False,
"_compose_scheduled_update_command": lambda *_: "",
"_inspect_lxc_reboot_requirement": reboot_probe,
"_scheduled_runner_reported_stopped": stopped_marker,
}
with patch.dict(sys.modules, {"lxc_apps": lxc}):
outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["executed_targets"], [])
self.assertIn("before scheduled update could begin", outcome["reason"])
self.assertEqual(outcome["reason_code"], "stopped_container")
self.assertEqual(runner.call_args.args[0], ["bash", "/bin/true"])
self.assertEqual(runner.call_args.kwargs["env"]["EXECUTION_MODE"], "scheduled")
self.assertTrue(runner.call_args.kwargs["env"]["SCHEDULED_STOPPED_MARKER"].startswith(
"PROXMENUX_SCHEDULED_STOPPED:scheduled-"))
stopped_marker.assert_called_once()
reboot_probe.assert_not_called()
stopped_marker.reset_mock()
stopped_marker.return_value = False
runner.return_value = SimpleNamespace(returncode=8, stdout="", stderr="")
reboot_probe.return_value = (None, [], None)
outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "failure")
self.assertIn("exited with code 8", outcome["reason"])
stopped_marker.assert_called_once()
reboot_probe.assert_called_once()
def test_oci_race_to_stopped_skips_before_the_oci_engine_runs(self):
runner = MagicMock(return_value=SimpleNamespace(returncode=0, stdout="", stderr=""))
finalizer = MagicMock(return_value={"recorded": True})
status = MagicMock(side_effect=["running", "stopped"])
scope = {
"time": SimpleNamespace(monotonic=MagicMock(return_value=10.0)),
"uuid": SimpleNamespace(uuid4=lambda: SimpleNamespace(hex="c" * 32)),
"datetime": __import__("datetime").datetime,
"subprocess": SimpleNamespace(
run=runner,
STDOUT=object(),
DEVNULL=object(),
TimeoutExpired=subprocess.TimeoutExpired,
),
"os": os,
"re": re,
"_create_lxc_update_log": lambda _vmid, _run_id: (None, None),
"_fast_guest_status": status,
"_normalise_schedule_targets": lambda _sched: ["oci_image"],
"_lxc_update_snapshot": lambda _vmid: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OCI image"],
"_append_lxc_update_log": MagicMock(),
"_prune_lxc_update_logs": MagicMock(),
"_finalize_lxc_update": finalizer,
}
outcome = self._worker(scope)(101, {"targets": ["oci_image"]})
self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["target"], "oci_image")
self.assertEqual(outcome["executed_targets"], [])
self.assertEqual(outcome["reason_code"], "stopped_container")
self.assertIn("before scheduled update could begin", outcome["reason"])
self.assertEqual(status.call_count, 2)
runner.assert_not_called()
self.assertEqual(finalizer.call_args.kwargs["status"], "skipped")
class DockerStackNotificationTests(unittest.TestCase):
def _docker_app(self):
return {
@@ -37,6 +37,7 @@ def routes():
_lxc_update_target_labels=lambda *_: [],
_append_lxc_update_log=lambda *_: None,
_prune_lxc_update_logs=lambda *_: None,
_scheduled_runner_reported_stopped=lambda *_: False,
_finalize_lxc_update=Mock(return_value={}),
_inspect_lxc_reboot_requirement=lambda *_, **__: (False, [], None))
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'update-routes', 'exec'), ns)
+204
View File
@@ -0,0 +1,204 @@
"""Exercise the real Figurine installer with isolated files and no network/root access."""
import ast
import importlib.util
import io
from pathlib import Path
import re
import subprocess
import tarfile
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts/post_install/customizable_post_install.sh"
FUNCTION = re.search(
r"^configure_figurine\(\) \{\n.*?^\}", SCRIPT.read_text(), re.M | re.S
).group(0)
VERSION = re.search(r'FIGURINE_VERSION:-([^}]+)', FUNCTION).group(1)
FUNCTION_VERSION = re.search(r'local FUNC_VERSION="([^"]+)"', FUNCTION).group(1)
class FigurineInstallation(unittest.TestCase):
def run_installer(self, arch="amd64", *, kernel="x86_64", dpkg_exit=0,
release=None, download_exit=0, package="valid", existing=True):
with tempfile.TemporaryDirectory(prefix="figurine-test-") as directory:
root = Path(directory)
binary_dir = root / "bin"
profile_dir = root / "profile.d"
binary_dir.mkdir()
profile_dir.mkdir()
(root / "tmp").mkdir()
binary = binary_dir / "figurine"
profile = profile_dir / "figurine.sh"
bashrc = root / "bashrc"
if existing:
binary.write_text("previous binary\n")
binary.chmod(0o755)
profile.write_text("previous profile\n")
bashrc.write_text("# previous bashrc\n")
archive = root / "fixture.tar.gz"
payload = b"#!/bin/sh\nprintf 'fixture Figurine\\n'\n"
if package == "corrupt":
archive.write_bytes(b"invalid tarball")
else:
with tarfile.open(archive, "w:gz") as tar:
name = "deploy/figurine" if package == "valid" else "README"
member = tarfile.TarInfo(name)
member.size = len(payload)
tar.addfile(member, io.BytesIO(payload))
# Relocate every installer filesystem boundary, including its login script.
function = (FUNCTION.replace("/usr/local/bin", str(binary_dir))
.replace("/etc/profile.d", str(profile_dir))
.replace("/root/.bashrc", str(bashrc)))
harness = r'''
translate() { printf '%s' "$1"; }
msg_info2() { :; }
msg_info() { :; }
msg_ok() { :; }
msg_success() { :; }
msg_error() { printf '%s\n' "$*" >&2; }
pmx_journal_context() { :; }
pmx_write_file() { cat > "$1"; }
pmx_append_file() { cat >> "$1"; }
pmx_edit_file() { :; }
chown() { :; }
dpkg() {
[[ "$TEST_DPKG_EXIT" == 0 ]] || return "$TEST_DPKG_EXIT"
printf '%s\n' "$TEST_ARCH"
}
uname() { printf '%s\n' "$TEST_KERNEL"; }
wget() {
[[ "$1" == -qO ]] || return 90
printf '%s\n' "$3" >> "$TEST_ROOT/downloads"
[[ "$TEST_DOWNLOAD_EXIT" == 0 ]] || return "$TEST_DOWNLOAD_EXIT"
cp "$TEST_ROOT/fixture.tar.gz" "$2"
}
register_tool() { printf '%s\n' "$*" > "$TEST_ROOT/registered"; }
'''
harness += function + "\nconfigure_figurine\nexit \"$?\"\n"
environment = {
"PATH": f"{binary_dir}:/usr/bin:/bin",
"HOME": str(root),
"TMPDIR": str(root / "tmp"),
"LC_ALL": "C",
"TEST_ROOT": str(root),
"TEST_ARCH": arch,
"TEST_DPKG_EXIT": str(dpkg_exit),
"TEST_KERNEL": kernel,
"TEST_DOWNLOAD_EXIT": str(download_exit),
}
if release is not None:
environment["FIGURINE_VERSION"] = release
result = subprocess.run(
["/bin/bash", "--noprofile", "--norc"], input=harness,
text=True, capture_output=True, env=environment, timeout=10,
)
return {
"result": result,
"binary": binary.read_bytes(),
"mode": binary.stat().st_mode & 0o777,
"profile": profile.read_text(),
"bashrc": bashrc.read_text(),
"downloads": (root / "downloads").read_text() if (root / "downloads").exists() else "",
"registered": (root / "registered").read_text() if (root / "registered").exists() else "",
"payload": payload,
"binary_path": str(binary),
}
def test_installs_release_matching_debian_userspace(self):
for arch, asset in (("amd64", "amd64"), ("arm64", "arm64"), ("armhf", "arm")):
with self.subTest(arch=arch):
state = self.run_installer(arch)
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertEqual(state["downloads"],
f"https://github.com/arsham/figurine/releases/download/v{VERSION}/"
f"figurine_linux_{asset}_v{VERSION}.tar.gz\n")
self.assertEqual(state["binary"], state["payload"])
self.assertEqual(state["mode"], 0o755)
self.assertIn(f'{state["binary_path"]} -f "3d.flf" $(hostname)', state["profile"])
self.assertEqual(state["registered"], f"figurine true {FUNCTION_VERSION}\n")
def test_falls_back_to_kernel_when_dpkg_is_unavailable(self):
for kernel, asset in (("x86_64", "amd64"), ("aarch64", "arm64"),
("armv7l", "arm"), ("armv8l", "arm")):
with self.subTest(kernel=kernel):
state = self.run_installer("", kernel=kernel, dpkg_exit=127)
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertIn(f"figurine_linux_{asset}_v{VERSION}.tar.gz", state["downloads"])
def test_userspace_takes_precedence_over_kernel(self):
state = self.run_installer("armhf", kernel="aarch64")
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertIn(f"figurine_linux_arm_v{VERSION}.tar.gz", state["downloads"])
def test_fresh_install_creates_binary_banner_and_aliases(self):
state = self.run_installer(existing=False)
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertEqual(state["binary"], state["payload"])
self.assertIn(' -f "3d.flf" $(hostname)', state["profile"])
self.assertIn("shopt -s expand_aliases", state["bashrc"])
self.assertIn("alias aptup='apt update && apt dist-upgrade'", state["bashrc"])
self.assertEqual(state["registered"], f"figurine true {FUNCTION_VERSION}\n")
def test_unsupported_architecture_fails_before_changes_or_download(self):
for arch in ("i386", "riscv64", "armel", "armv6l", "ppc64el", ""):
with self.subTest(arch=arch):
state = self.run_installer(arch)
self.assertNotEqual(state["result"].returncode, 0)
self.assertIn("Unsupported architecture for Figurine:", state["result"].stderr)
self.assertEqual(state["downloads"], "")
self.assert_preserved(state)
self.assertEqual(state["bashrc"], "# previous bashrc\n")
def assert_preserved(self, state):
self.assertEqual(state["binary"], b"previous binary\n")
self.assertEqual(state["profile"], "previous profile\n")
self.assertEqual(state["registered"], "")
def test_download_failure_preserves_previous_installation(self):
state = self.run_installer(download_exit=8)
self.assertNotEqual(state["result"].returncode, 0)
self.assertIn("Failed to download Figurine", state["result"].stderr)
self.assert_preserved(state)
def test_invalid_packages_preserve_previous_installation(self):
for package, error in (("corrupt", "Failed to extract package"),
("missing", "Binary not found in extracted content.")):
with self.subTest(package=package):
state = self.run_installer(package=package)
self.assertNotEqual(state["result"].returncode, 0)
self.assertIn(error, state["result"].stderr)
self.assert_preserved(state)
def test_release_override_is_preserved(self):
state = self.run_installer("arm64", release="2.1.0")
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
self.assertIn("/v2.1.0/figurine_linux_arm64_v2.1.0.tar.gz", state["downloads"])
def test_existing_installation_is_offered_the_update_once(self):
spec = importlib.util.spec_from_file_location(
"post_install_versions", ROOT / "AppImage/scripts/post_install_versions.py")
versions = importlib.util.module_from_spec(spec)
spec.loader.exec_module(versions)
metadata = versions.parse_post_install_script(SCRIPT)
installed = {"figurine": {"installed": True, "version": "1.1", "source": "custom"}}
updates = versions._detect_updates({}, metadata, installed)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0]["available_version"], FUNCTION_VERSION)
self.assertEqual(updates[0]["function"], "configure_figurine")
installed["figurine"]["version"] = FUNCTION_VERSION
self.assertEqual(versions._detect_updates({}, metadata, installed), [])
tree = ast.parse((ROOT / "AppImage/scripts/flask_proxmenux_routes.py").read_text())
fallback = next(ast.literal_eval(node.value) for node in tree.body
if isinstance(node, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == "TOOL_METADATA"
for t in node.targets))
self.assertEqual(fallback["figurine"]["version"], FUNCTION_VERSION)
if __name__ == "__main__":
unittest.main()