mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
247 lines
14 KiB
Python
247 lines
14 KiB
Python
"""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()
|