fix: read completed update results without a live terminal

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent 889409de8d
commit ba3fb1197f
14 changed files with 289 additions and 11 deletions
+24 -1
View File
@@ -249,7 +249,7 @@ class Routes(unittest.TestCase):
_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'}
'issue_terminal_ticket_route','host_update_jobs_status','host_update_result','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)
@@ -277,10 +277,33 @@ class Routes(unittest.TestCase):
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.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer '+token}).status_code,status)
self.attach(dict(resume=False))
spawn.assert_not_called()
self.assertEqual(jobs.list_jobs(),[])
def test_finished_result_is_read_only_and_bounded_by_owner_output(self):
view=dict(state='complete',exit_code=0,text='inert saved output',truncated=False)
with patch.object(jobs,'get',return_value=SimpleNamespace(read=Mock(return_value=view))) as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn:
response=self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'})
self.assertEqual(response.status_code,200)
self.assertEqual(response.json,view)
self.assertEqual(response.headers['Cache-Control'],'no-store')
get.assert_called_once_with('fixture');start.assert_not_called();spawn.assert_not_called()
def test_result_auth_missing_invalid_running_never_starts_job(self):
with patch.object(jobs,'get') as get,patch.object(jobs,'start') as start,patch.object(jobs.subprocess,'Popen') as spawn:
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result').status_code,401)
get.assert_not_called()
get.side_effect=KeyError('missing')
self.assertEqual(self.client.get('/api/host-update-jobs/missing/result',headers={'Authorization':'Bearer valid'}).status_code,404)
get.side_effect=ValueError('invalid')
self.assertEqual(self.client.get('/api/host-update-jobs/invalid/result',headers={'Authorization':'Bearer valid'}).status_code,404)
get.side_effect=None
for state in ('running','attention','finalizing'):
get.return_value=SimpleNamespace(read=Mock(return_value=dict(state=state,exit_code=None)))
self.assertEqual(self.client.get('/api/host-update-jobs/fixture/result',headers={'Authorization':'Bearer valid'}).status_code,409)
start.assert_not_called();spawn.assert_not_called()
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')
+4
View File
@@ -78,6 +78,10 @@ def wire_fixture():
assert events.read_text().splitlines() == ['start', 'poststep']
assert len(callbacks) == 1
assert 'completed' in ''.join(frame.get('text', '') for frame in frames)
saved=requests.get(base+'/api/host-update-jobs/one-host-run/result',headers=headers,timeout=3)
assert saved.status_code==200 and saved.headers['Cache-Control']=='no-store'
assert saved.json()['exit_code']==0 and 'completed' in saved.json()['text']
assert events.read_text().splitlines()==['start','poststep'] and len(callbacks)==1
if viewer.connected:
viewer.close()
viewer = connect('missing-host-run', True)
+33 -1
View File
@@ -60,4 +60,36 @@ const en=JSON.parse(fs.readFileSync(path.join(app,'messages/en/common.json'),'ut
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')
const savedTree=ts.createSourceFile('result.tsx',fs.readFileSync(path.join(app,'components/host-update-result.tsx'),'utf8'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX)
let readResult
function resultWalk(n){
if(ts.isArrowFunction(n)&&n.modifiers?.some(m=>m.kind===ts.SyntaxKind.AsyncKeyword)&&n.getText(savedTree).includes('value.state'))readResult=n.getText(savedTree)
ts.forEachChild(n,resultWalk)
}
resultWalk(savedTree);assert.ok(readResult)
async function results(){
const response={state:'complete',exit_code:0,text:'inert saved result',truncated:false}
for(const code of [0,1,130,-15]){
const calls=[],writes=[],codes=[],errors=[]
const scope={getAuthToken:()=>null,getApiUrl:p=>p,jobId:'saved-id',controller:new AbortController(),container:{current:{}},disposed:false,
fetch:async(p,options)=>{calls.push({p,options});return {ok:true,json:async()=>({...response,exit_code:code})}},
require:n=>n==='xterm'?{Terminal:class{loadAddon(){}open(){}write(v){writes.push(v)}writeln(v){writes.push(v)}}}:{FitAddon:class{fit(){}}},
ResizeObserver:class{observe(){}},setExitCode:v=>codes.push(v),setFailed:v=>errors.push(v),clearTimeout(){},timeout:1,t:s=>s}
await compile(readResult,scope)()
assert.deepEqual(codes,[code]);assert.deepEqual(errors,[]);assert.deepEqual(writes,['inert saved result'])
assert.equal(calls.length,1);assert.equal(calls[0].p,'/api/host-update-jobs/saved-id/result');assert.ok(!calls[0].options.method||calls[0].options.method==='GET')
for(const value of [null,{...response,state:'running'},{...response,exit_code:false},{...response,text:null},{...response,truncated:null}]){
codes.length=errors.length=writes.length=0
scope.fetch=async()=>({ok:true,json:async()=>value});await compile(readResult,scope)()
assert.deepEqual(codes,[]);assert.deepEqual(writes,[]);assert.deepEqual(errors,[true])
}
codes.length=errors.length=writes.length=0
scope.fetch=async()=>({ok:false});await compile(readResult,scope)()
assert.deepEqual(errors,[true]);assert.deepEqual(codes,[]);assert.deepEqual(writes,[])
codes.length=errors.length=writes.length=0;scope.disposed=true;scope.controller.abort()
scope.fetch=async()=>({ok:true,json:async()=>response});await compile(readResult,scope)()
assert.deepEqual(codes,[]);assert.deepEqual(errors,[]);assert.deepEqual(writes,[],'closed result cannot receive a late response')
}
console.log('PASS host/legacy/LXC transports and actual readonly result GET: success/error/cancel/invalid/late-response; zero WS/start callbacks')
}
results().catch(error=>{console.error(error);process.exitCode=1})
+52
View File
@@ -0,0 +1,52 @@
"""Actual installed-version function; package changes invalidate its old display."""
import ast
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[1]
class VersionCache(unittest.TestCase):
def setUp(self):
source = ast.parse((ROOT/'AppImage/scripts/flask_server.py').read_text())
node = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == 'get_proxmox_version')
self.cache = dict(proxmox_version='9.2.21', proxmox_version_time=100, proxmox_version_stamp=1)
self.command = Mock(return_value=SimpleNamespace(returncode=0, stdout='pve-manager/9.2.24/hash\n'))
self.stamp = Mock(return_value=1)
self.scope = dict(is_pbs_host=Mock(return_value=False), get_platform=Mock(return_value={'version':'4.1.0'}),
_system_info_cache=self.cache, _SYSTEM_INFO_CACHE_TTL=21600,
_AVAILABLE_UPDATES_MIN_INTERVAL=30, _apt_state_stamp=self.stamp,
time=SimpleNamespace(time=Mock(return_value=200)), subprocess=SimpleNamespace(run=self.command))
exec(compile(ast.Module(body=[node], type_ignores=[]), 'actual-installed-version', 'exec'), self.scope)
def test_unchanged_package_state_has_zero_commands(self):
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21')
self.command.assert_not_called()
def test_package_change_at_floor_refreshes_version_without_apt(self):
self.stamp.return_value=2
self.scope['time'].time.return_value=129.999
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.21')
self.command.assert_not_called()
self.scope['time'].time.return_value=130
self.assertEqual(self.scope['get_proxmox_version'](), '9.2.24')
self.command.assert_called_once_with(['pveversion'],capture_output=True,text=True,timeout=5)
self.assertEqual(self.cache['proxmox_version_stamp'],2)
def test_failed_read_returns_unknown_but_keeps_last_known_cache(self):
self.stamp.return_value=2
before=dict(self.cache)
self.command.side_effect=OSError('inert unavailable command')
self.assertIsNone(self.scope['get_proxmox_version']())
self.assertEqual(self.cache,before)
def test_pbs_guard_precedes_all_pve_package_probes(self):
self.scope['is_pbs_host'].return_value=True
self.assertEqual(self.scope['get_proxmox_version'](),'4.1.0')
self.stamp.assert_not_called();self.command.assert_not_called()
if __name__=='__main__':
unittest.main(verbosity=2)
+23
View File
@@ -143,6 +143,29 @@ class FullHealth(unittest.TestCase):
self.assertEqual(code, 200)
self.owner.get_detailed_status.assert_called_once()
def test_explicit_refresh_publishes_then_poll_never_rechecks(self):
result, code = self.call('?refresh=1')
self.assertEqual(code, 200)
self.assertIn('_bg_detailed', self.owner.cached_results)
self.assertEqual(self.owner.cached_results['_bg_overall']['status'],result['health']['overall'])
self.assertEqual(self.owner.last_check_times['_bg_overall'],self.owner.last_check_times['_bg_detailed'])
before = copy.deepcopy(self.owner.cached_results)
result, code = self.call('?cached=1')
self.assertEqual(code, 200)
self.assertEqual(result['health']['timestamp'], 'original')
self.assertEqual(self.owner.cached_results, before)
self.owner.get_detailed_status.assert_called_once()
def test_invalid_live_result_never_publishes_or_becomes_fresh(self):
for result in (None, {}, {'details': {}}, {'details': {'updates': {'status': 'OK'}}}):
with self.subTest(result=result):
self.owner.cached_results.clear()
self.owner.last_check_times.clear()
self.owner.get_detailed_status.return_value = result
self.assertEqual(self.call()[1], 500)
self.assertNotIn('_bg_detailed', self.owner.cached_results)
self.assertNotIn('_bg_detailed', self.owner.last_check_times)
class Overlay(unittest.TestCase):
def test_repeated_required_unknown_current_preserves_package_result_and_snapshot(self):