mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Preserve host updates across sign-out and report pending kernel activation
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user