Preserve host updates across sign-out and report pending kernel activation

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent 2d4b0cb746
commit f40e0a53e1
24 changed files with 1605 additions and 18 deletions
+84
View File
@@ -0,0 +1,84 @@
"""Real JWT verification, synthetic keys only; never imports live auth config."""
import ast
import contextlib
from datetime import datetime, timedelta
import hashlib
import io
from pathlib import Path
import threading
import time
import unittest
import jwt
ROOT = Path(__file__).resolve().parents[1]
class AuthReasons(unittest.TestCase):
def setUp(self):
source = ast.parse((ROOT / 'AppImage/scripts/auth_manager.py').read_text())
names = {'verify_token_full', '_log_auth_failure_throttled', 'generate_token'}
nodes = [n for n in source.body if isinstance(n, ast.FunctionDef) and n.name in names]
constants = {n.targets[0].id: ast.literal_eval(n.value) for n in source.body
if isinstance(n, ast.Assign) and isinstance(n.targets[0], ast.Name)
and n.targets[0].id in {'TOKEN_EXPIRATION_HOURS', 'JWT_ALGORITHM', 'JWT_ISSUER', 'JWT_AUDIENCE'}}
self.revoked = set()
self.scope = dict(jwt=jwt, JWT_AVAILABLE=True, hashlib=hashlib, time=time,
_get_jwt_secret=lambda: 'fixture-only-secret-for-offline-auth-tests',
_get_revoked_tokens_cached=lambda: self.revoked,
_AUTH_LOG_LOCK=threading.Lock(),
_AUTH_LOG_RATE=dict(last_ts=0., suppressed=0, last_msg=''),
datetime=datetime, timedelta=timedelta, **constants)
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'actual-auth', 'exec'), self.scope)
def token(self, **values):
claims = dict(username='fixture-admin', exp=time.time()+120,
iss=self.scope['JWT_ISSUER'], aud=self.scope['JWT_AUDIENCE'])
claims.update(values)
return jwt.encode(claims, self.scope['_get_jwt_secret'](), algorithm='HS256')
def rejection(self, token, reason):
self.scope['_AUTH_LOG_RATE'].update(last_ts=0., suppressed=0, last_msg='')
out = io.StringIO()
with contextlib.redirect_stdout(out):
result = self.scope['verify_token_full'](token)
self.assertEqual(result, (None, None))
log = out.getvalue()
self.assertIn(reason, log)
for secret in (self.scope['_get_jwt_secret'](), token or 'not-present', 'fixture-admin'):
self.assertNotIn(secret, log)
def test_expired_invalid_revoked_and_missing_are_safe_categories(self):
self.rejection(self.token(exp=time.time()-10), 'expired')
self.rejection('fixture.invalid.token', 'invalid')
token = self.token()
self.revoked.add(hashlib.sha256(token.encode()).hexdigest())
self.rejection(token, 'revoked')
self.rejection(None, 'missing token')
def test_valid_and_legacy_scopes_keep_existing_contract(self):
verify = self.scope['verify_token_full']
self.assertEqual(verify(self.token()), ('fixture-admin', 'full_admin'))
self.assertEqual(verify(self.token(scope='read_only')), ('fixture-admin', 'read_only'))
legacy = jwt.encode(dict(username='fixture-admin', exp=time.time()+120),
self.scope['_get_jwt_secret'](), algorithm='HS256')
self.assertEqual(verify(legacy), ('fixture-admin', 'full_admin'))
def test_normal_session_lifetime_remains_24_hours(self):
self.assertEqual(self.scope['TOKEN_EXPIRATION_HOURS'], 24)
token = self.scope['generate_token']('fixture-admin')
payload = jwt.decode(token, self.scope['_get_jwt_secret'](), algorithms=['HS256'],
audience=self.scope['JWT_AUDIENCE'], issuer=self.scope['JWT_ISSUER'])
self.assertAlmostEqual(payload['exp']-payload['iat'], 86400, delta=1)
def test_repeated_auth_failures_are_rate_limited_without_secrets(self):
out = io.StringIO()
with contextlib.redirect_stdout(out):
for _ in range(100):
self.scope['verify_token_full']('fixture.invalid.token')
self.assertEqual(out.getvalue().count('[auth]'), 1)
self.assertNotIn('fixture.invalid.token', out.getvalue())
if __name__ == '__main__':
unittest.main(verbosity=2)
+349
View File
@@ -0,0 +1,349 @@
"""Real inert host workers plus actual auth/ticket/viewer boundaries. No APT."""
import ast
import base64
import importlib.util
import io
import json
import os
from pathlib import Path
import shlex
import sys
import tempfile
import threading
import time
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from flask import Blueprint, Flask, jsonify, request
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
import host_update_jobs as jobs
def wait(predicate, timeout=6):
limit = time.monotonic() + timeout
while not predicate():
if time.monotonic() >= limit:
raise AssertionError('Inert job did not reach the expected boundary')
time.sleep(.01)
class Jobs(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.folder = Path(self.tmp.name)
self.script, self.gate, self.events = [self.folder / n for n in ('host.sh', 'finish', 'events')]
self.script.write_text(f'echo started >> {shlex.quote(str(self.events))}\n'
f'while [ ! -e {shlex.quote(str(self.gate))} ]; do sleep .02; done\n'
f'echo final-step >> {shlex.quote(str(self.events))}\necho done\n')
self.fixed = patch.object(jobs, 'SCRIPT', str(self.script))
self.fixed.start()
self.addCleanup(self.fixed.stop)
jobs._jobs.clear()
self.callback = Mock()
self.addCleanup(self.finish_all)
def finish_all(self):
self.gate.touch()
for job in list(jobs._jobs.values()):
wait(lambda: job.state == 'complete')
jobs._jobs.clear()
def start(self, identity='job-1'):
return jobs.start(identity, self.callback)
def finish(self, job):
self.gate.touch()
wait(lambda: job.state == 'complete')
def test_real_worker_finishes_post_steps_without_any_viewer(self):
job = self.start()
wait(self.events.exists)
self.assertEqual(self.events.read_text().splitlines(), ['started'])
self.finish(job)
self.assertEqual(self.events.read_text().splitlines(), ['started', 'final-step'])
self.assertEqual(job.exit_code, 0)
self.callback.assert_called_once()
def test_same_id_is_idempotent_and_other_id_cannot_start(self):
first = self.start()
wait(self.events.exists)
with patch.object(jobs.subprocess, 'Popen') as spawn:
self.assertIs(self.start(), first)
with self.assertRaises(jobs.JobConflict):
self.start('other')
self.assertIs(jobs.get(first.id), first)
spawn.assert_not_called()
self.finish(first)
self.assertIs(self.start(), first)
def test_concurrent_start_has_one_process(self):
barrier, results = threading.Barrier(6), []
def run():
barrier.wait()
results.append(self.start())
workers = [threading.Thread(target=run) for _ in range(6)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
self.assertEqual(len({id(job) for job in results}), 1)
self.finish(results[0])
self.assertEqual(self.events.read_text().splitlines().count('started'), 1)
def test_read_list_and_missing_resume_have_zero_effects(self):
with patch.object(jobs.subprocess, 'Popen') as spawn, patch.object(jobs.tempfile, 'mkstemp') as files:
self.assertEqual(jobs.list_jobs(), [])
for value in ('missing', '../bad', '', None):
with self.subTest(value=value), self.assertRaises((KeyError, ValueError)):
jobs.get(value)
spawn.assert_not_called()
files.assert_not_called()
def test_invalid_start_and_missing_script_spawn_nothing(self):
with patch.object(jobs.subprocess, 'Popen') as spawn, patch.object(jobs.tempfile, 'mkstemp') as files:
for value in ('../bad', 'x'*65, None):
with self.assertRaises(ValueError):
self.start(value)
with patch.object(jobs.os.path, 'isfile', return_value=False):
with self.assertRaises(ValueError):
self.start()
spawn.assert_not_called()
files.assert_not_called()
def test_owner_thread_failure_cannot_spawn_an_unowned_update(self):
with patch.object(jobs.threading.Thread, 'start', side_effect=RuntimeError('thread unavailable')), patch.object(jobs.subprocess, 'Popen') as spawn:
with self.assertRaises(RuntimeError):
self.start()
spawn.assert_not_called()
self.assertEqual(jobs.list_jobs(), [])
def test_popen_failure_is_recorded_not_success_or_automatic_retry(self):
with patch.object(jobs.subprocess, 'Popen', side_effect=OSError('fixture failure')) as spawn:
job = self.start()
wait(lambda: job.state == 'complete')
self.assertEqual(job.exit_code, 1)
self.assertIs(self.start(), job)
spawn.assert_called_once()
self.assertFalse(self.events.exists())
def test_runtime_limit_marks_attention_without_signal_or_second_update(self):
with patch.object(jobs, 'ATTENTION_AFTER', .05), patch.object(jobs.os, 'killpg') as signals:
job = self.start()
wait(lambda: job.state == 'attention')
self.assertIsNone(job.process.poll())
with self.assertRaises(jobs.JobConflict):
self.start('other')
signals.assert_not_called()
self.finish(job)
self.assertEqual(job.exit_code, 0)
def test_explicit_viewer_cancel_and_terminal_control_cannot_kill_host(self):
job = self.start()
wait(self.events.exists)
with patch.object(jobs.os, 'killpg') as signals:
for value in ({'type':'cancel'}, '\x03', 'paste\x1a', '\x1c'):
with self.assertRaises(ValueError):
job.input(value)
signals.assert_not_called()
self.finish(job)
def test_partial_interaction_replays_and_can_only_be_answered_once(self):
job = self.start()
wait(self.events.exists)
identity = 'host-fixture-' + os.urandom(8).hex()
target = Path('/tmp/proxmenux_response_' + identity)
self.addCleanup(lambda: target.unlink(missing_ok=True))
b64 = lambda s: base64.b64encode(s.encode()).decode()
text = f'WEB_INTERACTION:yesno:{identity}:{b64("Confirm")}:{b64("Question")}\n'
job._interactions(io.StringIO(text[:17]))
self.assertIsNone(job.interaction)
job._interactions(io.StringIO(text[17:]))
self.assertEqual(jobs.get(job.id).read(0)['interaction']['id'], identity)
job.input(dict(type='interaction_response', id=identity, value='yes'))
self.assertEqual(target.read_text(), 'yes')
self.assertEqual(target.stat().st_mode & 0o777, 0o600)
with patch.object(jobs.os, 'open') as write:
with self.assertRaises(ValueError):
job.input(dict(type='interaction_response', id=identity, value='yes'))
write.assert_not_called()
self.finish(job)
def test_symlink_response_is_not_overwritten(self):
job = self.start()
wait(self.events.exists)
identity = 'host-fixture-' + os.urandom(8).hex()
target = Path('/tmp/proxmenux_response_' + identity)
target.symlink_to(self.events)
self.addCleanup(lambda: target.unlink(missing_ok=True))
job.interaction = dict(id=identity)
before = self.events.read_bytes()
with self.assertRaises(OSError):
job.input(dict(type='interaction_response', id=identity, value='no'))
self.assertEqual(self.events.read_bytes(), before)
self.finish(job)
def test_finalizing_reserves_host_and_failure_is_not_hidden(self):
entered, release = threading.Event(), threading.Event()
self.addCleanup(release.set)
def finalize(*_):
entered.set()
release.wait(3)
raise RuntimeError('cache failure')
self.callback.side_effect = finalize
job = self.start()
self.gate.touch()
self.assertTrue(entered.wait(3))
with self.assertRaises(jobs.JobConflict):
self.start('other')
release.set()
wait(lambda: job.state == 'complete')
self.assertEqual(job.script_exit_code, 0)
self.assertEqual(job.exit_code, 1)
self.assertTrue(job.finalization_error)
self.callback.assert_called_once()
def test_failed_shell_with_surviving_child_remains_reserved(self):
self.script.write_text(
f'(trap "" HUP; echo child-ready >> {shlex.quote(str(self.events))}; '
f'while [ ! -e {shlex.quote(str(self.gate))} ]; do sleep .02; done; '
f'echo child-finished >> {shlex.quote(str(self.events))}) &\n'
f'while [ ! -s {shlex.quote(str(self.events))} ]; do sleep .01; done\nexit 7\n')
job = self.start()
wait(lambda: job.state == 'attention')
with self.assertRaises(jobs.JobConflict):
self.start('other')
self.gate.touch()
wait(lambda: job.state == 'complete')
self.assertEqual(job.exit_code, 7)
self.assertIn('child-finished', self.events.read_text())
class Routes(unittest.TestCase):
finish_all = Jobs.finish_all
finish = Jobs.finish
def setUp(self):
Jobs.setUp(self)
self.app = Flask(__name__)
auth = ModuleType('auth_manager')
auth.load_auth_config = lambda: {'enabled':True}
auth.verify_token = lambda token: 'fixture' if token=='valid' else None
auth.verify_token_full = lambda token: ('fixture','full_admin') if token=='valid' else ('fixture','read_only') if token=='read-only' else (None,None)
modules = patch.dict(sys.modules, {'auth_manager':auth})
modules.start()
self.addCleanup(modules.stop)
spec = importlib.util.spec_from_file_location('host_jwt_fixture',ROOT/'AppImage/scripts/jwt_middleware.py')
middleware = importlib.util.module_from_spec(spec)
spec.loader.exec_module(middleware)
self.ws_routes = {}
def route(path):
def register(fn):
self.ws_routes[path]=fn
return fn
return register
scope=dict(terminal_bp=Blueprint('host_fixture',__name__),sock=SimpleNamespace(route=route),
request=request,jsonify=jsonify,require_admin_scope=middleware.require_admin_scope,
_TERMINAL_TICKETS={},_TICKETS_LOCK=threading.Lock(),_TICKET_TTL=5,
_TICKET_MAX_INFLIGHT=256,time=time,secrets=__import__('secrets'),json=json,
_host_update_completed=self.callback)
names={'_issue_terminal_ticket','_consume_terminal_ticket','_ws_auth_check',
'issue_terminal_ticket_route','host_update_jobs_status','host_update_viewer'}
nodes=[n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body if isinstance(n,ast.FunctionDef) and n.name in names]
self.assertEqual(len(nodes),len(names))
exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-host-routes','exec'),scope)
self.app.register_blueprint(scope['terminal_bp'])
self.client=self.app.test_client()
def ticket(self, token='valid'):
return self.client.post('/api/terminal/ticket',headers={'Authorization':'Bearer '+token})
def attach(self, init, ticket=None, identity='job-1'):
frames=iter([json.dumps(init)])
ws=Mock()
def receive(**_):
try:
return next(frames)
except StopIteration:
raise ConnectionError('viewer signed out')
ws.receive.side_effect=receive
with self.app.test_request_context('/ws/host-update/'+identity+('?ticket='+ticket if ticket else '')):
self.ws_routes['/ws/host-update/<run_id>'](ws,identity)
return ws
def test_401_and_read_only_cannot_list_start_or_get_a_ticket(self):
for token,status in [('expired',401),('read-only',403)]:
with patch.object(jobs.subprocess,'Popen') as spawn:
self.assertEqual(self.ticket(token).status_code,status)
self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer '+token}).status_code,status)
self.attach(dict(resume=False))
spawn.assert_not_called()
self.assertEqual(jobs.list_jobs(),[])
def test_real_route_disconnect_401_then_relogin_replays_one_job(self):
self.attach(dict(resume=False),self.ticket().json['ticket'])
job=jobs.get('job-1')
wait(self.events.exists)
self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer expired'}).status_code,401)
self.assertEqual(self.client.get('/api/host-update-jobs',headers={'Authorization':'Bearer valid'}).json['jobs'][0]['id'],job.id)
with patch.object(jobs.subprocess,'Popen') as spawn:
self.attach(dict(resume=True),self.ticket().json['ticket'])
spawn.assert_not_called()
self.finish(job)
ws=self.attach(dict(resume=True),self.ticket().json['ticket'])
messages=[json.loads(call.args[0]) for call in ws.send.call_args_list]
self.assertEqual(messages[-1],dict(type='script_complete',exit_code=0))
self.assertEqual(self.events.read_text().splitlines(),['started','final-step'])
self.callback.assert_called_once()
def test_missing_resume_extra_params_and_boolean_cursor_spawn_nothing(self):
with patch.object(jobs.subprocess,'Popen') as spawn, patch.object(jobs.tempfile,'mkstemp') as files:
for init in (dict(resume=True),dict(resume=False,params={'LD_PRELOAD':'bad'}),dict(resume=False,offset=True),dict(resume='false')):
self.attach(init,self.ticket().json['ticket'])
spawn.assert_not_called()
files.assert_not_called()
def test_legacy_action_api_keeps_fixed_command_and_cancel_cannot_target_host_job(self):
# Execute the actual existing API functions, with only the systemd
# boundary replaced. They must keep their original unit/argv contract.
source=ast.parse((ROOT/'AppImage/scripts/flask_server.py').read_text())
names={'api_system_pve_update_run','api_system_pve_update_status','api_system_pve_update_cancel'}
nodes=[n for n in source.body if isinstance(n,ast.FunctionDef) and n.name in names]
app=Flask('legacy-host-action-fixture')
run=Mock(return_value=(True,None))
state=Mock(return_value={'status':'running'})
cancel=Mock()
scope=dict(app=app,require_admin_scope=lambda fn:fn,require_auth=lambda fn:fn,
os=os,jsonify=jsonify,_PVE_UPDATE_SCRIPT=str(self.script),
_action_run=run,_action_state=state,_action_cancel=cancel)
exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual-legacy-api','exec'),scope)
client=app.test_client()
self.assertEqual(client.post('/api/system/pve-update/run').status_code,202)
run.assert_called_once_with('proxmenux-action-pve-update',['/bin/bash',str(self.script)],
description='ProxMenux action: Proxmox VE update')
self.assertEqual(client.get('/api/system/pve-update/status').status_code,200)
self.assertEqual(client.delete('/api/system/pve-update').status_code,200)
cancel.assert_called_once_with('proxmenux-action-pve-update')
self.assertEqual(jobs.list_jobs(),[])
def test_actual_host_finalizer_invalidates_health_without_viewer_or_package_calls(self):
from types import SimpleNamespace
keys=('updates_check','overall_health','_bg_overall','_bg_detailed')
cache=SimpleNamespace(last_check_times=dict.fromkeys((*keys,'unrelated'),123),
cached_results=dict.fromkeys((*keys,'unrelated'),{'status':'old'}))
module=ModuleType('health_monitor')
module.health_monitor=cache
node=next(n for n in ast.parse((ROOT/'AppImage/scripts/flask_terminal_routes.py').read_text()).body
if isinstance(n,ast.FunctionDef) and n.name=='_host_update_completed')
scope={}
exec(compile(ast.Module(body=[node],type_ignores=[]),'actual-finalizer','exec'),scope)
with patch.dict(sys.modules,{'health_monitor':module}),patch.object(jobs.subprocess,'Popen') as spawn:
scope['_host_update_completed'](jobs.SCRIPT,{},0,1)
spawn.assert_not_called()
self.assertEqual(cache.last_check_times,{'unrelated':123})
self.assertEqual(cache.cached_results,{'unrelated':{'status':'old'}})
if __name__=='__main__':
unittest.main(verbosity=2)
+204
View File
@@ -0,0 +1,204 @@
"""Actual wrapper + worker, with every machine effect replaced by local fixtures.
Only fixed filesystem locations are redirected. Package commands and reboot
are inert executables which record calls; no root, network or host update.
"""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
class HostUpdateShell(unittest.TestCase):
def test_packaging_copies_required_modules_with_no_host_probe_on_import(self):
source = (ROOT / 'AppImage/scripts/build_appimage.sh').read_text().splitlines()
copies = [f'cp "$SCRIPT_DIR/{name}.py" "$APP_DIR/usr/bin/"'
for name in ('host_update_jobs', 'kernel_reboot')]
for line in copies:
self.assertIn(line, source)
app = self.folder / 'AppDir'
(app / 'usr/bin').mkdir(parents=True)
result = subprocess.run(['/bin/bash', '-c', '\n'.join(copies)],
env=dict(os.environ, SCRIPT_DIR=str(ROOT / 'AppImage/scripts'), APP_DIR=str(app)),
capture_output=True, text=True, timeout=3)
self.assertEqual(result.returncode, 0, result.stderr)
code = ('import sys; from unittest.mock import patch; sys.path.insert(0,sys.argv[1]); '
'guard=patch("subprocess.Popen",side_effect=AssertionError("forbidden probe")); '
'guard.start(); import host_update_jobs,kernel_reboot; '
'assert host_update_jobs.list_jobs()==[]')
result = subprocess.run([sys.executable, '-I', '-B', '-c', code, str(app / 'usr/bin')],
capture_output=True, text=True, timeout=3)
self.assertEqual(result.returncode, 0, result.stderr)
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='host-update-shell-')
self.addCleanup(self.temp.cleanup)
self.folder = Path(self.temp.name)
self.bin = self.folder / 'bin'
self.bin.mkdir()
scripts = self.folder / 'scripts'
(scripts / 'utilities').mkdir(parents=True)
(scripts / 'global').mkdir()
self.calls = self.folder / 'calls'
self.calls.touch()
self.lock = self.folder / 'update.lock'
for name in ('utilities/proxmox_update.sh', 'global/update-pve-safe.sh'):
text = (ROOT / 'scripts' / name).read_text()
text = text.replace('/usr/local/share/proxmenux', str(self.folder))
text = text.replace('/run/proxmenux-host-update.lock', str(self.lock))
text = text.replace('/var/run/proxmenux-update-', str(self.folder / 'marker-'))
text = text.replace('/var/run/reboot-required', str(self.folder / 'reboot-required'))
text = text.replace('/var/log/proxmox-update-', str(self.folder / 'update-'))
text = text.replace('/tmp/proxmenux_screen_capture_', str(self.folder / 'capture-'))
(scripts / name).write_text(text)
(self.folder / 'utils.sh').write_text('''
load_language() { :; }
initialize_cache() { :; }
translate() { printf '%s' "$1"; }
msg_error() { echo "$*"; }
msg_warn() { echo "$*"; }
msg_info() { echo "$*"; }
msg_info2() { echo "$*"; }
msg_ok() { echo "$*"; }
msg_title() { echo "$*"; }
msg_success() { echo "$*"; }
show_proxmenux_logo() { :; }
LOCAL_SCRIPTS="$FIXTURE/scripts"
''')
(scripts / 'global/common-functions.sh').write_text('''
pmx_format_upgradable() { cat; }
pmx_dpkg_changes_since() { echo 'package changes fixture'; }
lvm_repair_check() { echo lvm-poststep >> "$CALLS"; }
pmx_rebuild_dkms_after_kernel() { echo dkms-poststep >> "$CALLS"; }
''')
(scripts / 'global/utils-install-functions.sh').write_text('ensure_repositories() { return 0; }\n')
commands = {
'pveversion': "echo 'pve-manager/9.2.24/test (running kernel: 7.0.14-25-pve)'",
'df': "printf 'Filesystem 1K-blocks Used Available Use%% Mounted\\nfixture 9999999 1 9999999 1%% /\\n'",
'uname': "echo 7.0.14-25-pve",
'dpkg-query': "printf 'install ok installed\\tproxmox-kernel-7.0.14-26-pve-signed\\n'",
'apt-cache': "echo 'Candidate: 9.2.24'",
'apt-get': 'echo "apt-get $*" >> "$CALLS"; exit 0',
'apt': '''echo "apt $*" >> "$CALLS"
if [[ "$1" == list ]]; then
printf 'Listing...\\npve-manager/stable 9.2.24 amd64 [upgradable from: 9.2.21]\\n'
else
touch "$FIXTURE/upgrade-started"
while [[ ! -e "$FIXTURE/release" ]]; do sleep .02; done
exit "${UPGRADE_EXIT:-0}"
fi''',
'dialog': 'echo dialog >> "$CALLS"; exit "${CONFIRM_EXIT:-0}"',
'whiptail': 'echo "whiptail $*" >> "$CALLS"; exit 1',
'reboot': 'echo FORBIDDEN-REBOOT >> "$CALLS"; exit 99',
'clear': ':',
}
for name, body in commands.items():
path = self.bin / name
path.write_text('#!/bin/bash\n' + body + '\n')
path.chmod(0o700)
self.env = dict(os.environ, PATH=str(self.bin)+':'+os.environ['PATH'],
FIXTURE=str(self.folder), CALLS=str(self.calls),
EXECUTION_MODE='web', PROXMENUX_HOST_UPDATE_JOB='1')
self.wrapper = scripts / 'utilities/proxmox_update.sh'
self.procs = []
self.addCleanup(self.finish)
def finish(self):
(self.folder / 'release').touch()
for proc in self.procs:
if proc.poll() is None:
proc.communicate('\n', timeout=8)
def start(self, **env):
proc = subprocess.Popen(['/bin/bash', str(self.wrapper)], env=dict(self.env, **env),
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
self.procs.append(proc)
return proc
def wait_upgrade(self):
until = time.monotonic() + 5
while not (self.folder / 'upgrade-started').exists():
self.assertLess(time.monotonic(), until, 'inert upgrade did not start')
time.sleep(.01)
def test_web_full_flow_finishes_poststeps_and_footer_without_reboot_or_keypress(self):
proc = self.start()
self.wait_upgrade()
(self.folder / 'release').touch()
output, _ = proc.communicate(timeout=8)
self.assertEqual(proc.returncode, 0, output)
calls = self.calls.read_text()
self.assertIn('lvm-poststep', calls)
self.assertIn('dkms-poststep', calls)
self.assertIn('apt-get -y autoremove', calls)
self.assertIn('apt-get -y autoclean', calls)
self.assertNotIn('FORBIDDEN-REBOOT', calls)
self.assertNotIn('whiptail', calls)
self.assertIn('Reboot Required', output)
self.assertIn('You can reboot later manually.', output)
self.assertFalse((self.folder / 'marker-in-progress').exists())
self.assertTrue((self.folder / 'marker-just-finished').exists())
log = next(self.folder.glob('update-*.log')).read_text()
self.assertIn('--- Packages changed (exit 0) ---', log)
self.assertIn('Finished:', log)
def test_cancel_before_upgrade_has_no_upgrade_cleanup_or_reboot(self):
proc = self.start(CONFIRM_EXIT='1')
output, _ = proc.communicate(timeout=5)
self.assertEqual(proc.returncode, 130, output)
calls = self.calls.read_text()
for forbidden in ('full-upgrade', 'autoremove', 'autoclean', 'REBOOT', 'poststep'):
self.assertNotIn(forbidden, calls)
def test_failure_keeps_failure_and_never_offers_reboot(self):
(self.folder / 'release').touch()
proc = self.start(UPGRADE_EXIT='1')
output, _ = proc.communicate(timeout=5)
self.assertEqual(proc.returncode, 1, output)
for forbidden in ('autoremove', 'autoclean', 'REBOOT', 'poststep', 'whiptail'):
self.assertNotIn(forbidden, self.calls.read_text())
def test_old_menu_cli_contract_retains_explicit_reboot_prompt(self):
(self.folder / 'release').touch()
proc = self.start(PROXMENUX_HOST_UPDATE_JOB='', EXECUTION_MODE='cli')
output, _ = proc.communicate('\n', timeout=5)
self.assertEqual(proc.returncode, 0, output)
self.assertIn('whiptail --title Reboot Required --yesno', self.calls.read_text())
self.assertNotIn('FORBIDDEN-REBOOT', self.calls.read_text())
def test_shared_lock_rejects_second_legacy_or_web_caller_before_any_package_call(self):
first = self.start()
self.wait_upgrade()
before = self.calls.read_text()
for mode in ('cli', 'web'):
second = self.start(EXECUTION_MODE=mode)
output, _ = second.communicate(timeout=3)
self.assertEqual(second.returncode, 75, output)
self.assertEqual(self.calls.read_text(), before)
self.assertIn('already running', output)
self.assertIsNone(first.poll())
def test_lock_does_not_follow_symlink_or_truncate_existing_file(self):
target = self.folder / 'protected'
target.write_text('preserve')
self.lock.symlink_to(target)
proc = self.start()
output, _ = proc.communicate(timeout=3)
self.assertEqual(proc.returncode, 75, output)
self.assertEqual(target.read_text(), 'preserve')
self.assertEqual(self.calls.read_text(), '')
self.lock.unlink()
self.lock.write_text('existing metadata')
proc = self.start(CONFIRM_EXIT='1')
proc.communicate(timeout=5)
self.assertEqual(self.lock.read_text(), 'existing metadata')
if __name__ == '__main__':
unittest.main(verbosity=2)
+107
View File
@@ -0,0 +1,107 @@
"""Real HTTP + WebSocket + PTY under the same gevent patch as Monitor.
Authentication boundaries use synthetic identities; full JWT verification is
covered separately. The sole executable is an inert temporary shell script.
"""
from pathlib import Path
import subprocess
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
def wire_fixture():
import json
import shlex
import tempfile
import time
from types import ModuleType
import requests
from flask import Flask
from gevent.pywsgi import WSGIServer
from simple_websocket import Client
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
auth = ModuleType('auth_manager')
auth.load_auth_config = lambda: {'enabled': True}
auth.verify_token = lambda token: 'fixture' if token == 'valid' else None
auth.verify_token_full = lambda token: ('fixture', 'full_admin') if token == 'valid' else ('fixture', 'read_only') if token == 'read-only' else (None, None)
sys.modules['auth_manager'] = auth
import flask_terminal_routes as routes
import host_update_jobs as jobs
callbacks = []
routes._host_update_completed = lambda *args: callbacks.append(args)
app = Flask(__name__)
app.register_blueprint(routes.terminal_bp)
routes.sock.init_app(app)
server = WSGIServer(('127.0.0.1', 0), app, log=None)
server.start()
base = f'http://127.0.0.1:{server.server_port}'
headers = {'Authorization': 'Bearer valid'}
def wait(predicate):
end = time.monotonic() + 5
while not predicate():
assert time.monotonic() < end, 'fixture timeout'
time.sleep(.01)
def ticket():
response = requests.post(base + '/api/terminal/ticket', headers=headers, timeout=3)
assert response.status_code == 200
return response.json()['ticket']
def connect(identity, resume):
client = Client(base.replace('http:', 'ws:') + '/ws/host-update/' + identity + '?ticket=' + ticket())
client.send(json.dumps(dict(resume=resume, offset=0)))
return client
try:
with tempfile.TemporaryDirectory(prefix='host-update-wire-') as folder:
folder = Path(folder)
gate, events, script = [folder / n for n in ('release', 'events', 'update.sh')]
script.write_text(f'echo start >> {shlex.quote(str(events))}\n'
f'while [ ! -f {shlex.quote(str(gate))} ]; do sleep .02; done\n'
f'echo poststep >> {shlex.quote(str(events))}\necho completed\n')
jobs.SCRIPT = str(script)
viewer = connect('one-host-run', False)
wait(events.exists)
if viewer.connected:
viewer.close()
assert requests.get(base + '/api/host-update-jobs', headers={'Authorization': 'Bearer expired'}, timeout=3).status_code == 401
assert requests.post(base + '/api/terminal/ticket', headers={'Authorization': 'Bearer read-only'}, timeout=3).status_code == 403
listed = requests.get(base + '/api/host-update-jobs', headers=headers, timeout=3).json()['jobs']
assert listed[0]['id'] == 'one-host-run'
viewer = connect('one-host-run', True)
gate.touch()
frames = []
while not frames or frames[-1]['type'] != 'script_complete':
message = viewer.receive(timeout=5)
assert message is not None
frames.append(json.loads(message))
assert frames[-1]['exit_code'] == 0
assert events.read_text().splitlines() == ['start', 'poststep']
assert len(callbacks) == 1
assert 'completed' in ''.join(frame.get('text', '') for frame in frames)
if viewer.connected:
viewer.close()
viewer = connect('missing-host-run', True)
assert json.loads(viewer.receive(timeout=3))['type'] == 'job_unavailable'
if viewer.connected:
viewer.close()
assert len(jobs.list_jobs()) == 1
assert len(callbacks) == 1
finally:
server.stop(timeout=2)
print('PASS real gevent HTTP/WS disconnect, 401, relogin, resume, PTY poststep and missing-ID no restart')
class HostWire(unittest.TestCase):
def test_actual_gevent_transport_survives_viewer_loss(self):
command = ('from gevent import monkey; monkey.patch_all(); '
'import runpy,sys; runpy.run_path(sys.argv[1],run_name="__wire__")')
result = subprocess.run([sys.executable, '-I', '-B', '-c', command, str(Path(__file__).resolve())],
capture_output=True, text=True, timeout=25)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('PASS real gevent', result.stdout)
if __name__ == '__wire__':
wire_fixture()
elif __name__ == '__main__':
unittest.main(verbosity=2)
+63
View File
@@ -0,0 +1,63 @@
// Execute actual transport initializers, reconnect handlers and routing.
const assert = require('node:assert/strict')
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm')
const app = path.resolve(__dirname, '../AppImage')
const ts = require(path.join(app, 'node_modules/typescript'))
const text = fs.readFileSync(path.join(app, 'components/script-terminal-modal.tsx'), 'utf8')
const tree = ts.createSourceFile('modal.tsx', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const opens = [], messages = [], closes = []
let route, cleanup
function walk(n) {
if (ts.isBinaryExpression(n) && n.left.getText(tree) === 'ws.onopen') opens.push(n.right.getText(tree))
if (ts.isBinaryExpression(n) && n.left.getText(tree) === 'ws.onmessage') messages.push(n.right.getText(tree))
if (ts.isBinaryExpression(n) && n.left.getText(tree) === 'ws.onclose') closes.push(n.right.getText(tree))
if (ts.isVariableDeclaration(n) && n.name.getText(tree) === 'getScriptWebSocketUrl') route = n.initializer.getText(tree)
if (ts.isCallExpression(n) && n.expression.getText(tree) === 'useEffect'
&& n.arguments[0]?.getText(tree).includes('Transport mode is fixed')) cleanup = n.arguments[0].getText(tree)
ts.forEachChild(n, walk)
}
walk(tree)
const compile = (code, scope) => vm.runInNewContext(ts.transpileModule('('+code+')', {
compilerOptions:{target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.CommonJS},
}).outputText, scope)
assert.equal(opens.length, 2)
for (const [index, open] of opens.entries()) {
for (const resume of [false, true]) {
const sent = [], scope = {backgroundUpdate:{runId:'same-id',kind:'host',resume},
scriptPath:'/forbidden-client-path', paramsRef:{current:{FORBIDDEN:'client-env'}},
outputOffsetRef:{current:42}, setConnectionStatus(){}, reconnectAttemptsRef:{current:0},
keepAliveIntervalRef:{current:null}, clearInterval(){}, setInterval(){return 1}, setTimeout(){},
WebSocket:{OPEN:1}, ws:{readyState:1,send:s=>sent.push(JSON.parse(s))}}
compile(open, scope)()
assert.equal(sent.length,1)
assert.deepEqual(sent[0],{resume:index===0 ? true : resume, offset:42},
'reconnect must attach; explicit initial start is the sole resume=false message')
}
}
for (const backgroundUpdate of [{runId:'same-id',kind:'host'}, {runId:'lxc-id',vmid:101}, undefined]) {
const actual = compile(route,{backgroundUpdate,API_PORT:'8008'})('legacy-id')
assert.equal(actual,'ws://localhost:8008/ws/'+(backgroundUpdate?.kind==='host'?'host-update/same-id':backgroundUpdate?'lxc-update/lxc-id':'script/legacy-id'))
}
for (const close of closes) {
const sent=[], results=[], scope={backgroundUpdate:{runId:'same-id',kind:'host'},
ws:{send:s=>sent.push(s)}, isOpenRef:{current:true}, isComplete:false,
completionReceivedRef:{current:false}, keepAliveIntervalRef:{current:null},
reconnectAttemptsRef:{current:0},reconnectTimeoutRef:{current:null},setTimeout(fn){fn();return 1},clearTimeout(){},
setConnectionStatus(){}, clearInterval(){}, termRef:{current:{writeln(){}}},term:{writeln(){}},
attemptReconnect(){results.push('attach')}, onCompleteRef:{current:()=>results.push('complete')},t:s=>s}
compile(close,scope)({})
assert.deepEqual(results,['attach'])
assert.deepEqual(sent,[])
}
const sent=[], scope={backgroundUpdate:{runId:'same-id',kind:'host'},
openGenerationRef:{current:0}, isOpenRef:{current:true}, wsRef:{current:{close(){},send:x=>sent.push(x)}},
termRef:{current:{dispose(){}}}, clearTimeout(){},clearInterval(){},
waitingTimeoutRef:{current:null}, reconnectTimeoutRef:{current:null}, keepAliveIntervalRef:{current:null},checkConnectionInterval:{current:null}}
compile(cleanup,scope)()()
assert.deepEqual(sent,[],'unmount cannot send cancellation or restart')
const health=fs.readFileSync(path.join(app,'components/health-status-modal.tsx'),'utf8')
const en=JSON.parse(fs.readFileSync(path.join(app,'messages/en/common.json'),'utf8'))
const sk=JSON.parse(fs.readFileSync(path.join(app,'messages/sk/common.json'),'utf8'))
assert.deepEqual(Object.keys(en.healthStatus.hostUpdate).sort(),Object.keys(sk.healthStatus.hostUpdate).sort())
assert.ok(health.includes('Number(categoryData?.count ?? 0) > 0'),'reboot-only state has no Update Now action')
console.log('PASS host initial/resume transport, legacy/LXC routes, disconnect/unmount and EN/SK keys')
+134
View File
@@ -0,0 +1,134 @@
"""Real dpkg contract shape and actual cached HealthMonitor method. No APT."""
import ast
import copy
from pathlib import Path
import subprocess
import sys
import time
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'AppImage/scripts'))
import kernel_reboot as kernel
def listing(*releases):
return '\n'.join('installed\tproxmox-kernel-'+v+'-signed' for v in releases)+'\n'
class Reboot(unittest.TestCase):
def setUp(self):
self.uname=patch.object(kernel.os,'uname',return_value=SimpleNamespace(release='7.0.14-25-pve'))
self.uname.start()
self.addCleanup(self.uname.stop)
self.marker=patch.object(Path,'exists',return_value=False)
self.marker.start()
self.addCleanup(self.marker.stop)
def check(self, output, rc=0):
with patch.object(kernel.subprocess,'run',return_value=subprocess.CompletedProcess([],rc,output,'')) as call:
result=kernel.reboot_status()
if call.called:
self.assertEqual(call.call_args.args[0][0],'dpkg-query')
self.assertNotIn('apt',call.call_args.args[0])
return result
def test_installed_26_running_25_requires_reboot_without_marker_or_pending_updates(self):
result=self.check(listing('7.0.14-25-pve','7.0.14-26-pve'))
self.assertEqual(result['state'],'required')
self.assertEqual(result['installed_kernel'],'7.0.14-26-pve')
def test_old_kernel_meta_and_removed_config_are_not_new_installed_kernel(self):
text=listing('7.0.14-25-pve','6.17.13-21-pve')+'installed\tproxmox-kernel-7.0\nconfig-files\tproxmox-kernel-7.0.14-26-pve-signed\n'
self.assertEqual(self.check(text)['state'],'current')
def test_reboot_into_new_kernel_clears_the_requirement(self):
with patch.object(kernel.os,'uname',return_value=SimpleNamespace(release='7.0.14-26-pve')):
self.assertEqual(self.check(listing('7.0.14-25-pve','7.0.14-26-pve'))['state'],'current')
def test_version_comparison_is_numeric(self):
self.assertEqual(self.check(listing('7.0.14-9-pve','7.0.14-100-pve'))['state'],'required')
def test_legacy_pve_package_and_arch_suffix_are_supported(self):
self.assertEqual(self.check('installed\tpve-kernel-7.0.14-26-pve:amd64\n')['state'],'required')
def test_missing_empty_malformed_and_partial_are_unknown_not_zero(self):
for text in ('','garbage','installed\tother-package\n','half-configured\tproxmox-kernel-7.0.14-26-pve-signed\n','bad-state\tfoo\n'):
with self.subTest(text=text):
self.assertEqual(self.check(text)['state'],'unknown')
self.assertEqual(self.check(listing('7.0.14-26-pve'),rc=1)['state'],'unknown')
def test_timeout_and_read_error_are_unknown(self):
for error in (subprocess.TimeoutExpired('dpkg-query',5),OSError('unreadable')):
with patch.object(kernel.subprocess,'run',side_effect=error):
self.assertEqual(kernel.reboot_status()['state'],'unknown')
def test_unavailable_or_invalid_uname_never_probes_packages(self):
with patch.object(kernel.os,'uname',side_effect=OSError('unknown')),patch.object(kernel.subprocess,'run') as packages:
self.assertEqual(kernel.reboot_status()['state'],'unknown')
packages.assert_not_called()
for release in ('','invalid','7.0.14--pve'):
with patch.object(kernel.os,'uname',return_value=SimpleNamespace(release=release)),patch.object(kernel.subprocess,'run') as packages:
self.assertEqual(kernel.reboot_status()['state'],'unknown')
packages.assert_not_called()
def test_supported_marker_never_requires_a_package_probe(self):
with patch.object(Path,'exists',return_value=True),patch.object(kernel.subprocess,'run') as packages:
self.assertEqual(kernel.reboot_status()['state'],'required')
packages.assert_not_called()
def test_non_pve_kernel_is_not_a_failed_pve_read(self):
with patch.object(kernel.os,'uname',return_value=SimpleNamespace(release='6.12.0-amd64')),patch.object(kernel.subprocess,'run') as packages:
self.assertEqual(kernel.reboot_status()['state'],'not_applicable')
packages.assert_not_called()
def test_overlay_does_not_mutate_cache_or_hide_existing_error(self):
for original in ('OK','UNKNOWN','WARNING','CRITICAL'):
cache=dict(status=original,count=0,checks={'pending_updates':dict(status='OK')})
before=copy.deepcopy(cache)
result=kernel.with_reboot_status(cache,dict(state='required',running_kernel='old',installed_kernel='new'))
self.assertEqual(cache,before)
self.assertTrue(result['reboot_required'])
self.assertEqual(result['status'],'INFO' if original=='OK' else original)
result=kernel.with_reboot_status(dict(status='OK',checks={}),dict(state='unknown',running_kernel='old',installed_kernel=None))
self.assertEqual(result['status'],'UNKNOWN')
self.assertIsNone(result['reboot_required'])
def test_actual_cached_health_path_refreshes_reboot_evidence_without_apt(self):
source=ast.parse((ROOT/'AppImage/scripts/health_monitor.py').read_text())
node=next(n for n in ast.walk(source) if isinstance(n,ast.FunctionDef) and n.name=='_check_updates')
node.decorator_list=[]
import __future__
scope=dict(os=kernel.os,time=time,subprocess=kernel.subprocess)
exec(compile(ast.Module(body=[node],type_ignores=[]),'actual-health','exec',flags=__future__.annotations.compiler_flag),scope)
for age in (0,599.999):
cache=dict(status='OK',count=0,checks={})
owner=SimpleNamespace(last_check_times={'updates_check':time.time()-age},cached_results={'updates_check':cache})
with patch.object(kernel.os.path,'exists',return_value=True),patch.object(kernel.os.path,'getmtime',return_value=0),patch.object(kernel.subprocess,'run',return_value=subprocess.CompletedProcess([],0,listing('7.0.14-26-pve'),'')) as probe:
result=scope['_check_updates'](owner)
self.assertEqual(result['status'],'INFO')
self.assertTrue(result['reboot_required'])
self.assertEqual(cache,dict(status='OK',count=0,checks={}))
self.assertEqual([call.args[0][0] for call in probe.call_args_list],['dpkg-query'])
def test_actual_health_failure_paths_keep_independent_reboot_evidence(self):
source=ast.parse((ROOT/'AppImage/scripts/health_monitor.py').read_text())
node=next(n for n in ast.walk(source) if isinstance(n,ast.FunctionDef) and n.name=='_check_updates')
import __future__
scope=dict(os=kernel.os,time=time,subprocess=kernel.subprocess)
exec(compile(ast.Module(body=[node],type_ignores=[]),'actual-health','exec',flags=__future__.annotations.compiler_flag),scope)
for age in (600,600.001):
for error in (subprocess.TimeoutExpired('apt-get',30),OSError('fixture-unavailable')):
owner=SimpleNamespace(last_check_times={'updates_check':time.time()-age},cached_results={})
def command(args, **kwargs):
if args[0]=='dpkg-query':
return subprocess.CompletedProcess(args,0,listing('7.0.14-26-pve'),'')
raise error
with patch.object(kernel.os.path,'exists',return_value=False),patch.object(kernel.subprocess,'run',side_effect=command) as probe:
result=scope['_check_updates'](owner)
self.assertEqual(result['status'],'UNKNOWN')
self.assertTrue(result['reboot_required'])
self.assertEqual([call.args[0][0] for call in probe.call_args_list],['dpkg-query','apt-get'])
if __name__=='__main__':
unittest.main(verbosity=2)