mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
fix: separate LXC package checks from installation
This commit is contained in:
@@ -67,6 +67,10 @@ EOF
|
||||
cat >"$MOCK_BIN/python3" <<'EOF'
|
||||
#!/bin/bash
|
||||
cat >/dev/null
|
||||
if [[ "${1:-}" == '-c' ]]; then
|
||||
[[ "${MOCK_CHECK_FAIL:-0}" != '1' ]]
|
||||
exit $?
|
||||
fi
|
||||
if [[ "${2:-}" == 'protect-update-command' ]]; then
|
||||
printf '%s' "$UPDATE_COMMAND"
|
||||
exit 0
|
||||
@@ -82,6 +86,7 @@ EOF
|
||||
cat >"$MOCK_BIN/vzdump" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "vzdump $*" >>"$MOCK_LOG"
|
||||
[[ "${MOCK_BACKUP_FAIL:-0}" != '1' ]]
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN"/*
|
||||
|
||||
@@ -161,4 +166,18 @@ run_case locked RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_FLOCK_FAIL=1
|
||||
[[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
|
||||
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater"
|
||||
|
||||
run_case checked_missing TARGET=os REQUIRE_CHECKED_OS=1 MOCK_CHECK_FAIL=1
|
||||
[[ $CASE_RC -eq 4 ]] || fail "unchecked installation was not rejected"
|
||||
[[ $(count 'pct ') -eq 0 ]] || fail "unchecked installation probed or started a CT"
|
||||
[[ $(count OS_EXEC) -eq 0 ]] || fail "unchecked installation upgraded packages"
|
||||
|
||||
run_case checked_backup_fail TARGET=os REQUIRE_CHECKED_OS=1 MOCK_INITIAL_STATE=stopped BACKUP=1 BACKUP_STORAGE=local MOCK_BACKUP_FAIL=1
|
||||
[[ $CASE_RC -eq 3 ]] || fail "backup failure was not reported"
|
||||
[[ $(count OS_EXEC) -eq 0 ]] || fail "backup failure upgraded packages"
|
||||
[[ $(count 'pct shutdown 101 --timeout 60') -eq 1 ]] || fail "backup failure did not restore stopped state"
|
||||
|
||||
run_case checked_positive TARGET=os REQUIRE_CHECKED_OS=1
|
||||
[[ $CASE_RC -eq 0 ]] || fail "positive checked installation failed"
|
||||
[[ $(count OS_EXEC) -gt 0 ]] || fail "positive checked installation did not use existing OS path"
|
||||
|
||||
echo "apply_updates.sh: all deterministic tests passed"
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Real job, Flask routes and shared flock; guest transport is isolated."""
|
||||
import ast
|
||||
import datetime
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask import request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
|
||||
import lxc_check_job as job
|
||||
|
||||
|
||||
class CheckOnlyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.folder = Path(self.temp.name)
|
||||
self.conf = self.folder / '100.conf'
|
||||
self.conf.write_text('hostname: fixture\n')
|
||||
self.patches = [patch.object(job, 'LOCK_DIR', self.folder),
|
||||
patch.object(job, 'CONF_DIR', self.folder),
|
||||
patch.object(job, 'OCI_REGISTRY', self.folder / 'installed.json'),
|
||||
patch.object(job.managed_installs, '_lxc_updates_detection_enabled', return_value=True),
|
||||
patch.object(job.managed_installs, '_get_oci_managed_vmids', return_value={}),
|
||||
patch.object(job.managed_installs, 'get_active_items', return_value=[])]
|
||||
for p in self.patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
job._jobs.clear()
|
||||
job._attempts.clear()
|
||||
self.state = 'stopped'
|
||||
self.calls = []
|
||||
self.result = {'result': {'available': True, '_count': 2}, 'entry': {}}
|
||||
|
||||
def transport(self, vmid, action, *args, **kwargs):
|
||||
self.calls.append(action)
|
||||
if action == 'status':
|
||||
return 'status: ' + self.state
|
||||
if action == 'start':
|
||||
self.state = 'running'
|
||||
if action in ('stop', 'shutdown'):
|
||||
self.state = 'stopped'
|
||||
return ''
|
||||
|
||||
def run_check(self, capture_error=None, persistence_error=None, transport=None):
|
||||
reservation = job.acquire(100)
|
||||
job._jobs['run'] = dict(vmid=100, state='running', created=time.monotonic())
|
||||
with patch.object(job, 'command', side_effect=transport or self.transport), \
|
||||
patch.object(job.lxc_update_observation, 'capture', return_value=self.result,
|
||||
side_effect=capture_error) as capture, \
|
||||
patch.object(job.lxc_update_observation, 'discard'), \
|
||||
patch.object(job.managed_installs, 'store_lxc_update_observation',
|
||||
return_value=True, side_effect=persistence_error) as store:
|
||||
job.run(100, 'run', reservation)
|
||||
return job.get(100, 'run'), capture, store
|
||||
|
||||
def test_stopped_check_restores_and_never_installs(self):
|
||||
result, capture, store = self.run_check()
|
||||
self.assertEqual(result['state'], 'complete')
|
||||
self.assertEqual(self.state, 'stopped')
|
||||
self.assertEqual(self.calls, ['status', 'start', 'exec', 'status', 'shutdown', 'status'])
|
||||
capture.assert_called_once_with(100, 'run', refresh_indexes=True)
|
||||
store.assert_called_once_with(100, self.result)
|
||||
self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
|
||||
|
||||
def test_running_check_has_no_lifecycle_effect(self):
|
||||
self.state = 'running'
|
||||
result, _, _ = self.run_check()
|
||||
self.assertEqual(result['state'], 'complete')
|
||||
self.assertEqual(self.calls, ['status'])
|
||||
self.assertEqual(self.state, 'running')
|
||||
|
||||
def test_index_read_timeout_and_persistence_errors_restore_stopped(self):
|
||||
for error in [RuntimeError('index failed'), OSError('read failed'),
|
||||
subprocess.TimeoutExpired('package listing', 30)]:
|
||||
with self.subTest(error=type(error).__name__):
|
||||
result, _, _ = self.run_check(capture_error=error)
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertEqual(self.state, 'stopped')
|
||||
result, _, _ = self.run_check(persistence_error=OSError('disk full'))
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertEqual(self.state, 'stopped')
|
||||
self.assertIn('could not be persisted', result['error'])
|
||||
|
||||
def test_start_timeout_after_effect_is_restored(self):
|
||||
def transport(vmid, action, *args, **kwargs):
|
||||
value = self.transport(vmid, action, *args, **kwargs)
|
||||
if action == 'start':
|
||||
raise subprocess.TimeoutExpired('start', 60)
|
||||
return value
|
||||
result, capture, _ = self.run_check(transport=transport)
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertEqual(self.state, 'stopped')
|
||||
capture.assert_not_called()
|
||||
|
||||
def test_unknown_status_does_not_start(self):
|
||||
self.state = 'unknown'
|
||||
result, capture, _ = self.run_check()
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertEqual(self.calls, ['status'])
|
||||
capture.assert_not_called()
|
||||
|
||||
def test_shutdown_failure_uses_verified_stop(self):
|
||||
def transport(vmid, action, *args, **kwargs):
|
||||
if action == 'shutdown':
|
||||
raise RuntimeError('shutdown failed')
|
||||
return self.transport(vmid, action, *args, **kwargs)
|
||||
result, _, _ = self.run_check(transport=transport)
|
||||
self.assertEqual(result['state'], 'complete')
|
||||
self.assertEqual(self.state, 'stopped')
|
||||
self.assertIn('stop', self.calls)
|
||||
|
||||
def test_failed_restore_revokes_installation(self):
|
||||
def transport(vmid, action, *args, **kwargs):
|
||||
if action in ('shutdown', 'stop'):
|
||||
raise RuntimeError('stop failed')
|
||||
return self.transport(vmid, action, *args, **kwargs)
|
||||
result, _, _ = self.run_check(transport=transport)
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertIn('could not be restored', result['error'])
|
||||
self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
|
||||
|
||||
def test_guard_oci_config_lock_and_disabled_forbid_all_guest_calls(self):
|
||||
for config in ['lxc.environment.runtime: OCI=1\n', 'lock: backup\n']:
|
||||
self.conf.write_text(config)
|
||||
result, capture, _ = self.run_check()
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertEqual(self.calls, [])
|
||||
capture.assert_not_called()
|
||||
self.conf.unlink()
|
||||
result, capture, _ = self.run_check()
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertEqual(self.calls, [])
|
||||
capture.assert_not_called()
|
||||
self.conf.write_text('hostname: fixture\n')
|
||||
with patch.object(job.managed_installs, '_lxc_updates_detection_enabled', return_value=False):
|
||||
result, capture, _ = self.run_check()
|
||||
self.assertEqual(self.calls, [])
|
||||
capture.assert_not_called()
|
||||
|
||||
def test_registered_oci_is_excluded_before_probe(self):
|
||||
with patch.object(job.managed_installs, '_get_oci_managed_vmids', return_value={'100': 'fixture'}):
|
||||
result, capture, _ = self.run_check()
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertEqual(self.calls, [])
|
||||
capture.assert_not_called()
|
||||
|
||||
def test_unreadable_or_invalid_oci_registry_never_means_non_oci(self):
|
||||
for content in ['broken', '{"instances": []}', '{"instances": {"app": {"vmid": 100}}}']:
|
||||
job.OCI_REGISTRY.write_text(content)
|
||||
result, capture, _ = self.run_check()
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertEqual(self.calls, [])
|
||||
capture.assert_not_called()
|
||||
|
||||
def test_thread_start_failure_releases_lock_and_job(self):
|
||||
with patch.object(job.threading, 'Thread') as thread:
|
||||
thread.return_value.start.side_effect = RuntimeError('cannot create worker')
|
||||
with self.assertRaises(RuntimeError):
|
||||
job.start(100)
|
||||
self.assertEqual(job._jobs, {})
|
||||
self.assertEqual(job._attempts, {})
|
||||
reservation = job.acquire(100)
|
||||
reservation.close()
|
||||
|
||||
def test_corrupt_registry_is_preserved_and_installation_stays_blocked(self):
|
||||
registry = self.folder / 'registry.json'
|
||||
registry.write_text('malformed-but-must-not-be-overwritten')
|
||||
with patch.object(job.managed_installs, '_REGISTRY_PATH', str(registry)):
|
||||
result, _, _ = self.run_check(persistence_error=job.managed_installs.store_lxc_update_observation)
|
||||
self.assertEqual(result['state'], 'failed')
|
||||
self.assertEqual(self.state, 'stopped')
|
||||
self.assertEqual(registry.read_text(), 'malformed-but-must-not-be-overwritten')
|
||||
self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
|
||||
|
||||
def test_shared_lock_excludes_scheduler_and_manual_runner(self):
|
||||
reservation = job.acquire(100)
|
||||
self.addCleanup(reservation.close)
|
||||
with self.assertRaises(BlockingIOError):
|
||||
job.start(100)
|
||||
process = subprocess.run(['/usr/bin/flock', '-n',
|
||||
str(self.folder / 'proxmenux-lxc-update-100.lock'), '/bin/true'])
|
||||
self.assertEqual(process.returncode, 1)
|
||||
self.assertEqual(job._jobs, {})
|
||||
reservation.close()
|
||||
other = job.acquire(100)
|
||||
other.close()
|
||||
|
||||
def test_cooldown_boundaries_and_inflight(self):
|
||||
for elapsed, expected in [(59.999, 429), (60, 202), (60.001, 202)]:
|
||||
job._jobs.clear()
|
||||
job._attempts[100] = 100
|
||||
reservation = Mock()
|
||||
with patch.object(job.time, 'monotonic', return_value=100 + elapsed), \
|
||||
patch.object(job.threading, 'Thread'), patch.object(job, 'acquire', return_value=reservation):
|
||||
result, code = job.start(100)
|
||||
self.assertEqual(code, expected)
|
||||
job._jobs['active'] = dict(vmid=100, state='running', created=100)
|
||||
job._attempts[100] = 0
|
||||
with patch.object(job.time, 'monotonic', return_value=10000):
|
||||
self.assertEqual(job.start(100)[1], 429)
|
||||
|
||||
def test_poll_is_read_only_and_scoped_to_guest(self):
|
||||
job._jobs['run'] = dict(vmid=100, state='running', error=None, created=1)
|
||||
with patch.object(job, 'command') as command, patch.object(job, 'guard') as guard:
|
||||
self.assertEqual(job.get(100, 'run')['state'], 'running')
|
||||
self.assertIsNone(job.get(101, 'run'))
|
||||
self.assertIsNone(job.get(100, 'missing'))
|
||||
command.assert_not_called()
|
||||
guard.assert_not_called()
|
||||
|
||||
def test_install_gate_positive_zero_unknown_error_and_freshness(self):
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
for count, available, error, age, allowed in [
|
||||
(2, True, None, 60, True), (0, False, None, 60, False),
|
||||
(None, True, None, 60, False), (2, True, 'failed', 60, False),
|
||||
(2, True, None, 172799, True), (2, True, None, 172800, False),
|
||||
(2, True, None, -60, False)]:
|
||||
with self.subTest(count=count, age=age, error=error):
|
||||
item = {'id': 'lxc:100', 'update_check': {'_count': count,
|
||||
'available': available, 'error': error,
|
||||
'last_check': (now - datetime.timedelta(seconds=age)).isoformat()}}
|
||||
with patch.object(job.managed_installs, 'get_active_items', return_value=[item]):
|
||||
if allowed:
|
||||
job.checked_os(100)
|
||||
else:
|
||||
with self.assertRaises((RuntimeError, ValueError)):
|
||||
job.checked_os(100)
|
||||
|
||||
def test_pending_failure_marker_forbids_install_before_probe(self):
|
||||
(self.folder / 'proxmenux-lxc-check-invalid-100').touch()
|
||||
with self.assertRaises(RuntimeError):
|
||||
job.checked_os(100)
|
||||
|
||||
def test_real_capture_refreshes_lists_and_persists_without_installation(self):
|
||||
for count, index_rc in [(0, 0), (2, 0), (2, 1)]:
|
||||
with self.subTest(count=count, index_rc=index_rc):
|
||||
self.state = 'stopped'
|
||||
reservation = job.acquire(100)
|
||||
job._jobs['real'] = dict(vmid=100, state='running', created=time.monotonic())
|
||||
entry = {'id': 'lxc:100', 'type': 'lxc', '_vmid': '100', '_os_family': 'debian'}
|
||||
output = 'Listing...\n' + '\n'.join(
|
||||
f'pkg{i}/stable 2.0 amd64 [upgradable from: 1.0]' for i in range(count))
|
||||
commands = []
|
||||
def transport(command, **kwargs):
|
||||
commands.append(command)
|
||||
return subprocess.CompletedProcess(command, index_rc if 'update' in command else 0,
|
||||
output if 'list' in ' '.join(command) else '', '')
|
||||
with patch.object(job, 'command', side_effect=self.transport), \
|
||||
patch.object(job.managed_installs, '_DB_DIR', str(self.folder)), \
|
||||
patch.object(job.managed_installs, '_REGISTRY_PATH', str(self.folder / 'registry.json')), \
|
||||
patch.object(job.lxc_update_observation, 'OBSERVATION_DIR', self.folder / 'observations'), \
|
||||
patch.object(job.managed_installs, '_detect_lxc_containers', return_value=[entry]), \
|
||||
patch.object(job.managed_installs, '_probe_lxc_os', return_value='debian'), \
|
||||
patch.object(job.managed_installs.subprocess, 'run', side_effect=transport):
|
||||
job.run(100, 'real', reservation)
|
||||
stored = job.managed_installs.get_registry()['items'][0]['update_check']
|
||||
self.assertEqual(self.state, 'stopped')
|
||||
self.assertEqual(job.get(100, 'real')['state'], 'failed' if index_rc else 'complete')
|
||||
self.assertTrue(any('update' in command for command in commands))
|
||||
self.assertFalse(any(any(word in command for word in ('upgrade', 'install', 'full-upgrade'))
|
||||
for command in commands))
|
||||
if not index_rc:
|
||||
self.assertEqual(stored['_count'], count)
|
||||
else:
|
||||
self.assertTrue(stored['error'])
|
||||
self.assertFalse(any('apt list' in ' '.join(command) for command in commands))
|
||||
|
||||
|
||||
def test_actual_routes_submit_poll_and_control_lock(self):
|
||||
app = Flask(__name__)
|
||||
scope = {'app': app, 'jsonify': jsonify, 'require_auth': lambda fn: fn,
|
||||
'_vm_cache_invalidate': Mock(), '_lxc_os_info_cache': {},
|
||||
'_pvesh_cache': {}, '_get_lxc_update_status_map': lambda: {'100': {'count': 2}}}
|
||||
names = {'api_lxc_updates_refresh_check', 'api_lxc_updates_refresh_check_status'}
|
||||
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 names]
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'routes', 'exec'), scope)
|
||||
client = app.test_client()
|
||||
with patch.object(job, 'start', return_value=({'job_id': 'run'}, 202)):
|
||||
self.assertEqual(client.post('/api/vms/100/updates/refresh-check').status_code, 202)
|
||||
job._jobs['run'] = dict(vmid=100, state='complete', error=None, created=1)
|
||||
with patch.object(job, 'command') as command:
|
||||
response = client.get('/api/vms/100/updates/refresh-check/run')
|
||||
self.assertEqual(response.json['update_check']['count'], 2)
|
||||
self.assertEqual(client.get('/api/vms/101/updates/refresh-check/run').status_code, 404)
|
||||
command.assert_not_called()
|
||||
@@ -67,3 +67,90 @@ for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
|
||||
for (const key of ['dockerWorkloadsHeading', 'runsInsideDocker', 'upstreamDelegatedTitle', 'upstreamDelegatedHelp']) assert.equal(typeof messages.vmLxc.appEditor[key], 'string')
|
||||
}
|
||||
console.log('PASS Docker delegation: live cache, lifecycle invalidation, edit protection, no extra HTTP, unique image counts, eight locales')
|
||||
|
||||
// Exercise the actual bulk-choice construction, keeping target identities
|
||||
// intact while distinguishing a custom updater from a same-named image.
|
||||
const choicesSource = source.slice(source.indexOf('const bulkAppChoices ='), source.indexOf('const bulkChoiceIds ='))
|
||||
assert(choicesSource.startsWith('const bulkAppChoices ='))
|
||||
for (const locale of ['en', 'sk']) {
|
||||
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
|
||||
const t = (key, values = {}) => {
|
||||
const value = key.split('.').reduce((obj, part) => obj?.[part], messages)
|
||||
assert.equal(typeof value, 'string', key)
|
||||
return value.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? '')
|
||||
}
|
||||
const registeredApps = [
|
||||
{id: 'custom', name: 'ghstats', update_command: 'existing updater'},
|
||||
{id: 'helper', name: 'Helper app', update_method: 'helper', helper_slug: 'helper-app'},
|
||||
{id: 'unique', name: 'Only custom', update_command: 'another updater'},
|
||||
{id: 'engine', name: 'Docker', helper_slug: 'docker'},
|
||||
].map(Object.freeze)
|
||||
const units = [
|
||||
{id: 'docker-unit:ghstats', kind: 'standalone', display_name: 'ghstats', dependent_services: []},
|
||||
{id: 'docker-unit:helper', kind: 'compose', display_name: 'Helper app', dependent_services: ['database']},
|
||||
{id: 'docker-unit:unique', kind: 'standalone', display_name: 'Only image', dependent_services: []},
|
||||
].map(Object.freeze)
|
||||
const before = JSON.stringify({registeredApps, units})
|
||||
const result = compile(choicesSource + '\nexports.choices = bulkChoices;', {
|
||||
exports: {}, t, registeredApps, helperExists: true, uc: {helper_slug: 'helper-app'},
|
||||
dockerWorkloadsRegistered: true, dockerRegistered: true,
|
||||
selectedVM: {docker_inventory: {update_units: units}}, followedImageReferences: new Set(),
|
||||
}).choices
|
||||
assert.deepEqual(Array.from(result, x => x.id), ['os', 'app:custom', 'app:helper', 'app:unique',
|
||||
'docker-engine', 'docker-unit:ghstats', 'docker-unit:helper', 'docker-unit:unique'])
|
||||
assert.equal(result.find(x => x.id === 'app:custom').label, `ghstats — ${t('vmLxc.updates.customCommandTitle')}`)
|
||||
assert.equal(result.find(x => x.id === 'docker-unit:ghstats').label, `ghstats — ${t('vmLxc.bulkUpdate.dockerImageMethod')}`)
|
||||
assert.equal(result.find(x => x.id === 'app:helper').label, `Helper app — ${t('vmLxc.updates.helperMethod')}`)
|
||||
assert.equal(result.find(x => x.id === 'docker-unit:helper').label, 'Helper app — Docker Compose')
|
||||
assert.equal(result.find(x => x.id === 'app:unique').label, 'Only custom')
|
||||
assert.equal(result.find(x => x.id === 'docker-unit:unique').label, 'Only image')
|
||||
assert.equal(result.find(x => x.id === 'docker-unit:helper').detail,
|
||||
t('vmLxc.bulkUpdate.includesDependencies', {names: 'database'}))
|
||||
assert.equal(JSON.stringify({registeredApps, units}), before, 'display must never edit registrations or units')
|
||||
assert.equal(typeof messages.vmLxc.bulkUpdate.osDockerScope, 'string')
|
||||
}
|
||||
console.log('PASS bulk display: duplicate method names, unchanged unique names/target IDs/dependencies and frozen input, EN/SK')
|
||||
|
||||
const actionStyle = compile(fs.readFileSync(path.join(root, 'lib/update-action-style.ts'), 'utf8'), {exports: {}})
|
||||
let bulkButton
|
||||
function findBulkButton(node) {
|
||||
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'Button') {
|
||||
const props = node.openingElement.attributes.properties
|
||||
if (props.some(p => ts.isJsxAttribute(p) && p.name.text === 'onClick'
|
||||
&& p.initializer?.getText(tree).includes('applyBulkUpdate('))) bulkButton = props
|
||||
}
|
||||
ts.forEachChild(node, findBulkButton)
|
||||
}
|
||||
findBulkButton(tree)
|
||||
assert(bulkButton, 'actual bulk execution button exists')
|
||||
function bulkProp(name, context) {
|
||||
const prop = bulkButton.find(p => p.name?.text === name)
|
||||
assert(prop && ts.isJsxExpression(prop.initializer))
|
||||
return compile('exports.value = (' + prop.initializer.expression.getText(tree) + ');', {
|
||||
exports: {}, getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass, ...context,
|
||||
}).value
|
||||
}
|
||||
const enabledBulk = {bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false,
|
||||
osUpdateStatusKnown: true, bulkAllTargetsUpToDate: false, bulkHasPendingUpdates: true,
|
||||
bulkTargets: ['os', 'app:one'], dockerInventoryRefreshing: false, dockerInventoryAvailable: true}
|
||||
assert.equal(bulkProp('disabled', enabledBulk), false)
|
||||
assert.match(bulkProp('className', enabledBulk), /text-purple-300/)
|
||||
const unknownBulk = {...enabledBulk, osUpdateStatusKnown: false, bulkHasPendingUpdates: false}
|
||||
assert.equal(bulkProp('disabled', unknownBulk), true, 'consistent action color must not enable an unknown OS')
|
||||
assert.equal(bulkProp('className', unknownBulk), bulkProp('className', enabledBulk))
|
||||
const currentBulk = {...enabledBulk, bulkAllTargetsUpToDate: true, bulkHasPendingUpdates: false}
|
||||
assert.equal(bulkProp('disabled', currentBulk), true)
|
||||
assert.match(bulkProp('className', currentBulk), /text-green-400/)
|
||||
assert.equal(bulkProp('disabled', {...enabledBulk, bulkApplying: true}), true)
|
||||
assert.equal(bulkProp('disabled', {...enabledBulk, bulkTargets: ['os', 'docker-unit:one'],
|
||||
dockerInventoryAvailable: false}), true)
|
||||
const helperLabelSource = source.slice(source.indexOf('const cls = getUpdateActionButtonClass(hasUpd'),
|
||||
source.indexOf('const cls = getUpdateActionButtonClass(hasUpd') + 550)
|
||||
const helperLabel = helperLabelSource.slice(0, helperLabelSource.indexOf('return ('))
|
||||
const unknownHelper = compile(helperLabel + '\nexports.value = {cls, label};', {
|
||||
exports: {}, hasUpd: false, upToD: false, t: k => k,
|
||||
getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass,
|
||||
}).value
|
||||
assert.match(unknownHelper.cls, /text-purple-300/)
|
||||
assert.equal(unknownHelper.label, 'vmLxc.updates.runUpdater', 'unknown version must not be presented as a detected upgrade')
|
||||
console.log('PASS actual update actions: shared author colors, unknown remains unknown, bulk guards unchanged')
|
||||
|
||||
@@ -138,6 +138,7 @@ esac
|
||||
notification = Mock()
|
||||
ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification,
|
||||
_LXC_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'),
|
||||
_LXC_INSTALL_CHECKED_OS_SCRIPT=str(ROOT / 'scripts/lxc/install_checked_os_updates.sh'),
|
||||
_normalise_lxc_update_run_id=lambda value, **_: value,
|
||||
_normalise_lxc_update_targets=lambda values, _: values,
|
||||
_normalise_lxc_update_labels=lambda values: values,
|
||||
|
||||
@@ -22,6 +22,7 @@ from flask import Flask, jsonify
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
import managed_installs as managed
|
||||
import lxc_check_job
|
||||
|
||||
SERVER = ROOT / "AppImage/scripts/flask_server.py"
|
||||
FUNCTIONS = {
|
||||
@@ -50,6 +51,9 @@ class ObservedStateRoutesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
lock_patch = patch.object(lxc_check_job, 'LOCK_DIR', Path(self.temp.name))
|
||||
lock_patch.start()
|
||||
self.addCleanup(lock_patch.stop)
|
||||
self.conf = Path(self.temp.name) / "100.conf"
|
||||
self.conf.write_text("onboot: 0\nnet0: name=eth0,ip=dhcp\ntags: old\n")
|
||||
self.clock = Mock(time=Mock(return_value=1000), monotonic=Mock(return_value=1000))
|
||||
@@ -141,6 +145,8 @@ class ObservedStateRoutesTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200, response.json)
|
||||
return response.json[0]
|
||||
|
||||
|
||||
|
||||
def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self):
|
||||
first = self.row()
|
||||
self.assertEqual(first["ip"], "10.100.100.222")
|
||||
|
||||
@@ -19,6 +19,7 @@ const requests = []
|
||||
const helpers = compile(fs.readFileSync(path.join(root, "lib/lxc-update-observation.ts"), "utf8"), {
|
||||
exports: {}, require: () => ({ fetchApi: async (...args) => { requests.push(args); return {} } }),
|
||||
})
|
||||
const actionStyle = compile(fs.readFileSync(path.join(root, "lib/update-action-style.ts"), "utf8"), { exports: {} })
|
||||
assert.equal(requests.length, 0, "loading helpers never schedules a package check")
|
||||
const now = Date.parse("2026-10-13T12:00:00Z")
|
||||
const recent = { last_check: "2026-10-13T11:59:00Z" }
|
||||
@@ -41,25 +42,51 @@ for (const lang of ['en', 'sk']) {
|
||||
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)
|
||||
assert.equal(tree.parseDiagnostics.length, 0)
|
||||
const wanted = new Set(["checkOsUpdates", "getAggregateUpdateCheck", "renderLxcUpdateBadge"])
|
||||
const wanted = new Set(["checkOsUpdates", "getAggregateUpdateCheck", "renderLxcUpdateBadge", "openApplyTerminal", "handleVMControl"])
|
||||
const definitions = []
|
||||
const preferenceDefinitions = []
|
||||
const status = []
|
||||
let detailEffect
|
||||
let resultEffect
|
||||
let resultTimeout
|
||||
let osApplyButton
|
||||
let bulkApplyButton
|
||||
let osSection
|
||||
function walk(node) {
|
||||
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "div"
|
||||
&& node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "className"
|
||||
&& p.initializer?.getText(tree) === '"pb-4"')
|
||||
&& node.getText(tree).includes('vmLxc.updates.osPackagesTitle')) osSection = node.getText(tree)
|
||||
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "Button"
|
||||
&& node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "onClick"
|
||||
&& p.initializer?.getText(tree).includes('openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })')))
|
||||
osApplyButton = node.getText(tree)
|
||||
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "Button"
|
||||
&& node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "onClick"
|
||||
&& p.initializer?.getText(tree).includes('applyBulkUpdate(selectedVM.vmid)')))
|
||||
bulkApplyButton = node.getText(tree)
|
||||
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
|
||||
&& node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") {
|
||||
detailEffect = node.arguments[0].getText(tree)
|
||||
}
|
||||
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
|
||||
&& node.arguments[1]?.getText(tree) === "[selectedVM, updatesRefreshing, updatesBaselineCount]")
|
||||
resultEffect = node.arguments[0].getText(tree)
|
||||
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
|
||||
&& node.arguments[1]?.getText(tree) === "[updatesRefreshing]")
|
||||
resultTimeout = node.arguments[0].getText(tree)
|
||||
if (ts.isVariableDeclaration(node)) {
|
||||
const name = node.name.getText(tree)
|
||||
if (wanted.has(name)) definitions.push(`const ${node.getText(tree)};`)
|
||||
if (["applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name))
|
||||
preferenceDefinitions.push(`const ${node.getText(tree)};`)
|
||||
if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
|
||||
status.push(`const ${node.getText(tree)};`)
|
||||
}
|
||||
ts.forEachChild(node, walk)
|
||||
}
|
||||
walk(tree)
|
||||
assert.equal(definitions.length, 3)
|
||||
assert.equal(definitions.length, 5)
|
||||
assert.equal(status.length, 3)
|
||||
assert.ok(detailEffect)
|
||||
let checking = null
|
||||
@@ -73,27 +100,61 @@ const context = {
|
||||
Badge: "Badge", Clock: "Clock", ArrowUpCircle: "Arrow", ChevronRight: "Chevron",
|
||||
t: (key, args) => key + (args ? JSON.stringify(args) : ""),
|
||||
isLxcUpdateStale: o => helpers.isLxcUpdateStale(o, now),
|
||||
checkLxcUpdates: async id => {
|
||||
getLxcOsUpdateState: o => helpers.getLxcOsUpdateState(o, now),
|
||||
getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass,
|
||||
refreshCheckLxcUpdates: async id => {
|
||||
if (errorToThrow) throw errorToThrow
|
||||
return helpers.checkLxcUpdates(id)
|
||||
requests.push([`/api/vms/${id}/updates/refresh-check`, { method: "POST" }])
|
||||
return { ...recent, available: true, count: 2 }
|
||||
},
|
||||
fetchApi: async endpoint => { requests.push([endpoint]); return { config: { onboot: 1 }, os_info: { id: "debian" } } },
|
||||
mutate: async () => { polls++ },
|
||||
selectedVMRef: { current: { vmid: 100 } },
|
||||
osCheckRequestRef: { current: null },
|
||||
selectedVM: { vmid: 100 },
|
||||
setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 },
|
||||
clearInterval: id => { context.clearedTimer = id },
|
||||
vmModalCacheRef: { current: { details: new Map() } },
|
||||
vmModalCacheRef: { current: { details: new Map(), backups: new Map(), mountPoints: new Map(), schedule: new Map(), firewall: new Map() } },
|
||||
setVMDetails: value => { details = value },
|
||||
setOsCheckingVmid: value => { checking = value },
|
||||
setOsCheckError: value => { displayedError = value },
|
||||
updatesRefreshing: true,
|
||||
updatesBaselineCount: 20,
|
||||
updatesBaselineCountRef: { current: 20 },
|
||||
setUpdatesResult: value => { context.updateResult = value },
|
||||
setUpdatesRefreshing: value => { context.refreshing = value },
|
||||
setUpdatesBaselineCount: value => { context.baseline = value },
|
||||
setTimeout: callback => { context.resultTimer = callback; return 2 },
|
||||
clearTimeout: () => {},
|
||||
Button: "Button", RefreshCw: "Refresh", confirm: () => context.confirmed,
|
||||
window: { crypto: { randomUUID: () => "ui-test-run" } },
|
||||
applyBackupStorage: "", selectedBackupStorage: "", backupStorages: [],
|
||||
osCheckingVmid: null, pendingBtnCls: "pending", upToDateBtnCls: "current", neutralBtnCls: "unknown",
|
||||
bulkApplying: false, bulkTargets: ["os", "app:test"], bulkHasPendingUpdates: true,
|
||||
bulkAllTargetsUpToDate: false, dockerInventoryRefreshing: false, dockerInventoryAvailable: true,
|
||||
controlLoading: false, Package: "Package", Shield: "Shield", CheckCircle2: "Current", AlertTriangle: "Unknown",
|
||||
osCheckError: null, uc: undefined, osUpdateStale: true,
|
||||
setControlLoading: value => { context.controlLoading = value },
|
||||
invalidateLxcApps: () => {}, setSelectedVM: value => { context.selectionChanged = value },
|
||||
alert: value => { context.alerted = value },
|
||||
}
|
||||
for (const name of ["Vmid", "Target", "RunId", "TargetIds", "TargetLabels", "UpdateCommand", "AppName",
|
||||
"RunHelper", "AllowHelperWithCustom", "DockerStandaloneTargets", "DockerEngine", "DockerRefresh",
|
||||
"BackupStorage", "StartedAt", "Open", "CheckAndUpdate", "CheckedOs"])
|
||||
context[`setApply${name}`] = value => { context[`apply${name}`] = value }
|
||||
const ui = compile(definitions.join("\n") + `
|
||||
exports.check = checkOsUpdates;
|
||||
exports.aggregate = getAggregateUpdateCheck;
|
||||
exports.badge = renderLxcUpdateBadge;
|
||||
exports.osStatus = (uc) => { ${status.join("\n")} return { osUpdateStatusKnown, hasOsUpdates }; };
|
||||
exports.revalidateDetails = ${detailEffect};
|
||||
exports.settleResult = ${resultEffect};
|
||||
exports.resultTimeout = ${resultTimeout};
|
||||
exports.openApply = openApplyTerminal;
|
||||
exports.osApplyButton = () => (${osApplyButton});
|
||||
exports.bulkApplyButton = () => (${bulkApplyButton});
|
||||
exports.osSection = () => (${osSection});
|
||||
exports.control = handleVMControl;
|
||||
`, context)
|
||||
const legacy = { last_check: "2026-10-12T00:00:00Z", available: true, count: 51, stale: true }
|
||||
assert.equal(ui.osStatus(legacy).osUpdateStatusKnown, false, "old count must not claim live OS status")
|
||||
@@ -106,22 +167,285 @@ assert.equal(ui.badge(legacy).props.children[1], "vmLxc.updates.checkStale")
|
||||
assert.equal(ui.badge({ ...recent, available: true, count: 2 }).props.children[1], 2)
|
||||
assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null)
|
||||
assert.equal(requests.length, 0)
|
||||
context.updatesRefreshing = false
|
||||
for (const [observation, expected] of [
|
||||
[undefined, "unknown"], [legacy, "unknown"], [{ ...recent, error: "failed" }, "unknown"],
|
||||
[{ ...recent, available: false }, "unknown"],
|
||||
[{ ...recent, available: true, count: 0 }, "unknown"],
|
||||
[{ ...recent, available: false, count: 0 }, "current"],
|
||||
[{ ...recent, available: true, count: 3 }, "pending"],
|
||||
]) {
|
||||
assert.equal(helpers.getLxcOsUpdateState(observation, now), expected)
|
||||
context.selectedVMRef.current = { vmid: 100, status: "stopped", update_check: observation }
|
||||
context.selectedVM = context.selectedVMRef.current
|
||||
Object.assign(context, ui.osStatus(observation))
|
||||
context.applyOpen = false
|
||||
context.confirmed = true
|
||||
assert.equal(ui.osApplyButton().props.disabled, expected !== "pending")
|
||||
assert.equal(ui.bulkApplyButton().props.disabled, expected === "unknown")
|
||||
ui.openApply(100, "os")
|
||||
assert.equal(context.applyOpen, expected === "pending", "handler must enforce the same boundary as the button")
|
||||
}
|
||||
context.confirmed = false
|
||||
context.applyOpen = false
|
||||
ui.openApply(100, "os")
|
||||
assert.equal(context.applyOpen, true, "an explicit installation click must not ask for a second stopped-guest confirmation")
|
||||
const oldConfirm = context.confirm
|
||||
let confirmationCalls = 0
|
||||
context.confirm = () => { confirmationCalls++; return false }
|
||||
for (const [target, options] of [
|
||||
['os', { checkedOs: true }],
|
||||
['both', { updateCommand: 'registered-app-update', targetIds: ['os', 'app:test'] }],
|
||||
['app', { updateCommand: 'registered-app-update' }],
|
||||
['app', { dockerEngine: true }],
|
||||
['app', { dockerStandaloneTargets: ['registered-container'] }],
|
||||
]) {
|
||||
context.applyOpen = false
|
||||
ui.openApply(100, target, options)
|
||||
assert.equal(context.applyOpen, true, `explicit ${target} update opens the terminal directly`)
|
||||
}
|
||||
assert.equal(confirmationCalls, 0, 'ordinary update entry points never open a browser confirmation')
|
||||
assert(!source.includes('checkAndUpdate'), 'the abandoned combined check/install action has no UI path')
|
||||
assert(!fs.existsSync(path.join(root, '../scripts/lxc/check_apply_updates.sh')))
|
||||
context.confirm = oldConfirm
|
||||
context.confirmed = true
|
||||
context.applyOpen = false
|
||||
context.osCheckingVmid = 100
|
||||
ui.openApply(100, "os")
|
||||
assert.equal(context.applyOpen, false, "checking cannot race an update launch")
|
||||
context.osCheckingVmid = null
|
||||
context.bulkAllTargetsUpToDate = true
|
||||
assert.equal(ui.bulkApplyButton().props.disabled, true)
|
||||
context.bulkAllTargetsUpToDate = false
|
||||
context.confirmed = true
|
||||
context.selectedVMRef.current = { vmid: 100, status: "stopped", update_check: { ...recent, available: false, count: 0 } }
|
||||
ui.openApply(100, "both", { updateCommand: "app-updater", targetIds: ["os", "app:test"] })
|
||||
assert.equal(context.applyOpen, true, "combined app update remains supported when OS packages are current")
|
||||
assert.equal(context.applyUpdateCommand, "app-updater")
|
||||
context.selectedVMRef.current = { vmid: 100, status: "running", update_check: { ...recent, error: "failed" } }
|
||||
context.applyOpen = false
|
||||
ui.openApply(100, "both")
|
||||
assert.equal(context.applyOpen, false, "a combined plan cannot bypass unknown OS state")
|
||||
ui.openApply(100, "app", { updateCommand: "app-updater" })
|
||||
assert.equal(context.applyOpen, true, "app-only updates do not depend on OS package observation")
|
||||
function collectButtons(node) {
|
||||
if (!node || typeof node !== "object") return []
|
||||
if (Array.isArray(node)) return node.flatMap(collectButtons)
|
||||
return [...(node.type === "Button" ? [node] : []), ...collectButtons(node.props?.children)]
|
||||
}
|
||||
function collectText(node) {
|
||||
if (typeof node === "string") return node
|
||||
if (!node || typeof node !== "object") return ""
|
||||
if (Array.isArray(node)) return node.map(collectText).join(" ")
|
||||
return collectText(node.props?.children)
|
||||
}
|
||||
for (const observation of [undefined, legacy, { ...recent, error: "check in progress" },
|
||||
{ ...recent, available: false, count: 0 }, { ...recent, available: true, count: 3 }]) {
|
||||
context.selectedVM = { vmid: 100, status: "running" }
|
||||
context.uc = observation
|
||||
context.osUpdateStale = helpers.isLxcUpdateStale(observation, now)
|
||||
Object.assign(context, ui.osStatus(observation))
|
||||
context.osCheckingVmid = 100
|
||||
const rendered = ui.osSection()
|
||||
assert.match(collectText(rendered), /vmLxc\.updates\.checkInProgress/,
|
||||
"an active check takes precedence over old, missing and invalidated observations")
|
||||
assert.doesNotMatch(collectText(rendered), /osStatusUnavailable|osUpToDate|noPending/)
|
||||
assert.equal(collectButtons(rendered).length, 1, "hide the previous result during a check")
|
||||
context.osCheckingVmid = 211
|
||||
assert.doesNotMatch(collectText(ui.osSection()), /checkInProgress/,
|
||||
"another container's check cannot replace this container's result")
|
||||
}
|
||||
context.osCheckingVmid = null
|
||||
context.uc = { ...recent, error: "check failed" }
|
||||
Object.assign(context, ui.osStatus(context.uc))
|
||||
assert.match(collectText(ui.osSection()), /osStatusUnavailable/, "completed failures remain visible")
|
||||
for (const status of ["running", "stopped"]) {
|
||||
for (const observation of [undefined, legacy, { ...recent, error: 'read failed' },
|
||||
{ ...recent, available: true, count: 0 },
|
||||
{ ...recent, available: false, count: 0 }, { ...recent, available: true, count: 3 }]) {
|
||||
context.selectedVM = { vmid: 100, status, update_check: observation }
|
||||
context.selectedVMRef.current = context.selectedVM
|
||||
context.uc = observation
|
||||
context.osUpdateStale = helpers.isLxcUpdateStale(observation, now)
|
||||
Object.assign(context, ui.osStatus(observation))
|
||||
const buttons = collectButtons(ui.osSection())
|
||||
assert.equal(buttons.length, context.osUpdateStatusKnown ? 2 : 1,
|
||||
'retain the original bottom-right OS status when a verified zero is available')
|
||||
assert.equal(buttons[0].props.children[1], "vmLxc.updates.checkSystemPackages")
|
||||
if (context.osUpdateStatusKnown && !context.hasOsUpdates) {
|
||||
const currentStatus = buttons[1]
|
||||
assert.equal(currentStatus.props.asChild, true, 'current is a status, not an installation action')
|
||||
assert.equal(currentStatus.props.onClick, undefined)
|
||||
assert.equal(currentStatus.props.className, context.upToDateBtnCls)
|
||||
assert.equal(currentStatus.props.children.type, 'span')
|
||||
assert.equal(currentStatus.props.children.props.role, 'status')
|
||||
assert.equal(currentStatus.props.children.props.children, 'vmLxc.updates.osUpToDate')
|
||||
}
|
||||
assert.equal((osSection.match(/vmLxc\.updates\.refreshCheckHelp/g) || []).length, 1,
|
||||
'keep the explanation in the check tooltip without duplicating it as an inline paragraph')
|
||||
if (status === "stopped") {
|
||||
context.applyOpen = false
|
||||
context.confirmed = false
|
||||
ui.openApply(100, "os", { checkedOs: true })
|
||||
assert.equal(context.applyOpen, context.hasOsUpdates, "explicit OS installation has no second popup")
|
||||
if (context.hasOsUpdates) assert.equal(context.applyCheckedOs, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
context.selectedVMRef.current = { vmid: 100, status: "running" }
|
||||
context.updatesRefreshing = true
|
||||
context.selectedVM = { update_check: { count: 0, error: "verification failed" } }
|
||||
ui.settleResult()
|
||||
assert.equal(context.updateResult, null, "unknown must not produce an all-updated banner")
|
||||
context.selectedVMRef.current = context.selectedVM
|
||||
ui.resultTimeout()
|
||||
context.resultTimer()
|
||||
assert.equal(context.updateResult, null, "timeout must not turn an unknown observation into success")
|
||||
context.selectedVM = { update_check: { count: 0, error: null } }
|
||||
ui.settleResult()
|
||||
assert.equal(context.updateResult.pendingAfter, 0)
|
||||
assert.equal(context.updateResult.appliedCount, 20)
|
||||
context.selectedVM = { vmid: 100 }
|
||||
context.selectedVMRef.current = { vmid: 100 }
|
||||
|
||||
;(async () => {
|
||||
// Execute the actual loaders with delayed GETs to test guest isolation.
|
||||
const preferenceState = {}
|
||||
const pendingReads = []
|
||||
const preferenceContext = {
|
||||
exports: {}, CRON_PRESETS: [{ value: "daily-3am", cron: "0 3 * * *" }],
|
||||
selectedVMRef: { current: { vmid: 213 } },
|
||||
scheduleLoadRequestRef: { current: 0 }, bulkLoadRequestRef: { current: 0 },
|
||||
vmModalCacheRef: { current: { schedule: new Map() } },
|
||||
t: key => key,
|
||||
fetchApi: (url, options) => new Promise((resolve, reject) => {
|
||||
assert.equal(options, undefined, "loading preferences never writes or starts an update")
|
||||
pendingReads.push({ url, resolve, reject })
|
||||
}),
|
||||
}
|
||||
for (const name of ["ScheduleEnabled", "ScheduleConfigured", "ScheduleCron", "SchedulePreset",
|
||||
"ScheduleTarget", "ScheduleTargets", "ScheduleReleaseDelayDays", "ApplyBackup", "ApplyBackupStorage",
|
||||
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunLog",
|
||||
"ScheduleLastRunRebootRequired", "ScheduleLastRunRebootPackages", "ExternalCron", "ScheduleError",
|
||||
"ScheduleLoaded", "BulkError", "BulkLoaded", "BulkEditMode", "BulkConfigured", "BulkTargets", "BulkPersistedTargets"])
|
||||
preferenceContext[`set${name}`] = value => { preferenceState[name] = value }
|
||||
assert.equal(preferenceDefinitions.length, 3)
|
||||
const preferences = compile(preferenceDefinitions.join("\n") + `
|
||||
exports.apply = applySchedulePayload; exports.schedule = loadSchedule; exports.bulk = loadBulkUpdate;
|
||||
`, preferenceContext)
|
||||
preferences.apply({ backup: true, backup_storage: "local", restart: true })
|
||||
preferences.apply({})
|
||||
assert.equal(preferenceState.ApplyBackup, false, "a guest without saved options cannot inherit backup")
|
||||
assert.equal(preferenceState.ApplyBackupStorage, "", "missing storage clears another guest's choice")
|
||||
assert.equal(preferenceState.ApplyRestart, false, "missing restart cannot inherit a reboot")
|
||||
preferences.apply({ backup: false, backup_storage: "", restart: false })
|
||||
assert.equal(preferenceState.ApplyBackupStorage, "")
|
||||
preferenceContext.vmModalCacheRef.current.schedule.set(213, { backup: true, backup_storage: "local", restart: true })
|
||||
const previousSchedule = preferences.schedule(213)
|
||||
assert.equal(preferenceState.ApplyBackup, true, "use only the selected guest's own cache")
|
||||
preferenceContext.selectedVMRef.current = { vmid: 210 }
|
||||
const currentSchedule = preferences.schedule(210)
|
||||
assert.equal(preferenceState.ApplyBackup, false, "clear inherited options before the GET completes")
|
||||
assert.equal(preferenceState.ApplyRestart, false)
|
||||
pendingReads[1].resolve({})
|
||||
await currentSchedule
|
||||
pendingReads[0].resolve({ backup: true, backup_storage: "wrong", restart: true })
|
||||
await previousSchedule
|
||||
assert.equal(preferenceState.ApplyBackup, false)
|
||||
assert.equal(preferenceState.ScheduleLoaded, 210, "old completion cannot mark another guest loaded")
|
||||
const older = preferences.schedule(210)
|
||||
const newer = preferences.schedule(210)
|
||||
pendingReads[3].resolve({ backup: true, backup_storage: "new", restart: false })
|
||||
await newer
|
||||
pendingReads[2].resolve({ backup: false, backup_storage: "old", restart: true })
|
||||
await older
|
||||
assert.equal(preferenceState.ApplyBackupStorage, "new", "only the latest request may publish preferences")
|
||||
assert.equal(preferenceState.ApplyRestart, false)
|
||||
const closed = preferences.schedule(210)
|
||||
const closedState = JSON.stringify(preferenceState)
|
||||
preferenceContext.selectedVMRef.current = null
|
||||
pendingReads[4].reject(new Error("old guest failure"))
|
||||
await closed
|
||||
assert.equal(JSON.stringify(preferenceState), closedState, "closing the modal forbids late error and loaded writes")
|
||||
preferenceContext.selectedVMRef.current = { vmid: 213 }
|
||||
const oldBulk = preferences.bulk(213)
|
||||
preferenceContext.selectedVMRef.current = { vmid: 210 }
|
||||
const newBulk = preferences.bulk(210)
|
||||
pendingReads[6].resolve({ targets: ["os", "app:ghstats"] })
|
||||
await newBulk
|
||||
pendingReads[5].resolve({ targets: ["os", "docker-engine"] })
|
||||
await oldBulk
|
||||
assert.equal(JSON.stringify(preferenceState.BulkTargets), '["os","app:ghstats"]')
|
||||
assert.equal(preferenceState.BulkLoaded, 210)
|
||||
const oldBulkSameGuest = preferences.bulk(210)
|
||||
const newBulkSameGuest = preferences.bulk(210)
|
||||
pendingReads[8].resolve({ targets: ["os", "docker-engine"] })
|
||||
await newBulkSameGuest
|
||||
pendingReads[7].reject(new Error("superseded bulk failure"))
|
||||
await oldBulkSameGuest
|
||||
assert.equal(preferenceState.BulkError, null)
|
||||
assert.equal(JSON.stringify(preferenceState.BulkTargets), '["os","docker-engine"]')
|
||||
const failedSchedule = preferences.schedule(210)
|
||||
pendingReads[9].resolve(null)
|
||||
await failedSchedule
|
||||
assert.equal(preferenceState.ScheduleError, "vmLxc.scheduled.loadFailed", "invalid responses are failed reads")
|
||||
const failedBulk = preferences.bulk(210)
|
||||
pendingReads[10].resolve([])
|
||||
await failedBulk
|
||||
assert.equal(preferenceState.BulkError, "vmLxc.bulkUpdate.loadFailed")
|
||||
const closedBulk = preferences.bulk(210)
|
||||
const closedBulkState = JSON.stringify(preferenceState)
|
||||
preferenceContext.selectedVMRef.current = null
|
||||
pendingReads[11].resolve({ targets: ["os", "app:wrong-guest"] })
|
||||
await closedBulk
|
||||
assert.equal(JSON.stringify(preferenceState), closedBulkState)
|
||||
const readsBeforeClosedLoad = pendingReads.length
|
||||
await preferences.schedule(210)
|
||||
await preferences.bulk(210)
|
||||
assert.equal(pendingReads.length, readsBeforeClosedLoad, "an unselected guest cannot start a preferences GET")
|
||||
assert(pendingReads.every(read => /\/(schedule|bulk-update)$/.test(read.url)), "no guest or updater routes")
|
||||
// Exercise the real separate helper: POST submits, GET waits, no apply.
|
||||
const io = []
|
||||
let jobState = 'running'
|
||||
const jobHelpers = compile(fs.readFileSync(path.join(root, 'lib/lxc-update-observation.ts'), 'utf8'), {
|
||||
exports: {}, Date, setTimeout: callback => { jobState = 'complete'; callback() },
|
||||
require: () => ({ fetchApi: async (url, opts) => {
|
||||
io.push([url, opts])
|
||||
if (opts?.method === 'POST') return { job_id: 'check-only' }
|
||||
return { state: jobState, update_check: { count: 0, available: false }, error: 'index failed' }
|
||||
} }),
|
||||
})
|
||||
assert.equal((await jobHelpers.refreshCheckLxcUpdates(100)).count, 0)
|
||||
assert.equal(io[0][0], '/api/vms/100/updates/refresh-check')
|
||||
assert.equal(io.filter(entry => entry[1]?.method === 'POST').length, 1)
|
||||
assert(io.slice(1).every(entry => entry[0].endsWith('/refresh-check/check-only')))
|
||||
jobState = 'failed'
|
||||
await assert.rejects(jobHelpers.refreshCheckLxcUpdates(100), /osStatusUnavailable/)
|
||||
io.length = 0
|
||||
await jobHelpers.checkLxcUpdates(100)
|
||||
assert.equal(io[0][0], '/api/vms/100/updates/check', 'legacy helper retains read-only endpoint')
|
||||
context.applyOpen = false
|
||||
context.updatesRefreshing = false
|
||||
await ui.check(100)
|
||||
assert.equal(requests[0][0], "/api/vms/100/updates/check")
|
||||
assert.equal(requests[0][0], "/api/vms/100/updates/refresh-check")
|
||||
assert.equal(requests[0][1].method, "POST")
|
||||
assert.equal(requests[1][0], "/api/vms/100")
|
||||
assert.equal(polls, 1)
|
||||
assert.equal(polls, 2)
|
||||
assert.equal(details.os_info.id, "debian")
|
||||
assert.equal(checking, null)
|
||||
errorToThrow = new Error('raw transport error must not appear in translated UI')
|
||||
await ui.check(100)
|
||||
assert.equal(displayedError, 'vmLxc.updates.osStatusUnavailable')
|
||||
errorToThrow = new Error('checkStillRunning')
|
||||
await ui.check(100)
|
||||
assert.equal(displayedError, 'vmLxc.updates.checkStillRunning')
|
||||
const before = requests.length
|
||||
errorToThrow = { status: 429, body: { retry_after: 60 } }
|
||||
await ui.check(100)
|
||||
assert.match(displayedError, /checkCooldown.*60/)
|
||||
assert.equal(checking, null)
|
||||
assert.equal(requests.length, before)
|
||||
assert.equal(polls, 1)
|
||||
assert.equal(polls, 2)
|
||||
// Switching guests while a request runs must not replace another modal.
|
||||
errorToThrow = null
|
||||
context.selectedVMRef.current = { vmid: 200 }
|
||||
@@ -143,9 +467,11 @@ assert.equal(requests.length, 0)
|
||||
assert.equal(context.clearedTimer, 1)
|
||||
for (const locale of ["en", "sk"]) {
|
||||
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`))).vmLxc.updates
|
||||
for (const key of ["checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp"])
|
||||
for (const key of ["checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp",
|
||||
"checkBeforeUpdate", "startBeforeCheck", "notCheckedYet",
|
||||
"checkStillRunning"])
|
||||
assert.ok(messages[key]?.trim())
|
||||
assert.match(messages.checkCooldown, /\{seconds\}/)
|
||||
}
|
||||
console.log("PASS observed-state UI: freshness, badges, safe button, cooldown, modal isolation, EN/SK")
|
||||
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,129 @@
|
||||
// Execute the real Updates JSX and handlers with independent state fixtures.
|
||||
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 appRender, bulkRender, checkButton, checkHandler
|
||||
const declarations = []
|
||||
function walk(node) {
|
||||
if (ts.isCallExpression(node) && node.expression.getText(tree) === 'appSections.map') appRender = node.arguments[0].getText(tree)
|
||||
if (ts.isConditionalExpression(node) && node.condition.getText(tree) === 'bulkConfigured'
|
||||
&& node.whenTrue.getText(tree).includes('applyBulkUpdate(')) bulkRender = node.whenTrue.getText(tree)
|
||||
if (ts.isVariableDeclaration(node)) {
|
||||
if (node.name.getText(tree) === 'checkOsUpdates') checkHandler = node.getText(tree)
|
||||
if (['selectedBulkUpdateStates', 'bulkHasPendingUpdates', 'bulkAllTargetsUpToDate', 'bulkHasUnknownTargets'].includes(node.name.getText(tree)))
|
||||
declarations.push('const ' + node.getText(tree) + ';')
|
||||
}
|
||||
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'Button'
|
||||
&& node.openingElement.attributes.getText(tree).includes('checkOsUpdates(selectedVM.vmid)')) checkButton = node.getText(tree)
|
||||
ts.forEachChild(node, walk)
|
||||
}
|
||||
walk(tree)
|
||||
assert(appRender && bulkRender && checkButton && checkHandler)
|
||||
function compile(code, context) {
|
||||
const result = ts.transpileModule(code, {compilerOptions: {target: ts.ScriptTarget.ES2020,
|
||||
module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX}, reportDiagnostics: true})
|
||||
assert.equal(result.diagnostics.length, 0)
|
||||
vm.runInNewContext(result.outputText, context)
|
||||
return context.exports
|
||||
}
|
||||
function flatten(node) {
|
||||
if (Array.isArray(node)) return node.flatMap(flatten)
|
||||
return node && typeof node === 'object' ? [node, ...flatten(node.props?.children)] : []
|
||||
}
|
||||
function text(node) {
|
||||
if (Array.isArray(node)) return node.map(text).join(' ')
|
||||
return node && typeof node === 'object' ? text(node.props?.children) : typeof node === 'string' ? node : ''
|
||||
}
|
||||
for (const locale of ['en', 'sk']) {
|
||||
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
|
||||
const t = key => key.split('.').reduce((o, part) => o?.[part], messages) ?? key
|
||||
const calls = []
|
||||
const context = {exports: {}, t, customCmdEditingApp: null, uc: null, helperExists: false,
|
||||
helperUsesWebUpdater: false, selectedVM: {vmid: 210}, openCustomCmdEditor() {},
|
||||
openApplyTerminal: (...args) => calls.push(args), getUpdateActionButtonClass: () => 'original-style',
|
||||
bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false, controlLoading: false,
|
||||
applyOpen: false, osUpdateStatusKnown: true, hasOsUpdates: false,
|
||||
dockerEngineHasUpdate: false, dockerEngineUpToDate: true, dockerInventoryRefreshing: false,
|
||||
dockerInventoryAvailable: true, dockerUpdateUnits: [], selectedBulkLabels: ['OS', 'Custom app'],
|
||||
bulkTargets: ['os', 'app:custom'], applyBulkUpdate: () => calls.push('bulk'), checkOsUpdates() {},
|
||||
require: () => ({jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})})}
|
||||
for (const name of ['Button', 'Package', 'Settings2', 'ArrowUpCircle', 'CheckCircle2', 'RefreshCw', 'Pin', 'Loader2']) context[name] = name
|
||||
const ui = compile('exports.app = (' + appRender + '); exports.check = () => (' + checkButton + ');', context)
|
||||
const original = {id: 'custom', name: 'Custom app', installed_via: 'manual', installed_version: null,
|
||||
latest_version: '2.0', update_available: null, update_command: 'approved-command', checked_at: '2026-10-14T10:43:19Z'}
|
||||
const before = JSON.stringify(original)
|
||||
const rendered = ui.app(original)
|
||||
assert.match(text(rendered), locale === 'sk' ? /Nainštalovaná verzia nie je overená/ : /Installed version is unverified/)
|
||||
assert.doesNotMatch(text(rendered), /Čaká sa|pending|upstreamAvailable/)
|
||||
const action = flatten(rendered).find(n => n.type === 'Button')
|
||||
assert.equal(text(action).trim(), locale === 'sk' ? 'Spustiť vlastný príkaz' : 'Run custom command')
|
||||
assert.equal(calls.length, 0, 'render/check timestamp must not execute an updater')
|
||||
action.props.onClick()
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0][0], 210)
|
||||
assert.equal(calls[0][1], 'app')
|
||||
assert.equal(calls[0][2].updateCommand, 'approved-command')
|
||||
assert.deepEqual(Array.from(calls[0][2].targetIds), ['app:custom'])
|
||||
assert.equal(JSON.stringify(original), before, 'UI must not confirm or edit the stored version')
|
||||
calls.length = 0
|
||||
const knownVersion = ui.app({...original, installed_version: '1.0'})
|
||||
assert(text(knownVersion).includes('1.0'), 'keep the known installed version when comparison is unavailable')
|
||||
assert.doesNotMatch(text(knownVersion), /Čaká sa|Version tracking pending/)
|
||||
const noDetector = ui.app({...original, installed_via: undefined})
|
||||
assert(text(noDetector).includes(t('vmLxc.updates.versionTrackingNotConfigured')))
|
||||
assert.equal(text(flatten(noDetector).find(n => n.type === 'Button')).trim(), t('vmLxc.updates.runCustomCommand'))
|
||||
assert.equal(text(ui.check()).trim(), locale === 'sk' ? 'Skontrolovať balíky systému' : 'Check system packages')
|
||||
for (const installed_via of ['binary', 'file', 'dpkg', 'apk']) {
|
||||
const unavailable = ui.app({...original, installed_via, error: 'probe failed'})
|
||||
assert.match(text(unavailable), locale === 'sk' ? /Dostupnosť aktualizácie nie je overená/ : /Update availability is unverified/)
|
||||
assert.doesNotMatch(text(unavailable), /Čaká sa|next scheduled|upToDateAtLabel/)
|
||||
}
|
||||
const current = {...original, installed_version: '2.0', update_available: false}
|
||||
const pending = {...original, installed_version: '1.0', update_available: true}
|
||||
assert(text(ui.app(current)).includes(t('vmLxc.updates.upToDateAtLabel')))
|
||||
assert(text(ui.app(pending)).includes(t('vmLxc.updates.installedLabel')))
|
||||
assert.equal(text(flatten(ui.app(pending)).find(n => n.type === 'Button')).trim(), t('vmLxc.updates.applyUpdate'))
|
||||
assert(!flatten(ui.app({...original, update_command: ''})).some(n => n.type === 'Button'), 'no command means no execution')
|
||||
function bulk(app, osKnown = true, extra = {}) {
|
||||
return compile(declarations.join('\n') + '\nexports.rendered=(' + bulkRender + ');',
|
||||
{...context, exports: {}, registeredApps: [app], osUpdateStatusKnown: osKnown, ...extra}).rendered
|
||||
}
|
||||
const unknownBulk = bulk(original)
|
||||
assert.match(text(unknownBulk), locale === 'sk' ? /nie je overená/ : /is unverified/)
|
||||
assert.equal(flatten(unknownBulk).find(n => n.type === 'Button').props.disabled, false, 'preserve explicit manual execution')
|
||||
assert.equal(flatten(bulk(current)).find(n => n.type === 'Button').props.disabled, true)
|
||||
assert.equal(flatten(bulk(original, false)).find(n => n.type === 'Button').props.disabled, true, 'unknown OS blocks bulk')
|
||||
assert.equal(flatten(bulk(original, true, {bulkApplying: true})).find(n => n.type === 'Button').props.disabled, true)
|
||||
const mixed = bulk(pending, true, {registeredApps: [pending, {...original, id: 'unknown'}],
|
||||
bulkTargets: ['os', 'app:custom', 'app:unknown']})
|
||||
assert.match(text(mixed), locale === 'sk' ? /nie je overená/ : /is unverified/)
|
||||
assert.equal(flatten(mixed).find(n => n.type === 'Button').props.disabled, false)
|
||||
const imageUnavailable = bulk(original, true, {bulkTargets: ['os', 'docker-unit:one'], dockerInventoryAvailable: false})
|
||||
assert.equal(flatten(imageUnavailable).find(n => n.type === 'Button').props.disabled, true, 'unavailable image cannot be run')
|
||||
assert.doesNotMatch(text(bulk(pending)), locale === 'sk' ? /nie je overená/ : /is unverified/)
|
||||
assert.equal(calls.length, 0, 'all status fixtures are side-effect free')
|
||||
}
|
||||
;(async () => {
|
||||
const context = {exports: {}, osCheckRequestRef: {current: null}, applyOpen: false, controlLoading: false,
|
||||
selectedVMRef: {current: {vmid: 210}}, vmModalCacheRef: {current: {details: new Map()}},
|
||||
setOsCheckingVmid() {}, setOsCheckError() {}, setSelectedVM() {}, setVMDetails() {}, mutate: async () => {},
|
||||
t: key => key, requests: []}
|
||||
context.refreshCheckLxcUpdates = async vmid => {context.requests.push(['check', vmid]); return {count: 0}}
|
||||
context.fetchApi = async (url, options) => {context.requests.push([url, options]); return {}}
|
||||
const handler = compile('const ' + checkHandler + '; exports.check = checkOsUpdates;', context)
|
||||
await handler.check(210)
|
||||
assert.deepEqual(context.requests, [['check', 210], ['/api/vms/210', undefined]])
|
||||
context.requests.length = 0
|
||||
for (const guard of ['applyOpen', 'controlLoading']) {
|
||||
context[guard] = true
|
||||
await handler.check(210)
|
||||
context[guard] = false
|
||||
}
|
||||
assert.equal(context.requests.length, 0, 'blocked checks make no API/probe requests')
|
||||
console.log('PASS common update status: EN/SK unknown/current/pending/error/custom/no-command/bulk/OS check guards; actual JSX and handlers')
|
||||
})().catch(error => {console.error(error); process.exitCode = 1})
|
||||
Reference in New Issue
Block a user