mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
fix: wait for VM power tasks before completing controls
This commit is contained in:
@@ -288,6 +288,40 @@ class CheckOnlyTests(unittest.TestCase):
|
||||
self.assertTrue(stored['error'])
|
||||
self.assertFalse(any('apt list' in ' '.join(command) for command in commands))
|
||||
|
||||
def test_control_route_uses_same_lock_and_preserves_qemu(self):
|
||||
app = Flask(__name__)
|
||||
command = Mock(return_value=subprocess.CompletedProcess(
|
||||
[], 0, '"UPID:fixture:000A:000B:000C:vzstart:100:root@pam:"\n', ''))
|
||||
scope = {'app': app, 'jsonify': jsonify, 'request': request,
|
||||
'require_auth': lambda fn: fn,
|
||||
'get_cached_pvesh_cluster_resources_vm': lambda: [
|
||||
{'vmid': 100, 'type': 'lxc', 'node': 'fixture'},
|
||||
{'vmid': 200, 'type': 'qemu', 'node': 'fixture'}],
|
||||
'subprocess': SimpleNamespace(run=command), '_pvesh_cache': {},
|
||||
'_vm_cache_invalidate': Mock()}
|
||||
node = next(n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == 'api_vm_control')
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), 'route', 'exec'), scope)
|
||||
client = app.test_client()
|
||||
reservation = job.acquire(100)
|
||||
try:
|
||||
self.assertEqual(client.post('/api/vms/100/control', json={'action': 'stop'}).status_code, 409)
|
||||
command.assert_not_called()
|
||||
self.assertEqual(client.post('/api/vms/200/control', json={'action': 'reboot'}).status_code, 200)
|
||||
finally:
|
||||
reservation.close()
|
||||
# The production waiter owns a long-lived reservation. Isolate its
|
||||
# launch here so it cannot probe through another test's transport.
|
||||
with patch.object(job.threading, 'Thread') as worker:
|
||||
response = client.post('/api/vms/100/control', json={'action': 'start'})
|
||||
worker.return_value.start.assert_called_once_with()
|
||||
retained = worker.call_args.kwargs['args'][1]
|
||||
self.addCleanup(retained.close)
|
||||
job.cancel_control(retained)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json['success'])
|
||||
self.assertEqual(response.json['task'], 'UPID:fixture:000A:000B:000C:vzstart:100:root@pam:')
|
||||
self.assertIn('/nodes/fixture/lxc/100/status/start', command.call_args.args[0])
|
||||
|
||||
def test_actual_routes_submit_poll_and_control_lock(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Actual Flask submission, real flock, isolated Proxmox reads and shell caller."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import test_vm_control_task as control_fixture
|
||||
|
||||
TASK, ROOT = control_fixture.TASK, control_fixture.ROOT
|
||||
|
||||
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
|
||||
import lxc_check_job as job
|
||||
|
||||
|
||||
class ControlReservationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.folder = Path(self.temp.name)
|
||||
self.lock_path = self.folder / 'proxmenux-lxc-update-100.lock'
|
||||
self.lock_patch = patch.object(job, 'LOCK_DIR', self.folder)
|
||||
self.lock_patch.start()
|
||||
self.addCleanup(self.lock_patch.stop)
|
||||
job._jobs.clear()
|
||||
job._attempts.clear()
|
||||
self.route = control_fixture.ControlTaskTests()
|
||||
self.route.setUp()
|
||||
self.thread = Mock()
|
||||
self.factory = patch.object(job.threading, 'Thread', return_value=self.thread)
|
||||
self.thread_factory = self.factory.start()
|
||||
self.addCleanup(self.factory.stop)
|
||||
self.reservations = []
|
||||
self.addCleanup(lambda: [handle.close() for handle in self.reservations if not handle.closed])
|
||||
|
||||
def accepted(self, action='reboot'):
|
||||
task = TASK.replace('vzreboot', 'vz' + action)
|
||||
self.route.run.return_value = self.route.result(task)
|
||||
response = self.route.client.post('/api/vms/100/control', json={'action': action})
|
||||
self.assertEqual(response.status_code, 200, response.json)
|
||||
handle = self.thread_factory.call_args.kwargs['args'][1]
|
||||
self.reservations.append(handle)
|
||||
self.assertEqual(response.json['task'], task)
|
||||
return handle
|
||||
|
||||
@staticmethod
|
||||
def result(payload, code=0):
|
||||
return subprocess.CompletedProcess([], code, json.dumps(payload), '')
|
||||
|
||||
def test_every_power_action_keeps_reservation_after_post_and_blocks_second_caller(self):
|
||||
for action in ('start', 'shutdown', 'reboot', 'stop'):
|
||||
with self.subTest(action=action):
|
||||
handle = self.accepted(action)
|
||||
self.route.run.reset_mock()
|
||||
response = self.route.client.post('/api/vms/100/control', json={'action': 'start'})
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.route.run.assert_not_called()
|
||||
with self.assertRaises(BlockingIOError):
|
||||
job.start(100)
|
||||
self.assertFalse(handle.closed)
|
||||
job.cancel_control(handle)
|
||||
handle.close()
|
||||
|
||||
def test_waiter_survives_read_failure_and_running_until_confirmed_terminal_status(self):
|
||||
handle = self.accepted()
|
||||
probes = [subprocess.TimeoutExpired('pvesh', 5),
|
||||
self.result({'status': 'running'}), self.result({'status': 'stopped'}),
|
||||
self.result({'status': 'stopped', 'exitstatus': 'OK'})]
|
||||
def still_reserved(_):
|
||||
with self.assertRaises(BlockingIOError):
|
||||
job.acquire(100)
|
||||
self.assertFalse(handle.closed)
|
||||
with patch.object(job.subprocess, 'run', side_effect=probes) as reads, \
|
||||
patch.object(job.time, 'sleep', side_effect=still_reserved) as sleeps:
|
||||
job._wait_control(100, handle)
|
||||
self.assertEqual(sleeps.call_count, 3)
|
||||
self.assertTrue(handle.closed)
|
||||
self.assertEqual(self.lock_path.read_text(), '')
|
||||
self.assertTrue(all(call.args[0][:2] == ['pvesh', 'get'] for call in reads.call_args_list))
|
||||
job.acquire(100).close()
|
||||
|
||||
def test_restart_releases_fd_but_marker_blocks_until_pve_verifies_completion(self):
|
||||
handle = self.accepted()
|
||||
handle.close() # simulate Monitor shutdown, not guest task termination
|
||||
with patch.object(job.subprocess, 'run', return_value=self.result({'status': 'running'})):
|
||||
with self.assertRaises(BlockingIOError):
|
||||
job.acquire(100)
|
||||
with patch.object(job.subprocess, 'run', return_value=self.result(
|
||||
{'status': 'stopped', 'exitstatus': 'start failed'})):
|
||||
job.acquire(100).close()
|
||||
self.assertEqual(self.lock_path.read_text(), '')
|
||||
|
||||
def test_unknown_or_malformed_records_make_no_pve_probe_and_keep_blocked(self):
|
||||
for content in ['not-json', '{}', json.dumps({'node': 'fixture', 'task': None}),
|
||||
json.dumps({'node': 'other', 'task': TASK}),
|
||||
json.dumps({'node': 'fixture', 'task': TASK.replace(':100:', ':200:')}),
|
||||
'x' * 1025]:
|
||||
self.lock_path.write_text(content)
|
||||
with patch.object(job.subprocess, 'run') as probe:
|
||||
with self.assertRaises(BlockingIOError):
|
||||
job.acquire(100)
|
||||
probe.assert_not_called()
|
||||
self.assertEqual(self.lock_path.read_text(), content)
|
||||
|
||||
def test_plain_lock_acquire_has_no_new_probe(self):
|
||||
with patch.object(job.subprocess, 'run') as probe:
|
||||
job.acquire(100).close()
|
||||
probe.assert_not_called()
|
||||
|
||||
def test_definite_failure_clears_marker_but_timeout_or_missing_upid_cannot_confirm_completion(self):
|
||||
self.route.run.return_value = self.result({}, code=1)
|
||||
self.assertEqual(self.route.client.post('/api/vms/100/control', json={'action': 'start'}).status_code, 500)
|
||||
self.assertEqual(self.lock_path.read_text(), '')
|
||||
for response in [subprocess.TimeoutExpired('pvesh', 30), self.result('')]:
|
||||
self.route.run.side_effect = response if isinstance(response, Exception) else None
|
||||
self.route.run.return_value = response
|
||||
self.route.client.post('/api/vms/100/control', json={'action': 'start'})
|
||||
self.assertIsNone(json.loads(self.lock_path.read_text())['task'])
|
||||
with self.assertRaises(BlockingIOError):
|
||||
job.acquire(100)
|
||||
self.lock_path.write_text('') # fixture reset after explicit assertions
|
||||
|
||||
def test_thread_launch_failure_retains_marker_for_recovery(self):
|
||||
self.thread.start.side_effect = RuntimeError('thread failed')
|
||||
self.route.run.return_value = self.route.result(TASK)
|
||||
self.assertEqual(self.route.client.post('/api/vms/100/control', json={'action': 'reboot'}).status_code, 500)
|
||||
self.assertEqual(json.loads(self.lock_path.read_text())['task'], TASK)
|
||||
with patch.object(job.subprocess, 'run', return_value=self.result({'status': 'running'})):
|
||||
with self.assertRaises(BlockingIOError):
|
||||
job.acquire(100)
|
||||
|
||||
def test_failed_task_record_write_keeps_uncertainty_before_any_next_operation(self):
|
||||
original = job._write_control
|
||||
calls = 0
|
||||
def fail_second(handle, record):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise OSError('persistence failed')
|
||||
original(handle, record)
|
||||
self.route.run.return_value = self.route.result(TASK)
|
||||
with patch.object(job, '_write_control', side_effect=fail_second):
|
||||
response = self.route.client.post('/api/vms/100/control', json={'action': 'reboot'})
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertIsNone(json.loads(self.lock_path.read_text())['task'])
|
||||
with patch.object(job.subprocess, 'run') as probe:
|
||||
with self.assertRaises(BlockingIOError):
|
||||
job.acquire(100)
|
||||
probe.assert_not_called()
|
||||
|
||||
def test_shell_blocked_by_live_reservation_runs_no_probe_or_guest_action(self):
|
||||
self.accepted()
|
||||
import os
|
||||
env = {**os.environ, 'PROXMENUX_LOCK_DIR': str(self.folder),
|
||||
'VMID': '100', 'TARGET': 'os', 'BACKUP': '1'}
|
||||
result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 7, result.stdout + result.stderr)
|
||||
self.assertIn('another ProxMenux update', result.stderr)
|
||||
self.assertEqual(result.stdout, '')
|
||||
|
||||
def test_retired_combined_mode_fails_before_lock_or_any_guest_operation(self):
|
||||
import os
|
||||
env = {**os.environ, 'PROXMENUX_LOCK_DIR': str(self.folder),
|
||||
'VMID': '100', 'TARGET': 'os', 'CHECK_APPLY_OS': '1'}
|
||||
result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 4, result.stdout + result.stderr)
|
||||
self.assertFalse(self.lock_path.exists())
|
||||
self.assertEqual(result.stdout, '')
|
||||
|
||||
def test_shell_caller_preserves_pending_record_and_performs_no_guest_operation(self):
|
||||
# Covers process-restart recovery: no Python thread holds this flock.
|
||||
self.lock_path.write_text(json.dumps({'node': 'fixture', 'task': TASK}))
|
||||
binary = self.folder / 'bin'
|
||||
binary.mkdir()
|
||||
call_log = self.folder / 'calls'
|
||||
for name, body in [('pvesh', 'echo \'{"status":"running"}\''),
|
||||
('pct', f'echo pct >> "{call_log}"; exit 99')]:
|
||||
script = binary / name
|
||||
script.write_text('#!/bin/sh\n' + body + '\n')
|
||||
script.chmod(0o755)
|
||||
import os
|
||||
before = self.lock_path.read_text()
|
||||
env = {**os.environ, 'PATH': str(binary) + ':' + os.environ['PATH'],
|
||||
'PYTHONPATH': str(ROOT / 'AppImage/scripts'),
|
||||
'PROXMENUX_LOCK_DIR': str(self.folder), 'VMID': '100', 'TARGET': 'os', 'BACKUP': '1'}
|
||||
result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')],
|
||||
env=env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 7, result.stdout + result.stderr)
|
||||
self.assertFalse(call_log.exists())
|
||||
self.assertEqual(self.lock_path.read_text(), before)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -145,7 +145,25 @@ class ObservedStateRoutesTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200, response.json)
|
||||
return response.json[0]
|
||||
|
||||
def test_completed_shutdown_cannot_revert_to_stale_cluster_running(self):
|
||||
self.running = False
|
||||
self.scope['get_cached_pvesh_cluster_resources_vm'] = lambda: [
|
||||
dict(type='lxc', node='fixture-pve', vmid=100, name='dev-trixie', status='running')]
|
||||
for _ in range(3):
|
||||
row = self.row()
|
||||
self.assertEqual(row['status'], 'stopped', 'cached summary cannot override live local state')
|
||||
self.assertEqual(row['ips'], [])
|
||||
self.ip_probe.assert_not_called()
|
||||
self.assertEqual(self.commands, [], 'status polling never enters the guest')
|
||||
|
||||
def test_completed_start_cannot_revert_to_stale_cluster_stopped(self):
|
||||
self.running = True
|
||||
self.scope['get_cached_pvesh_cluster_resources_vm'] = lambda: [
|
||||
dict(type='lxc', node='fixture-pve', vmid=100, name='dev-trixie', status='stopped')]
|
||||
row = self.row()
|
||||
self.assertEqual(row['status'], 'running')
|
||||
self.assertEqual(row['ip'], self.ip)
|
||||
self.assertEqual(self.commands, [])
|
||||
|
||||
def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self):
|
||||
first = self.row()
|
||||
|
||||
@@ -473,5 +473,39 @@ context.selectedVMRef.current = { vmid: 100 }
|
||||
assert.ok(messages[key]?.trim())
|
||||
assert.match(messages.checkCooldown, /\{seconds\}/)
|
||||
}
|
||||
const oldFetch = context.fetchApi
|
||||
const oldMutate = context.mutate
|
||||
context.controlRequestRef = { current: false }
|
||||
context.setControlAction = () => {}
|
||||
context.waitForVmControlTask = async () => ({ guest_status: 'running' })
|
||||
context.setSelectedVM = update => {
|
||||
const previous = context.selectedVM
|
||||
const next = typeof update === 'function' ? update(previous) : update
|
||||
assert.equal(next?.vmid, previous?.vmid, 'control preserves the selected guest')
|
||||
context.selectedVM = next
|
||||
}
|
||||
context.fetchApi = async () => ({ config: { onboot: 1 } })
|
||||
context.mutate = async () => {}
|
||||
for (const action of ["start", "shutdown", "reboot", "stop"]) {
|
||||
context.selectedVMRef.current = { vmid: 100 }
|
||||
context.selectionChanged = undefined
|
||||
await ui.control(100, action)
|
||||
assert.equal(context.selectionChanged, undefined, "control must not close or reset the selected modal")
|
||||
assert.equal(context.controlLoading, false)
|
||||
}
|
||||
for (const selection of [null, { vmid: 200 }]) {
|
||||
context.selectedVMRef.current = { vmid: 100 }
|
||||
context.mutate = async () => { context.selectedVMRef.current = selection }
|
||||
details = null
|
||||
await ui.control(100, "start")
|
||||
assert.equal(details, null, "closing or switching guests during control must not reopen/overwrite a modal")
|
||||
}
|
||||
context.selectedVMRef.current = { vmid: 100 }
|
||||
context.mutate = async () => { throw new Error("poll failed") }
|
||||
context.alerted = null
|
||||
await ui.control(100, "shutdown")
|
||||
assert.equal(context.alerted, null, "failed revalidation is not failed control")
|
||||
context.fetchApi = oldFetch
|
||||
context.mutate = oldMutate
|
||||
console.log("PASS observed-state UI: freshness, safe actions, per-guest preferences/bulk races, EN/SK")
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Actual Flask routes: additive task metadata and confined read-only status."""
|
||||
import ast
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TASK = 'UPID:fixture:000A:000B:000C:vzreboot:100:root@pam:'
|
||||
|
||||
class ControlTaskTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = Flask(__name__)
|
||||
self.run = Mock()
|
||||
self.cache = {}
|
||||
self.invalidate = Mock()
|
||||
scope = dict(app=self.app, jsonify=jsonify, request=request, require_auth=lambda fn: fn,
|
||||
get_cached_pvesh_cluster_resources_vm=lambda: [
|
||||
dict(vmid=100, type='lxc', node='fixture'),
|
||||
dict(vmid=200, type='qemu', node='fixture')],
|
||||
subprocess=SimpleNamespace(run=self.run, TimeoutExpired=subprocess.TimeoutExpired),
|
||||
_pvesh_cache=self.cache, _vm_cache_invalidate=self.invalidate)
|
||||
nodes = [n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name in ('api_vm_control_task', 'api_vm_control')]
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'routes', 'exec'), scope)
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def get(self, task=TASK, vmid=100):
|
||||
return self.client.get(f'/api/vms/{vmid}/control-task/{task}')
|
||||
|
||||
def result(self, payload, code=0):
|
||||
return subprocess.CompletedProcess([], code, json.dumps(payload), '')
|
||||
|
||||
def test_actual_control_post_preserves_each_action_and_adds_its_task(self):
|
||||
lock = Mock()
|
||||
retained = Mock()
|
||||
module = SimpleNamespace(acquire=lambda _: lock, begin_control=Mock(),
|
||||
retain_control=retained, cancel_control=Mock())
|
||||
with patch.dict('sys.modules', {'lxc_check_job': module}):
|
||||
for family, vmid in [('vz', 100), ('qm', 200)]:
|
||||
for action in ['start', 'shutdown', 'reboot', 'stop']:
|
||||
self.run.reset_mock()
|
||||
task = TASK.replace('vzreboot:100', f'{family}{action}:{vmid}')
|
||||
self.run.return_value = self.result(task)
|
||||
response = self.client.post(f'/api/vms/{vmid}/control', json={'action': action})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json['success'])
|
||||
self.assertEqual(response.json['action'], action)
|
||||
self.assertEqual(response.json['vmid'], vmid)
|
||||
self.assertEqual(response.json['task'], task)
|
||||
self.assertEqual(self.run.call_count, 1, 'POST accepts once without polling or extra action')
|
||||
self.assertEqual(self.run.call_args.args[0],
|
||||
['pvesh', 'create', f'/nodes/fixture/{"lxc" if family == "vz" else "qemu"}/{vmid}/status/{action}'])
|
||||
self.assertEqual(retained.call_count, 4)
|
||||
lock.close.assert_not_called()
|
||||
|
||||
def test_legacy_acceptance_without_task_and_invalid_action(self):
|
||||
self.run.return_value = subprocess.CompletedProcess([], 0, '', '')
|
||||
response = self.client.post('/api/vms/200/control', json={'action': 'start'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json['success'])
|
||||
self.assertIsNone(response.json['task'])
|
||||
self.run.reset_mock()
|
||||
self.assertEqual(self.client.post('/api/vms/100/control', json={'action': 'destroy'}).status_code, 400)
|
||||
self.run.assert_not_called()
|
||||
|
||||
def test_running_task_never_probes_guest_or_mutates(self):
|
||||
self.run.return_value = self.result(dict(status='running'))
|
||||
response = self.get()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json, dict(status='running', exitstatus=None))
|
||||
self.assertEqual(self.run.call_count, 1)
|
||||
self.assertEqual(self.run.call_args.args[0][:2], ['pvesh', 'get'])
|
||||
self.invalidate.assert_not_called()
|
||||
|
||||
def test_complete_task_checks_actual_guest_for_all_power_actions_and_qemu(self):
|
||||
for family, vmid in [('vz', 100), ('qm', 200)]:
|
||||
for action in ['start', 'shutdown', 'reboot', 'stop']:
|
||||
with self.subTest(family=family, action=action):
|
||||
self.run.reset_mock()
|
||||
expected = 'stopped' if action in ('shutdown', 'stop') else 'running'
|
||||
self.run.side_effect = [self.result(dict(status='stopped', exitstatus='OK')),
|
||||
self.result(dict(status=expected))]
|
||||
task = TASK.replace('vzreboot:100', f'{family}{action}:{vmid}')
|
||||
response = self.get(task, vmid)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json['guest_status'], expected)
|
||||
self.assertEqual(self.run.call_count, 2)
|
||||
self.assertTrue(all(call.args[0][:2] == ['pvesh', 'get'] for call in self.run.call_args_list))
|
||||
|
||||
def test_wrong_guest_node_type_and_path_make_zero_probes(self):
|
||||
for task in [TASK.replace(':100:', ':200:'), TASK.replace('fixture', 'other'),
|
||||
TASK.replace('vzreboot', 'qmreboot'), TASK.replace('vzreboot', 'vzdump'),
|
||||
TASK + '/extra', '../../etc/passwd', 'not-a-task']:
|
||||
with self.subTest(task=task):
|
||||
self.run.reset_mock()
|
||||
self.assertIn(self.get(task).status_code, [400, 404])
|
||||
self.run.assert_not_called()
|
||||
self.assertEqual(self.get(vmid=999).status_code, 404)
|
||||
self.run.assert_not_called()
|
||||
|
||||
def test_task_failure_never_probes_guest_or_claims_success(self):
|
||||
self.run.return_value = self.result(dict(status='stopped', exitstatus='start failed'))
|
||||
self.assertEqual(self.get().json['exitstatus'], 'start failed')
|
||||
self.assertEqual(self.run.call_count, 1)
|
||||
self.invalidate.assert_not_called()
|
||||
|
||||
def test_unknown_and_invalid_reads_are_errors_not_success(self):
|
||||
for value in [[], {}, dict(status='unknown'), dict(status='running')]:
|
||||
self.run.return_value = self.result(value, code=1 if value == dict(status='running') else 0)
|
||||
self.assertEqual(self.get().status_code, 502)
|
||||
self.run.return_value = subprocess.CompletedProcess([], 0, '', '')
|
||||
self.assertEqual(self.get().status_code, 502)
|
||||
self.run.side_effect = subprocess.TimeoutExpired('pvesh', 5)
|
||||
self.assertEqual(self.get().status_code, 502)
|
||||
|
||||
def test_invalid_final_guest_never_clears_cache(self):
|
||||
for value in [{}, [], dict(status='unknown')]:
|
||||
self.run.side_effect = [self.result(dict(status='stopped', exitstatus='OK')), self.result(value)]
|
||||
self.assertEqual(self.get().status_code, 502)
|
||||
self.invalidate.assert_not_called()
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,135 @@
|
||||
// Execute the actual handler and footer: acceptance is not task completion.
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const root = path.resolve(__dirname, '../../AppImage')
|
||||
const ts = require(path.join(root, 'node_modules/typescript'))
|
||||
const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
|
||||
const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
let handler, footer
|
||||
function walk(node) {
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'handleVMControl') handler = node.getText(tree)
|
||||
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'div'
|
||||
&& node.openingElement.attributes.getText(tree).includes('border-t border-border bg-background px-6 py-4 mt-auto'))
|
||||
footer = node.getText(tree)
|
||||
ts.forEachChild(node, walk)
|
||||
}
|
||||
walk(tree)
|
||||
function compile(code, context) {
|
||||
vm.runInNewContext(ts.transpileModule(code, { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
||||
}}).outputText, context)
|
||||
return context.exports
|
||||
}
|
||||
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||
function buttons(node) {
|
||||
if (!node || typeof node !== 'object') return []
|
||||
if (Array.isArray(node)) return node.flatMap(buttons)
|
||||
return [...(node.type === 'Button' ? [node] : []), ...buttons(node.props?.children)]
|
||||
}
|
||||
;(async () => {
|
||||
for (const action of ['start', 'shutdown', 'reboot', 'stop']) {
|
||||
const completion = deferred(), accepted = deferred()
|
||||
const context = {
|
||||
exports: {}, console: { error() {} }, selectedVM: { vmid: 100, status: action === 'start' ? 'stopped' : 'running', type: 'lxc' },
|
||||
selectedVMRef: { current: { vmid: 100 } }, controlRequestRef: { current: false },
|
||||
controlLoading: false, controlAction: null, posts: 0, alerts: [],
|
||||
setControlLoading(value) { context.controlLoading = value },
|
||||
setControlAction(value) { context.controlAction = value },
|
||||
setSelectedVM(value) { context.selectedVM = typeof value === 'function' ? value(context.selectedVM) : value },
|
||||
rows: [{ vmid: 100, status: 'running' }, { vmid: 200, status: 'stopped' }], publications: 0,
|
||||
setVMDetails() {}, mutate: async (update, options) => {
|
||||
if (!update) return
|
||||
assert.equal(options.revalidate, false, 'publish the confirmation before another GET')
|
||||
context.rows = update(context.rows)
|
||||
assert.equal(context.rows[1].status, 'stopped', 'only the operated guest changes')
|
||||
context.publications++
|
||||
}, invalidateLxcApps() {},
|
||||
vmModalCacheRef: { current: Object.fromEntries(['details','backups','mountPoints','schedule','firewall'].map(n => [n, new Map()])) },
|
||||
fetchApi: async (url, options) => {
|
||||
if (options?.method === 'POST') { context.posts++; return accepted.promise }
|
||||
return { config: {} }
|
||||
},
|
||||
waitForVmControlTask: () => completion.promise,
|
||||
t: key => key, alert: text => context.alerts.push(text),
|
||||
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }) }),
|
||||
}
|
||||
for (const name of ['Button','Terminal','Play','Power','RotateCcw','StopCircle','Loader2']) context[name] = name
|
||||
const ui = compile(`const ${handler}; exports.control = handleVMControl; exports.footer = () => (${footer});`, context)
|
||||
const run = ui.control(100, action)
|
||||
assert.equal(context.controlLoading, true, 'busy starts before the POST')
|
||||
accepted.resolve({ success: true, task: 'task-id' })
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(context.controlLoading, true, 'accepted control must stay busy until the Proxmox task completes')
|
||||
assert.equal(context.publications, 0, 'acceptance never publishes an assumed final status')
|
||||
assert(buttons(ui.footer()).filter(b => b.props.title).every(b => b.props.disabled), 'all power buttons stay disabled')
|
||||
await ui.control(100, action)
|
||||
assert.equal(context.posts, 1, 'a double click never submits a second operation')
|
||||
completion.resolve({ guest_status: action === 'shutdown' || action === 'stop' ? 'stopped' : 'running' })
|
||||
await run
|
||||
assert.equal(context.controlLoading, false)
|
||||
assert.equal(context.controlRequestRef.current, false)
|
||||
assert.equal(context.selectedVM.status, action === 'shutdown' || action === 'stop' ? 'stopped' : 'running')
|
||||
assert.equal(context.alerts.length, 0)
|
||||
assert.equal(context.controlAction, null)
|
||||
assert.equal(context.publications, 1)
|
||||
assert.equal(context.rows[0].status, action === 'shutdown' || action === 'stop' ? 'stopped' : 'running')
|
||||
const failed = deferred()
|
||||
context.fetchApi = async () => ({ task: 'task-id' })
|
||||
context.waitForVmControlTask = () => failed.promise
|
||||
const failureRun = ui.control(100, action)
|
||||
failed.reject(new Error('task failed'))
|
||||
await failureRun
|
||||
assert.equal(context.controlLoading, false)
|
||||
assert.equal(context.alerts.length, 1, 'a task error cannot look like completion')
|
||||
const switched = deferred()
|
||||
context.waitForVmControlTask = () => switched.promise
|
||||
const switchRun = ui.control(100, action)
|
||||
context.selectedVMRef.current = { vmid: 200 }
|
||||
context.selectedVM = { vmid: 200, status: 'stopped' }
|
||||
switched.resolve({ guest_status: 'running' })
|
||||
await switchRun
|
||||
assert.equal(context.selectedVM.vmid, 200)
|
||||
assert.equal(context.selectedVM.status, 'stopped', 'completion cannot overwrite another guest')
|
||||
assert.equal(buttons(ui.footer()).filter(b => b.props.title).some(b => /Pending/.test(b.props.title)), false)
|
||||
}
|
||||
const polls = [], sequence = []
|
||||
let clock = 0, readError = null
|
||||
const helper = compile(fs.readFileSync(path.join(root, 'lib/vm-control.ts'), 'utf8'), {
|
||||
exports: {}, Date: { now: () => clock }, AbortSignal: { timeout: () => ({}) },
|
||||
setTimeout: callback => { clock += 1000; callback() },
|
||||
require: () => ({ fetchApi: async (url, options) => {
|
||||
polls.push([url, options]); if (readError) throw readError
|
||||
return sequence.shift() || { status: 'running' }
|
||||
}}),
|
||||
})
|
||||
// A reboot with "running" status is still pending until its task reports OK.
|
||||
for (const action of ['start','shutdown','reboot','stop']) {
|
||||
clock = 0; polls.length = 0
|
||||
const expected = ['shutdown','stop'].includes(action) ? 'stopped' : 'running'
|
||||
sequence.push({ status: 'running', guest_status: expected },
|
||||
{ status: 'stopped', exitstatus: 'OK', guest_status: expected === 'running' ? 'stopped' : 'running' },
|
||||
{ status: 'stopped', exitstatus: 'OK', guest_status: expected })
|
||||
assert.equal((await helper.waitForVmControlTask(100, action, 'UPID:test')).guest_status, expected)
|
||||
assert.equal(polls.length, 3, 'wait for both the task and actual target state')
|
||||
assert(polls.every(([url, options]) => url === '/api/vms/100/control-task/UPID%3Atest' && !options.method),
|
||||
'polling only reads the accepted task; no second POST or package check')
|
||||
}
|
||||
for (const response of [null, {}, { status:'stopped' }, { status:'stopped', exitstatus:'failed' }]) {
|
||||
sequence.push(response)
|
||||
// null must reach the helper, rather than the fake's default running state.
|
||||
if (response === null) sequence[sequence.length - 1] = { status: null }
|
||||
await assert.rejects(helper.waitForVmControlTask(100, 'reboot', 'task'), /controlNotConfirmed|failed/)
|
||||
}
|
||||
clock = 0; polls.length = 0
|
||||
await assert.rejects(helper.waitForVmControlTask(100, 'reboot', 'task'), /controlNotConfirmed/)
|
||||
assert.equal(clock, 180000, 'bounded wait includes the exact deadline')
|
||||
polls.length = 0
|
||||
await assert.rejects(helper.waitForVmControlTask(100, 'start', ''), /controlNotConfirmed/)
|
||||
assert.equal(polls.length, 0, 'a missing task never starts speculative probes')
|
||||
clock = 0; readError = new Error('unavailable')
|
||||
await assert.rejects(helper.waitForVmControlTask(100, 'start', 'task'), /controlNotConfirmed/)
|
||||
assert.equal(polls.length, 3, 'repeated read failures do not report success')
|
||||
console.log('PASS VM control UI: accepted/pending/completed, all actions, duplicate guard and task failure')
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
Reference in New Issue
Block a user