From 56844d7f04369cc8e05ec82123abcf03427d5895 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:54:25 +0000 Subject: [PATCH] i18n: clarify Slovak interrupted OCI recovery and cancellation --- .github/scripts/tests/test_oci_recovery_sk.py | 246 ++++++++++++++++++ AppImage/components/virtual-machines.tsx | 7 +- AppImage/messages/en/common.json | 1 + AppImage/messages/sk/common.json | 3 +- lang/sk.json | 120 ++++----- oci/src/proxmenux_oci/management.py | 6 +- tests/test_oci_update_cancellation.cjs | 31 ++- 7 files changed, 345 insertions(+), 69 deletions(-) create mode 100644 .github/scripts/tests/test_oci_recovery_sk.py diff --git a/.github/scripts/tests/test_oci_recovery_sk.py b/.github/scripts/tests/test_oci_recovery_sk.py new file mode 100644 index 00000000..a7100933 --- /dev/null +++ b/.github/scripts/tests/test_oci_recovery_sk.py @@ -0,0 +1,246 @@ +"""Pending recovery through real Menu/CLI/Dialog paths; all host effects forbidden.""" +from contextlib import redirect_stdout +import copy +import io +import json +from pathlib import Path +import subprocess +import sys +from types import SimpleNamespace +from unittest.mock import patch + +from test_oci_update_cancellation import CancellationFixture, ROOT +from proxmenux_oci import cli, i18n, management as menu, ui as native_ui +import oci_instances as instances +import oci_instance_transaction as transaction +import oci_native_modify as native_modify +import oci_stack_transaction as stack_transaction +import oci_ui + +ACTUAL_RECOVER = transaction.recover + + +class PendingRecoveryFixture(CancellationFixture): + def setUp(self): + super().setUp() + self.record.update(pending_transaction='/fiction-only/transaction.json') + self.row['pending'] = True + self.ui.choose.return_value = 'recover' + self.original = copy.deepcopy(self.record) + self.writes = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')] + self.processes = [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')] + self.recovery = self.guard(transaction, 'recover') + self.native_recovery = self.guard(native_modify, 'recover') + self.stack_recovery = self.guard(stack_transaction, 'execute') + + def stack(self): + self.row['stack'] = True + self.record['pending_stack_transaction'] = '/fiction-only/stack.json' + self.original = copy.deepcopy(self.record) + + def no_effects(self): + super().no_effects() + for mock in [*self.writes, *self.processes, self.recovery, self.native_recovery, self.stack_recovery]: + mock.assert_not_called() + + +class PendingRecoveryTests(PendingRecoveryFixture): + def test_single_no_is_distinct_falsy_cancellation(self): + self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED) + self.assertFalse(menu.CANCELLED) + self.no_effects() + + def test_single_menu_cancel_is_distinct_cancellation(self): + self.ui.choose.return_value = None + self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED) + self.ui.review.assert_not_called() + self.no_effects() + + def test_real_cli_single_no_and_cancel_return_six_without_duplicate_output(self): + for choice in ('recover', None): + with self.subTest(choice=choice), patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output: + self.ui.choose.return_value = choice + self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6) + self.assertEqual(output.getvalue(), '') + self.no_effects() + + def test_real_cli_stack_no_returns_six_before_reading_the_journal(self): + self.stack() + with patch.object(cli, 'Catalog'), patch.object(Path, 'read_text', side_effect=AssertionError('Journal read after No')) as read: + self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6) + read.assert_not_called() + self.no_effects() + + def test_actual_dialog_no_and_escape_cancel_single_and_stack(self): + dialog = native_ui.DialogUI() + for stacked in (False, True): + if stacked: + self.stack() + for code in (1, 255): + with self.subTest(stack=stacked, code=code): + answers = ([] if stacked else [subprocess.CompletedProcess('dialog', 0, stderr='2')]) + answers.append(subprocess.CompletedProcess('dialog', code, stderr='')) + with patch.object(native_ui, 'interactive_ui', return_value=dialog), \ + patch.object(dialog, '_run', side_effect=answers): + self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6) + self.no_effects() + + def test_actual_dialog_cancel_and_escape_from_single_operation_menu(self): + dialog = native_ui.DialogUI() + for code in (1, 255): + with self.subTest(code=code), patch.object(native_ui, 'interactive_ui', return_value=dialog), \ + patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')): + self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6) + self.no_effects() + + def test_native_menu_returns_to_its_list_after_pending_single_or_stack_no(self): + for stacked in (False, True): + if stacked: + self.stack() + self.ui.choose.reset_mock() + self.ui.choose.side_effect = ['110', None] if stacked else ['110', 'recover', None] + with self.subTest(stack=stacked), patch.object(menu, '_clean_orphans'), \ + patch.object(menu, 'offer_recovery'), patch.object(menu, '_records_unread', return_value=[]): + menu.interactive_management(ROOT/'oci', self.ui) + self.assertEqual(self.ui.choose.call_count, 2 if stacked else 3) + self.no_effects() + + def test_confirmed_single_recovery_and_status_preserve_exact_commands_and_results(self): + self.ui.review.return_value = True + for action in ('recover', 'status'): + for success, code in ((True, 0), (False, 1)): + self.lifecycle.reset_mock() + self.lifecycle.side_effect = None + self.lifecycle.return_value = success + self.ui.choose.return_value = action + with self.subTest(action=action, result=success): + self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code) + self.lifecycle.assert_called_once_with( + [sys.executable, str(ROOT/'oci/remote/oci_instance_transaction.py'), action, '110'], + 'Recover OCI' if action == 'recover' else 'OCI management') + self.write.assert_not_called(); self.backup.assert_not_called() + self.assertEqual(self.record, self.original) + + def test_confirmed_stack_recovery_preserves_command_and_failure_is_not_cancel(self): + self.stack() + self.ui.review.return_value = True + for success, code in ((True, 0), (False, 1)): + self.lifecycle.reset_mock() + self.lifecycle.side_effect = None + self.lifecycle.return_value = success + with self.subTest(result=success), patch.object(Path, 'read_text', return_value=json.dumps({'plan': {'members': [{'vmid': 110}, {'vmid': 111}]}})): + self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code) + self.lifecycle.assert_called_once_with( + [sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--recover', '--acknowledge-external-data'], + 'Recover OCI stack') + self.write.assert_not_called(); self.backup.assert_not_called() + self.assertEqual(self.record, self.original) + + def test_identity_failure_stays_failure_and_does_not_offer_recovery(self): + with patch.object(menu, 'check_selected', return_value={**self.row, 'reason': 'different-installation'}): + self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 1) + self.ui.choose.assert_not_called(); self.ui.review.assert_not_called() + self.no_effects() + + def test_unattended_does_not_turn_pending_menu_into_an_automatic_restore(self): + self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update', unattended=True), 1) + self.no_effects() + + def test_registration_cli_retains_its_separate_entry_point(self): + with patch.object(cli, 'Catalog'), patch.object(menu, 'direct_recovery', return_value=0) as register, \ + patch.object(menu, 'direct_management', side_effect=AssertionError('Registration routed to rollback')): + self.assertEqual(cli.main(['recover']), 0) + register.assert_called_once_with(ROOT/'oci') + self.no_effects() + + +class RecoverySlovakMeaning(PendingRecoveryFixture): + def test_actual_recovery_dialogs_preserve_host_data_and_bounded_recovery_meaning(self): + cache = json.loads((ROOT/'lang/sk.json').read_text()) + with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache): + menu.manage_instance(ROOT/'oci', self.ui, self.row) + text = self.ui.review.call_args.args[0] + self.assertIn('Zdieľané hostiteľské adresáre sa nevrátia späť', text) + self.assertIn('po úspešnom vyčistení', text) + self.assertEqual(self.ui.choose.call_args.args[0], 'Prerušená operácia') + self.assertEqual(dict(self.ui.choose.call_args.args[1])['status'], 'Zobraziť stav') + self.stack() + menu.manage_instance(ROOT/'oci', self.ui, self.row) + self.assertEqual(self.ui.review.call_args.args[1], 'Obnoviť OCI aplikáciu s viacerými kontajnermi') + self.assertIn('môžu zlyhať', self.ui.review.call_args.args[0]) + self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Obnoviť alebo dokončiť operáciu?') + self.no_effects() + + +class RecoveryGuardMessages(PendingRecoveryFixture): + def setUp(self): + super().setUp() + cache = json.loads((ROOT/'lang/sk.json').read_text()) + for name, value in (('_language', 'sk'), ('_cache', cache)): + p = patch.object(oci_ui, name, value) + p.start(); self.addCleanup(p.stop) + self.record.update(installation_id='fiction-installation', transaction_id='fiction-tx') + self.original = copy.deepcopy(self.record) + self.journal = Path(self.record['pending_transaction']) + self.state = {'id': 'fiction-tx', 'vmid': 110, 'phase': 'backup-ready', + 'record': {'installation_id': 'fiction-installation'}, 'before_config': 'before', + 'backup': None, 'backup_sha256': 'saved-sha'} + + def rejected(self, text, *, node='fiction-host', changed_backup=False): + calls = [] + def readonly(*args): + calls.append(args) + if args[0] == 'pvesh': + return json.dumps([{'type': 'lxc', 'vmid': 110, 'node': node}]).encode() + if args == ('pct', 'config', '110'): + return b'changed' + raise AssertionError('Unexpected recovery effect: ' + repr(args)) + with patch.object(Path, 'read_text', return_value=json.dumps(self.state)), \ + patch.object(Path, 'exists', return_value=True), patch.object(Path, 'is_file', return_value=True), \ + patch.object(transaction.socket, 'gethostname', return_value='fiction-host'), \ + patch.object(transaction.host_mounts, 'verify_sources'), patch.object(transaction.gpu_devices, 'verify'), \ + patch.object(transaction, 'owned', return_value=b'fiction config'), \ + patch.object(transaction, 'filehash', return_value='changed-sha' if changed_backup else 'saved-sha'), \ + patch.object(transaction, 'run', side_effect=readonly), redirect_stdout(io.StringIO()): + # Only reads before the rejecting guard are supplied. Any next + # command or attempt to persist state fails this fixture. + with self.assertRaisesRegex(ValueError, text): + ACTUAL_RECOVER(ROOT/'oci', self.journal) + self.no_effects() + return calls + + def test_foreign_transaction_is_rejected_before_probing_the_container(self): + self.state['record']['installation_id'] = 'different-installation' + self.assertEqual(self.rejected('Záznam nepatrí k tejto operácii'), []) + + def test_missing_verified_backup_cannot_touch_a_changed_container(self): + self.assertEqual(len(self.rejected('Chýba overená záloha; zmenený kontajner zostáva nedotknutý')), 2) + + def test_changed_backup_is_rejected_before_decompression_or_restore(self): + self.state['backup'] = '/fiction-only/backup.zst' + self.assertEqual(len(self.rejected('Záloha bola zmenená; obnova zablokovaná', changed_backup=True)), 2) + + def test_guest_on_another_node_is_never_overwritten(self): + self.assertEqual(len(self.rejected('kontajner je na inom uzle; nie je prepísaný', node='another-node')), 1) + + def test_incomplete_stack_recovery_state_fails_before_any_adapter_call(self): + adapter = SimpleNamespace(validate=self.recovery, stop=self.native_recovery, + restore=self.stack_recovery) + state = {'phase': 'recovery-failed', 'plan': {'start_order': [110, 111]}, + 'running': {'110': True}, 'stop_intent': True, 'replacement_intent': False} + before = copy.deepcopy(state) + with self.assertRaisesRegex(ValueError, 'Záznam operácie obsahuje neúplný stav obnovy'): + stack_transaction.recover_state(self.journal, state, adapter) + self.assertEqual(state, before) + self.no_effects() + + def test_critical_safety_and_untouched_result_have_the_correct_meaning(self): + cache = json.loads((ROOT/'lang/sk.json').read_text()) + self.assertEqual(cache['A host mount is not part of the journal; recovery blocked'], + 'Pripojenie adresára hostiteľa nie je uvedené v zázname operácie; obnova je zablokovaná') + self.assertEqual(cache['Recovery completed. The container had not been modified yet.'], + 'Obnova dokončená. Kontajner ešte nebol zmenený.') + self.assertEqual(cache['A backup was modified'], 'Záloha bola zmenená') + self.assertEqual(cache['Recovering the previous installation'], 'Obnovovanie predchádzajúcej inštalácie') + self.assertEqual(cache['Recover the previous installation'], 'Obnoviť predchádzajúcu inštaláciu') + self.no_effects() diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 19d5b81d..1dc21098 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -978,7 +978,7 @@ export function VirtualMachines() { // A container restored from a backup carries the mark of its installation // and has no record on this host until it is recovered from the menu. const [ociRestored, setOciRestored] = useState(false) - const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover" } | null>(null) + const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover"; recoverPrevious?: boolean } | null>(null) const ociCompletionCodeRef = useRef(undefined) useEffect(() => { ociCompletionCodeRef.current = undefined }, [ociAction]) @@ -5518,7 +5518,7 @@ export function VirtualMachines() {