Merge pull request #510 from Vaso73/i18n/slovak-oci-manager

i18n: complete Slovak OCI Manager messages and cancellation
This commit is contained in:
MacRimi
2026-10-21 22:16:09 +01:00
committed by GitHub
44 changed files with 4394 additions and 1282 deletions
+1
View File
@@ -31,6 +31,7 @@ PYTHON_FILES = (
"AppImage/scripts/tests/test_health_persistence_recurrence.py", "AppImage/scripts/tests/test_health_persistence_recurrence.py",
) )
NODE_FILES = ( NODE_FILES = (
"tests/test_oci_update_cancellation.cjs",
"tests/test_host_backup_messages.cjs", "tests/test_host_backup_messages.cjs",
"tests/test_pbs_i18n.cjs", "tests/test_pbs_i18n.cjs",
"tests/test_monitor_links_i18n.cjs", "tests/test_monitor_links_i18n.cjs",
@@ -0,0 +1,284 @@
"""Human diagnostics translate; IDs, JSON, error types and forbidden effects do not."""
from contextlib import ExitStack, nullcontext, redirect_stderr, redirect_stdout
import copy
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
import urllib.error
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_oci_complete_ui_sk as fixtures
from proxmenux_oci import casaos, cli, converter, i18n
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.github_source import GitHubSource, SourceError
import oci_image_cache
import oci_dhcp_lease
import oci_rocm_check
import oci_ui
import oci_watchdog
import configure_jellyfin_encoding
class TechnicalMessages(unittest.TestCase):
setUp = fixtures.CompleteUI.setUp
patch = fixtures.CompleteUI.patch
def remote_language(self):
self.patch(oci_ui, '_language', 'sk')
self.patch(oci_ui, '_cache', self.sk)
def test_real_readme_errors_translate_only_at_the_display_boundary(self):
for text, expected in [('# App', 'README neobsahuje'),
('## docker-compose\nplain text', 'Sekcia docker-compose neobsahuje blok kódu'),
('## docker-compose\n```yaml\nservices: {}', 'Blok docker-compose nie je ukončený'),
('## docker-compose\n```yaml\nnot-compose\n```', 'Nájdený blok nevyzerá')]:
with self.subTest(text=text), self.assertRaises(converter.ConversionError) as caught:
converter.extract_compose(text)
raw = str(caught.exception)
self.assertIn(expected, i18n.diagnostic(caught.exception))
self.assertEqual(str(caught.exception), raw)
def test_schema_error_keeps_field_details_and_original_exception(self):
with self.assertRaises(converter.ConversionError) as caught:
self.catalog.validate({})
raw = str(caught.exception)
self.assertTrue(raw.startswith('La plantilla generada no cumple el esquema:'))
self.assertEqual(i18n.diagnostic(caught.exception), 'Vygenerovaná šablóna nezodpovedá schéme:' + raw.split(':', 1)[1])
def test_missing_schema_validator_is_distinct_from_invalid_schema_without_effects(self):
template = {}
read = self.patch(Path, 'read_text', side_effect=AssertionError('schema must not be read without a validator'))
with patch.dict(sys.modules, {'jsonschema': None}):
with self.assertRaises(converter.ConversionError) as caught:
self.catalog.validate(template)
raw = 'python3-jsonschema is required to generate templates'
self.assertEqual(str(caught.exception), raw)
self.assertEqual(i18n.diagnostic(caught.exception), 'Na generovanie šablón je potrebný python3-jsonschema')
self.assertEqual(str(caught.exception), raw)
self.assertIsNone(self.catalog.validate(template, required=False))
read.assert_not_called()
self.assertEqual(template, {})
for effect in self.effects:
effect.assert_not_called()
def test_real_casaos_error_preserves_the_protocol_and_localizes_the_message(self):
with self.assertRaises(converter.ConversionError) as caught:
casaos.parse_casaos_compose('[]')
self.assertEqual(str(caught.exception), 'The CasaOS document is not a Compose mapping')
self.assertEqual(i18n.diagnostic(caught.exception), 'Dokument Compose musí byť mapa kľúčov a hodnôt.')
def test_github_rate_limit_keeps_url_and_token_variable_without_a_network_call(self):
url = 'https://fiction.invalid/catalogue?ref=example'
error = urllib.error.HTTPError(url, 403, 'Forbidden', {'X-RateLimit-Remaining': '0'}, None)
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen', side_effect=error)
with self.assertRaises(SourceError) as caught:
GitHubSource(token='fictional-token')._request(url)
request.assert_called_once()
text = i18n.diagnostic(caught.exception)
self.assertIn('GitHub vrátil HTTP 403', text)
self.assertIn(url, text)
self.assertIn('GITHUB_TOKEN', text)
self.assertNotIn('fictional-token', text)
def test_github_timeout_retains_external_reason_and_three_attempts(self):
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen',
side_effect=urllib.error.URLError('fictional external transport reason'))
self.patch(__import__('time'), 'sleep')
with self.assertRaises(SourceError) as caught:
GitHubSource()._request('https://fiction.invalid/template')
self.assertEqual(request.call_count, 3)
self.assertIn('ani po 3 pokusoch: fictional external transport reason', i18n.diagnostic(caught.exception))
def test_unknown_diagnostics_and_machine_codes_pass_through_unchanged(self):
for value in ['service:db:healthcheck-format', 'external tool: failure at 0x1234',
'Check failed', 'Invalid cache', 'Ambiguous application', 'private-operation-id']:
self.assertEqual(i18n.diagnostic(value), value)
def test_cli_help_preserves_flags_actions_defaults_and_numeric_types(self):
argv = ['manage', '104', '--action', 'recreate', '--unattended', '--min-image-age-days', '7']
slovak = cli.build_parser()
self.assertIn('Vypísať aplikácie', slovak.format_help())
with patch.object(i18n, '_language', 'en'):
english = cli.build_parser()
self.assertIn('List applications', english.format_help())
self.assertEqual(vars(english.parse_args(argv)), vars(slovak.parse_args(argv)))
def test_cli_summary_preserves_image_reference_ports_mount_paths_and_status_code(self):
template = self.catalog.compose('tandoor')
saved = copy.deepcopy(template)
text = cli._template_summary_text(template)
self.assertIn('Obraz: ' + template['container_contract']['image']['reference'], text)
self.assertIn('Dátové zväzky:', text)
self.assertIn('Stav: ' + template['status'], text)
for mount in template['container_contract']['volumes']:
self.assertIn(mount['container_path'], text)
self.assertEqual(template, saved)
def test_install_cli_no_stays_successful_cancellation_without_dispatch(self):
template = self.catalog.compose('jellyfin')
saved = copy.deepcopy(template)
self.patch(cli.Catalog, 'compose', return_value=template)
self.patch(cli, 'build_deployment', return_value={'fiction': True})
self.patch(cli, '_deployment_summary_text', return_value='Fiktívny plán')
answer = self.patch(__import__('builtins'), 'input', return_value='n')
output = io.StringIO()
with redirect_stdout(output): self.assertEqual(cli.main(['install', 'jellyfin']), 0)
self.assertIn('Nainštalovať? [y/N]:', answer.call_args.args[0])
self.assertEqual(output.getvalue().count('Inštalácia bola zrušená.'), 1)
self.assertEqual(template, saved)
def test_blocked_install_remains_error_one_and_never_asks_to_install(self):
template = self.catalog.compose('jellyfin')
template['compatibility'].update(automatic_install_candidate=False, untranslated_blockers=['fiction-blocker-code'])
self.patch(cli.Catalog, 'compose', return_value=template)
answer = self.patch(__import__('builtins'), 'input', side_effect=AssertionError('blocked installation asked a question'))
output = io.StringIO()
with redirect_stdout(io.StringIO()), redirect_stderr(output):
self.assertEqual(cli.main(['install', 'jellyfin']), 1)
self.assertIn('Inštalácia je zablokovaná: fiction-blocker-code', output.getvalue())
answer.assert_not_called()
def test_watchdog_manual_stop_stays_stopped_without_start_or_notice(self):
self.remote_language()
self.patch(oci_watchdog, 'watched', return_value={777: 'Fiction'})
self.patch(oci_watchdog, 'is_running', return_value=False)
self.patch(oci_watchdog, 'is_busy', return_value=False)
self.patch(oci_watchdog, 'read_tasks', return_value=[('vzshutdown', '777', 75)])
start = self.patch(oci_watchdog, 'start', side_effect=AssertionError('manual stop was undone'))
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
state = oci_watchdog.new_state(); state['seen'] = 50
output = io.StringIO()
with redirect_stdout(output): oci_watchdog.look({777: state}, 100)
self.assertTrue(state['settled'])
self.assertEqual(output.getvalue(), '')
start.assert_not_called(); notice.assert_not_called()
def test_watchdog_localization_does_not_change_retry_state_or_notification_payload(self):
self.patch(oci_watchdog, 'watched', return_value={777: 'Fiction'})
self.patch(oci_watchdog, 'is_running', return_value=False)
self.patch(oci_watchdog, 'is_busy', return_value=False)
self.patch(oci_watchdog, 'read_tasks', return_value=[])
start = self.patch(oci_watchdog, 'start', return_value=True)
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
outcomes = []
for locale in ('en', 'sk'):
with patch.object(oci_ui, '_language', locale), patch.object(oci_ui, '_cache', self.sk):
state = oci_watchdog.new_state(); state['seen'] = 50
text = io.StringIO()
with redirect_stdout(text): oci_watchdog.look({777: state}, 100)
outcomes.append((copy.deepcopy(state), notice.call_args, start.call_args))
self.assertIn('CT 777 (Fiction)', text.getvalue())
if locale == 'sk': self.assertIn('spúšťa sa znova (pokus 1)', text.getvalue())
self.assertEqual(outcomes[0], outcomes[1])
def test_image_cache_json_contract_and_invalid_command_have_no_mutations(self):
self.remote_language()
self.patch(oci_image_cache.instances, 'locked', side_effect=lambda *a: nullcontext())
self.patch(oci_image_cache, 'archives_of', return_value={})
output = io.StringIO()
with redirect_stdout(output): self.assertEqual(oci_image_cache.main(['list', '777']), 0)
self.assertEqual(json.loads(output.getvalue()), {'archives': [], 'freed': 0})
error = io.StringIO()
with redirect_stderr(error): self.assertEqual(oci_image_cache.main(['invalid']), 2)
self.assertIn('Použitie: oci_image_cache.py [prune | list VMID... | remove VMID...]', error.getvalue())
def test_rocm_timeout_localizes_but_external_gpu_errors_are_preserved(self):
self.remote_language()
self.patch(sys, 'argv', ['oci_rocm_check.py', '777'])
for raw, expected in [('the GPU did not answer in time', 'GPU neodpovedalo včas'),
('external provider exception 0x123', 'external provider exception 0x123')]:
with self.subTest(raw=raw), patch.object(oci_rocm_check, 'check', side_effect=RuntimeError(raw)):
output = io.StringIO()
with redirect_stderr(output): self.assertEqual(oci_rocm_check.main(), 1)
self.assertEqual(output.getvalue().strip(), expected)
def test_jellyfin_completion_distinguishes_changed_from_already_applied(self):
self.remote_language()
self.patch(sys, 'argv', ['configure_jellyfin_encoding.py', '/fiction-root', '{}'])
for changed, expected in [(True, 'Nastavenia Jellyfin boli aktualizované:'),
(False, 'Nastavenia Jellyfin už boli použité:')]:
with self.subTest(changed=changed), patch.object(configure_jellyfin_encoding, 'update_encoding',
return_value=(Path('/fiction-root/config/encoding.xml'), changed)):
output = io.StringIO()
with redirect_stdout(output): configure_jellyfin_encoding.main()
self.assertEqual(output.getvalue().strip(), expected + ' /config/encoding.xml')
def test_dhcp_completion_keeps_interface_and_ip_version_without_repairing_a_guest(self):
self.remote_language()
self.patch(oci_dhcp_lease, 'LOCKS', MagicMock())
self.patch(oci_dhcp_lease.fcntl, 'flock')
repair = self.patch(oci_dhcp_lease, 'repair', return_value=[('eth0', 4)])
output = io.StringIO()
with redirect_stdout(output): oci_dhcp_lease.check(777, 0)
repair.assert_called_once_with(777, 0)
self.assertEqual(output.getvalue().strip(), 'CT 777: klient DHCP rozhrania eth0 (IPv4) bol znova spustený')
class StandalonePublisher(unittest.TestCase):
def test_mount_error_keeps_host_language_after_entering_the_container_root(self):
spec = importlib.util.spec_from_file_location('fiction_namespace_publisher',
fixtures.ROOT / 'oci/remote/rclone_mount_publish.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
inside = False
def chroot(path):
nonlocal inside
inside = True
def read(path, **kwargs):
if inside:
raise FileNotFoundError('Host language catalog is outside the container root')
return ('{"language":"sk"}' if path.name == 'config.json' else
'{"clone source mount tree":"naklonovať strom zdrojového pripojenia"}')
libc = Mock(); libc.setns.return_value = 0; libc.syscall.return_value = -1
with ExitStack() as guards:
for target, name, options in [
(sys, 'argv', {'new': ['publisher.py', '777', '/fiction-source', '/fiction-target', 'ro']}),
(module.platform, 'machine', {'return_value': 'x86_64'}),
(Path, 'read_text', {'autospec': True, 'side_effect': read}),
(module.ctypes, 'CDLL', {'return_value': libc}),
(module.ctypes, 'get_errno', {'return_value': 13}),
(module.os, 'open', {'return_value': 42}),
(module.os, 'close', {}), (module.os, 'fchdir', {}),
(module.os, 'chroot', {'side_effect': chroot}), (module.os, 'chdir', {})]:
guards.enter_context(patch.object(target, name, **options))
with self.assertRaises(OSError) as caught:
module.main()
self.assertTrue(inside)
self.assertEqual(caught.exception.errno, 13)
self.assertIn('naklonovať strom zdrojového pripojenia', str(caught.exception))
self.assertEqual(libc.syscall.call_args.args[0], module.SYSCALLS['x86_64'][0])
def test_copied_helper_has_localized_errors_without_engine_import_or_mount_syscalls(self):
with tempfile.TemporaryDirectory(prefix='oci-publisher-language-') as directory:
root = Path(directory); (root / 'lang').mkdir()
(root / 'config.json').write_text('{"language":"sk"}')
(root / 'lang/sk.json').write_text(json.dumps({'Usage:': 'Použitie:', 'Unsupported host architecture:': 'Nepodporovaná architektúra hostiteľa:'}))
source = fixtures.ROOT / 'oci/remote/rclone_mount_publish.py'
copied = root / 'publisher.py'; copied.write_bytes(source.read_bytes())
spec = importlib.util.spec_from_file_location('fiction_standalone_publisher', copied)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {'oci_ui': None}), patch.dict(__import__('os').environ, {'PMX_BASE_DIR': str(root)}):
spec.loader.exec_module(module)
with patch.object(module.ctypes, 'CDLL', side_effect=AssertionError('mount syscall path entered')) as syscall:
with patch.object(sys, 'argv', ['publisher.py']), redirect_stderr(io.StringIO()) as output:
self.assertEqual(module.main(), 2)
self.assertIn('Použitie: publisher.py PID SOURCE TARGET rw|ro', output.getvalue())
with patch.object(sys, 'argv', ['publisher.py', '1', '/fiction-source', '/fiction-target', 'ro']), \
patch.object(module.platform, 'machine', return_value='fiction-architecture'), redirect_stderr(io.StringIO()) as output:
self.assertEqual(module.main(), 2)
self.assertIn('Nepodporovaná architektúra hostiteľa: fiction-architecture', output.getvalue())
syscall.assert_not_called()
(root / 'config.json').write_text('invalid json')
module._messages.cache_clear()
self.assertEqual(module.translate('Usage:'), 'Usage:')
(root / 'config.json').write_text('{"language":"en"}')
module._messages.cache_clear()
self.assertEqual(module.translate('Usage:'), 'Usage:')
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,148 @@
"""Real catalogue/wizard rendering, with host and installation effects forbidden."""
import copy
from contextlib import ExitStack, redirect_stdout
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(os.environ.get('OCI_I18N_TEST_ROOT', Path(__file__).resolve().parents[3]))
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote'), str(ROOT / 'oci/tests')]
from proxmenux_oci import cli, i18n, installer, stack
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.ui import UserCancelled
from test_advanced_flow_order import RecordingUI, addresses, storages
class CompleteUI(unittest.TestCase):
def setUp(self):
self.scope = ExitStack()
self.addCleanup(self.scope.close)
self.sk = json.loads((ROOT / 'lang/sk.json').read_text())
self.catalog = Catalog(ROOT / 'oci')
self.patch(i18n, '_language', 'sk')
self.patch(i18n, '_cache', self.sk)
self.patch(installer.host, 'storages', side_effect=storages)
self.patch(installer.host, 'bridges', return_value=[{'iface': 'vmbr0', 'cidr': '192.0.2.10/24'}])
self.patch(installer.host, 'timezone', return_value='Europe/Bratislava')
self.patch(installer.host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': False})
self.patch(installer.access, 'ask_addresses', side_effect=addresses)
self.patch(installer.access, 'ask_ipv4', return_value=('dhcp', None))
self.effects = []
for module, name in [(cli, '_install'), (cli, '_rclone_mount'), (cli, 'run_remote_install'),
(installer, 'run_remote_install'), (cli.images, 'offer_removal'),
(subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink')]:
self.effects.append(self.patch(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
self.addCleanup(lambda: [effect.assert_not_called() for effect in self.effects])
def patch(self, obj, name, *args, **kwargs):
return self.scope.enter_context(patch.object(obj, name, *args, **kwargs))
def test_search_no_match_uses_application_wording_and_never_installs(self):
ui = Mock()
ui.ask.return_value = 'fiction-no-such-app'
cli._search(self.catalog, ui, [])
self.assertEqual(ui.ask.call_args.args[0], 'Názov alebo časť popisu aplikácie')
self.assertIn('Žiadna zhoda aplikácií', ui.message.call_args.args[0])
ui.choose.assert_not_called()
def test_immich_detail_translates_service_roles_without_changing_template(self):
template = self.catalog.compose('immich')
before = copy.deepcopy(template)
text = cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template)
self.assertIn('Strojové učenie', text)
self.assertIn('Aplikácia', text)
self.assertIn('Oficiálny obraz', text)
self.assertNotIn('Machine learning', text)
self.assertEqual(template, before)
with patch.object(i18n, '_language', 'en'):
self.assertIn('Machine learning', cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template))
def test_missing_catalogue_entry_preserves_raw_error_and_localizes_cli(self):
for language, expected in [('sk', "Aplikácia 'fiction-missing' nie je v indexe"),
('en', "The application 'fiction-missing' is not in the index")]:
with self.subTest(language=language), patch.object(i18n, '_language', language):
with self.assertRaisesRegex(ConversionError, "The application 'fiction-missing' is not in the index"):
self.catalog.find_item('fiction-missing')
output = io.StringIO()
with redirect_stdout(io.StringIO()), patch.object(sys, 'stderr', output), patch.object(cli, 'PROJECT_ROOT', ROOT / 'oci'):
self.assertEqual(cli.main(['show', 'fiction-missing']), 1)
self.assertEqual(output.getvalue().strip(), 'ERROR: ' + expected)
def test_missing_template_uses_existing_translation_and_keeps_identifier(self):
self.patch(self.catalog, 'find_item', return_value={'id': 'fiction-missing'})
with self.assertRaises(ConversionError) as caught:
self.catalog.compose('fiction-missing')
self.assertEqual(str(caught.exception), "No template is available for 'fiction-missing'")
self.assertEqual(i18n.diagnostic(caught.exception), "Pre aplikáciu 'fiction-missing' nie je dostupná šablóna")
def test_detail_cancel_and_escape_do_not_enter_install_or_rclone(self):
for app in ('jellyfin', 'rclone', 'immich', 'suite-arr'):
for escape in (False, True):
with self.subTest(app=app, escape=escape):
ui = Mock()
ui.confirm.return_value = False
ui.detail_menu.return_value = None
if escape:
ui.detail_menu.side_effect = UserCancelled()
try:
cli._app_detail(self.catalog, ui, self.catalog.find_item(app))
except UserCancelled:
self.assertTrue(escape)
def test_special_wizard_plans_are_identical_in_english_and_slovak(self):
for app in ('immich', 'nextcloud-stack', 'paperless-ngx', 'tandoor'):
for mode in (installer.DEFAULT_MODE, installer.ADVANCED_MODE):
with self.subTest(app=app, mode=mode):
template = self.catalog.compose(app)
before = copy.deepcopy(template)
plan_sk = installer.build_deployment(template, RecordingUI(), mode)
with patch.object(i18n, '_language', 'en'):
plan_en = installer.build_deployment(template, RecordingUI(), mode)
self.assertEqual(plan_sk, plan_en)
self.assertEqual(template, before)
text = cli._deployment_summary_text(template, plan_sk)
self.assertNotIn('Consume/export:', text)
if app == 'paperless-ngx':
self.assertIn('Priečinky consume a export', text)
def test_romm_optional_credentials_are_localized_but_variable_names_stay(self):
ui = RecordingUI()
ui.confirm = lambda text, default=False, **kwargs: (ui.asked.append(text) or text.startswith('Nastaviť IGDB'))
ui.password = lambda text, **kwargs: (ui.asked.append(text) or 'fiction-secret')
ui.ask = lambda text, default=None, **kwargs: (ui.asked.append(text) or (default if default is not None else 'fiction-value'))
plan = installer.build_deployment(self.catalog.compose('romm'), ui, installer.ADVANCED_MODE)
self.assertTrue(any('Tajný kľúč klienta IGDB' == text for text in ui.asked))
self.assertFalse(any('IGDB Client Secret' == text for text in ui.asked))
server = next(service for service in plan['services'] if service['name'] == 'romm')
self.assertTrue(any(row['name'] == 'IGDB_CLIENT_SECRET' for row in server['deployment']['environment']))
def test_tandoor_warning_keeps_negative_requirement_and_first_run_account(self):
template = self.catalog.compose('tandoor')
warning = i18n.translate(template['proxmox']['security_profile']['warning'])
self.assertIn('nevyžaduje privilegovaný', warning)
credentials = template['first_run']['credentials']
self.assertTrue(any('Vygenerovaný účet správcu Tandoor' == i18n.translate(row['label']) for row in credentials))
def test_documented_commands_paths_and_literals_remain_copyable(self):
fragments = {
'rsnapshot starts with the default configuration, which backs up /data into /.snapshots. Edit /config/rsnapshot.conf inside the container to set your own backup points, snapshot root and retention intervals.': ['/data', '/.snapshots', '/config/rsnapshot.conf'],
'Each /request command needs a backend: add a [[backends]] block in the same file with the url and api_key of your Sonarr, Radarr or Seerr instance, then restart the container.': ['/request', '[[backends]]', 'url', 'api_key'],
'Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>': ['public: false', 'pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>'],
'Certificate provider: empty for Let\'s Encrypt, zerossl for ZeroSSL': ["Let's Encrypt", 'zerossl', 'ZeroSSL'],
'Libraries: /data/media/movies, /data/media/series and /data/media/music. Select them in the media server.': ['/data/media/movies', '/data/media/series', '/data/media/music'],
}
for source, tokens in fragments.items():
with self.subTest(source=source):
for token in tokens:
self.assertIn(token, i18n.translate(source))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,268 @@
"""Exercise the native custom-image and registry UI with the real EN/SK lookup."""
import json
from pathlib import Path
import runpy
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / 'oci/src'))
from proxmenux_oci import custom, i18n, registry_accounts as accounts, registry_auth as auth
from proxmenux_oci.ui import UserCancelled
COMPOSE = 'services:\n app:\n image: ghcr.io/owner/app:latest\n'
class UI:
def __init__(self, choices=(), answers=(), confirms=()):
self.choices = iter(choices)
self.answers = iter(answers)
self.confirms = iter(confirms)
self.seen = []
def choose(self, text, options, default=None, **kwargs):
self.seen.append((text, options, kwargs))
return next(self.choices)
def ask(self, text, default=None, **kwargs):
self.seen.append(text)
return next(self.answers)
def password(self, text):
self.seen.append(text)
raise UserCancelled(text)
def confirm(self, text, default=False, **kwargs):
self.seen.append((text, default))
return next(self.confirms)
def review(self, text, title=None, **kwargs):
self.seen.append((text, title, kwargs))
return False
def message(self, text, title=None):
self.seen.append((text, title))
info = message
class CustomRegistryLocalizationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
(self.base / 'lang').mkdir()
(self.base / 'config.json').write_text('{"language":"sk"}')
(self.base / 'lang/sk.json').write_bytes((ROOT / 'lang/sk.json').read_bytes())
for name, value in [('BASE_DIR', self.base), ('_language', None), ('_cache', None)]:
item = patch.object(i18n, name, value)
item.start()
self.addCleanup(item.stop)
# Any unmocked subprocess, network fetch or installation is a failure.
self.run = self.guard(custom.subprocess, 'run')
self.network = self.guard(custom.urllib.request, 'urlopen')
self.install = self.guard(custom, 'install_template')
def guard(self, module, name):
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
mock = item.start()
self.addCleanup(item.stop)
return mock
def no_effects(self):
self.run.assert_not_called()
self.network.assert_not_called()
self.install.assert_not_called()
def manager(self):
return SimpleNamespace(path=Path('/fixture/auth.json'),
accounts=MagicMock(return_value=[{'registry': 'ghcr.io', 'account': 'owner',
'method': 'Používateľské meno / token', 'status': 'Saved'}]),
login=MagicMock(), logout=MagicMock())
def menu(self, ui, manager):
with patch.object(accounts, 'require_root'):
accounts.registry_accounts_menu(ui, auth=manager)
def test_dynamic_blockers_are_extracted_and_rendered_in_slovak(self):
expected = {
'multi-service-compose': 'definuje viac než jeden obraz',
'native-multi-lxc-orchestrator-not-yet-implemented': 'definuje viac než jeden obraz',
'top-level-configs': 'používa nastavenia Compose configs, ktoré tu nemajú ekvivalent',
'devices-format': 'požadované zariadenia sú zadané v nepodporovanom formáte',
'healthcheck-format': 'kontrola stavu je zadaná v nepodporovanom formáte',
'stop-grace-period-format': 'časový limit zastavenia je zadaný v nepodporovanom formáte',
'shm-size-format': 'veľkosť zdieľanej pamäte je zadaná v nepodporovanom formáte',
'ulimits-format-or-resource': 'požadované limity systémových prostriedkov nemožno uplatniť',
'mem-limit-format-or-below-proxmox-minimum': 'požadovaný limit pamäte nemožno uplatniť',
'mem-limit-deploy-consistency-review': 'požaduje dva rozdielne limity pamäte',
}
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
extracted = extractor['extract_python_texts']([ROOT / 'oci/src'])
self.assertTrue(set(custom.BLOCKER_TEXTS.values()) <= set(extracted))
for code, message in expected.items():
with self.subTest(code=code):
self.assertEqual(custom.blocker_text('service:app:' + code), message)
self.assertEqual(custom.blocker_text('unrecognized:raw-code'), 'unrecognized:raw-code')
self.no_effects()
def test_cancelled_source_menu_is_translated_without_reading_an_image(self):
ui = UI([None])
with self.assertRaises(UserCancelled) as caught:
custom.explore(ui)
self.assertEqual(str(caught.exception), 'Výber obrazu bol zrušený')
self.assertIn('Obraz, ktorý nie je v katalógu', repr(ui.seen))
self.assertIn('Len odkaz na obraz bez súboru Compose', repr(ui.seen))
self.no_effects()
def test_invalid_compose_inputs_reach_localized_error_before_registry_or_install(self):
cases = [
('services: {}', 'Súbor Compose neobsahuje žiadnu službu'),
('services: [', 'Súbor Compose nie je platný YAML:'),
(COMPOSE + ' environment: 42\n', 'Položka environment'),
(COMPOSE + ' volumes: {}\n', 'Položka volumes'),
(COMPOSE + ' ports: invalid\n', 'Položka ports'),
(COMPOSE + ' ports: [invalid]\n', 'Neplatný port'),
(COMPOSE + ' volumes: [relative:relative]\n', 'Neplatná cesta dátového úložiska'),
(COMPOSE + 'x-casaos:\n main: missing\n', 'Položka x-casaos.main'),
]
for text, expected in cases:
with self.subTest(text=text), patch.object(custom, 'read_definition', return_value=(text, 'fixture')), \
self.assertRaises(custom.ConversionError) as caught:
custom.explore(UI())
self.assertIn(expected, str(caught.exception))
self.no_effects()
def test_converter_raw_contract_and_english_ui_are_preserved(self):
bad = COMPOSE + ' ports: [invalid]\n'
with self.assertRaises(custom.ConversionError) as raw:
custom.template_from_compose(bad)
self.assertEqual(str(raw.exception), "Puerto no valido: 'invalid'")
with patch.object(i18n, '_language', 'en'), \
patch.object(custom, 'read_definition', return_value=(bad, 'fixture')), \
self.assertRaises(custom.ConversionError) as ui_error:
custom.explore(UI())
self.assertEqual(str(ui_error.exception), str(raw.exception))
self.no_effects()
def test_conversion_message_retains_literal_diagnostic_data_and_unknown_fallback(self):
detail = " {'target': '<path>{literal}\\á'}"
message = custom.conversion_message(custom.ConversionError('Ruta de volumen no valida:' + detail))
self.assertEqual(message, 'Neplatná cesta dátového úložiska v súbore Compose:' + detail)
unknown = 'unknown <diagnostic> {literal}'
self.assertEqual(custom.conversion_message(custom.ConversionError(unknown)), unknown)
self.no_effects()
def test_all_legacy_converter_diagnostics_have_slovak_ui_and_unchanged_english(self):
messages = [
'environment must be a list or a mapping', 'volumes must be a list', 'ports must be a list',
'The CasaOS Compose file has no services', 'The CasaOS document is not a Compose mapping',
'The Compose file has no x-casaos metadata', 'x-casaos.main no identifica un servicio valido',
'The main CasaOS service declares no image', 'Ruta de volumen no valida: /data',
'Puerto no valido: invalid', 'Cannot normalise the CasaOS identifier: ???',
'Docker Compose CasaOS no valido: fixture',
]
for text in messages:
with self.subTest(text=text):
self.assertNotEqual(custom.conversion_message(custom.ConversionError(text)), text)
with patch.object(i18n, '_language', 'en'):
self.assertEqual(custom.conversion_message(custom.ConversionError(text)), text)
self.no_effects()
def test_custom_review_cancel_keeps_installation_disabled(self):
ui = UI()
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'vloženého súboru Compose')), \
patch.object(custom, 'registry_report', return_value=(True, 'Dostupný obraz')):
custom.explore(ui)
self.assertIn('Nainštalovať tento obraz?', repr(ui.seen))
self.assertIn('Obraz:', repr(ui.seen))
self.assertIn('ghcr.io/owner/app:latest', repr(ui.seen))
self.no_effects()
def test_unavailable_image_explains_registry_accounts_without_authentication_or_install(self):
ui = UI()
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'fixture')), \
patch.object(custom, 'registry_report', return_value=(False, 'Nedostupný obraz')), \
patch.object(custom, '_registry_refused', return_value=False), \
patch.object(accounts, 'offer_sign_in') as offer:
custom.explore(ui)
self.assertIn('prihláste cez Účty registrov', repr(ui.seen))
offer.assert_not_called()
self.no_effects()
def test_registry_metadata_dialog_uses_image_and_digest_and_retains_values(self):
manager = self.manager()
ui = UI(['account-0', 'inspect', None], ['ghcr.io/owner/app:latest'])
metadata = dict(architecture='amd64', version='<v>{literal}', size=1024, digest='sha256:' + 'a'*64)
with patch.object(accounts, 'inspect_registry_image', return_value=metadata) as inspect:
self.menu(ui, manager)
inspect.assert_called_once_with('ghcr.io/owner/app:latest')
rendered = repr(ui.seen)
for value in ('Prístup k obrazu bol potvrdený.', 'Digest: sha256:', '<v>{literal}', 'Účty registrov'):
self.assertIn(value, rendered)
self.assertNotIn('obráz', rendered)
manager.login.assert_not_called()
manager.logout.assert_not_called()
self.no_effects()
def test_credential_help_retains_permissions_and_no_encryption_claim(self):
manager = self.manager()
ui = UI(['storage', None])
self.menu(ui, manager)
rendered = repr(ui.seen)
for value in ('root', '700', '600', 'base64', 'nie sú šifrované', 'každý uzol klastra', '/fixture/auth.json'):
self.assertIn(value, rendered)
manager.login.assert_not_called()
manager.logout.assert_not_called()
self.no_effects()
def test_cancelled_password_and_declined_logout_do_not_change_accounts(self):
for ui in (UI(['add', None], ['ghcr.io', 'owner']), UI(['account-0', 'logout', None], confirms=[False])):
manager = self.manager()
self.menu(ui, manager)
manager.login.assert_not_called()
manager.logout.assert_not_called()
self.no_effects()
def test_mismatched_registry_is_rejected_before_download(self):
manager = self.manager()
ui = UI(['account-0', 'download', None], ['quay.io/owner/app:latest'])
with patch.object(accounts, 'download_registry_image') as download:
self.menu(ui, manager)
download.assert_not_called()
self.assertIn('Zadajte obraz z tohto registra.', repr(ui.seen))
self.no_effects()
def test_declined_private_image_signin_does_not_even_construct_auth_manager(self):
ui = UI(confirms=[False])
with patch.object(accounts, 'RegistryAuth') as manager, patch.object(accounts, 'require_root') as root:
self.assertFalse(accounts.offer_sign_in(ui, 'ghcr.io/owner/app:latest'))
manager.assert_not_called()
root.assert_not_called()
self.assertIn('Prihlásiť sa teraz?', repr(ui.seen))
self.no_effects()
def test_registry_error_classes_hide_untrusted_remote_data_in_both_languages(self):
cases = [('status code: 429', 'obmedzuje počet požiadaviek'),
('requested access to the resource is denied', 'odmietol prístup'),
('manifest unknown', 'Obraz alebo tag'),
('unsupported manifest', 'metadáta obrazu'),
('docker-credential-helper', 'správu prihlasovacích údajov'),
('unknown failure', 'Obraz sa nepodarilo načítať')]
for language in ('sk', 'en'):
for error, expected in cases:
with self.subTest(language=language, error=error), patch.object(i18n, '_language', language):
message = auth.registry_error('registry.test:5000', error + ' REMOTE_PRIVATE_VALUE')
self.assertNotIn('REMOTE_PRIVATE_VALUE', message)
if language == 'sk':
self.assertIn(expected, message)
if 'credential' not in error:
self.assertIn('registry.test:5000', message)
self.no_effects()
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,176 @@
"""Cancel a real editor through Menu/CLI/Monitor contracts, never apply it."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import sys
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, management as menu, recreation
from proxmenux_oci.ui import UserCancelled
class ModifyCancellationTests(CancellationFixture):
def setUp(self):
super().setUp()
self.record.update({
'installation_id': 'fixture-oci-edit-cancel',
'observed': {'config_sha256': 'fixture-config-hash'},
'template': {'id': 'fixture-app', 'catalog_ui': {'title': 'Fixture'},
'container_contract': {'image': {'reference': 'example/fixture:latest'}}},
'deployment': {'vmid': 110, 'hostname': 'fixture',
'resources': {'cores': 2, 'memory_mb': 512},
'rootfs': {'storage': 'fixture-storage', 'size_gb': 8},
'mounts': [], 'devices': [], 'environment': [
{'name': 'PASSWORD', 'value': 'private-fixture-value', 'sensitive': True}],
'network': {'bridge': 'vmbr-fixture', 'ipv4': 'dhcp'}}})
self.original = copy.deepcopy(self.record)
self.ui.back_enabled = False
self.path_write = self.guard(Path, 'write_text')
self.path_bytes = self.guard(Path, 'write_bytes')
def script(self, choices, answers=(), confirms=(), reviews=()):
self.ui.choose.side_effect = choices
self.ui.ask.side_effect = answers
self.ui.confirm.side_effect = confirms
self.ui.review.side_effect = reviews
def no_effects(self):
super().no_effects()
self.path_write.assert_not_called()
self.path_bytes.assert_not_called()
self.assertFalse(self.ui.back_enabled)
def test_main_cancel_is_distinct_falsy_cancellation(self):
self.script([None])
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row, action='modify'), menu.CANCELLED)
self.assertFalse(menu.CANCELLED)
self.no_effects()
def test_main_discard_without_edits_returns_cancel_code(self):
self.script(['discard'])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.ui.confirm.assert_not_called()
self.no_effects()
def test_real_cli_cancel_returns_code_six_without_extra_console_output(self):
self.script([None])
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
self.assertEqual(output.getvalue(), '')
self.no_effects()
def test_real_cli_discard_after_edits_never_calls_backup_or_lifecycle(self):
self.script(['cores', 'discard'], ['3'], [True])
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
self.no_effects()
def test_main_cancel_after_edits_requires_discard_confirmation(self):
self.script(['cores', None], ['3'], [True])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
self.no_effects()
def test_declining_discard_preserves_draft_until_later_main_cancel(self):
self.script(['cores', 'discard', None], ['3'], [False, True])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
last_options = dict(self.ui.choose.call_args.args[1])
self.assertTrue(last_options['cores'].rstrip().endswith('3'))
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
self.assertEqual(self.ui.confirm.call_count, 2)
self.no_effects()
def test_section_cancel_returns_to_editor_before_main_cancel(self):
self.script(['cores', None], [UserCancelled()])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.assertEqual(self.ui.choose.call_count, 2)
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
self.ui.review.assert_not_called()
self.no_effects()
def test_zero_cores_rejected_then_section_and_main_cancel_are_safe(self):
self.script(['cores', None], ['0', UserCancelled()])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.assertIn('Cores: minimum 1', self.ui.message.call_args.args[0])
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
self.no_effects()
def test_review_no_returns_to_editor_with_draft_then_discard_cancels(self):
self.script(['cores', 'apply', 'discard'], ['3'], [True], [False])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.assertEqual(self.ui.choose.call_count, 3)
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
self.assertNotIn('private-fixture-value', self.ui.review.call_args.args[0])
self.no_effects()
def test_native_menu_returns_to_list_after_editor_cancel(self):
self.script(['110', 'modify', None, None])
with 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, 4)
self.no_effects()
def test_legacy_editor_callers_still_receive_none_on_cancel(self):
for choice in (None, 'discard'):
with self.subTest(choice=choice):
self.script([choice])
self.assertIsNone(recreation.edit_recreation(self.record, self.ui))
self.no_effects()
def test_unchanged_review_is_not_mislabeled_as_user_cancellation(self):
self.script(['apply'])
self.assertIsNone(recreation.edit_recreation(self.record, self.ui, cancelled_result=menu.CANCELLED))
self.ui.message.assert_called_once_with('Nothing was changed.', 'Modify OCI')
self.no_effects()
def test_real_editor_error_is_not_converted_to_cancellation(self):
self.script(['cores'], [RuntimeError('fixture editor failure')])
with self.assertRaisesRegex(RuntimeError, 'fixture editor failure'):
menu.direct_management(ROOT/'oci', 110, 'modify')
self.no_effects()
def test_real_cli_validation_failure_remains_failure_one(self):
self.script(['cores'], [cli.InstallError('fixture validation failure')])
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 1)
self.no_effects()
def test_unattended_modify_is_rejected_before_opening_the_editor(self):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify', unattended=True), 1)
self.ui.choose.assert_not_called()
self.no_effects()
def test_accepted_proposal_keeps_original_backend_and_private_payload(self):
self.script(['cores', 'apply'], ['3'], reviews=[True])
self.backup.side_effect = None
self.backup.return_value = True
def lifecycle(command, title):
self.assertEqual(command[:3], [sys.executable, str(ROOT/'oci/remote/oci_update_current.py'), '110'])
proposal_file = Path(command[command.index('--proposal')+1])
self.assertEqual(proposal_file.stat().st_mode & 0o777, 0o600)
proposal = json.loads(proposal_file.read_text())
self.assertEqual(proposal['operation'], 'recreate')
self.assertEqual(proposal['candidate']['deployment']['resources']['cores'], 3)
self.assertEqual(proposal['base_config_sha256'], 'fixture-config-hash')
self.assertNotIn('private-fixture-value', ' '.join(command))
return True
self.lifecycle.side_effect = lifecycle
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 0)
self.backup.assert_called_once()
self.lifecycle.assert_called_once()
self.assertEqual(self.record, self.original)
self.write.assert_not_called()
def test_failed_backup_after_review_remains_failure_without_lifecycle(self):
self.script(['cores', 'apply'], ['3'], reviews=[True])
self.backup.side_effect = None
self.backup.return_value = False
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 1)
self.lifecycle.assert_not_called()
self.write.assert_not_called()
self.assertEqual(self.record, self.original)
@@ -116,7 +116,7 @@ class RecoveryMessages(unittest.TestCase):
instances.ROOT = Path('/inert') instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: self.primary instances.read = lambda root, vmid: self.primary
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')} self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, 'CANCELLED': None,
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None), 'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
'_remove': lambda *args: self.fail('remove must not run'), '_remove': lambda *args: self.fail('remove must not run'),
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False} '_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
@@ -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()
@@ -0,0 +1,234 @@
"""Restored-app registration through real Menu/CLI/Dialog; no host effects."""
from contextlib import nullcontext, redirect_stdout
import copy
import io
import json
from pathlib import Path
import string
import subprocess
import tempfile
import unittest
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_restore_recovery as restored
import oci_ui
class RegistrationFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.found = [{'vmid':110, 'hostname':'fiction-primary'}]
self.original_found = copy.deepcopy(self.found)
self.ui.confirm.return_value = False
item = patch.object(menu, 'restored_applications', side_effect=lambda project:self.found)
self.pending = item.start();self.addCleanup(item.stop)
self.firewall = self.guard(menu, '_restored_firewall_rules')
self.backend = [self.guard(restored, name) for name in ('recover','register','run')]
self.files = [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')]
def members(self):
self.found.append({'vmid':111,'hostname':'fiction-dependent'})
self.original_found = copy.deepcopy(self.found)
def no_effects(self):
super().no_effects()
self.firewall.assert_not_called()
self.safe_backend()
def safe_backend(self):
for item in [*self.backend,*self.files,*self.processes]:item.assert_not_called()
self.assertEqual(self.found,self.original_found)
def accepted(self, answers, completed=True):
self.ui.confirm.side_effect=answers
self.firewall.side_effect=None
self.firewall.return_value=[{'vmid':row['vmid'],'port':61208 if row['vmid']==110 else 19999,
'source':'192.0.2.0/24'} for row in self.found]
self.lifecycle.side_effect=None;self.lifecycle.return_value=completed
self.pending.side_effect=lambda project:[] if completed and self.lifecycle.called else self.found
class RegistrationCancellationTests(RegistrationFixture):
def test_offer_no_is_explicit_falsy_cancellation(self):
self.assertIs(menu.offer_recovery(ROOT/'oci',self.ui),menu.CANCELLED)
self.assertFalse(menu.CANCELLED);self.no_effects()
def test_direct_single_no_finishes_before_a_third_pending_read(self):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
self.assertEqual(self.pending.call_count,2)
self.no_effects()
def test_real_cli_single_no_returns_six_without_output(self):
with patch.object(cli,'Catalog'),redirect_stdout(io.StringIO()) as output:
self.assertEqual(cli.main(['recover']),6)
self.assertEqual(output.getvalue(),'');self.no_effects()
def test_real_cli_multi_container_no_is_not_a_guest_action(self):
self.members()
with patch.object(cli,'Catalog'):
self.assertEqual(cli.main(['recover']),6)
text=self.ui.confirm.call_args.args[0]
self.assertIn('CT 110',text);self.assertIn('CT 111',text)
self.no_effects()
def test_actual_dialog_initial_no_and_escape_for_single_and_multi(self):
dialog=native_ui.DialogUI()
for multi in (False,True):
if multi:self.members()
for code in (1,255):
with self.subTest(multi=multi,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_recovery(ROOT/'oci'),6)
self.no_effects()
def test_native_menu_continues_to_list_after_registration_no(self):
self.ui.choose.return_value=None
with patch.object(menu,'_clean_orphans'),patch.object(menu,'_records_unread',return_value=[]):
menu.interactive_management(ROOT/'oci',self.ui)
self.ui.choose.assert_called_once();self.no_effects()
def test_optional_firewall_and_start_no_registers_without_flags(self):
self.members();self.accepted([True,False,False,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0],
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
self.assertEqual(self.ui.confirm.call_count,4);self.safe_backend()
def test_single_optional_no_preserves_registration_without_start(self):
self.accepted([True,False,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0],
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
self.assertEqual(self.ui.confirm.call_count,3);self.safe_backend()
def test_only_one_confirmed_firewall_is_forwarded_and_no_start(self):
self.members();self.accepted([True,False,True,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0][-2:],['--host-firewall','111'])
self.assertNotIn('--start',self.lifecycle.call_args.args[0]);self.safe_backend()
def test_explicit_start_yes_retains_flag_but_never_starts_any_guest_here(self):
self.accepted([True,False,True])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0][-1],'--start');self.safe_backend()
def test_failed_registration_stays_failure_with_pending_records(self):
self.accepted([True,False,False],completed=False)
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
self.safe_backend()
def test_empty_list_is_success_without_confirmation_or_backend(self):
self.found.clear();self.original_found=[]
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.ui.confirm.assert_not_called();self.ui.message.assert_called_once();self.no_effects()
def test_root_identity_failure_never_probes_restored_apps(self):
with patch.object(menu.os,'geteuid',return_value=1000):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
self.pending.assert_not_called();self.ui.confirm.assert_not_called();self.no_effects()
def test_empty_offer_does_not_cancel_the_native_menu(self):
self.found.clear();self.original_found=[]
self.assertIsNone(menu.offer_recovery(ROOT/'oci',self.ui))
self.ui.confirm.assert_not_called();self.no_effects()
def test_empty_registration_state_is_rendered_in_slovak_without_recovery(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
with patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
self.found.clear();self.original_found=[]
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.ui.message.call_args.args[0],
'Žiadna OCI aplikácia obnovená zo zálohy nečaká na opätovnú registráciu.')
self.no_effects()
def test_initial_single_and_multi_dialog_describes_registration_precheck(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
for multi in (False,True):
if multi:self.members()
with self.subTest(multi=multi),patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
text=self.ui.confirm.call_args.args[0]
self.assertIn('Pred opätovnou registráciou',text)
self.assertNotIn('Obnova najprv',text)
self.assertIn('aplikácie sa potom nespúšťajú automaticky',text)
self.no_effects()
def test_real_backend_blocker_stays_error_and_uses_registration_wording(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
plan={'title':'Fictional restored application','restored':[{'vmid':110}],
'blockers':['Fictional missing resource'],'notes':[]}
with patch.object(menu.sys,'argv',['fixture','recover','--root','/fiction-only']), \
patch.object(restored.instances,'locked',return_value=nullcontext()), \
patch.object(restored,'prepare',return_value=[plan]), \
patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
patch.object(restored,'msg_error') as error,patch.object(restored,'msg_info2'):
self.assertEqual(restored.main(),1)
error.assert_called_once_with('Túto aplikáciu zatiaľ nemožno znova zaregistrovať:')
self.no_effects()
def test_real_host_resource_check_reports_missing_data_without_creating_anything(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
plan={'restored':[{'vmid':110,'config':'mp0: /fiction-only/data,mp=/data\n',
'copy':{'record':{'deployment':{},'observed':{}}}}], 'blockers':[],'notes':[]}
with patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
patch.object(restored.os.path,'isdir',return_value=False), \
patch.object(restored,'firewall_rule',return_value=None):
restored.check_host_resources(plan)
self.assertEqual(len(plan['blockers']),1)
self.assertIn('/fiction-only/data',plan['blockers'][0])
self.assertIn('registráciu zopakujte',plan['blockers'][0])
self.no_effects()
class RegistrationContractTests(unittest.TestCase):
def test_real_monitor_shell_recover_keeps_global_cli_contract_and_exit_six(self):
with tempfile.TemporaryDirectory() as folder:
base=Path(folder);(base/'scripts/oci').mkdir(parents=True)
(base/'utils.sh').write_text('load_language() { :; }\ninitialize_cache() { :; }\n')
(base/'scripts/oci/oci_manager_apps.sh').write_text('printf "%s\\n" "$@"\nexit 6\n')
script=(ROOT/'scripts/oci/manage_instance.sh').read_text()
script=script.replace('/usr/local/share/proxmenux',folder)
import os
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,
env={**os.environ,'ACTION':'recover','VMID':'not-a-vmid'})
self.assertEqual(result.returncode,6);self.assertEqual(result.stdout,'recover\n')
self.assertEqual(result.stderr,'')
def test_curated_slovak_registration_words_and_format_fields(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
expected={
'Recover restored OCI applications':'Registrácia',
'No restored OCI application is waiting to be recovered.':'registráciu',
'Recover them now?':'zaregistrovať',
'This application cannot be recovered yet:':'zaregistrovať',
'The application could not be recovered:':'zaregistrovať',
'Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.':'vyberte Nie',
'it is locked or its disk could not be mounted':'uzamknutý',
'The local storage does not accept snippets':'local',
'it uses an NVIDIA GPU and this host has no working NVIDIA driver and Container Toolkit. Install them and run the recovery again.':'NVIDIA Container Toolkit',
'the NVIDIA hook of the container is not available:':'spúšťací skript',
'its Rclone mount is published again on this host at':'v ceste',
'Choose another bridge for the container in Proxmox (Network) and run the recovery again.':'registráciu',
'Mount or create it with its data and run the recovery again.':'registráciu',
'Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.':'registráciu',
}
fields=lambda text:sorted(f for _,f,_,_ in string.Formatter().parse(text) if f)
for key,word in expected.items():
with self.subTest(key=key):
self.assertIn(word,catalog[key]);self.assertEqual(fields(key),fields(catalog[key]))
def test_registration_web_text_is_distinct_from_previous_installation_recovery(self):
for locale in ('en','sk'):
data=json.loads((ROOT/'AppImage/messages'/locale/'common.json').read_text())['vmLxc']
with self.subTest(locale=locale):
self.assertEqual(data['ociUpdates']['registerRestored'],
'Znova zaregistrovať' if locale=='sk' else 'Register again')
self.assertEqual(data['ociUpdates']['recover'],'Obnoviť' if locale=='sk' else 'Recover')
self.assertIn('zaregistrovať' if locale=='sk' else 'Register again',data['updates']['ociRestoredBody'])
self.assertIn('Zaregistrovať' if locale=='sk' else 'Register',data['ociUpdates']['terminalTitleRecover'])
if __name__=='__main__':unittest.main()
@@ -0,0 +1,235 @@
"""Actual removal UI/guards in SK and EN; real host effects are forbidden."""
from contextlib import nullcontext
import copy
import json
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import i18n, management
from proxmenux_oci.ui import UserCancelled
import oci_instances as instances
import oci_remove as removal
import oci_ui
class RemovalSlovakTests(unittest.TestCase):
def setUp(self):
cache = json.loads((ROOT / 'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in (('_language', 'sk'), ('_cache', cache)):
p = patch.object(module, name, value)
p.start(); self.addCleanup(p.stop)
self.records = {}
self.make_records(2)
self.read = self.start(instances, 'read', side_effect=lambda root, vmid: self.records[vmid])
self.start(management, '_selected_configuration', side_effect=self.config)
self.bridge = self.start(removal, 'bridge_in_use', return_value=False)
self.ui = Mock()
self.ui.review.return_value = False
self.forbidden = []
for module, name in ((subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink'),
(removal.shutil, 'rmtree'), (instances, 'write'), (instances, 'command'),
(management, '_run_lifecycle'), (removal, 'run'),
(removal, 'remove_host_state'), (removal.oci_console, 'remove_log'),
(removal, 'release_bridge'), (removal, 'remove_owned_host_firewall'),
(removal, 'release_shared_host_files'), (removal.work_backup, 'forget'),
(removal.image_cache, 'prune')):
self.forbidden.append(self.start(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
def start(self, module, name, **kwargs):
p = patch.object(module, name, **kwargs)
result = p.start(); self.addCleanup(p.stop)
return result
def make_records(self, count):
self.records.clear()
for offset in range(count):
vmid = 999990 + offset
self.records[vmid] = {'vmid': vmid, 'installation_id': 'fixture-' + str(vmid),
'template': {'catalog_ui': {'title': 'Fixture App'}},
'deployment': {'mounts': [{'type': 'host-bind', 'source': '/fixture/saved',
'container_path': '/saved'}]}}
if offset:
self.records[vmid]['stack_member'] = {'primary_vmid': 999990}
if count > 1:
self.records[999990]['stack'] = {'members': [{'vmid': vmid} for vmid in self.records],
'deployment': {'network': {'private_bridge': 'vmbr999', 'private_subnet': '10.77.99.0/24'}}}
self.records[999990]['deployment']['host_firewall'] = {'port': 8080}
self.original = copy.deepcopy(self.records)
def config(self, vmid):
return (f'hostname: fixture-{vmid}\nrootfs: fixture:vm-{vmid}-disk-0,size=8G\n'
f'mp0: fixture:vm-{vmid}-disk-1,mp=/data,size=16G\n'
'mp1: /fixture/shared,mp=/shared\n').encode()
def tearDown(self):
self.assertEqual(self.records, self.original)
for effect in self.forbidden:
effect.assert_not_called()
def test_single_summary_separates_deleted_disks_and_retained_host_paths(self):
self.make_records(1)
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Kontajnery určené na odstránenie:', text)
self.assertIn('Údaje kontajnera určené na vymazanie:', text)
self.assertIn('/data (16G)', text)
kept = text.split('Cesty hostiteľa nájdené')[1]
self.assertIn('/fixture/shared', kept); self.assertIn('/fixture/saved', kept)
self.assertNotIn('/fixture/shared', text.split('Cesty hostiteľa nájdené')[0])
self.assertNotIn('súkromnej siete:', text)
def test_primary_summary_counts_are_grammatical_for_two_three_and_five(self):
for count in (2, 3, 5):
with self.subTest(count=count):
self.make_records(count)
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn(f'Počet kontajnerov aplikácie Fixture App: {count}.', text)
self.assertIn('Všetky sú určené na odstránenie.', text)
for vmid in self.records:
self.assertIn(f'CT {vmid}', text)
def test_member_summary_warns_that_entire_application_is_selected(self):
text = management._removal_summary(ROOT / 'oci', 999991)
self.assertIn('CT 999991 patrí do aplikácie Fixture App (počet kontajnerov: 2).', text)
self.assertIn('odstránenie celej aplikácie', text)
self.assertNotIn('nádob', text)
def test_free_bridge_is_only_targeted_and_firewall_removal_is_conditional(self):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Súkromná sieť určená na uvoľnenie, ak ju nepoužíva iná VM alebo iný CT: vmbr999', text)
self.assertIn('Môže sa odstrániť aj zodpovedajúce spravované pravidlo firewallu hostiteľa.', text)
def test_shared_bridge_is_kept_and_not_reported_as_released(self):
self.bridge.return_value = True
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Súkromná sieť zachovaná', text)
self.assertNotIn('určená na uvoľnenie', text)
def test_missing_config_still_discloses_saved_host_paths_without_deleting(self):
with patch.object(management, '_selected_configuration', side_effect=OSError('fixture missing')):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('/fixture/saved', text)
self.assertIn('rootfs -', text)
def test_no_from_real_remove_review_never_calls_lifecycle(self):
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999991))
review = self.ui.review.call_args
self.assertFalse(review.kwargs['default'])
self.assertEqual(review.kwargs['question'],
'Odstrániť aplikáciu? Jej kontajnerové disky sa vymažú a obnoviť ich možno iba zo zálohy.')
def test_cancelled_real_remove_review_never_calls_lifecycle(self):
self.ui.review.side_effect = UserCancelled('fixture cancelled')
with self.assertRaises(UserCancelled):
management._remove(ROOT / 'oci', self.ui, 999990)
def test_preparation_error_does_not_show_confirmation_or_call_lifecycle(self):
with patch.object(removal, 'members_of', side_effect=ValueError('fixture record invalid')):
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999990))
self.ui.review.assert_not_called()
self.assertIn('Odstránenie nebolo možné pripraviť:', self.ui.message.call_args.args[0])
def test_pending_operation_error_precedes_configuration_probe_and_all_mutations(self):
self.make_records(1)
self.records[999990]['pending_transaction'] = {'fixture': True}
self.original = copy.deepcopy(self.records)
with patch.object(removal, 'guest_config') as config:
with self.assertRaisesRegex(ValueError, 'Operácia tejto inštalácie sa ešte nedokončila'):
removal.remove(Path('/fixture/oci'), 999990)
config.assert_not_called()
def test_container_on_other_node_errors_before_host_cleanup(self):
with patch.object(removal, 'guest_config', return_value=None), \
patch.object(removal, 'guest_node', return_value='fixture-other-node'):
with self.assertRaisesRegex(ValueError, 'Kontajner beží na inom uzle klastra'):
removal.remove(Path('/fixture/oci'), 999990)
def run_main(self, result=False, busy=False):
with patch.object(sys, 'argv', ['oci_remove.py', '999990']), \
patch.object(removal.os, 'geteuid', return_value=0), \
patch.object(instances, 'locked', side_effect=BlockingIOError if busy else None,
return_value=nullcontext()), \
patch.object(removal, 'remove', return_value=result) as mocked_remove, \
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_error') as error:
code = removal.main()
return code, mocked_remove, ok, error
def test_busy_registry_error_does_not_enter_removal(self):
code, remove, ok, error = self.run_main(busy=True)
self.assertEqual(code, 1); remove.assert_not_called(); ok.assert_not_called()
self.assertEqual(error.call_args.args[0], 'Register inštancií používa iná operácia OCI')
def test_partial_completion_is_a_warning_to_review_not_unqualified_success(self):
code, remove, ok, error = self.run_main(result=True)
self.assertEqual(code, 0); error.assert_not_called()
self.assertEqual(ok.call_args.args[0],
'Príkaz na odstránenie sa dokončil; skontrolujte všetky upozornenia vyššie.')
self.assertNotEqual(ok.call_args.args[0], i18n.translate('The application was removed'))
def test_clean_completion_keeps_existing_success_contract(self):
code, remove, ok, error = self.run_main()
self.assertEqual(code, 0); error.assert_not_called()
self.assertEqual(ok.call_args.args[0], 'Aplikácia bola odstránená')
def test_english_primary_and_member_summaries_keep_identical_sentences(self):
with patch.object(i18n, '_language', 'en'):
primary = management._removal_summary(ROOT / 'oci', 999990)
member = management._removal_summary(ROOT / 'oci', 999991)
self.assertTrue(primary.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
self.assertTrue(member.startswith('CT 999991 is one of the 2 containers of Fixture App. It cannot be removed on its own,'))
def test_missing_translation_falls_back_to_the_same_english_summary(self):
with patch.object(i18n, '_cache', {}):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertTrue(text.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
def test_yes_only_builds_the_existing_command_when_lifecycle_is_inert(self):
self.ui.review.return_value = True
with patch.object(management, '_run_lifecycle', return_value=True) as inert:
self.assertTrue(management._remove(ROOT / 'oci', self.ui, 999990))
self.assertEqual(inert.call_args.args[0],
[sys.executable, str(ROOT / 'oci/remote/oci_remove.py'), '999990'])
def test_single_and_stack_menu_routes_use_the_translated_label_and_real_no_review(self):
for count in (1, 2):
with self.subTest(count=count):
self.make_records(count)
self.ui.reset_mock()
self.ui.choose.return_value = 'remove'
row = {'vmid': 999990, 'stack': count > 1, 'pending': False,
'status': 'installed', 'reason': 'matched'}
with patch.object(management, 'carry_records', return_value={}), \
patch.object(management, 'check_selected', return_value=row):
self.assertFalse(management.manage_instance(ROOT / 'oci', self.ui, row))
options = self.ui.choose.call_args.args[1]
self.assertIn(('remove', 'Odstrániť: zmazať aplikáciu a jej kontajnery'), options)
self.ui.review.assert_called_once()
self.assertFalse(self.ui.review.call_args.kwargs['default'])
def test_changed_identity_warning_does_not_claim_the_other_container_was_deleted(self):
self.make_records(1)
# Exercise the real backend branch; record cleanup is inert as well.
with patch.object(removal, 'guest_config', return_value=b'fixture'), \
patch.object(instances, 'identity', return_value='fixture-other-identity'), \
patch.object(removal, 'remove_owned_host_firewall', return_value=True), \
patch.object(removal, 'release_shared_host_files'), \
patch.object(removal.work_backup, 'forget'), \
patch.object(removal.image_cache, 'prune', return_value=[]), \
patch.object(Path, 'exists', return_value=False), \
patch.object(Path, 'is_dir', return_value=False), \
patch.object(removal, 'msg_warn') as warning, \
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_info'):
self.assertTrue(removal.remove(Path('/fixture/oci'), 999990))
self.assertEqual(warning.call_args.args[0],
'VMID teraz patrí inému kontajneru; ten zostane nedotknutý: CT 999990')
self.assertNotIn('Odstránený kontajner:', str(ok.mock_calls))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,239 @@
"""Exercise the multi-container editor in Slovak without applying a change."""
from contextlib import redirect_stderr
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import host, i18n, installer, management, stack_recreation as editor
import oci_immich_recognition as recognition
import oci_instances as instances
import oci_stack_modify as modify
import oci_ui
CONFIG = {
'rootfs': 'fixture:vm-999991-disk-0,size=8G',
'mp0': 'fixture:vm-999991-disk-1,mp=/opt/recipes/mediafiles,size=8G',
'mp1': 'fixture:vm-999991-disk-2,mp=/extra,size=4G',
'mp2': '/fixture/shared,mp=/shared',
'dev0': 'path=/dev/ttyACM7,mode=0660',
}
class StackEditorSlovakTests(unittest.TestCase):
def setUp(self):
self.translations = json.loads((ROOT / 'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in (('_language', 'sk'), ('_cache', self.translations)):
item = patch.object(module, name, value)
item.start()
self.addCleanup(item.stop)
self.member = {'vmid': 999991, 'stack_member': {'name': 'application'},
'deployment': {'replay_profile': {'adapter': 'install_tandoor_stack.sh',
'role': 'application'}}}
self.primary = {'vmid': 999990, 'stack': {'members': [self.member,
{'vmid': 999992, 'deployment': {'replay_profile': {'role': 'database'}}}]}}
self.original = copy.deepcopy(self.primary)
self.ui = Mock()
self.ui.choose.return_value = None
self.ui.confirm.return_value = False
self.ui.review.return_value = False
self.ui.checklist.side_effect = lambda text, options, defaults: list(defaults)
config = patch.object(editor, '_config', return_value=CONFIG)
config.start()
self.addCleanup(config.stop)
self.forbidden = []
for target, name in ((subprocess, 'run'), (subprocess, 'Popen'),
(subprocess, 'check_output'), (Path, 'write_text'),
(Path, 'write_bytes'), (instances, 'write'),
(instances, 'command'), (modify, 'apply'),
(recognition, 'apply'), (recognition, 'configure'),
(management, '_run_lifecycle')):
item = patch.object(target, name, side_effect=AssertionError('Forbidden effect: ' + name))
self.forbidden.append(item.start())
self.addCleanup(item.stop)
self.lifecycle = Mock(side_effect=AssertionError('Editor called lifecycle'))
self.forbidden.append(self.lifecycle)
def tearDown(self):
self.assertEqual(self.primary, self.original)
for callback in self.forbidden:
callback.assert_not_called()
def shown(self):
return '\n'.join(str(call) for call in self.ui.mock_calls)
def immich(self, acceleration='cpu'):
self.member['deployment']['replay_profile'] = {
'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}
self.member['deployment']['machine_learning'] = {'acceleration': acceleration}
self.original = copy.deepcopy(self.primary)
return self.member
def changes(self, **values):
return {'add_mounts': [], 'remove_mounts': [], 'add_devices': [],
'remove_devices': [], **values}
def test_editor_only_offers_extra_paths_and_leaves_database_out(self):
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
shown = self.shown()
self.assertIn('odznačené sa odstránia po potvrdení', shown)
self.assertIn('odznačené sa odpoja po potvrdení', shown)
options = self.ui.checklist.call_args_list[0].args[1]
self.assertEqual([path for path, label in options], ['/extra', '/shared'])
self.assertNotIn('zásobník', shown)
def test_declining_volume_deletion_keeps_it_and_only_detaches_host_path_in_draft(self):
self.ui.checklist.side_effect = [[], ['/dev/ttyACM7']]
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
warning = self.ui.confirm.call_args_list[0].args[0]
self.assertIn('trvalé úložisko aj všetky údaje v ňom', warning)
self.assertIn('/extra', warning)
summary = self.ui.review.call_args.args[0]
self.assertIn('- /shared', summary)
self.assertNotIn('- /extra', summary)
self.assertIn('Spustený kontajner sa reštartuje', summary)
self.assertIn('zastavený zostane zastavený', summary)
self.assertIn('Databáza ani ostatné kontajnery sa nemenia', summary)
def test_declined_review_describes_confirmed_volume_deletion_without_performing_it(self):
self.ui.checklist.side_effect = [[], []]
self.ui.confirm.side_effect = [True, False, False]
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('- /extra', self.ui.review.call_args.args[0])
self.assertIn('vymažú aj údaje', self.ui.review.call_args.args[0])
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Použiť tieto zmeny?')
def test_member_selection_cancels_before_any_configuration_read(self):
other = copy.deepcopy(self.member)
other['vmid'] = 999993
self.primary['stack']['members'].append(other)
self.original = copy.deepcopy(self.primary)
with patch.object(editor, '_config') as read:
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
read.assert_not_called()
self.assertEqual(self.ui.choose.call_args.args[0], 'Kontajner, ktorý chcete upraviť')
def test_missing_member_error_uses_application_language(self):
self.primary['stack']['members'] = []
self.original = copy.deepcopy(self.primary)
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('Pre túto aplikáciu nie je uložený zoznam kontajnerov.', self.shown())
self.assertIn('Správa OCI aplikácie s viacerými kontajnermi', self.shown())
def test_immich_action_menu_identifies_recognition_instead_of_video_transcoding(self):
self.immich()
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('Vybrať CPU alebo GPU pre rozpoznávanie', self.shown())
self.assertIn('Upraviť pripojené cesty a zariadenia', self.shown())
def test_cpu_only_host_explains_why_no_gpu_choice_is_offered(self):
member = self.immich()
with patch.object(host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': []}):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
self.assertIn('nenašla použiteľná grafická karta', self.shown())
self.assertIn('Rozpoznávanie zostáva na CPU.', self.shown())
self.ui.review.assert_not_called()
def test_gpu_review_explains_whole_application_update_and_kept_data_before_no(self):
member = self.immich()
self.ui.choose.return_value = 'openvino'
found = {'intel': ['/dev/dri/renderD128'], 'amd': [], 'nvidia': []}
with patch.object(host, 'gpus', return_value=found):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
choices = self.ui.choose.call_args.args[1]
self.assertEqual(choices, [('cpu', 'CPU'), ('openvino', 'Intel GPU')])
text = self.ui.review.call_args.args[0]
for fragment in ('CPU → Intel GPU', 'pamäť modelov, knižnica a databáza sa zachovajú',
'Celá aplikácia sa zastaví a aktualizuje', 'ProxMenux sa pokúsi obnoviť',
'ponuky správy OCI'):
self.assertIn(fragment, text)
self.assertEqual(self.ui.review.call_args.kwargs['question'],
'Použiť zvolený CPU/GPU pre rozpoznávanie?')
def test_unchanged_recognition_does_not_open_confirmation_or_apply(self):
member = self.immich()
self.ui.choose.return_value = 'cpu'
with patch.object(editor, 'recognition_choices', return_value=[('cpu', 'CPU', {}),
('cuda', 'NVIDIA GPU', {})]):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
self.ui.review.assert_not_called()
self.assertIn('Nič sa nezmenilo.', self.shown())
def test_experimental_rocm_can_be_declined_before_final_review(self):
member = self.immich()
self.ui.choose.return_value = 'rocm'
choices = [('cpu', 'CPU', {}), ('rocm', 'AMD GPU', {'experimental': True})]
with patch.object(editor, 'recognition_choices', return_value=choices), \
patch.object(host, 'amd_gpu_name', return_value='Fixture AMD'):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
text = self.ui.confirm.call_args.args[0]
self.assertIn('ROCm túto grafickú kartu oficiálne nepodporuje.', text)
self.assertIn('GPU prestať reagovať', text)
self.assertIn('bez preinštalovania', text)
self.assertIn('Použiť napriek tomu GPU s ROCm?', text)
self.assertFalse(self.ui.confirm.call_args.args[1])
self.ui.review.assert_not_called()
def test_backend_invalid_volume_size_preserves_path_and_fails_before_mutation(self):
mount = {'type': 'managed-volume', 'container_path': '/new-data',
'size_gb': 0, 'source': 'fixture'}
with patch.object(modify, 'entries', return_value={}):
with self.assertRaisesRegex(ValueError, 'Neplatná veľkosť trvalého úložiska: /new-data'):
modify.validate(999991, self.changes(add_mounts=[mount]))
def test_backend_unsupported_device_explains_separate_profile_before_mutation(self):
device = {'kind': 'character-device', 'host_path': '/dev/sda'}
with patch.object(modify, 'entries', return_value={}):
with self.assertRaisesRegex(ValueError, 'NVIDIA a celé stromy zariadení vyžadujú iný profil: /dev/sda'):
modify.validate(999991, self.changes(add_devices=[device]))
def test_root_error_is_localized_without_starting_the_operation(self):
with patch.object(modify.os, 'geteuid', return_value=1000), \
patch.object(sys, 'argv', ['fixture', '999991', '--changes', '/not-read.json']), \
redirect_stderr(io.StringIO()) as output:
with self.assertRaises(SystemExit) as result:
modify.main()
self.assertEqual(result.exception.code, 2)
self.assertIn('Vyžadujú sa oprávnenia používateľa root', output.getvalue())
self.assertNotIn('required', output.getvalue())
def test_invalid_recognition_profile_is_localized_without_any_gpu_probe(self):
with self.assertRaisesRegex(ValueError, 'Profil GPU pre Immich nebol overený'):
recognition.validate('unknown', None, None)
def test_recognition_errors_distinguish_unchanged_from_incomplete_operation(self):
incomplete = RuntimeError('fixture diagnostic')
incomplete.recognition_kept = False
for error, expected in ((ValueError('fixture diagnostic'), 'Spôsob rozpoznávania v Immichu sa nezmenil:'),
(incomplete, 'Zmenu CPU/GPU pre rozpoznávanie v Immichu sa nepodarilo dokončiť:')):
with self.subTest(error=type(error).__name__), \
patch.object(recognition.os, 'geteuid', return_value=0), \
patch.object(sys, 'argv', ['fixture', '999990', '--acceleration', 'cpu']), \
patch.object(recognition, 'change', side_effect=error), \
patch.object(recognition, 'msg_error') as message:
self.assertEqual(recognition.main(), 1)
self.assertEqual(message.call_args.args[0], expected + ' fixture diagnostic')
def test_english_and_missing_translation_fallback_preserve_source_text(self):
changes = self.changes(remove_mounts=['/extra'])
for language, cache in (('en', self.translations), ('sk', {})):
with self.subTest(language=language, empty_cache=not cache), \
patch.object(i18n, '_language', language), patch.object(i18n, '_cache', cache):
text = editor.summary(self.member, changes)
self.assertIn('A running container is restarted', text)
self.assertIn('- /extra', text)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,196 @@
"""Coordinated update/recreate contracts; no host lifecycle is permitted."""
import copy
import json
from pathlib import Path
import subprocess
import sys
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, updates
import oci_instances as instances
import oci_stack_plan as stack_plan
import oci_stack_transaction as transaction
import oci_ui
class StackFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.row['stack'] = True
self.record.update(installation_id='fiction-main', stack={
'id': 'fiction-main', 'members': [
{'vmid': vmid, 'installation_id': identity, 'status': 'installed',
'stack_member': {'stack_id': 'fiction-main', 'primary_vmid': 110}}
for vmid, identity in [(110, 'fiction-main'), (111, 'fiction-dependent')]],
'deployment': {'services': [{'vmid': 111}, {'vmid': 110}]}})
self.original = copy.deepcopy(self.record)
self.extra_effects = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
self.extra_effects += [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
self.extra_effects += [self.guard(transaction, 'execute'), self.guard(transaction, 'save'),
self.guard(menu.images, 'offer_removal')]
def no_effects(self):
super().no_effects()
for effect in self.extra_effects:
effect.assert_not_called()
class StackCancellationTests(StackFixture):
def test_update_and_recreate_no_are_falsy_distinct_cancellation(self):
for action in ('update', 'recreate'):
with self.subTest(action=action):
result = menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
self.assertIs(result, menu.CANCELLED)
self.assertFalse(result)
self.no_effects()
def test_real_cli_cancel_returns_six_for_both_actions(self):
for action in ('update', 'recreate'):
with self.subTest(action=action), patch.object(cli, 'Catalog'):
self.assertEqual(cli.main(['manage', '110', '--action', action]), 6)
self.no_effects()
def test_actual_dialog_no_and_escape_return_six_before_any_effect(self):
dialog = native_ui.DialogUI()
for action in ('update', 'recreate'):
for code in (1, 255):
with self.subTest(action=action, 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, action), 6)
self.no_effects()
def test_confirmation_defaults_and_member_count_are_preserved(self):
for action, default in [('update', True), ('recreate', False)]:
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
args, kwargs = self.ui.review.call_args
self.assertIn('2 containers', args[0])
self.assertIn('main CT: 110', args[0])
self.assertEqual(kwargs['default'], default)
self.no_effects()
def test_native_menu_returns_to_the_application_list_after_no(self):
for action in ('update', 'recreate'):
with self.subTest(action=action):
self.ui.choose.reset_mock()
self.ui.choose.side_effect = ['110', action, None]
with 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, 3)
self.no_effects()
def test_batch_cancel_never_checks_the_next_application(self):
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 112, 'title': 'Second'}]
self.ui.checklist.return_value = ['all']
self.ui.review.side_effect = [True, False]
with patch.object(updates, 'UpdateCheck') as checker, \
patch.object(menu, 'saved_inventory', return_value=[self.row, {**self.row, 'vmid': 112}]), \
patch.object(menu, 'check_selected', side_effect=lambda project, row: row) as selected:
checker.return_value.status.return_value = {'available': available}
self.assertFalse(updates.select_updates(ROOT/'oci', self.ui, available))
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
self.no_effects()
def test_rejected_backup_remains_failure_one_without_dispatch(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = False
for action in ('update', 'recreate'):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 1)
self.lifecycle.assert_not_called()
self.assertEqual(self.record, self.original)
def test_positive_confirmation_preserves_success_failure_and_exact_commands(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
for action in ('update', 'recreate'):
for result in (False, True):
self.lifecycle.reset_mock()
self.lifecycle.return_value = result
with patch.object(menu.images, 'offer_removal') as offer:
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action,
lifecycle_args=('--keep-backup',)), 0 if result else 1)
expected = [sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--keep-backup']
if action == 'recreate':
expected += ['--operation', 'recreate']
self.assertEqual(self.lifecycle.call_args.args[0], expected+['--acknowledge-external-data'])
self.assertEqual(offer.call_count, int(result))
self.assertEqual(self.record, self.original)
self.write.assert_not_called()
self.command.assert_not_called()
def test_acknowledgement_is_not_duplicated(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = False
menu.direct_management(ROOT/'oci', 110, 'recreate', lifecycle_args=('--acknowledge-external-data',))
self.assertEqual(self.lifecycle.call_args.args[0].count('--acknowledge-external-data'), 1)
class StackSlovakTests(StackFixture):
def setUp(self):
super().setUp()
self.catalog = json.loads((ROOT/'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in [('_language', 'sk'), ('_cache', self.catalog)]:
item = patch.object(module, name, value)
item.start()
self.addCleanup(item.stop)
def test_questions_and_titles_name_the_whole_application(self):
for action, question in [('update', 'Aktualizovať celú aplikáciu?'),
('recreate', 'Znovu vytvoriť všetky kontajnery aplikácie?')]:
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
self.assertEqual(self.ui.review.call_args.kwargs['question'], question)
self.assertIn('aplikáciu s viacerými kontajnermi', self.ui.review.call_args.args[1])
self.no_effects()
def test_recreation_dialog_explains_saved_images_and_restoring_backups(self):
menu.manage_instance(ROOT/'oci', self.ui, self.row, action='recreate')
text = self.ui.review.call_args.args[0]
self.assertIn('uložených identifikátorov obrazov', text)
self.assertIn('2', text)
self.assertIn('110', text)
self.assertIn('zálohy sa obnovia', text)
self.assertNotIn('Stack', text)
self.no_effects()
def test_shared_missing_primary_error_in_plan_and_publish_before_any_effect(self):
primary = copy.deepcopy(self.record)
primary['vmid'] = 112
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
stack_plan.build(primary, {}, {}, 'update')
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
instances.publish_stack(Path('/fiction-only'), 110, {}, {}, [])
self.no_effects()
def test_missing_member_error_keeps_volume_verification_requirement(self):
records = {row['vmid']: row for row in self.record['stack']['members']}
with self.assertRaisesRegex(ValueError, 'znova ich vytvorte až po overení ich dátových zväzkov'):
stack_plan.build(self.record, records, {110: 'fiction-main'}, 'update')
self.no_effects()
def test_pending_and_conflicting_member_errors_keep_the_identifier(self):
records = {row['vmid']: copy.deepcopy(row) for row in self.record['stack']['members']}
inventory = {key: row['installation_id'] for key, row in records.items()}
records[111]['status'] = 'pending'
with self.assertRaisesRegex(ValueError, '^Nedokončená operácia existuje pre CT 111$'):
stack_plan.build(self.record, records, inventory, 'update')
records[111]['status'] = 'installed'
inventory[111] = 'different-installation'
with self.assertRaisesRegex(ValueError, '^Iná inštancia už používa VMID 111$'):
stack_plan.build(self.record, records, inventory, 'update')
self.no_effects()
def test_plan_failure_is_read_only_and_distinguishes_invalid_identity(self):
primary = copy.deepcopy(self.record)
primary['stack']['id'] = 'different-stack'
with self.assertRaisesRegex(ValueError, '^Nekonzistentná identita aplikácie s viacerými kontajnermi$'):
stack_plan.build(primary, {}, {}, 'recreate')
self.no_effects()
@@ -0,0 +1,210 @@
"""Slovak storage questions through real builders and UI; installation forbidden."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
ROOT=Path(__file__).resolve().parents[3]
sys.path[:0]=[str(ROOT/'oci/src'),str(ROOT/'oci/remote')]
from proxmenux_oci import cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
from proxmenux_oci.catalog import Catalog
class Recorder:
def __init__(self, answers=None):
self.answers=answers or {}
self.asked=[]
self.messages=[]
def _answer(self,text,default):
self.asked.append(text)
return self.answers.get(text,default)
def ask(self,text,default=None,**kwargs):return self._answer(text,default if default is not None else '')
def password(self,text,**kwargs):return self._answer(text,'fictional-value-for-tests-000000')
def choose(self,text,options,default=None,**kwargs):return self._answer(text,default)
def confirm(self,text,default=False,**kwargs):return self._answer(text,default)
def checklist(self,text,options,defaults=None,**kwargs):return self._answer(text,defaults or [])
def review(self,*args,**kwargs):return False
def info(self,text):pass
def message(self,text,*args):self.messages.append(text)
class StorageFixture(unittest.TestCase):
def setUp(self):
self.catalog=Catalog(ROOT/'oci')
self.sk=json.loads((ROOT/'lang/sk.json').read_text())
self.forbidden=[]
self.patch(i18n,'_language','sk')
self.patch(i18n,'_cache',self.sk)
self.patch(installer.host,'storages',side_effect=lambda content: [
{'storage':'local-lvm','type':'lvmthin','avail':100*2**30},
{'storage':'fiction-storage','type':'dir','avail':200*2**30}] if content=='rootdir' else [
{'storage':'local','type':'dir','avail':100*2**30}])
self.patch(installer.host,'bridges',return_value=[{'iface':'vmbr0','cidr':'192.0.2.10/24'}])
self.patch(installer.host,'timezone',return_value='Europe/Bratislava')
self.patch(installer.host,'gpus',return_value={'intel':[],'amd':[],'nvidia':False})
self.patch(installer.access,'ask_addresses',side_effect=lambda ui,bridge,names,*a:({name:'dhcp' for name in names},None))
self.patch(installer.access,'ask_ipv4',return_value=('dhcp',None))
for module,name in [(installer,'run_remote_install'),(cli,'run_remote_install'),
(cli.images,'offer_removal'),(subprocess,'run'),(subprocess,'Popen'),(subprocess,'check_output'),
(Path,'write_text'),(Path,'write_bytes'),(Path,'unlink')]:
self.forbidden.append(self.patch(module,name,side_effect=AssertionError('Forbidden effect: '+name)))
self.addCleanup(self.no_effects)
def patch(self,module,name,*args,**kwargs):
item=patch.object(module,name,*args,**kwargs);value=item.start();self.addCleanup(item.stop);return value
def no_effects(self):
for effect in self.forbidden:effect.assert_not_called()
def recorder(self,answers=None):
return Recorder({i18n.translate(key):value for key,value in (answers or {}).items()})
def build(self,app,answers=None,mode=installer.ADVANCED_MODE):
ui=self.recorder(answers)
plan=installer.build_deployment(self.catalog.compose(app),ui,mode)
return ui,plan
class StorageWordingTests(StorageFixture):
def test_tandoor_files_question_and_minimum_error_keep_the_limit(self):
ui,plan=self.build('tandoor',{'Files volume size in GB':'9'})
self.assertIn('Veľkosť dátového zväzku pre súbory v GB',ui.asked)
self.assertEqual(plan['media']['size_gb'],9)
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok súborov Tandoor musí mať najmenej 2 GB'):
self.build('tandoor',{'Files volume size in GB':'1'})
def test_paperless_storage_documents_and_consume_export_names(self):
ui,plan=self.build('paperless-ngx',{'Documents volume size in GB':'30',
'Where to store the consume and export folders':'managed-volume','Size of each consume/export volume in GB':'3'})
for text in ['Úložisko pre údaje a dokumenty aplikácie Paperless',
'Kam uložiť priečinky consume a export',
'Veľkosť dátového zväzku pre dokumenty v GB',
'Veľkosť každého dátového zväzku pre consume a export v GB']:
self.assertIn(text,ui.asked)
self.assertEqual((plan['media_size_gb'],plan['transfer']['size_gb'],plan['transfer']['backup']),(30,3,True))
def test_paperless_volume_limit_and_transfer_limit_errors(self):
with self.assertRaisesRegex(installer.InstallError,'Trvalé dátové zväzky aplikácie Paperless musia mať najmenej 8 GB'):
self.build('paperless-ngx',{'Documents volume size in GB':'7'})
with self.assertRaisesRegex(installer.InstallError,'Dátové zväzky pre consume a export musia mať najmenej 1 GB'):
self.build('paperless-ngx',{'Where to store the consume and export folders':'managed-volume',
'Size of each consume/export volume in GB':'0'})
def test_postgresql_question_and_error_are_shared_by_immich_and_nextcloud(self):
for app in ('immich','nextcloud-stack'):
with self.subTest(app=app):
ui,plan=self.build(app,{'PostgreSQL volume size in GB':'12'})
self.assertIn('Veľkosť dátového zväzku PostgreSQL v GB',ui.asked)
self.assertEqual(plan['database_size_gb'],12)
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok PostgreSQL musí mať najmenej 8 GB'):
self.build(app,{'PostgreSQL volume size in GB':'7'})
def test_host_directory_remains_outside_the_container_backup(self):
ui,plan=self.build('nextcloud-stack',{'Where to store the Nextcloud files, configuration and data':'host-bind',
'Shared host directory':'/fiction-only/existing-data'})
self.assertIn('Zdieľaný adresár hostiteľa',ui.asked)
self.assertEqual((plan['application']['mode'],plan['application']['host_path'],plan['application']['backup']),
('host-bind','/fiction-only/existing-data',False))
def test_default_and_advanced_builders_preserve_language_independent_plans(self):
for app in ('jellyfin','immich','nextcloud-stack','paperless-ngx','tandoor'):
for mode in (installer.DEFAULT_MODE,installer.ADVANCED_MODE):
with self.subTest(app=app,mode=mode):
_ui,slovak=self.build(app,mode=mode)
with patch.object(i18n,'_language','en'):
_ui,english=self.build(app,mode=mode)
self.assertEqual(slovak,english)
def test_multi_path_cancel_and_invalid_selection_preserve_all_members(self):
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
{'name':'database','deployment':{'mounts':[]}}]
original=copy.deepcopy(services)
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':[]})
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertIn('Pridať ďalšie cesty do aplikácie s viacerými kontajnermi',ui.asked)
self.assertEqual(services,original)
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main'],
'Data location':None})
with self.assertRaises(native_ui.UserCancelled):custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertEqual(services,original)
def test_shared_selection_error_in_device_flow_also_preserves_members(self):
services=[{'name':'main','main':True,'deployment':{}}];original=copy.deepcopy(services)
ui=self.recorder()
with patch.object(extra_devices,'ask_extra_devices',return_value=[{'kind':'usb','host_path':'/fiction-only/device'}]), \
patch.object(ui,'checklist',return_value=[]):
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
extra_devices.ask_stack_extra_devices(ui,services)
self.assertEqual(services,original)
def test_selected_path_is_added_only_to_selected_member_with_backup(self):
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
{'name':'database','deployment':{'mounts':[]}}]
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main']})
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertEqual(services[1]['deployment']['mounts'],[])
mount=services[0]['deployment']['mounts'][0]
self.assertEqual((mount['source'],mount['container_path'],mount['size_gb'],mount['backup']),
('fiction-storage','/media-extra',8,True))
self.assertIn('Pridať ďalšiu cestu do aplikácie s viacerými kontajnermi',ui.asked)
def test_rclone_shared_directory_question_is_localized_without_creating_it(self):
ui=self.recorder({'Shared directory for copy/sync operations':'/fiction-only/rclone'})
with patch.object(ui,'confirm',return_value=True):
plan=installer.build_deployment(self.catalog.compose('rclone'),ui,installer.ADVANCED_MODE)
self.assertIn('Zdieľaný adresár na kopírovanie a synchronizáciu',ui.asked)
self.assertTrue(any(m.get('source')=='/fiction-only/rclone' and not m['backup'] for m in plan['mounts']))
class InputAndCancellationTests(StorageFixture):
def test_dialog_required_value_and_password_use_slovak_then_retry(self):
dialog=native_ui.DialogUI()
empty=subprocess.CompletedProcess('dialog',0,stderr='')
value=subprocess.CompletedProcess('dialog',0,stderr='fiction-value')
with patch.object(dialog,'_run',side_effect=[empty,value]),patch.object(dialog,'message') as message:
self.assertEqual(dialog.ask('fiction-field'),'fiction-value')
message.assert_called_once_with('Táto hodnota je povinná.')
with patch.object(dialog,'_run',side_effect=[empty,value,value]),patch.object(dialog,'message') as message:
self.assertEqual(dialog.password('fiction-field'),'fiction-value')
message.assert_called_once_with('Táto hodnota je povinná.')
def test_terminal_required_value_and_password_are_also_localized(self):
terminal=native_ui.TerminalUI()
with patch('builtins.input',side_effect=['','fiction-value']),redirect_stdout(io.StringIO()) as output:
self.assertEqual(terminal.ask('fiction-field'),'fiction-value')
self.assertIn('Táto hodnota je povinná.',output.getvalue())
with patch.object(native_ui.getpass,'getpass',side_effect=['','fiction-value','fiction-value']),redirect_stdout(io.StringIO()) as output:
self.assertEqual(terminal.password('fiction-field'),'fiction-value')
self.assertIn('Táto hodnota je povinná.',output.getvalue())
def test_real_install_flow_storage_cancel_and_final_no_never_dispatch(self):
template=self.catalog.compose('tandoor');original=copy.deepcopy(template)
dialog=native_ui.DialogUI()
for code in (1,255):
replies=[subprocess.CompletedProcess('dialog',0,stderr=value) for value in ('','fiction-stack','2','2048')]
replies.append(subprocess.CompletedProcess('dialog',code,stderr=''))
with self.subTest(code=code),patch.object(dialog,'_run',side_effect=replies) as run:
with self.assertRaises(native_ui.UserCancelled):cli.install_template(dialog,template,'tandoor',installer.ADVANCED_MODE)
self.assertEqual(run.call_count,5)
self.assertEqual(template,original)
self.assertIsNone(cli.install_template(self.recorder(),template,'tandoor',installer.ADVANCED_MODE))
self.assertEqual(template,original)
def test_numeric_retry_keeps_previous_answers_and_already_correct_slovak(self):
ui=Recorder();wizard=native_ui.BacktrackUI(ui)
wizard.answers=[('ask','previous'),('ask','abc')];wizard.cursor=2
self.assertTrue(wizard.retry_last(ValueError("invalid literal for int() with base 10: 'abc'")))
self.assertEqual(ui.messages,["Hodnota musí byť číslo: 'abc'\n\nZadajte hodnotu znova."])
self.assertEqual((wizard.answers,wizard.cursor),([('ask','previous')],0));wizard.close()
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
ui=Mock();ui.choose.side_effect=['custom',None]
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
patch.object(updates,'UpdateCheck') as checker:
check=checker.return_value.__enter__.return_value
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
self.assertEqual(cli.interactive(self.catalog),0)
ui.message.assert_called_once_with('Operáciu sa nepodarilo dokončiť:\n\nfiction-detail')
@@ -0,0 +1,168 @@
"""Real native OCI menu/CLI paths with all external effects replaced."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
import oci_instances as instances
import oci_instance_reconcile as reconcile
class CancellationFixture(unittest.TestCase):
def setUp(self):
self.record = {'status': 'installed', 'vmid': 110, 'deployment': {}, 'template': {}}
self.original = copy.deepcopy(self.record)
self.row = dict(vmid=110, reason='matched', stack=False, pending=False, status='installed',
title='Fixture application', hostname='fixture', image='fixture:latest')
self.ui = Mock()
self.ui.unattended = False
self.ui.review.return_value = False
for module, name, options in [
(i18n, '_language', dict(new='en')), (i18n, '_cache', dict(new={})),
(menu.os, 'geteuid', dict(return_value=0)), (menu.shutil, 'which', dict(return_value='/fixture/pct')),
(native_ui, 'interactive_ui', dict(return_value=self.ui)),
(menu, 'recover_automatically', dict(return_value=None)),
(menu, 'carry_records', dict(return_value={})),
(menu, 'check_selected', dict(side_effect=lambda project, row: row)),
(menu, 'saved_inventory', dict(return_value=[self.row])),
(menu, '_selected_configuration', dict(return_value=b'fixture configuration')),
(instances, 'read', dict(return_value=self.record)),
(reconcile, 'propose', dict(return_value=None)),
]:
item = patch.object(module, name, **options)
item.start()
self.addCleanup(item.stop)
self.lifecycle = self.guard(menu, '_run_lifecycle')
self.backup = self.guard(menu, '_work_backup_ready')
self.write = self.guard(instances, 'write')
self.command = self.guard(instances, 'command')
self.commit = self.guard(reconcile, 'commit')
def guard(self, module, name):
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
mock = item.start()
self.addCleanup(item.stop)
return mock
def no_effects(self):
for mock in [self.lifecycle, self.backup, self.write, self.command, self.commit]:
mock.assert_not_called()
self.assertEqual(self.record, self.original)
class UpdateCancellationTests(CancellationFixture):
def test_no_is_a_distinct_falsy_result_before_backup_or_lifecycle(self):
result = menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
self.assertIs(result, menu.CANCELLED)
self.assertFalse(result)
self.no_effects()
def test_real_cli_no_returns_cancel_code_instead_of_failure_or_success(self):
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
result = cli.main(['manage', '110', '--action', 'update'])
self.assertEqual(result, 6)
self.assertEqual(output.getvalue(), '') # The terminal renders the localized outcome once.
self.no_effects()
def test_no_in_stack_confirmation_keeps_every_member_unchanged(self):
self.row['stack'] = True
self.record['stack'] = {'members': [{'vmid': 110}, {'vmid': 111}]}
self.original = copy.deepcopy(self.record)
with redirect_stdout(io.StringIO()):
result = menu.direct_management(ROOT / 'oci', 110, 'update')
self.assertEqual(result, 6)
self.no_effects()
def test_native_menu_returns_to_its_list_after_no(self):
self.ui.choose.side_effect = ['110', 'update', None]
with 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, 3)
self.no_effects()
def test_batch_no_does_not_start_the_next_application(self):
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 111, 'title': 'Second'}]
rows = [self.row, {**self.row, 'vmid': 111}]
self.ui.checklist.return_value = ['all']
self.ui.review.side_effect = [True, False] # Batch review, first application's confirmation.
with patch.object(updates, 'UpdateCheck') as checker, patch.object(menu, 'saved_inventory', return_value=rows), \
patch.object(menu, 'check_selected', wraps=lambda project, row: row) as selected:
checker.return_value.status.return_value = {'available': available}
self.assertFalse(updates.select_updates(ROOT / 'oci', self.ui, available))
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
self.no_effects()
def test_accepted_update_failure_keeps_failure_code_one(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = False
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
self.lifecycle.assert_not_called()
self.write.assert_not_called()
def test_accepted_update_preserves_success_and_existing_lifecycle_command(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = True
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 0)
self.lifecycle.assert_called_once()
command = self.lifecycle.call_args.args[0]
self.assertEqual(command[:3], [sys.executable, str(ROOT / 'oci/remote/oci_update_current.py'), '110'])
self.assertIn('--acknowledge-external-data', command)
self.write.assert_not_called()
def test_actual_lifecycle_failure_remains_failure_after_positive_confirmation(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = False
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
self.lifecycle.assert_called_once()
self.write.assert_not_called()
def test_unattended_positive_default_still_reaches_existing_update(self):
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = True
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 0)
self.lifecycle.assert_called_once()
self.write.assert_not_called()
def test_existing_unattended_review_age_busy_and_missing_codes_are_preserved(self):
with patch.object(reconcile, 'propose', return_value={'details': ['external change']}), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 4)
with patch.object(menu, '_image_too_recent', return_value=True), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True, min_image_age_days=3), 5)
with patch.object(menu, 'saved_inventory', return_value=[]):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 2)
with patch.object(menu, 'recover_automatically', side_effect=BlockingIOError()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 3)
self.no_effects()
def test_slovak_dialog_and_recovery_choice_have_the_approved_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, action='update')
self.assertEqual(i18n.translate('Recover the previous installation'), 'Obnoviť predchádzajúcu inštaláciu')
text = self.ui.review.call_args.args[0]
for part in ['novší obraz aplikácie', 'overí sa jej integrita', 'pokúsi obnoviť', 'Pripojené adresáre hostiteľa']:
self.assertIn(part, text)
self.assertNotIn('kanál obrazov', text)
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Spustiť aktualizáciu?')
self.no_effects()
if __name__ == '__main__':
unittest.main()
@@ -24,9 +24,12 @@ OLD_GUARD = 'The current template changes the image or identity; an explicit mig
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required' GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and ' OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
'a native backup is verified before its root is replaced. Host directories are outside that backup.') 'a native backup is verified before its root is replaced. Host directories are outside that backup.')
PREVIEW = ('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with ' PREVIEW = ('If a newer application image is available, the container is stopped and backed up, and the backup '
'the new image to update the container. If the update fails after the container was changed, the backup ' 'integrity is verified. The container is then updated using the new image.\n\n'
'is restored. An update that stops halfway is recovered from the OCI management menu.') 'If the update fails after the container was changed, ProxMenux attempts to restore the previous '
'installation from the backup. If the operation remains incomplete, select the application in OCI '
'management and choose "Recover the previous installation".\n\n'
'Host directories mounted in the container are not restored by the container backup.')
def extracted(path, name, scope): def extracted(path, name, scope):
@@ -130,7 +133,7 @@ class UpdateWording(unittest.TestCase):
reconcile = ModuleType('oci_instance_reconcile') reconcile = ModuleType('oci_instance_reconcile')
reconcile.propose = lambda *args: None reconcile.propose = lambda *args: None
scope = {'sys': SimpleNamespace(path=[], executable='python3'), scope = {'sys': SimpleNamespace(path=[], executable='python3'),
'os': os, 'tempfile': tempfile, 'Path': Path, 'os': os, 'tempfile': tempfile, 'Path': Path, 'CANCELLED': None,
'translate': lambda text: text, 'check_selected': lambda project, row: row, 'translate': lambda text: text, 'check_selected': lambda project, row: row,
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)), '_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
'images': SimpleNamespace(offer_removal=lambda *args: None), 'images': SimpleNamespace(offer_removal=lambda *args: None),
+1 -1
View File
@@ -121,7 +121,7 @@ class OciWordingTests(TestCase):
def test_failed_registry_inspection_points_to_registry_accounts(self): def test_failed_registry_inspection_points_to_registry_accounts(self):
ui = SimpleNamespace(message=Mock()) ui = SimpleNamespace(message=Mock())
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'), fn = function(CUSTOM, '_explore', read_definition=lambda _: ('definition', 'source'),
ignored_settings=lambda _: [], template_from_compose=lambda _: { ignored_settings=lambda _: [], template_from_compose=lambda _: {
'container_contract': {'image': {'reference': 'example:latest'}}}, 'container_contract': {'image': {'reference': 'example:latest'}}},
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'), describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
@@ -20,7 +20,11 @@ class OutcomePromises(TestCase):
texts = oci_menu_texts() texts = oci_menu_texts()
self.assertEqual([text for text in texts if 'If anything fails' in text], []) self.assertEqual([text for text in texts if 'If anything fails' in text], [])
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text] previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
self.assertEqual(len(previews), 3) self.assertEqual(len(previews), 2) # Coordinated stack update/recreation previews.
update = next(text for text in texts if text.startswith('If a newer application image is available'))
self.assertIn('attempts to restore the previous installation', update)
self.assertIn('If the operation remains incomplete', update)
self.assertIn('choose "Recover the previous installation"', update)
def test_the_recovery_preview_says_when_each_step_happens(self): def test_the_recovery_preview_says_when_each_step_happens(self):
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text) preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
+2 -2
View File
@@ -71,8 +71,8 @@ jobs:
run: | run: |
test -x /bin/bash test -x /bin/bash
test -x /usr/bin/jq test -x /usr/bin/jq
- name: Install OCI wording test dependency - name: Install OCI wording test dependencies
run: python3 -m pip install 'PyYAML>=6,<7' run: python3 -m pip install 'PyYAML>=6,<7' 'jsonschema==4.25.1'
- name: Run offline i18n and script regressions - name: Run offline i18n and script regressions
env: env:
PYTHONDONTWRITEBYTECODE: '1' PYTHONDONTWRITEBYTECODE: '1'
+22 -10
View File
@@ -31,6 +31,7 @@ import {
import "xterm/css/xterm.css" import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config" import { API_PORT } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws" import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { scriptCompletion, scriptCompletionOutput } from "@/lib/script-completion"
import { useT } from "../lib/i18n/provider" import { useT } from "../lib/i18n/provider"
interface WebInteraction { interface WebInteraction {
@@ -52,6 +53,7 @@ interface ScriptTerminalModalProps {
params?: Record<string, string> params?: Record<string, string>
completedSuccessfullyMessage?: string completedSuccessfullyMessage?: string
completedWithErrorMessage?: (exitCode: number) => string completedWithErrorMessage?: (exitCode: number) => string
cancelledExitCode?: number
// Optional callback fired when the script's WebSocket closes // Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the // (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore // parent auto-dismiss the modal — used by host-backup's Restore
@@ -69,6 +71,7 @@ export function ScriptTerminalModal({
params = { EXECUTION_MODE: "web" }, params = { EXECUTION_MODE: "web" },
completedSuccessfullyMessage, completedSuccessfullyMessage,
completedWithErrorMessage, completedWithErrorMessage,
cancelledExitCode,
onComplete, onComplete,
}: ScriptTerminalModalProps) { }: ScriptTerminalModalProps) {
const t = useT() const t = useT()
@@ -103,10 +106,12 @@ export function ScriptTerminalModal({
const getCompletionMessage = useCallback( const getCompletionMessage = useCallback(
(exitCode: number) => (exitCode: number) =>
exitCode === 0 scriptCompletion(exitCode, cancelledExitCode).kind === "cancelled"
? t("scriptTerminal.cancelled")
: exitCode === 0
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully")) ? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })), : (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
[completedSuccessfullyMessage, completedWithErrorMessage, t], [completedSuccessfullyMessage, completedWithErrorMessage, cancelledExitCode, t],
) )
const terminalContainerRef = useRef<HTMLDivElement>(null) const terminalContainerRef = useRef<HTMLDivElement>(null)
@@ -139,6 +144,11 @@ export function ScriptTerminalModal({
reconnectTimeoutRef.current = setTimeout(async () => { reconnectTimeoutRef.current = setTimeout(async () => {
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) { if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
if (wsRef.current) { if (wsRef.current) {
// Replacing a stale socket is not a script result. Its close event
// must not complete the operation before the new socket reports it.
wsRef.current.onclose = null
wsRef.current.onerror = null
wsRef.current.onmessage = null
wsRef.current.close() wsRef.current.close()
} }
@@ -176,6 +186,7 @@ const initMessage = {
} }
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat // Filter out pong responses from heartbeat
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') { if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return return
@@ -189,10 +200,10 @@ const initMessage = {
: null : null
if (exitMatch) { if (exitMatch) {
const exitCode = Number(exitMatch[1]) const exitCode = Number(exitMatch[1])
termRef.current?.write(event.data) if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") termRef.current?.write(event.data)
completionReceivedRef.current = true completionReceivedRef.current = true
setIsComplete(true) setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode) onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return return
@@ -223,7 +234,7 @@ const initMessage = {
const exitCode = Number(msg.exit_code ?? 1) const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true completionReceivedRef.current = true
setIsComplete(true) setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode) onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return return
@@ -261,7 +272,7 @@ const initMessage = {
} }
} }
}, 1000) }, 1000)
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t]) }, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t])
const sendKey = useCallback((key: string) => { const sendKey = useCallback((key: string) => {
if (!termRef.current) return if (!termRef.current) return
@@ -392,6 +403,7 @@ const initMessage = {
} }
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat - don't display in terminal // Filter out pong responses from heartbeat - don't display in terminal
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') { if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return return
@@ -405,10 +417,10 @@ const initMessage = {
: null : null
if (exitMatch) { if (exitMatch) {
const exitCode = Number(exitMatch[1]) const exitCode = Number(exitMatch[1])
term.write(event.data) if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") term.write(event.data)
completionReceivedRef.current = true completionReceivedRef.current = true
setIsComplete(true) setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode) onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return return
@@ -441,7 +453,7 @@ const initMessage = {
const exitCode = Number(msg.exit_code ?? 1) const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true completionReceivedRef.current = true
setIsComplete(true) setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode) onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return return
@@ -814,7 +826,7 @@ const initMessage = {
)} )}
{(isMobile || isTablet) && ( {(isMobile || isTablet) && (
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="flex flex-wrap items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button <Button
onPointerDown={(e) => { onPointerDown={(e) => {
e.preventDefault() e.preventDefault()
+11 -4
View File
@@ -22,6 +22,7 @@ import useSWR from "swr"
import { MetricsView } from "./metrics-dialog" import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal" import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal" import { ScriptTerminalModal } from "./script-terminal-modal"
import { OCI_CANCELLED_EXIT_CODE, shouldRefreshOciAfterCompletion } from "@/lib/script-completion"
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel" import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
import { GuestBackupList } from "./guest-backup-list" import { GuestBackupList } from "./guest-backup-list"
import type { GuestBackup } from "../lib/guest-backup-archives" import type { GuestBackup } from "../lib/guest-backup-archives"
@@ -977,7 +978,9 @@ export function VirtualMachines() {
// A container restored from a backup carries the mark of its installation // 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. // and has no record on this host until it is recovered from the menu.
const [ociRestored, setOciRestored] = useState(false) 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<number | undefined>(undefined)
useEffect(() => { ociCompletionCodeRef.current = undefined }, [ociAction])
// Firewall log state — fetched only when the operator opens that tab // Firewall log state — fetched only when the operator opens that tab
// so a CT/VM without firewall use doesn't pay the pvesh cost on every // so a CT/VM without firewall use doesn't pay the pvesh cost on every
@@ -5515,7 +5518,7 @@ export function VirtualMachines() {
<Button <Button
size="sm" size="sm"
className={updateBtnCls} className={updateBtnCls}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update" })} onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update", recoverPrevious: !!ociInstance.pending })}
> >
{hasUpdate {hasUpdate
? <ArrowUpCircle className="h-4 w-4 mr-1.5" /> ? <ArrowUpCircle className="h-4 w-4 mr-1.5" />
@@ -5744,7 +5747,7 @@ export function VirtualMachines() {
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "recover" })} onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "recover" })}
> >
<RotateCcw className="h-4 w-4 mr-1.5" /> <RotateCcw className="h-4 w-4 mr-1.5" />
{t("vmLxc.ociUpdates.recover")} {t("vmLxc.ociUpdates.registerRestored")}
</Button> </Button>
</div> </div>
)} )}
@@ -8014,6 +8017,7 @@ export function VirtualMachines() {
onClose={() => { onClose={() => {
const vmid = ociAction.vmid const vmid = ociAction.vmid
setOciAction(null) setOciAction(null)
if (!shouldRefreshOciAfterCompletion(ociCompletionCodeRef.current)) return
// The image, the digest and the record changed: read them again. // The image, the digest and the record changed: read them again.
fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" }) fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
.catch(() => undefined) .catch(() => undefined)
@@ -8027,7 +8031,10 @@ export function VirtualMachines() {
}} }}
scriptPath="/usr/local/share/proxmenux/scripts/oci/manage_instance.sh" scriptPath="/usr/local/share/proxmenux/scripts/oci/manage_instance.sh"
scriptName="oci_manage_instance" scriptName="oci_manage_instance"
title={ociAction.action === "update" ? t("vmLxc.ociUpdates.terminalTitleUpdate") cancelledExitCode={OCI_CANCELLED_EXIT_CODE}
onComplete={(exitCode) => { ociCompletionCodeRef.current = exitCode }}
title={ociAction.action === "update" ? t(ociAction.recoverPrevious
? "vmLxc.ociUpdates.terminalTitleRecoverPrevious" : "vmLxc.ociUpdates.terminalTitleUpdate")
: ociAction.action === "recover" ? t("vmLxc.ociUpdates.terminalTitleRecover") : ociAction.action === "recover" ? t("vmLxc.ociUpdates.terminalTitleRecover")
: t("vmLxc.ociUpdates.terminalTitleModify")} : t("vmLxc.ociUpdates.terminalTitleModify")}
description={t("vmLxc.ociUpdates.terminalDescription")} description={t("vmLxc.ociUpdates.terminalDescription")}
+22
View File
@@ -0,0 +1,22 @@
// Native OCI `manage` reserves this code for explicit user cancellation.
export const OCI_CANCELLED_EXIT_CODE = 6
export function scriptCompletion(exitCode: number, cancelledExitCode?: number) {
if (exitCode === 0) return { kind: "success", color: "32" } as const
if (cancelledExitCode !== undefined && exitCode === cancelledExitCode) {
return { kind: "cancelled", color: "36" } as const
}
return { kind: "error", color: "31" } as const
}
export function shouldRefreshOciAfterCompletion(exitCode?: number) {
return exitCode !== OCI_CANCELLED_EXIT_CODE
}
export function scriptCompletionOutput(exitCode: number, message: string, cancelledExitCode?: number) {
const completion = scriptCompletion(exitCode, cancelledExitCode)
// Queue RIS with the result so earlier ncurses writes finish before reset.
// A synchronous terminal.reset() could be overwritten by that pending output.
const reset = completion.kind === "cancelled" ? "\x1bc" : ""
return `${reset}\x1b[${completion.color}m${message}\x1b[0m`
}
+5 -2
View File
@@ -1380,9 +1380,11 @@
"update": "Update", "update": "Update",
"modify": "Modify", "modify": "Modify",
"recover": "Recover", "recover": "Recover",
"registerRestored": "Register again",
"terminalTitleUpdate": "Update OCI application", "terminalTitleUpdate": "Update OCI application",
"terminalTitleRecoverPrevious": "Recover the previous OCI installation",
"terminalTitleModify": "Modify OCI application", "terminalTitleModify": "Modify OCI application",
"terminalTitleRecover": "Recover restored OCI application", "terminalTitleRecover": "Register restored OCI applications",
"terminalDescription": "Runs the same process as the ProxMenux menu: OCI manager Apps → Manage installed OCI applications.", "terminalDescription": "Runs the same process as the ProxMenux menu: OCI manager Apps → Manage installed OCI applications.",
"keepBackup": "Keep the backup taken before updating", "keepBackup": "Keep the backup taken before updating",
"noKeepBackup": "The backup taken before updating is not kept", "noKeepBackup": "The backup taken before updating is not kept",
@@ -1613,7 +1615,7 @@
"ociTitle": "OCI image container", "ociTitle": "OCI image container",
"ociBody": "This container was created from an OCI image outside OCI manager Apps. It is updated by replacing its image, which ProxMenux does not manage for it.", "ociBody": "This container was created from an OCI image outside OCI manager Apps. It is updated by replacing its image, which ProxMenux does not manage for it.",
"ociRestoredTitle": "Restored OCI application", "ociRestoredTitle": "Restored OCI application",
"ociRestoredBody": "This container was restored from a backup and this host has no record of its application, so it cannot be updated from here yet. Recover registers it again on this host, the same as OCI manager Apps → Manage installed OCI applications.", "ociRestoredBody": "This container was restored from a backup and this host has no record of its OCI application, so it cannot be updated here yet. Register again opens the registration of restored OCI applications on this node, as in OCI manager Apps → Manage installed OCI applications.",
"dockerImagesTitle": "Docker images", "dockerImagesTitle": "Docker images",
"dockerAppTitle": "Docker", "dockerAppTitle": "Docker",
"dockerImagesSubheading": "Images", "dockerImagesSubheading": "Images",
@@ -5566,6 +5568,7 @@
"websocketError": "WebSocket error occurred", "websocketError": "WebSocket error occurred",
"connectionClosed": "Connection closed", "connectionClosed": "Connection closed",
"completedSuccessfully": "Script completed successfully", "completedSuccessfully": "Script completed successfully",
"cancelled": "Operation cancelled.",
"completedWithError": "Script finished with error code {code}", "completedWithError": "Script finished with error code {code}",
"yes": "Yes", "yes": "Yes",
"yourInput": "Your input:", "yourInput": "Your input:",
+20 -17
View File
@@ -1032,7 +1032,7 @@
"pciPassthrough": "PCI Passthrough", "pciPassthrough": "PCI Passthrough",
"usbDevices": "USB zariadenia", "usbDevices": "USB zariadenia",
"serialPorts": "Sériové porty", "serialPorts": "Sériové porty",
"bridge": "Sieťový bridge", "bridge": "Sieťový most (bridge)",
"enabled": "Zapnuté", "enabled": "Zapnuté",
"disabled": "Vypnuté", "disabled": "Vypnuté",
"on": "zapnuté", "on": "zapnuté",
@@ -1248,13 +1248,15 @@
"stackNote": "Súčasť aplikácie s viacerými kontajnermi (hlavný CT {primary}, {count} kontajnerov): aktualizácia sa týka všetkých.", "stackNote": "Súčasť aplikácie s viacerými kontajnermi (hlavný CT {primary}, {count} kontajnerov): aktualizácia sa týka všetkých.",
"memberNote": "Tento kontajner patrí do aplikácie s viacerými kontajnermi, ktorej hlavný CT je {primary}, a neaktualizuje sa samostatne. Aktualizácia sa vykonáva z hlavného CT, ktorý aktualizuje všetkých {count} kontajnerov naraz.", "memberNote": "Tento kontajner patrí do aplikácie s viacerými kontajnermi, ktorej hlavný CT je {primary}, a neaktualizuje sa samostatne. Aktualizácia sa vykonáva z hlavného CT, ktorý aktualizuje všetkých {count} kontajnerov naraz.",
"openPrimary": "Otvoriť hlavný CT", "openPrimary": "Otvoriť hlavný CT",
"pendingNote": "Operácia na tomto kontajneri bola prerušená; tlačidlo Obnoviť spustí obnovenie.", "pendingNote": "Operácia na tomto kontajneri zostala nedokončená. Tlačidlo Obnoviť otvorí možnosti obnovenia.",
"update": "Aktualizovať", "update": "Aktualizovať",
"modify": "Upraviť", "modify": "Upraviť",
"recover": "Obnoviť", "recover": "Obnoviť",
"registerRestored": "Znova zaregistrovať",
"terminalTitleUpdate": "Aktualizovať OCI aplikáciu", "terminalTitleUpdate": "Aktualizovať OCI aplikáciu",
"terminalTitleRecoverPrevious": "Obnoviť predchádzajúcu OCI inštaláciu",
"terminalTitleModify": "Upraviť OCI aplikáciu", "terminalTitleModify": "Upraviť OCI aplikáciu",
"terminalTitleRecover": "Obnoviť obnovenú OCI aplikáciu", "terminalTitleRecover": "Zaregistrovať OCI aplikácie obnovené zo zálohy",
"terminalDescription": "Použije sa rovnaký postup ako v ponuke Správca OCI aplikácií → Spravovať nainštalované OCI aplikácie.", "terminalDescription": "Použije sa rovnaký postup ako v ponuke Správca OCI aplikácií → Spravovať nainštalované OCI aplikácie.",
"keepBackup": "Ponechať zálohu vytvorenú pred aktualizáciou", "keepBackup": "Ponechať zálohu vytvorenú pred aktualizáciou",
"noKeepBackup": "Záloha vytvorená pred aktualizáciou sa neponecháva", "noKeepBackup": "Záloha vytvorená pred aktualizáciou sa neponecháva",
@@ -1485,7 +1487,7 @@
"ociTitle": "Kontajner z OCI obrazu", "ociTitle": "Kontajner z OCI obrazu",
"ociBody": "Tento kontajner bol vytvorený z OCI (Docker) obrazu mimo Správcu OCI aplikácií. Aktualizuje sa nahradením obrazu, čo ProxMenux pri tomto kontajneri nespravuje.", "ociBody": "Tento kontajner bol vytvorený z OCI (Docker) obrazu mimo Správcu OCI aplikácií. Aktualizuje sa nahradením obrazu, čo ProxMenux pri tomto kontajneri nespravuje.",
"ociRestoredTitle": "Obnovená OCI aplikácia", "ociRestoredTitle": "Obnovená OCI aplikácia",
"ociRestoredBody": "Tento kontajner bol obnovený zo zálohy a tento hostiteľ nemá záznam o jeho aplikácii, preto ho zatiaľ nemožno aktualizovať odtiaľto. Obnoviť ho znova zaregistruje na tomto hostiteľovi, rovnako ako OCI manager Apps → Spravovať nainštalované OCI aplikácie.", "ociRestoredBody": "Tento kontajner bol obnovený zo zálohy, ale tento server nemá záznam o jeho OCI aplikácii, preto ju tu zatiaľ nemožno aktualizovať. Možnosť Znova zaregistrovať otvorí registráciu obnovených OCI aplikácií na tomto uzle, rovnako ako ponuka Správca OCI aplikácií → Spravovať nainštalované OCI aplikácie.",
"dockerImagesTitle": "Docker obrazy", "dockerImagesTitle": "Docker obrazy",
"dockerAppTitle": "Docker", "dockerAppTitle": "Docker",
"dockerImagesSubheading": "Obrazy", "dockerImagesSubheading": "Obrazy",
@@ -1872,7 +1874,7 @@
}, },
"networkInterfaces": { "networkInterfaces": {
"title": "Vylúčené sieťové rozhrania", "title": "Vylúčené sieťové rozhrania",
"description": "Vylúčte sieťové rozhrania, napríklad most, bond alebo fyzické sieťové karty, z kontroly stavu a upozornení. Hodí sa to pre rozhrania, ktoré sú zámerne vypnuté alebo sa nepoužívajú.", "description": "Vylúčte sieťové rozhrania, napríklad sieťové mosty (bridge), bond alebo fyzické sieťové karty, z kontroly stavu a upozornení. Hodí sa to pre rozhrania, ktoré sú zámerne vypnuté alebo sa nepoužívajú.",
"emptyTitle": "Nenašli sa žiadne sieťové rozhrania", "emptyTitle": "Nenašli sa žiadne sieťové rozhrania",
"interface": "Rozhranie", "interface": "Rozhranie",
"down": "VYPNUTÉ", "down": "VYPNUTÉ",
@@ -3504,7 +3506,7 @@
}, },
"loading": { "loading": {
"title": "Načítavam údaje o sieti...", "title": "Načítavam údaje o sieti...",
"description": "Kontrolujem rozhrania, sieťové mosty a premávku" "description": "Kontrolujem rozhrania, sieťové mosty (bridge) a premávku"
}, },
"errors": { "errors": {
"serverUnavailableTitle": "Server nie je dostupný", "serverUnavailableTitle": "Server nie je dostupný",
@@ -3518,7 +3520,7 @@
}, },
"sections": { "sections": {
"physicalInterfaces": "Fyzické rozhrania", "physicalInterfaces": "Fyzické rozhrania",
"bridgeInterfaces": "Sieťové mosty", "bridgeInterfaces": "Sieťové mosty (bridge)",
"vmLxcInterfaces": "Sieťové rozhrania VM a LXC" "vmLxcInterfaces": "Sieťové rozhrania VM a LXC"
}, },
"summary": { "summary": {
@@ -3538,8 +3540,8 @@
}, },
"interfaceTypes": { "interfaceTypes": {
"physical": "Fyzické", "physical": "Fyzické",
"bridge": "Most", "bridge": "Sieťový most (bridge)",
"bridges": "Mosty", "bridges": "Sieťové mosty (bridge)",
"bond": "Bond", "bond": "Bond",
"vlan": "VLAN", "vlan": "VLAN",
"virtual": "Virtuálne", "virtual": "Virtuálne",
@@ -3633,7 +3635,7 @@
"inactiveTitle": "Rozhranie je neaktívne", "inactiveTitle": "Rozhranie je neaktívne",
"inactiveDescription": "Toto rozhranie je momentálne vypnuté. Štatistiky sieťovej premávky nie sú dostupné.", "inactiveDescription": "Toto rozhranie je momentálne vypnuté. Štatistiky sieťovej premávky nie sú dostupné.",
"bondConfiguration": "Nastavenie bondu", "bondConfiguration": "Nastavenie bondu",
"bridgeConfiguration": "Nastavenie mosta" "bridgeConfiguration": "Nastavenie sieťového mosta (bridge)"
}, },
"flow": { "flow": {
"title": "Tok siete" "title": "Tok siete"
@@ -5437,6 +5439,7 @@
"websocketError": "Chyba WebSocket spojenia", "websocketError": "Chyba WebSocket spojenia",
"connectionClosed": "Spojenie bolo zatvorené", "connectionClosed": "Spojenie bolo zatvorené",
"completedSuccessfully": "Skript sa úspešne dokončil", "completedSuccessfully": "Skript sa úspešne dokončil",
"cancelled": "Operácia bola zrušená.",
"completedWithError": "Skript skončil s chybovým kódom {code}", "completedWithError": "Skript skončil s chybovým kódom {code}",
"yes": "Áno", "yes": "Áno",
"yourInput": "Tvoj vstup:", "yourInput": "Tvoj vstup:",
@@ -6169,11 +6172,11 @@
} }
}, },
"bridge_without_ports": { "bridge_without_ports": {
"title": "Porty bridgeov", "title": "Porty sieťových mostov (bridge)",
"rationale": "Konfigurácia portov každého bridge. Bridge bez fyzického portu obsluhuje internú alebo smerovanú sieť.", "rationale": "Konfigurácia portov každého sieťového mosta (bridge). Sieťový most (bridge) bez fyzického portu obsluhuje internú alebo smerovanú sieť.",
"summary": { "summary": {
"allConnected": "Všetkých {total} bridgeov má port", "allConnected": "Všetkých {total} sieťových mostov (bridge) má port",
"isolated": "{count} z {total} bridgeov nemá port", "isolated": "{count} z {total} sieťových mostov (bridge) nemá port",
"evaluationFailed": "Kontrolu sa nepodarilo vyhodnotiť" "evaluationFailed": "Kontrolu sa nepodarilo vyhodnotiť"
} }
} }
@@ -6389,13 +6392,13 @@
"network": "Sieť", "network": "Sieť",
"adapters": "Adaptéry", "adapters": "Adaptéry",
"bond": "Bond", "bond": "Bond",
"bridge": "Bridge", "bridge": "Sieťový most (bridge)",
"bridges": "Bridge", "bridges": "Sieťové mosty (bridge)",
"physicalAdapters": "Fyzické adaptéry", "physicalAdapters": "Fyzické adaptéry",
"interface": "Rozhranie", "interface": "Rozhranie",
"driver": "Ovládač", "driver": "Ovládač",
"state": "Stav", "state": "Stav",
"networkDiagramNote": "Cesta od kábla ku každému VM a LXC: fyzický adaptér, bond, ak ich zoskupuje, bridge a pripojené VM a LXC.", "networkDiagramNote": "Cesta od kábla ku každému VM a LXC: fyzický adaptér, bond, ak ich zoskupuje, sieťový most (bridge) a pripojené VM a LXC.",
"storageAndProtection": "Úložisko a ochrana", "storageAndProtection": "Úložisko a ochrana",
"storage": "Úložisko", "storage": "Úložisko",
"content": "Obsah", "content": "Obsah",
+1233 -1141
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -93,8 +93,8 @@ def main() -> None:
rootfs = Path(sys.argv[1]) rootfs = Path(sys.argv[1])
configuration = json.loads(sys.argv[2]) configuration = json.loads(sys.argv[2])
path, changed = update_encoding(rootfs, configuration) path, changed = update_encoding(rootfs, configuration)
state = "updated" if changed else "already applied" state = translate("Jellyfin configuration updated:") if changed else translate("Jellyfin configuration already applied:")
print(f"Jellyfin configuration {state}: /{path.relative_to(rootfs.resolve())}") print(f"{state} /{path.relative_to(rootfs.resolve())}")
if __name__ == "__main__": if __name__ == "__main__":
+4 -2
View File
@@ -20,6 +20,7 @@ import re
import subprocess import subprocess
import sys import sys
import time import time
from oci_ui import translate
CONFIGS = Path('/etc/pve/lxc') CONFIGS = Path('/etc/pve/lxc')
HOOKS = Path('/var/lib/lxc') HOOKS = Path('/var/lib/lxc')
@@ -121,12 +122,13 @@ def check(vmid, settle=0):
except BlockingIOError: except BlockingIOError:
return return
for name, version in repair(vmid, settle): for name, version in repair(vmid, settle):
print(f'CT {vmid}: DHCP client of {name} (IPv{version}) started again', flush=True) print(translate('CT {vmid}: DHCP client of {name} (IPv{version}) started again').format(
vmid=vmid, name=name, version=version), flush=True)
def main(): def main():
if len(sys.argv) != 2 or not (sys.argv[1].isdigit() or sys.argv[1] == '--all'): if len(sys.argv) != 2 or not (sys.argv[1].isdigit() or sys.argv[1] == '--all'):
print('usage: oci_dhcp_lease.py <vmid> | --all', file=sys.stderr) print(translate('Usage:') + ' oci_dhcp_lease.py <vmid> | --all', file=sys.stderr)
return 2 return 2
LOCKS.mkdir(parents=True, exist_ok=True) LOCKS.mkdir(parents=True, exist_ok=True)
if sys.argv[1] == '--all': if sys.argv[1] == '--all':
+4 -3
View File
@@ -10,6 +10,7 @@ import sys
import time import time
import oci_instances as instances import oci_instances as instances
from oci_ui import translate
# Names given by the OCI installers and by updates; other archives are never touched. # Names given by the OCI installers and by updates; other archives are never touched.
NAME = re.compile(r'(?:proxmenux-update-[a-z0-9]+-[0-9a-f]{64}' NAME = re.compile(r'(?:proxmenux-update-[a-z0-9]+-[0-9a-f]{64}'
@@ -82,10 +83,10 @@ def main(arguments):
command = arguments[0] if arguments else 'prune' command = arguments[0] if arguments else 'prune'
if command == 'prune': if command == 'prune':
for path, size in prune(): for path, size in prune():
print(f'removed unused image archive: {path} ({size} bytes)') print(translate('removed unused image archive: {path} ({size} bytes)').format(path=path, size=size))
return 0 return 0
if command not in ('list', 'remove') or not all(a.isdigit() for a in arguments[1:]): if command not in ('list', 'remove') or not all(a.isdigit() for a in arguments[1:]):
print('usage: oci_image_cache.py [prune | list VMID... | remove VMID...]', file=sys.stderr) print(translate('Usage:') + ' oci_image_cache.py [prune | list VMID... | remove VMID...]', file=sys.stderr)
return 2 return 2
with instances.locked(instances.ROOT): with instances.locked(instances.ROOT):
archives = archives_of([int(a) for a in arguments[1:]]) archives = archives_of([int(a) for a in arguments[1:]])
@@ -103,5 +104,5 @@ if __name__ == '__main__':
try: try:
sys.exit(main(sys.argv[1:])) sys.exit(main(sys.argv[1:]))
except (OSError, ValueError, BlockingIOError) as error: except (OSError, ValueError, BlockingIOError) as error:
print(f'image cache: {error}', file=sys.stderr) print(f"{translate('Image cache')}: {error}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+1 -1
View File
@@ -284,7 +284,7 @@ def change(root, primary_id, acceleration, render_device=None, gfx_override=None
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int, help='main container of the application') parser.add_argument('vmid', type=int, help=translate('main container of the application'))
parser.add_argument('--acceleration', required=True, choices=PROFILES) parser.add_argument('--acceleration', required=True, choices=PROFILES)
parser.add_argument('--render-device') parser.add_argument('--render-device')
parser.add_argument('--gfx-override') parser.add_argument('--gfx-override')
+1 -1
View File
@@ -301,7 +301,7 @@ def main(argv=None):
diagnose = sub.add_parser('diagnose') diagnose = sub.add_parser('diagnose')
diagnose.add_argument('vmid', type=int) diagnose.add_argument('vmid', type=int)
diagnose.add_argument('--check-registry', action='store_true') diagnose.add_argument('--check-registry', action='store_true')
record = sub.add_parser('record', help='Internal installer operation; not legacy adoption') record = sub.add_parser('record', help=translate('Internal installer operation; not legacy adoption'))
record.add_argument('vmid', type=int) record.add_argument('vmid', type=int)
for flag in ('template', 'deployment', 'archive', 'digest'): for flag in ('template', 'deployment', 'archive', 'digest'):
record.add_argument('--' + flag, required=True) record.add_argument('--' + flag, required=True)
+2 -2
View File
@@ -1029,9 +1029,9 @@ def main():
parser.add_argument('--root', type=Path, default=instances.ROOT) parser.add_argument('--root', type=Path, default=instances.ROOT)
parser.add_argument('--start', action='store_true') parser.add_argument('--start', action='store_true')
parser.add_argument('--host-firewall', type=int, action='append', default=[], metavar='VMID', parser.add_argument('--host-firewall', type=int, action='append', default=[], metavar='VMID',
help='Add the host firewall rule of this container; one option for each confirmed rule') help=translate('Add the host firewall rule of this container; one option for each confirmed rule'))
parser.add_argument('--automatic', action='store_true', parser.add_argument('--automatic', action='store_true',
help='Only the applications that need no question: the ones the cluster keeps a record of') help=translate('Only the applications that need no question: the ones the cluster keeps a record of'))
args = parser.parse_args() args = parser.parse_args()
if os.geteuid() != 0: if os.geteuid() != 0:
parser.error(translate('Root privileges are required')) parser.error(translate('Root privileges are required'))
+3 -1
View File
@@ -12,6 +12,7 @@ import argparse
import re import re
import subprocess import subprocess
import sys import sys
from oci_ui import translate
# A model of five operators (convolution, pooling and a dense layer), enough # A model of five operators (convolution, pooling and a dense layer), enough
# to make ROCm compile and run on the GPU without downloading anything. # to make ROCm compile and run on the GPU without downloading anything.
@@ -72,7 +73,8 @@ def main():
try: try:
check(args.vmid) check(args.vmid)
except RuntimeError as error: except RuntimeError as error:
print(error, file=sys.stderr) message = translate('the GPU did not answer in time') if str(error) == 'the GPU did not answer in time' else str(error)
print(message, file=sys.stderr)
return 1 return 1
return 0 return 0
+2 -1
View File
@@ -18,6 +18,7 @@ import time
import oci_instances as instances import oci_instances as instances
from oci_installation_state import parse_config from oci_installation_state import parse_config
from oci_ui import translate
APPS = Path('/etc/proxmenux/apps') APPS = Path('/etc/proxmenux/apps')
SOCKET = Path('/run/proxmenux-monitor/oci-check.sock') SOCKET = Path('/run/proxmenux-monitor/oci-check.sock')
@@ -206,7 +207,7 @@ def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=instances.ROOT) parser.add_argument('--root', type=Path, default=instances.ROOT)
parser.add_argument('--configs', type=Path, default=Path('/etc/pve/lxc')) parser.add_argument('--configs', type=Path, default=Path('/etc/pve/lxc'))
parser.add_argument('--progress', action='store_true', help='Emit numeric progress for the OCI menu') parser.add_argument('--progress', action='store_true', help=translate('Emit numeric progress for the OCI menu'))
args = parser.parse_args() args = parser.parse_args()
try: try:
if args.progress: if args.progress:
+6 -3
View File
@@ -19,6 +19,7 @@ import time
import oci_instances as instances import oci_instances as instances
import oci_operation_notice import oci_operation_notice
from oci_ui import translate
UNIT = 'proxmenux-oci-watchdog.service' UNIT = 'proxmenux-oci-watchdog.service'
UNIT_FILE = Path('/etc/systemd/system') / UNIT UNIT_FILE = Path('/etc/systemd/system') / UNIT
@@ -191,7 +192,8 @@ def look(states, now):
lambda seen: stop_requested(read_tasks(), vmid, seen)) lambda seen: stop_requested(read_tasks(), vmid, seen))
data = {'app_name': name, 'vmid': vmid, 'containers': f'CT {vmid}'} data = {'app_name': name, 'vmid': vmid, 'containers': f'CT {vmid}'}
if action == 'start': if action == 'start':
print(f'CT {vmid} ({name}) stopped on its own; starting it again (attempt {state["attempts"]})', flush=True) print(translate('CT {vmid} ({name}) stopped on its own; starting it again (attempt {attempt})').format(
vmid=vmid, name=name, attempt=state['attempts']), flush=True)
try: try:
started = start(vmid) started = start(vmid)
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
@@ -199,7 +201,8 @@ def look(states, now):
if started and notice_due(state, now): if started and notice_due(state, now):
oci_operation_notice.notify('oci_watchdog_restarted', data) oci_operation_notice.notify('oci_watchdog_restarted', data)
elif action == 'give-up': elif action == 'give-up':
print(f'CT {vmid} ({name}) could not be kept running; it is left stopped', flush=True) print(translate('CT {vmid} ({name}) could not be kept running; it is left stopped').format(
vmid=vmid, name=name), flush=True)
oci_operation_notice.notify('oci_watchdog_failed', data) oci_operation_notice.notify('oci_watchdog_failed', data)
@@ -290,7 +293,7 @@ def main():
try: try:
vmids = set_watchdog(instances.ROOT, args.vmid, args.state == 'on') vmids = set_watchdog(instances.ROOT, args.vmid, args.state == 'on')
except BlockingIOError: except BlockingIOError:
print('Another OCI operation is using the instance registry.', file=sys.stderr) print(translate('Another OCI operation is using the instance registry.'), file=sys.stderr)
return 3 return 3
except (OSError, ValueError, KeyError) as error: except (OSError, ValueError, KeyError) as error:
print(str(error) or type(error).__name__, file=sys.stderr) print(str(error) or type(error).__name__, file=sys.stderr)
+32 -7
View File
@@ -4,9 +4,32 @@
from __future__ import annotations from __future__ import annotations
import ctypes import ctypes
import json
import os import os
import platform import platform
import sys import sys
from pathlib import Path
from functools import lru_cache
@lru_cache(maxsize=1)
def _messages() -> dict:
# This helper is also installed alone in libexec: importing oci_ui would
# break that existing caller. Read the same catalog using stdlib only.
base = Path(os.environ.get('PMX_BASE_DIR', '/usr/local/share/proxmenux'))
try:
language = json.loads((base / 'config.json').read_text(encoding='utf-8')).get('language', 'en')
if language == 'en' or not isinstance(language, str) or not language:
return {}
data = json.loads((base / 'lang' / (language + '.json')).read_text(encoding='utf-8'))
return data if isinstance(data, dict) else {}
except (OSError, ValueError, AttributeError):
return {}
def translate(text: str) -> str:
value = _messages().get(text)
return value if isinstance(value, str) and value else text
AT_FDCWD = -100 AT_FDCWD = -100
@@ -41,14 +64,16 @@ def fail(step: str) -> None:
def main() -> int: def main() -> int:
if len(sys.argv) != 5 or sys.argv[4] not in {"rw", "ro"}: if len(sys.argv) != 5 or sys.argv[4] not in {"rw", "ro"}:
print(f"usage: {sys.argv[0]} PID SOURCE TARGET rw|ro", file=sys.stderr) print(f"{translate('Usage:')} {sys.argv[0]} PID SOURCE TARGET rw|ro", file=sys.stderr)
return 2 return 2
machine = platform.machine().lower() machine = platform.machine().lower()
if machine not in SYSCALLS: if machine not in SYSCALLS:
print(f"unsupported host architecture: {machine}", file=sys.stderr) print(f"{translate('Unsupported host architecture:')} {machine}", file=sys.stderr)
return 2 return 2
open_tree_nr, move_mount_nr, mount_setattr_nr = SYSCALLS[machine] open_tree_nr, move_mount_nr, mount_setattr_nr = SYSCALLS[machine]
pid, source, target, mode = sys.argv[1:] pid, source, target, mode = sys.argv[1:]
# The host catalog is no longer reachable after chroot into the container.
_messages()
libc = ctypes.CDLL(None, use_errno=True) libc = ctypes.CDLL(None, use_errno=True)
libc.syscall.restype = ctypes.c_long libc.syscall.restype = ctypes.c_long
libc.setns.argtypes = (ctypes.c_int, ctypes.c_int) libc.setns.argtypes = (ctypes.c_int, ctypes.c_int)
@@ -60,7 +85,7 @@ def main() -> int:
ct_root = os.open(f"/proc/{pid}/root", os.O_PATH | os.O_DIRECTORY | os.O_CLOEXEC) ct_root = os.open(f"/proc/{pid}/root", os.O_PATH | os.O_DIRECTORY | os.O_CLOEXEC)
try: try:
if libc.setns(ct_ns, CLONE_NEWNS) != 0: if libc.setns(ct_ns, CLONE_NEWNS) != 0:
fail("enter container namespace") fail(translate("enter container namespace"))
os.fchdir(ct_root) os.fchdir(ct_root)
os.chroot(".") os.chroot(".")
os.chdir("/") os.chdir("/")
@@ -71,7 +96,7 @@ def main() -> int:
OPEN_TREE_CLONE | os.O_CLOEXEC, OPEN_TREE_CLONE | os.O_CLOEXEC,
) )
if tree < 0: if tree < 0:
fail("clone source mount tree") fail(translate("clone source mount tree"))
try: try:
if mode == "ro": if mode == "ro":
attributes = MountAttr(attr_set=MOUNT_ATTR_RDONLY) attributes = MountAttr(attr_set=MOUNT_ATTR_RDONLY)
@@ -84,9 +109,9 @@ def main() -> int:
ctypes.sizeof(attributes), ctypes.sizeof(attributes),
) )
if result != 0: if result != 0:
fail("make cloned mount tree read-only") fail(translate("make cloned mount tree read-only"))
if libc.setns(host_ns, CLONE_NEWNS) != 0: if libc.setns(host_ns, CLONE_NEWNS) != 0:
fail("return to host namespace") fail(translate("return to host namespace"))
os.fchdir(host_root) os.fchdir(host_root)
os.chroot(".") os.chroot(".")
os.chdir("/") os.chdir("/")
@@ -99,7 +124,7 @@ def main() -> int:
MOVE_MOUNT_F_EMPTY_PATH, MOVE_MOUNT_F_EMPTY_PATH,
) )
if result != 0: if result != 0:
fail("publish mount tree") fail(translate("publish mount tree"))
finally: finally:
os.close(tree) os.close(tree)
finally: finally:
+1 -1
View File
@@ -164,7 +164,7 @@ def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("archive", type=Path) parser.add_argument("archive", type=Path)
parser.add_argument("--extracted-mib", action="store_true", parser.add_argument("--extracted-mib", action="store_true",
help="print the estimated unpacked size in MiB (0 when unknown)") help=translate("print the estimated unpacked size in MiB (0 when unknown)"))
args = parser.parse_args() args = parser.parse_args()
if args.extracted_mib: if args.extracted_mib:
try: try:
+38 -39
View File
@@ -15,7 +15,7 @@ from . import console, images
from .catalog import Catalog from .catalog import Catalog
from .converter import ConversionError from .converter import ConversionError
from .github_source import SourceError from .github_source import SourceError
from .i18n import N_, source_text, translate from .i18n import N_, diagnostic, source_text, translate
from .installer import ( from .installer import (
ADDRESS_SHOWN, ADDRESS_SHOWN,
ADVANCED_MODE, ADVANCED_MODE,
@@ -38,7 +38,7 @@ PUBLISHERS = {"linuxserver.io": "LinuxServer", "official": N_("Official image")}
STACK_LABELS = { STACK_LABELS = {
"server": N_("Server"), "server": N_("Server"),
"application": N_("Application"), "application": N_("Application"),
"machine_learning": "Machine learning", "machine_learning": N_("Machine learning"),
"database": "PostgreSQL", "database": "PostgreSQL",
"valkey": "Valkey", "valkey": "Valkey",
"cache": "Redis", "cache": "Redis",
@@ -133,7 +133,7 @@ def _service_kind(service: dict[str, Any]) -> str:
if service.get("is_main"): if service.get("is_main"):
return translate("Application") return translate("Application")
if "machine-learning" in name or "machine-learning" in image: if "machine-learning" in name or "machine-learning" in image:
return "Machine learning" return translate("Machine learning")
for key, label in SERVICE_KINDS: for key, label in SERVICE_KINDS:
if key in image: if key in image:
return label return label
@@ -267,7 +267,7 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
target = (f"{mount['size_gb']} GB {on} {mount['source']}" target = (f"{mount['size_gb']} GB {on} {mount['source']}"
if mount["type"] == "managed-volume" else mount["source"]) if mount["type"] == "managed-volume" else mount["source"])
lines.append(f" {mount['container_path']} → {target}") lines.append(f" {mount['container_path']} → {target}")
for key, label in (("media", "Library"), ("application", "Application data"), ("transfer", "Consume/export")): for key, label in (("media", N_("Library")), ("application", N_("Application data")), ("transfer", N_("Consume/export"))):
storage = plan.get(key) storage = plan.get(key)
if isinstance(storage, dict) and "mode" in storage: if isinstance(storage, dict) and "mode" in storage:
if storage["mode"] == "host-bind": if storage["mode"] == "host-bind":
@@ -472,7 +472,7 @@ def _rclone_mount(catalog: Catalog, ui) -> None:
if result: if result:
from .management import carry_records from .management import carry_records
carry_records(PROJECT_ROOT, shown=True) carry_records(PROJECT_ROOT, shown=True)
console.msg_ok(f"Remote: {result['remote']}:") console.msg_ok(f"{translate('Rclone remote')}: {result['remote']}:")
console.msg_ok(f"{translate('Read/write')}: {result['read_write_path']}") console.msg_ok(f"{translate('Read/write')}: {result['read_write_path']}")
console.msg_ok(f"{translate('Read-only')}: {result['read_only_path']}") console.msg_ok(f"{translate('Read-only')}: {result['read_only_path']}")
console.wait_for_enter(translate("Press Enter to return to the menu...")) console.wait_for_enter(translate("Press Enter to return to the menu..."))
@@ -614,7 +614,7 @@ def interactive(catalog: Catalog) -> int:
continue continue
except (ConversionError, InstallError, OSError, ValueError) as exc: except (ConversionError, InstallError, OSError, ValueError) as exc:
console.stop_spinner() console.stop_spinner()
ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}") ui.message(f"{translate('The operation could not be completed')}:\n\n{diagnostic(exc)}")
# ---------------------------------------------------------------- maintenance CLI # ---------------------------------------------------------------- maintenance CLI
@@ -623,52 +623,52 @@ def _template_summary_text(template: dict[str, Any]) -> str:
contract = template["container_contract"] contract = template["container_contract"]
lines = [ lines = [
source_text(template["catalog_ui"]["title"]), source_text(template["catalog_ui"]["title"]),
f"Image: {contract['image']['reference']}", f"{translate('Image')}: {contract['image']['reference']}",
f"Status: {template['status']}", f"{translate('Status')}: {template['status']}",
"Ports: " + (", ".join(f"{p['container_port']}/{p['protocol']}" for p in contract["ports"]) or "none"), translate("Ports") + ": " + (", ".join(f"{p['container_port']}/{p['protocol']}" for p in contract["ports"]) or translate("none")),
"Volumes: " + (", ".join(v["container_path"] for v in contract["volumes"]) or "none"), translate("Volumes") + ": " + (", ".join(v["container_path"] for v in contract["volumes"]) or translate("none")),
] ]
blockers = template["compatibility"]["untranslated_blockers"] blockers = template["compatibility"]["untranslated_blockers"]
if blockers: if blockers:
lines.append(f"Blockers: {', '.join(blockers)}") lines.append(f"{translate('Blockers')}: {', '.join(blockers)}")
return "\n".join(lines) return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="oci_manager_apps.sh", parser = argparse.ArgumentParser(prog="oci_manager_apps.sh",
description="ProxMenux OCI manager Apps (Technology Preview)") description=translate("ProxMenux OCI manager Apps (Technology Preview)"))
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("sync", help="Refresh the index from the image sources") subparsers.add_parser("sync", help=translate("Refresh the index from the image sources"))
list_parser = subparsers.add_parser("list", help="List applications") list_parser = subparsers.add_parser("list", help=translate("List applications"))
list_parser.add_argument("--filter", default="") list_parser.add_argument("--filter", default="")
generate_parser = subparsers.add_parser("generate", help="Regenerate the template of one application") generate_parser = subparsers.add_parser("generate", help=translate("Regenerate the template of one application"))
generate_parser.add_argument("app") generate_parser.add_argument("app")
generate_all_parser = subparsers.add_parser("generate-all", help="Regenerate the catalog templates") generate_all_parser = subparsers.add_parser("generate-all", help=translate("Regenerate the catalog templates"))
generate_all_parser.add_argument("--provider", choices=["all", "linuxserver.io", "imported", "curated"], generate_all_parser.add_argument("--provider", choices=["all", "linuxserver.io", "imported", "curated"],
default="all") default="all")
subparsers.add_parser("apply-icons", help="Resolve catalog icons against the jsdelivr icon sets") subparsers.add_parser("apply-icons", help=translate("Resolve catalog icons against the jsdelivr icon sets"))
show_parser = subparsers.add_parser("show", help="Show the summary of a template") show_parser = subparsers.add_parser("show", help=translate("Show the summary of a template"))
show_parser.add_argument("app") show_parser.add_argument("app")
open_parser = subparsers.add_parser("open", help="Open the page of an application in the menu") open_parser = subparsers.add_parser("open", help=translate("Open the page of an application in the menu"))
open_parser.add_argument("app") open_parser.add_argument("app")
install_parser = subparsers.add_parser("install", help="Configure and install an application") install_parser = subparsers.add_parser("install", help=translate("Configure and install an application"))
install_parser.add_argument("app") install_parser.add_argument("app")
install_parser.add_argument("--host", default="auto", help="'auto'/'local', or root@IP for development") install_parser.add_argument("--host", default="auto", help=translate("'auto'/'local', or root@IP for development"))
install_parser.add_argument("--advanced", action="store_true") install_parser.add_argument("--advanced", action="store_true")
install_parser.add_argument("--dry-run", action="store_true") install_parser.add_argument("--dry-run", action="store_true")
rclone_parser = subparsers.add_parser("rclone-mount", help="Enable a mount on an installed Rclone OCI") rclone_parser = subparsers.add_parser("rclone-mount", help=translate("Enable a mount on an installed Rclone OCI"))
rclone_parser.add_argument("--host", default="auto") rclone_parser.add_argument("--host", default="auto")
rclone_parser.add_argument("--dry-run", action="store_true") rclone_parser.add_argument("--dry-run", action="store_true")
subparsers.add_parser("recover", help="Register again the OCI applications restored from a backup") subparsers.add_parser("recover", help=translate("Register again the OCI applications restored from a backup"))
manage_parser = subparsers.add_parser("manage", help="Update, modify or recreate one installed OCI instance") manage_parser = subparsers.add_parser("manage", help=translate("Update, modify or recreate one installed OCI instance"))
manage_parser.add_argument("vmid", type=int) manage_parser.add_argument("vmid", type=int)
manage_parser.add_argument("--action", choices=("update", "modify", "recreate"), required=True) manage_parser.add_argument("--action", choices=("update", "modify", "recreate"), required=True)
manage_parser.add_argument("--keep-backup", metavar="STORAGE", manage_parser.add_argument("--keep-backup", metavar="STORAGE",
help="Keep the backup taken before the update in this Proxmox storage") help=translate("Keep the backup taken before the update in this Proxmox storage"))
manage_parser.add_argument("--unattended", action="store_true", manage_parser.add_argument("--unattended", action="store_true",
help="Scheduled run: no questions; stops where a person has to decide") help=translate("Scheduled run: no questions; stops where a person has to decide"))
manage_parser.add_argument("--acknowledge-external-data", action="store_true", manage_parser.add_argument("--acknowledge-external-data", action="store_true",
help="Host directories are not reverted by the backup (confirmed beforehand)") help=translate("Host directories are not reverted by the backup (confirmed beforehand)"))
manage_parser.add_argument("--min-image-age-days", type=int, default=0) manage_parser.add_argument("--min-image-age-days", type=int, default=0)
return parser return parser
@@ -681,15 +681,15 @@ def main(argv: list[str] | None = None) -> int:
return interactive(catalog) return interactive(catalog)
if args.command == "sync": if args.command == "sync":
payload = catalog.sync_index() payload = catalog.sync_index()
print(f"Index updated: {len(payload['applications'])} candidate applications") print(translate('Index updated: {count} candidate applications').format(count=len(payload['applications'])))
return 0 return 0
if args.command == "list": if args.command == "list":
query = args.filter.casefold() query = args.filter.casefold()
for item in catalog.load_index()["applications"]: for item in catalog.load_index()["applications"]:
if not item.get("hidden", False) and query in item["id"].casefold(): if not item.get("hidden", False) and query in item["id"].casefold():
candidate = item.get("automatic_install_candidate") candidate = item.get("automatic_install_candidate")
status = ("installable" if candidate else "pending adaptation" if candidate is False status = (translate("installable") if candidate else translate("pending adaptation") if candidate is False
else item.get("template_status") or "not generated") else item.get("template_status") or translate("not generated"))
print(f"{item['id']:<30} {_display_architectures(item):<12} {publisher(item):<14} {status}") print(f"{item['id']:<30} {_display_architectures(item):<12} {publisher(item):<14} {status}")
return 0 return 0
if args.command == "generate": if args.command == "generate":
@@ -702,13 +702,12 @@ def main(argv: list[str] | None = None) -> int:
print(f"\r[{current:3}/{total}] {marker:<5} {app_id:<32}", end="", flush=True) print(f"\r[{current:3}/{total}] {marker:<5} {app_id:<32}", end="", flush=True)
report = catalog.generate_all(progress=progress, provider=args.provider) report = catalog.generate_all(progress=progress, provider=args.provider)
print(f"\nGenerated: {report['generated_count']}; failed: {report['failed_count']}; " print('\n' + translate('Generated: {generated}; failed: {failed}; family: {provider}').format(
f"family: {args.provider}") generated=report['generated_count'], failed=report['failed_count'], provider=args.provider))
return 0 if not report["failed"] else 2 return 0 if not report["failed"] else 2
if args.command == "apply-icons": if args.command == "apply-icons":
report = catalog.apply_icons() report = catalog.apply_icons()
print(f"Icons resolved: {report['resolved']}; cleared: {report['cleared']}; " print(translate('Icons resolved: {resolved}; cleared: {cleared}; unchanged: {unchanged}; with a theme variant: {themed}').format(**report))
f"unchanged: {report['unchanged']}; with a theme variant: {report['themed']}")
return 0 return 0
if args.command == "show": if args.command == "show":
print(_template_summary_text(catalog.compose(args.app))) print(_template_summary_text(catalog.compose(args.app)))
@@ -728,14 +727,14 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "install": if args.command == "install":
template = catalog.compose(args.app) template = catalog.compose(args.app)
if not template["compatibility"]["automatic_install_candidate"]: if not template["compatibility"]["automatic_install_candidate"]:
raise InstallError("Installation blocked: " + ", ".join(template["compatibility"]["untranslated_blockers"])) raise InstallError(translate("Installation blocked:") + " " + ", ".join(template["compatibility"]["untranslated_blockers"]))
notice = external_services_notice(template) notice = external_services_notice(template)
if notice: if notice:
print(notice + "\n") print(notice + "\n")
deployment = build_deployment(template, None, ADVANCED_MODE if args.advanced else DEFAULT_MODE) deployment = build_deployment(template, None, ADVANCED_MODE if args.advanced else DEFAULT_MODE)
print(_deployment_summary_text(template, deployment)) print(_deployment_summary_text(template, deployment))
if not args.dry_run and input("\nInstall? [y/N]: ").strip().casefold() not in {"y", "yes"}: if not args.dry_run and input('\n' + translate('Install? [y/N]:') + ' ').strip().casefold() not in {"y", "yes"}:
print("Installation cancelled.") print(translate("Installation cancelled."))
return 0 return 0
result = run_remote_install(PROJECT_ROOT, template, deployment, args.host, args.dry_run) result = run_remote_install(PROJECT_ROOT, template, deployment, args.host, args.dry_run)
if result: if result:
@@ -764,7 +763,7 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps(deployment, ensure_ascii=False, indent=2)) print(json.dumps(deployment, ensure_ascii=False, indent=2))
result = run_remote_rclone_mount(PROJECT_ROOT, template, deployment, args.host, args.dry_run) result = run_remote_rclone_mount(PROJECT_ROOT, template, deployment, args.host, args.dry_run)
if result: if result:
print(f"Read/write: {result['read_write_path']}\nRead-only: {result['read_only_path']}") print(f"{translate('Read/write')}: {result['read_write_path']}\n{translate('Read-only')}: {result['read_only_path']}")
return 0 return 0
return 1 return 1
except (KeyboardInterrupt, UserCancelled): except (KeyboardInterrupt, UserCancelled):
@@ -773,7 +772,7 @@ def main(argv: list[str] | None = None) -> int:
return 130 return 130
except (ConversionError, SourceError, InstallError, OSError, ValueError) as exc: except (ConversionError, SourceError, InstallError, OSError, ValueError) as exc:
console.stop_spinner() console.stop_spinner()
print(f"ERROR: {exc}", file=sys.stderr) print(f"ERROR: {diagnostic(exc)}", file=sys.stderr)
return 1 return 1
+52 -12
View File
@@ -21,7 +21,7 @@ from .cli import install_template
from .i18n import source_text from .i18n import source_text
from .installer import ADVANCED_MODE, DEFAULT_MODE from .installer import ADVANCED_MODE, DEFAULT_MODE
from .converter import ConversionError from .converter import ConversionError
from .i18n import translate from .i18n import N_, translate
from .registry_auth import RegistryError, refuses_access, validate_image from .registry_auth import RegistryError, refuses_access, validate_image
from .ui import UserCancelled from .ui import UserCancelled
@@ -533,16 +533,16 @@ def read_definition(ui) -> tuple[str, str]:
BLOCKER_TEXTS = { BLOCKER_TEXTS = {
'multi-service-compose': 'it describes more than one image', 'multi-service-compose': N_('it describes more than one image'),
'native-multi-lxc-orchestrator-not-yet-implemented': 'it describes more than one image', 'native-multi-lxc-orchestrator-not-yet-implemented': N_('it describes more than one image'),
'top-level-configs': 'it uses Compose configs, which have no equivalent here', 'top-level-configs': N_('it uses Compose configs, which have no equivalent here'),
'devices-format': 'the devices it asks for are not written in a way that can be read', 'devices-format': N_('the devices it asks for are not written in a way that can be read'),
'healthcheck-format': 'its health check is not written in a way that can be read', 'healthcheck-format': N_('its health check is not written in a way that can be read'),
'stop-grace-period-format': 'its stop timeout is not written in a way that can be read', 'stop-grace-period-format': N_('its stop timeout is not written in a way that can be read'),
'shm-size-format': 'its shared memory size is not written in a way that can be read', 'shm-size-format': N_('its shared memory size is not written in a way that can be read'),
'ulimits-format-or-resource': 'the resource limits it asks for cannot be applied', 'ulimits-format-or-resource': N_('the resource limits it asks for cannot be applied'),
'mem-limit-format-or-below-proxmox-minimum': 'the memory limit it asks for cannot be applied', 'mem-limit-format-or-below-proxmox-minimum': N_('the memory limit it asks for cannot be applied'),
'mem-limit-deploy-consistency-review': 'it asks for two different memory limits', 'mem-limit-deploy-consistency-review': N_('it asks for two different memory limits'),
} }
@@ -730,7 +730,7 @@ def _name(ui, template: dict[str, Any]) -> str:
return name return name
def explore(ui) -> None: def _explore(ui) -> None:
"""Reads a Compose file, a docker run command, an image reference or an """Reads a Compose file, a docker run command, an image reference or an
oci-compose, reports what ProxMenux would install from it and installs it.""" oci-compose, reports what ProxMenux would install from it and installs it."""
text, origin = read_definition(ui) text, origin = read_definition(ui)
@@ -784,3 +784,43 @@ def explore(ui) -> None:
else: else:
name = _name(ui, template) name = _name(ui, template)
install_template(ui, template, name, mode) install_template(ui, template, name, mode)
def conversion_message(error: ConversionError) -> str:
"""Render legacy converter diagnostics at the custom-image UI boundary.
Converter exceptions are also consumed by catalog tools. Keep their
original text, and any diagnostic data, outside this localized UI.
"""
text = str(error)
exact = {
'environment must be a list or a mapping': N_('Compose environment must be a list or a mapping.'),
'volumes must be a list': N_('Compose volumes must be a list.'),
'ports must be a list': N_('Compose ports must be a list.'),
'The CasaOS Compose file has no services': N_('The Compose file has no services.'),
'The CasaOS document is not a Compose mapping': N_('The Compose document must be a mapping.'),
'The Compose file has no x-casaos metadata': N_('The Compose file has no x-casaos metadata.'),
'x-casaos.main no identifica un servicio valido': N_('x-casaos.main must name an existing service.'),
'The main CasaOS service declares no image': N_('The main Compose service has no image.'),
}
prefixes = {
'Ruta de volumen no valida:': N_('Invalid Compose volume path:'),
'Puerto no valido:': N_('Invalid Compose port:'),
'Cannot normalise the CasaOS identifier:': N_('Invalid application identifier:'),
'Docker Compose CasaOS no valido:': N_('The Compose file is not valid YAML:'),
}
if text in exact:
translated = translate(exact[text])
return text if translated == exact[text] else translated
for prefix, source in prefixes.items():
if text.startswith(prefix):
translated = translate(source)
return text if translated == source else translated + text[len(prefix):]
return text
def explore(ui) -> None:
try:
_explore(ui)
except ConversionError as error:
raise ConversionError(conversion_message(error)) from error
+63
View File
@@ -7,6 +7,7 @@ is the source language: a missing translation returns the English text.
from __future__ import annotations from __future__ import annotations
import json import json
import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -46,6 +47,68 @@ def translate(text: str) -> str:
return _cache.get(text) or text return _cache.get(text) or text
def diagnostic(error: Any) -> str:
"""Translate legacy diagnostics only when presenting them to a person.
The catalog's exception types and original messages remain stable for
internal callers; identifiers are inserted after the translation lookup.
"""
text = str(error)
for pattern, source in (
(r"No template is available for '(.*)'", N_("No template is available for '{app_id}'")),
(r"The application '(.*)' is not in the index", N_("The application '{app_id}' is not in the index")),
(r"The application '(.*)' does not come from LinuxServer", N_("The application '{app_id}' does not come from LinuxServer")),
):
match = re.fullmatch(pattern, text)
if match:
return translate(source).format(app_id=match[1])
exact = {
'The CasaOS Compose file has no services': N_('The Compose file has no services.'),
'The Compose file has no services': N_('The Compose file has no services.'),
'x-casaos.main no identifica un servicio valido': N_('x-casaos.main must name an existing service.'),
'The main CasaOS service declares no image': N_('The main Compose service has no image.'),
'El servicio principal no declara una imagen': N_('The main Compose service has no image.'),
'El servicio no declara una imagen': N_('The service declares no image:'),
'The CasaOS document is not a Compose mapping': N_('The Compose document must be a mapping.'),
'The Compose file has no x-casaos metadata': N_('The Compose file has no x-casaos metadata.'),
'environment must be a list or a mapping': N_('Compose environment must be a list or a mapping.'),
'volumes must be a list': N_('Compose volumes must be a list.'),
'ports must be a list': N_('Compose ports must be a list.'),
'The README has no recognisable docker-compose section': N_('The README has no recognisable docker-compose section'),
'La seccion docker-compose no contiene un bloque de codigo': N_('The docker-compose section contains no code block'),
'The docker-compose block is not closed': N_('The docker-compose block is not closed'),
'The block found does not look like a Docker Compose file': N_('The block found does not look like a Docker Compose file'),
'No se puede identificar la imagen principal del Compose': N_('The main Compose service cannot be identified unambiguously'),
'No se puede identificar de forma univoca el servicio principal del Compose': N_('The main Compose service cannot be identified unambiguously'),
'python3-jsonschema is required to generate templates': N_('python3-jsonschema is required to generate templates'),
'Bulk generation requires GITHUB_TOKEN to obtain an immutable revision per application': N_('Bulk generation requires GITHUB_TOKEN to obtain an immutable revision per application'),
'The ProxMenux application catalogue is not a list': N_('The ProxMenux application catalogue is not a list'),
'GitHub returned a truncated tree for the CasaOS catalogue': N_('GitHub returned a truncated tree for the CasaOS catalogue'),
'No Compose files were found in the CasaOS catalogue': N_('No Compose files were found in the CasaOS catalogue'),
}
if text in exact:
return translate(exact[text])
for prefix, source in (
('Cannot normalise the CasaOS identifier:', N_('Invalid application identifier:')),
('Cannot normalise the identifier:', N_('Invalid application identifier:')),
('Docker Compose CasaOS no valido:', N_('The Compose file is not valid YAML:')),
('Docker Compose no valido:', N_('The Compose file is not valid YAML:')),
('La plantilla generada no cumple el esquema:', N_('The generated template does not match the schema:')),
):
if text.startswith(prefix):
return translate(source) + text[len(prefix):]
match = re.fullmatch(r'GitHub returned HTTP 403 for (.+)\. Set GITHUB_TOKEN to raise the API rate limit\.', text)
if match:
return translate('GitHub returned HTTP 403 for {url}. Set GITHUB_TOKEN to raise the API rate limit.').format(url=match[1])
match = re.fullmatch(r'GitHub returned HTTP ([0-9]{3}) (?:for|para) (.+)\.', text)
if match:
return translate('GitHub returned HTTP {status} for {url}.').format(status=match[1], url=match[2])
match = re.fullmatch(r'No se pudo acceder a (.+) despues de 3 intentos: (.*)', text, re.S)
if match:
return translate('Could not reach {url} after 3 attempts: {reason}').format(url=match[1], reason=match[2])
return text
def source_text(value: Any) -> str: def source_text(value: Any) -> str:
"""The English a catalog field was written in. """The English a catalog field was written in.
+33 -14
View File
@@ -19,6 +19,16 @@ STATUS_LABELS = {'installed': N_('installed'), 'failed': N_('failed'), 'updating
'assembling': N_('assembling')} 'assembling': N_('assembling')}
class CancelledOperation:
"""An explicit user decline; remains false for menu and batch callers."""
def __bool__(self):
return False
CANCELLED = CancelledOperation()
def public_row(record, decision): def public_row(record, decision):
return {'vmid': record['vmid'], 'hostname': record.get('deployment', {}).get('hostname', return {'vmid': record['vmid'], 'hostname': record.get('deployment', {}).get('hostname',
f'OCI {record["vmid"]}'), f'OCI {record["vmid"]}'),
@@ -219,7 +229,7 @@ def offer_recovery(project, ui):
f"{translate('Until they are registered again they cannot be updated or managed from here. The recovery checks first what prevents their registration; an application with such a problem is not registered. A Rclone mount that has to be enabled again does not prevent it, but then the applications are not started automatically.')}" f"{translate('Until they are registered again they cannot be updated or managed from here. The recovery checks first what prevents their registration; an application with such a problem is not registered. A Rclone mount that has to be enabled again does not prevent it, but then the applications are not started automatically.')}"
f"\n\n{translate('Recover them now?')}") f"\n\n{translate('Recover them now?')}")
if not ui.confirm(text, default=True): if not ui.confirm(text, default=True):
return return CANCELLED
command = [sys.executable, str(project / 'remote/oci_restore_recovery.py'), 'recover'] command = [sys.executable, str(project / 'remote/oci_restore_recovery.py'), 'recover']
for rule in _restored_firewall_rules(project): for rule in _restored_firewall_rules(project):
if ui.confirm(translate('CT {vmid} is a host monitor and had a rule in the host firewall. Allow TCP port {port} from {subnet} through the firewall of this host? Existing firewall rules are not changed.').format( if ui.confirm(translate('CT {vmid} is a host monitor and had a rule in the host firewall. Allow TCP port {port} from {subnet} through the firewall of this host? Existing firewall rules are not changed.').format(
@@ -242,7 +252,9 @@ def direct_recovery(project):
if not restored_applications(project): if not restored_applications(project):
ui.message(translate('No restored OCI application is waiting to be recovered.'), translate('Restored OCI applications')) ui.message(translate('No restored OCI application is waiting to be recovered.'), translate('Restored OCI applications'))
return EXIT_DONE return EXIT_DONE
offer_recovery(project, ui) completed = offer_recovery(project, ui)
if completed is CANCELLED:
return EXIT_CANCELLED
return EXIT_DONE if not restored_applications(project) else EXIT_FAILED return EXIT_DONE if not restored_applications(project) else EXIT_FAILED
@@ -458,12 +470,14 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=(), heading=No
proposal = None proposal = None
if action == 'modify': if action == 'modify':
from .recreation import edit_recreation from .recreation import edit_recreation
proposal = edit_recreation(record, ui) proposal = edit_recreation(record, ui, cancelled_result=CANCELLED)
if proposal is CANCELLED:
return CANCELLED
if proposal is None: if proposal is None:
return False return False
elif not ui.review(translate('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If the update fails after the container was changed, the backup is restored. An update that stops halfway is recovered from the OCI management menu.'), elif not ui.review(translate('If a newer application image is available, the container is stopped and backed up, and the backup integrity is verified. The container is then updated using the new image.\n\nIf the update fails after the container was changed, ProxMenux attempts to restore the previous installation from the backup. If the operation remains incomplete, select the application in OCI management and choose "Recover the previous installation".\n\nHost directories mounted in the container are not restored by the container backup.'),
translate('Update OCI'), question=translate('Update now?'), default=True): translate('Update OCI'), question=translate('Update now?'), default=True):
return False return CANCELLED
# Opening/cancelling a confirmation needs no disk or storage probes. # Opening/cancelling a confirmation needs no disk or storage probes.
# Both accepted operations retain this check before the backend starts. # Both accepted operations retain this check before the backend starts.
if not _work_backup_ready(project, ui, row['vmid']): if not _work_backup_ready(project, ui, row['vmid']):
@@ -498,11 +512,11 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=(), heading=No
action = ui.choose(translate('Interrupted operation'), [('status', translate('View status')), action = ui.choose(translate('Interrupted operation'), [('status', translate('View status')),
('recover', translate('Recover the previous installation'))], 'status') ('recover', translate('Recover the previous installation'))], 'status')
if action is None: if action is None:
return False return CANCELLED
if action == 'recover' and not ui.review( if action == 'recover' and not ui.review(
translate('If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.'), translate('If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.'),
translate('Recover OCI'), question=translate('Recover now?'), default=True): translate('Recover OCI'), question=translate('Recover now?'), default=True):
return False return CANCELLED
return _run_lifecycle([sys.executable, str(project / 'remote/oci_instance_transaction.py'), return _run_lifecycle([sys.executable, str(project / 'remote/oci_instance_transaction.py'),
action, str(row['vmid'])], action, str(row['vmid'])],
translate('Recover OCI') if action == 'recover' else translate('OCI management')) translate('Recover OCI') if action == 'recover' else translate('OCI management'))
@@ -552,13 +566,15 @@ def _removal_summary(project, vmid):
bridge = oci_remove.private_bridge(primary) bridge = oci_remove.private_bridge(primary)
text = [] text = []
if len(members) > 1 and vmid == primary_id: if len(members) > 1 and vmid == primary_id:
text += [f"{application} {translate('runs in')} {len(members)} {translate('containers')}. " sentence = translate('{application} runs in {count} containers.').format(
f"{translate('All of them are targeted for removal.')}", ''] application=application, count=len(members))
text += [f"{sentence} {translate('All of them are targeted for removal.')}", '']
elif len(members) > 1: elif len(members) > 1:
alone = translate('It cannot be removed on its own, because the application would stop ' alone = translate('It cannot be removed on its own, because the application would stop '
'working: continuing targets the whole application for removal.') 'working: continuing targets the whole application for removal.')
text += [f"CT {vmid} {translate('is one of the')} {len(members)} " sentence = translate('CT {vmid} is one of the {count} containers of {application}.').format(
f"{translate('containers of')} {application}. {alone}", ''] vmid=vmid, count=len(members), application=application)
text += [f"{sentence} {alone}", '']
text += [translate('Containers targeted for removal:'), *lines, '', text += [translate('Containers targeted for removal:'), *lines, '',
translate('Container data targeted for deletion:'), *volumes] translate('Container data targeted for deletion:'), *volumes]
if bridge and not oci_remove.bridge_in_use(bridge, set(members)): if bridge and not oci_remove.bridge_in_use(bridge, set(members)):
@@ -642,7 +658,7 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
'the OCI management menu.') 'the OCI management menu.')
.format(count=len(members), vmid=primary_id), translate('Recreate OCI stack'), .format(count=len(members), vmid=primary_id), translate('Recreate OCI stack'),
question=translate('Recreate the whole stack?'), default=False): question=translate('Recreate the whole stack?'), default=False):
return False return CANCELLED
else: else:
if not ui.review(translate('All {count} containers of the application are updated together (main CT: {vmid}). ' if not ui.review(translate('All {count} containers of the application are updated together (main CT: {vmid}). '
'If there are new versions, all images are downloaded and verified, the application ' 'If there are new versions, all images are downloaded and verified, the application '
@@ -650,11 +666,11 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
'fails after a container was changed, the backups are restored. An operation that stops ' 'fails after a container was changed, the backups are restored. An operation that stops '
'halfway is recovered from the OCI management menu.').format(count=len(members), vmid=primary_id), 'halfway is recovered from the OCI management menu.').format(count=len(members), vmid=primary_id),
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True): translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
return False return CANCELLED
else: else:
if not ui.review(translate('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.'), translate('Recover OCI stack'), if not ui.review(translate('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.'), translate('Recover OCI stack'),
question=translate('Recover or complete the operation?'), default=True): question=translate('Recover or complete the operation?'), default=True):
return False return CANCELLED
import json import json
members = json.loads(Path(pending).read_text())['plan']['members'] members = json.loads(Path(pending).read_text())['plan']['members']
if not pending and not _work_backup_ready(project, ui, primary_id): if not pending and not _work_backup_ready(project, ui, primary_id):
@@ -707,6 +723,7 @@ class UnattendedUI:
# Exit codes of `manage`, read by ProxMenux Monitor. # Exit codes of `manage`, read by ProxMenux Monitor.
EXIT_DONE, EXIT_FAILED, EXIT_NOT_OCI, EXIT_BUSY, EXIT_NEEDS_REVIEW, EXIT_TOO_RECENT = 0, 1, 2, 3, 4, 5 EXIT_DONE, EXIT_FAILED, EXIT_NOT_OCI, EXIT_BUSY, EXIT_NEEDS_REVIEW, EXIT_TOO_RECENT = 0, 1, 2, 3, 4, 5
EXIT_CANCELLED = 6 # Explicit interactive cancellation; never a successful update.
def _image_too_recent(project, vmid, min_age_days): def _image_too_recent(project, vmid, min_age_days):
@@ -765,4 +782,6 @@ def direct_management(project, vmid, action, lifecycle_args=(), unattended=False
return EXIT_BUSY return EXIT_BUSY
if unattended and ui.declined: if unattended and ui.declined:
return EXIT_NEEDS_REVIEW return EXIT_NEEDS_REVIEW
if completed is CANCELLED and not unattended:
return EXIT_CANCELLED
return EXIT_DONE if completed else EXIT_FAILED return EXIT_DONE if completed else EXIT_FAILED
+29 -5
View File
@@ -3,7 +3,7 @@ import copy
import re import re
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from .i18n import translate from .i18n import N_, translate
from .ui import BacktrackUI, RestartWizard, UserCancelled from .ui import BacktrackUI, RestartWizard, UserCancelled
@@ -212,8 +212,8 @@ def edit_section(candidate, ui, setting):
wizard.close() wizard.close()
def edit_recreation(record, ui): def edit_recreation(record, ui, *, cancelled_result=None):
"""Keep a draft across independently editable settings; apply it once.""" """Keep a draft; optionally distinguish cancellation from an unchanged review."""
from .cli import _deployment_summary_text from .cli import _deployment_summary_text
candidate = copy.deepcopy(record) candidate = copy.deepcopy(record)
selected = 'cores' selected = 'cores'
@@ -227,7 +227,7 @@ def edit_recreation(record, ui):
title=translate('Modify OCI'), show_tags=False) title=translate('Modify OCI'), show_tags=False)
if selected is None or selected == 'discard': if selected is None or selected == 'discard':
if not pending or ui.confirm(translate('Discard unapplied changes?'), False): if not pending or ui.confirm(translate('Discard unapplied changes?'), False):
return None return cancelled_result
selected = 'apply' selected = 'apply'
elif selected == 'apply': elif selected == 'apply':
if not pending: if not pending:
@@ -243,8 +243,29 @@ def edit_recreation(record, ui):
candidate = edit_section(candidate, ui, selected) candidate = edit_section(candidate, ui, selected)
def _catalog_message(error):
"""Localize legacy catalog errors here, preserving the catalog API."""
text = str(error)
patterns = (
(r"No template is available for '(.*)'", N_("No template is available for '{app_id}'")),
(r"The application '(.*)' is not in the index", N_("The application '{app_id}' is not in the index")),
)
for pattern, source in patterns:
match = re.fullmatch(pattern, text)
if match:
translated = translate(source)
return text if translated == source else translated.format(app_id=match[1])
prefix = 'La plantilla generada no cumple el esquema:'
if text.startswith(prefix):
source = N_('The generated template does not match the schema:')
translated = translate(source)
return text if translated == source else translated + text[len(prefix):]
return text
def refresh_template(candidate, ui): def refresh_template(candidate, ui):
from .catalog import Catalog from .catalog import Catalog
from .converter import ConversionError
old = candidate.get('template', {}) old = candidate.get('template', {})
template_id = old.get('id', '') template_id = old.get('id', '')
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', template_id): if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', template_id):
@@ -258,7 +279,10 @@ def refresh_template(candidate, ui):
if len(matching) != 1: if len(matching) != 1:
return return
name = matching[0]['id'] name = matching[0]['id']
latest = catalog.compose(name) try:
latest = catalog.compose(name)
except ConversionError as error:
raise ConversionError(_catalog_message(error)) from error
if latest == old: if latest == old:
return return
if not ui.confirm(translate('Apply options from the current catalog template? New required paths and settings may be requested. Review the resulting configuration before recreating the CT.'), True): if not ui.confirm(translate('Apply options from the current catalog template? New required paths and settings may be requested. Review the resulting configuration before recreating the CT.'), True):
+3 -3
View File
@@ -365,13 +365,13 @@ def build_stack(template, ui, mode='advanced'):
service_name = group['service'] service_name = group['service']
if service_name not in envs: if service_name not in envs:
raise StackError(f"{translate('Unknown credential service:')} {service_name}") raise StackError(f"{translate('Unknown credential service:')} {service_name}")
if not quiet.confirm(f"{translate('Configure')} {group['label']} ({translate('optional')})", False): if not quiet.confirm(f"{translate('Configure')} {translate(group['label'])} ({translate('optional')})", False):
continue continue
for field in group['fields']: for field in group['fields']:
if field['name'] not in envs[service_name] or envs[service_name][field['name']] != '': if field['name'] not in envs[service_name] or envs[service_name][field['name']] != '':
raise StackError(f"{translate('External field not reserved:')} {field['name']}") raise StackError(f"{translate('External field not reserved:')} {field['name']}")
value = (ui.password(field['label'], required=True) if field.get('sensitive',True) value = (ui.password(translate(field['label']), required=True) if field.get('sensitive',True)
else ui.ask(field['label'], required=True)) else ui.ask(translate(field['label']), required=True))
if not value or any(c in value for c in '\r\n'): if not value or any(c in value for c in '\r\n'):
raise StackError(translate('External credential is empty or spans multiple lines')) raise StackError(translate('External credential is empty or spans multiple lines'))
envs[service_name][field['name']] = value envs[service_name][field['name']] = value
+10 -1
View File
@@ -3,6 +3,7 @@ transcoding and its recognition: the CPU, or each usable GPU of the host for
both or for only one of them.""" both or for only one of them."""
from pathlib import Path from pathlib import Path
import json
import sys import sys
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -165,7 +166,15 @@ class ImmichAccelerationTests(unittest.TestCase):
def test_the_name_of_the_machine_learning_container_is_not_translated(self, *_): def test_the_name_of_the_machine_learning_container_is_not_translated(self, *_):
script = (ROOT / "remote/install_immich_stack.sh").read_text() script = (ROOT / "remote/install_immich_stack.sh").read_text()
self.assertNotIn('translate "Machine learning"', script) self.assertNotIn('translate "Machine learning"', script)
self.assertNotIn('translate("Machine learning")', (ROOT / "src/proxmenux_oci/cli.py").read_text()) from proxmenux_oci.cli import _service_kind
from proxmenux_oci import i18n
service = {'name': 'immich-machine-learning', 'image': 'ghcr.io/immich-app/immich-machine-learning:release'}
original = dict(service)
with patch.object(i18n, 'language', return_value='sk'), patch.object(i18n, '_cache', json.loads((ROOT.parent / 'lang/sk.json').read_text())):
self.assertEqual(_service_kind(service), 'Strojové učenie')
_, _, plan = self.plan(NOTHING, DEFAULT_MODE)
self.assertIn('machine_learning', plan)
self.assertEqual(service, original)
if __name__ == "__main__": if __name__ == "__main__":
+1 -1
View File
@@ -185,7 +185,7 @@ class ModifyDispatchTests(unittest.TestCase):
patch.object(management, '_run_lifecycle', side_effect=lifecycle): patch.object(management, '_run_lifecycle', side_effect=lifecycle):
return management.manage_instance(ROOT, ui or Mock(), row, action='modify') return management.manage_instance(ROOT, ui or Mock(), row, action='modify')
def test_cancel_does_not_start_a_lifecycle_transaction(self): def test_legacy_none_result_does_not_start_a_lifecycle_transaction(self):
lifecycle = Mock() lifecycle = Mock()
self.assertFalse(self.call_manager(None, lifecycle)) self.assertFalse(self.call_manager(None, lifecycle))
lifecycle.assert_not_called() lifecycle.assert_not_called()
+216
View File
@@ -0,0 +1,216 @@
"""Single-container editor wording, real helper paths and no-apply boundaries."""
import copy
import json
from pathlib import Path
import subprocess
import unittest
from unittest.mock import patch
from test_modify_menu import MenuUI, record
from proxmenux_oci import i18n, installer, management, recreation
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.cli import _deployment_summary_text
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.ui import UserCancelled
ROOT = Path(__file__).resolve().parents[2]
class EditorUI(MenuUI):
def info(self, text):
self.events.append(('info', text))
def password(self, text, **kwargs):
self.events.append(('password', text))
return next(self.answers)
class SlovakEditorTests(unittest.TestCase):
def setUp(self):
translations = json.loads((ROOT / 'lang/sk.json').read_text())
for mock in (patch.object(i18n, '_language', 'sk'),
patch.object(i18n, '_cache', translations)):
mock.start()
self.addCleanup(mock.stop)
self.forbidden = []
# The actual editor/helpers run, but any external action fails the test.
for target, name in ((management, '_run_lifecycle'), (subprocess, 'run'),
(subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes')):
mock = patch.object(target, name, side_effect=AssertionError('editor performed an external action'))
self.forbidden.append(mock.start())
self.addCleanup(mock.stop)
self.original = record()
self.saved = copy.deepcopy(self.original)
def tearDown(self):
self.assertEqual(self.original, self.saved)
for mock in self.forbidden:
mock.assert_not_called()
def text(self, ui):
return '\n'.join(str(e[1:]) for e in ui.events)
def test_cpu_minimum_then_invalid_number_can_be_corrected_and_discarded(self):
ui = EditorUI(['cores', 'discard'], ['0', 'two', '3'], confirms=[True])
self.assertIsNone(recreation.edit_recreation(self.original, ui))
text = self.text(ui)
self.assertIn('Počet jadier CPU', text)
self.assertIn('Počet jadier: minimum 1', text)
self.assertIn('Hodnota musí byť číslo:', text)
self.assertIn('Zadajte hodnotu znova.', text)
self.assertIn('Zahodiť nepoužité zmeny?', text)
self.assertNotIn('private-test-secret', text)
def test_ram_boundary_and_declined_review_do_not_apply_a_proposal(self):
ui = EditorUI(['memory', 'apply', 'discard'], ['127', '128'],
reviews=[False], confirms=[True])
self.assertIsNone(recreation.edit_recreation(self.original, ui))
self.assertIn('RAM v MiB: minimum 128', self.text(ui))
summary = next(e[1] for e in ui.events if e[0] == 'review')
self.assertIn('128 MB RAM', summary)
self.assertIn('rootfs 8 GB na local-zfs', summary)
self.assertIn('vyrovnávacia pamäť obrazov na', summary)
self.assertIn('neprivilegovaný LXC', summary)
self.assertNotIn('private-test-secret', summary)
def test_no_change_and_cancelled_section_keep_the_existing_record(self):
for choices, answers in ((['apply'], []), (['cores', 'discard'], [UserCancelled()])):
with self.subTest(choices=choices):
ui = EditorUI(choices, answers)
self.assertIsNone(recreation.edit_recreation(self.original, ui))
self.assertFalse(any(e[0] == 'review' for e in ui.events))
def test_environment_duplicate_is_an_error_not_an_instruction_to_duplicate(self):
self.original['deployment']['environment'] *= 2
self.saved = copy.deepcopy(self.original)
ui = EditorUI([], ['PASSWORD', UserCancelled()], confirms=[True])
result = recreation.edit_section(self.original, ui, 'environment')
self.assertIs(result, self.original)
self.assertIn('Premenná je v definícii kontajnera uvedená viackrát; pred úpravou ju skontrolujte', self.text(ui))
def test_invalid_variable_name_and_value_are_rejected_before_draft_is_returned(self):
for name, value, expected in (('INVALID NAME', None, 'Neplatný názov premennej'),
('VISIBLE', 'bad\x00value', 'Hodnota obsahuje nepodporovaný znak')):
with self.subTest(name=name):
answers = [name] + ([value] if value is not None else []) + [UserCancelled()]
ui = EditorUI([], answers, confirms=[True, False])
self.assertIs(recreation.edit_section(self.original, ui, 'environment'), self.original)
self.assertIn(expected, self.text(ui))
def test_new_secret_prompt_and_summary_do_not_expose_the_value(self):
candidate = copy.deepcopy(self.original)
ui = EditorUI([], ['NEW_SECRET', 'fixture-new-secret'], confirms=[True, True, False])
recreation.edit_setting(candidate, ui, 'environment')
self.assertIn(('password', 'Nová hodnota pre NEW_SECRET'), ui.events)
self.assertNotIn('fixture-new-secret', self.text(ui))
self.assertNotIn('fixture-new-secret', _deployment_summary_text(candidate['template'], candidate['deployment']))
def test_storage_picker_reports_free_space_not_customs_duty(self):
ui = EditorUI(['local-zfs'])
rows = [{'storage': name, 'type': 'zfspool', 'avail': 8 * 1024**3}
for name in ('local-zfs', 'other')]
with patch.object(installer.host, 'storages', return_value=rows):
self.assertEqual(installer.ask_storage(ui, 'Úložisko', 'rootdir', 'local-zfs'), 'local-zfs')
self.assertIn('8 GB voľné', self.text(ui))
def test_shared_host_path_prompt_changes_only_the_draft(self):
candidate = copy.deepcopy(self.original)
candidate['template']['proxmox']['installer_profile']['shared_directories'] = {
'container_root': '/shares'}
ui = EditorUI(['host-bind'], ['media', '/fixture/nonexistent'], confirms=[True, False])
with patch.object(installer.os.path, 'isdir', return_value=False):
recreation.edit_setting(candidate, ui, 'shares')
self.assertIn('Cesta na hostiteľovi pre /shares/media', self.text(ui))
self.assertEqual(candidate['deployment']['mounts'][-1]['source'], '/fixture/nonexistent')
def test_extra_path_cannot_cover_system_directories(self):
ui = EditorUI(['host-bind'], ['/etc', '/fixture/host', UserCancelled()],
confirms=[True, False])
self.assertIs(recreation.edit_section(self.original, ui, 'paths'), self.original)
self.assertIn('Vlastná cesta nemôže skryť systémové adresáre', self.text(ui))
def test_network_gateway_explains_outbound_route(self):
ui = EditorUI(['static'], ['vmbr0', '192.0.2.42/24', ''], confirms=[])
with patch.object(installer.host, 'bridges', return_value=[]), \
patch.object(installer.host, 'address_answers', return_value=False):
recreation.edit_setting(copy.deepcopy(self.original), ui, 'network')
self.assertIn('Brána IPv4 (prázdne = bez odchádzajúcej trasy)', self.text(ui))
def test_incompatible_docker_mods_are_localized_without_gpu_or_image_probe(self):
self.original['template']['proxmox']['installer_profile']['hardware_acceleration'] = {
'profiles': [{'id': 'gpu', 'environment': [{'name': 'DOCKER_MODS', 'value': 'mod'}]}]}
self.saved = copy.deepcopy(self.original)
ui = EditorUI([])
self.assertIs(recreation.edit_section(self.original, ui, 'acceleration'), self.original)
self.assertIn('Docker Mods sú dostupné iba pre kompatibilné obrazy LinuxServer', self.text(ui))
def test_duplicate_usb_device_is_rejected_without_attaching_it(self):
self.original['deployment']['devices'] = [{'host_path': '/dev/ttyACM0'}]
self.saved = copy.deepcopy(self.original)
ui = EditorUI(['usb'], ['/dev/ttyACM0', UserCancelled()], confirms=[True])
with patch.object(installer.host, 'usb_devices', return_value=[]):
self.assertIs(recreation.edit_section(self.original, ui, 'devices'), self.original)
self.assertIn('Toto zariadenie je už pripojené', self.text(ui))
def catalog_refresh(self, error=None, latest=None):
candidate = copy.deepcopy(self.original)
candidate['template']['container_contract']['image']['repository'] = 'example/app'
ui = EditorUI(['managed-volume'], ['local-zfs', '4'], confirms=[True])
index = {'applications': [{'id': 'example', 'template_id': 'example-app'}]}
with patch.object(Catalog, 'load_index', return_value=index), \
patch.object(Catalog, 'compose', side_effect=error, return_value=latest):
recreation.refresh_template(candidate, ui)
return candidate, ui
def test_required_catalog_path_has_a_clear_persistence_prompt(self):
latest = copy.deepcopy(self.original['template'])
latest['container_contract']['image']['repository'] = 'example/app'
latest['container_contract']['volumes'] = [{'id': 'new', 'container_path': '/new-data'}]
candidate, ui = self.catalog_refresh(latest=latest)
self.assertIn('Aktuálna šablóna vyžaduje nové trvalé úložisko pre cestu: /new-data', self.text(ui))
self.assertIn('Nové úložisko bude prázdne; existujúce údaje sa doň automaticky neprenesú.', self.text(ui))
self.assertIn('Trvalé úložisko pre /new-data', self.text(ui))
self.assertEqual(candidate['deployment']['mounts'][-1]['container_path'], '/new-data')
def test_template_identity_change_is_a_localized_migration_error(self):
latest = copy.deepcopy(self.original['template'])
latest['container_contract']['image']['repository'] = 'different/app'
with self.assertRaisesRegex(ValueError, 'Aktuálna šablóna mení identitu šablóny alebo repozitár obrazu; je potrebná výslovná migrácia'):
self.catalog_refresh(latest=latest)
def test_real_catalog_missing_file_error_is_localized_only_in_editor(self):
index = {'applications': [{'id': 'example', 'template_id': 'example-app'}]}
item = dict(index['applications'][0], template_family='curated-profile', curated_path='absent-template.json')
with patch.object(Catalog, 'load_index', return_value=index), patch.object(Catalog, 'find_item', return_value=item):
with self.assertRaisesRegex(ConversionError, "No template is available for 'example'"):
Catalog(ROOT / 'oci').compose('example')
with self.assertRaisesRegex(ConversionError, "Pre aplikáciu 'example' nie je dostupná šablóna"):
recreation.refresh_template(copy.deepcopy(self.original), EditorUI([]))
def test_real_schema_diagnostic_preserves_field_paths_and_exception_type(self):
with self.assertRaises(ConversionError) as raw:
Catalog(ROOT / 'oci').validate({})
self.assertTrue(str(raw.exception).startswith('La plantilla generada no cumple el esquema:'))
details = str(raw.exception).split(':', 1)[1]
with self.assertRaises(ConversionError) as localized:
self.catalog_refresh(error=raw.exception)
self.assertEqual(str(localized.exception), 'Vygenerovaná šablóna nezodpovedá schéme:' + details)
def test_missing_index_entry_preserves_the_application_identifier(self):
with self.assertRaisesRegex(ConversionError, "Aplikácia 'example' nie je v indexe"):
self.catalog_refresh(error=ConversionError("The application 'example' is not in the index"))
def test_english_missing_translation_and_unknown_diagnostics_are_preserved(self):
for message in ("No template is available for 'example'", "The application 'example' is not in the index",
'La plantilla generada no cumple el esquema: field: required', 'unknown technical failure'):
for language, cache in (('en', {}), ('sk', {})):
with self.subTest(message=message, language=language), \
patch.object(i18n, '_language', language), patch.object(i18n, '_cache', cache):
with self.assertRaises(ConversionError) as error:
self.catalog_refresh(error=ConversionError(message))
self.assertEqual(str(error.exception), message)
if __name__ == '__main__':
unittest.main()
+157
View File
@@ -0,0 +1,157 @@
// Execute the real WebSocket handlers and the OCI caller's actual close callback.
const assert = require('node:assert/strict'), vm = require('node:vm');
const {ts,read,compile,translator,catalogs} = require('./backup_i18n_seam.cjs');
const moduleScope = {exports:{}};
vm.runInNewContext(compile(read('lib/script-completion.ts')), moduleScope);
const completion = moduleScope.exports;
const source = ts.createSourceFile('modal.tsx', read('components/script-terminal-modal.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const handlers = []; let messageCallback,reconnectCallback;
function walk(node) {
if (ts.isBinaryExpression(node) && node.left.getText(source) === 'ws.onmessage') handlers.push(node.right);
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'getCompletionMessage') messageCallback = node.initializer.arguments[0];
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'attemptReconnect') reconnectCallback = node.initializer.arguments[0];
ts.forEachChild(node, walk);
}
walk(source);
assert.equal(handlers.length,2,'initial and reconnect handlers');
assert.ok(messageCallback);
function executable(node, file, scope) {
const local = {...scope,exports:{}};
vm.runInNewContext(compile('exports.fn = '+node.getText(file)+';'),local);
return local.exports.fn;
}
function fixture(handler, locale, optedIn=true) {
const writes=[], callbacks=[], state={closed:0};
const t=translator(locale), terminal={write:s=>writes.push(s),writeln:s=>writes.push(s)};
const shared={...completion, cancelledExitCode:optedIn?6:undefined,t,
completedSuccessfullyMessage:undefined,completedWithErrorMessage:undefined};
const getCompletionMessage=executable(messageCallback,source,shared);
const scope={...shared,getCompletionMessage,term:terminal,termRef:{current:terminal},
completionReceivedRef:{current:false},onCompleteRef:{current:code=>callbacks.push(code)},
setIsComplete:v=>state.complete=v,ws:{readyState:1,close:()=>state.closed++},WebSocket:{OPEN:1},
setIsWaitingNextInteraction:()=>{},waitingTimeoutRef:{current:null},clearTimeout:()=>{},
setCurrentInteraction:()=>{}};
return {receive:executable(handler,source,scope),writes,callbacks,state,t,scope};
}
let scenarios=0;
for (const handler of handlers) for (const locale of ['en','sk']) for (const frame of ['raw','json']) {
for (const [code,optedIn,kind] of [[6,true,'cancelled'],[6,false,'error'],[1,true,'error'],[0,true,'success'],[-1,true,'error']]) {
const f=fixture(handler,locale,optedIn);
const data=frame==='raw'?`\r\n[Script exited with code ${code}]\r\n`:JSON.stringify({type:'script_complete',exit_code:code});
f.receive({data});
assert.deepEqual(f.callbacks,[code]); assert.equal(f.state.closed,1);
const rendered=f.writes.join('');
if(kind==='cancelled') {
assert.ok(rendered.includes(f.t('scriptTerminal.cancelled')));
assert.ok(rendered.includes('\x1b[36m'));
assert.ok(rendered.startsWith('\x1bc'),'cancel resets the obsolete dialog in the queued output');
assert.ok(!rendered.includes('Script exited with code'));
assert.ok(!rendered.includes('\x1b[31m'));
assert.ok(!rendered.includes(f.t('scriptTerminal.completedSuccessfully')));
} else {
assert.ok(!rendered.includes('\x1bc'),'success/errors and non-opted-in scripts keep their logs');
assert.ok(rendered.includes(kind==='success'?f.t('scriptTerminal.completedSuccessfully'):f.t('scriptTerminal.completedWithError',{code})));
}
// Worker sends both forms; neither a second frame nor a heartbeat may complete twice.
f.receive({data:JSON.stringify({type:'script_complete',exit_code:code})});
f.receive({data:'{"type":"pong"}'});
assert.deepEqual(f.callbacks,[code]);assert.equal(f.state.closed,1);scenarios++;
}
for(const payload of [{type:'script_complete'},{type:'script_complete',exit_code:'invalid'}]) {
const f=fixture(handler,locale);f.receive({data:JSON.stringify(payload)});
assert.ok(f.writes.join('').includes('\x1b[31m'));
assert.ok(!f.writes.join('').includes(f.t('scriptTerminal.cancelled')));scenarios++;
}
}
for(const locale of Object.keys(catalogs)) assert.ok(translator(locale)('scriptTerminal.cancelled')!=='scriptTerminal.cancelled');
// The native CLI and the explicitly opted-in caller share the documented code6 contract.
const fs=require('node:fs');
assert.match(fs.readFileSync('oci/src/proxmenux_oci/management.py','utf8'),/^EXIT_CANCELLED = 6 /m);
assert.equal(completion.OCI_CANCELLED_EXIT_CODE,6);
const parent=ts.createSourceFile('vms.tsx',read('components/virtual-machines.tsx'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX);
let ociElement,reset,pendingRecoveryClick,registrationClick,registrationLabel;
function find(node) {
if(ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent)==='ScriptTerminalModal' &&
node.attributes.properties.some(p=>p.name?.getText(parent)==='scriptPath' && p.initializer?.text?.endsWith('/oci/manage_instance.sh'))) ociElement=node;
if(ts.isCallExpression(node) && node.expression.getText(parent)==='useEffect' && node.arguments[0]?.getText(parent).includes('ociCompletionCodeRef.current = undefined')) reset=node.arguments[0];
if(ts.isJsxElement(node) && node.openingElement.tagName.getText(parent)==='Button') {
const click=node.openingElement.attributes.properties.find(p=>p.name?.getText(parent)==='onClick')?.initializer?.expression;
if(click?.getText(parent).includes('setOciAction(') && click.getText(parent).includes('action: "update"')) pendingRecoveryClick=click;
if(click?.getText(parent).includes('setOciAction(') && click.getText(parent).includes('action: "recover"')) {
registrationClick=click;
registrationLabel=node.children.find(child=>ts.isJsxExpression(child)&&child.expression)?.expression;
}
}
ts.forEachChild(node,find);
}
find(parent);assert.ok(ociElement);assert.ok(reset);assert.ok(pendingRecoveryClick);assert.ok(registrationClick);assert.ok(registrationLabel);
const attribute=name=>ociElement.attributes.properties.find(p=>p.name?.getText(parent)===name)?.initializer?.expression;
assert.equal(attribute('cancelledExitCode').getText(parent),'OCI_CANCELLED_EXIT_CODE');
(async()=>{
for(const locale of ['en','sk']) {
const scope={t:translator(locale),selectedVM:{vmid:110},applyBackup:false,applyBackupStorage:'',selectedBackupStorage:''};
scope.setOciAction=value=>scope.ociAction=value;
executable(registrationClick,parent,scope)();
assert.equal(scope.ociAction.action,'recover');assert.equal(scope.ociAction.vmid,110);
assert.equal(executable(registrationLabel,parent,scope),locale==='sk'?'Znova zaregistrovať':'Register again');
assert.equal(executable(attribute('title'),parent,scope),locale==='sk'?
'Zaregistrovať OCI aplikácie obnovené zo zálohy':'Register restored OCI applications');
scope.selectedVM={vmid:111};
assert.equal(executable(attribute('params'),parent,scope).VMID,'110','registration preserves the clicked target context');
assert.equal(executable(attribute('params'),parent,scope).ACTION,'recover','registration retains its existing global CLI action');
assert.equal(scope.t('vmLxc.ociUpdates.recover'),locale==='sk'?'Obnoviť':'Recover');
}
for(const locale of ['en','sk']) for(const pending of [false,true]) {
const scope={t:translator(locale), selectedVM:{vmid:110}, ociInstance:{pending},
applyBackup:false,applyBackupStorage:'',selectedBackupStorage:''};
scope.setOciAction=value=>scope.ociAction=value;
executable(pendingRecoveryClick,parent,scope)();
assert.equal(scope.ociAction.vmid,110);
assert.equal(scope.ociAction.action,'update','pending recovery retains the existing manage contract');
// Background refresh or another selected guest must not rename this open operation.
scope.ociInstance={pending:!pending};scope.selectedVM={vmid:999};
const title=executable(attribute('title'),parent,scope);
assert.equal(title,scope.t(pending?'vmLxc.ociUpdates.terminalTitleRecoverPrevious':'vmLxc.ociUpdates.terminalTitleUpdate'));
assert.equal(title,pending?(locale==='sk'?'Obnoviť predchádzajúcu OCI inštaláciu':'Recover the previous OCI installation'):
(locale==='sk'?'Aktualizovať OCI aplikáciu':'Update OCI application'));
const params=executable(attribute('params'),parent,scope);
assert.equal(params.ACTION,'update');assert.equal(params.VMID,'110');
assert.equal(params.KEEP_BACKUP,'');
}
for(const action of ['modify','recover']) {
const scope={t:translator('sk'),ociAction:{vmid:110,action,recoverPrevious:true}};
assert.equal(executable(attribute('title'),parent,scope),scope.t(action==='recover'?
'vmLxc.ociUpdates.terminalTitleRecover':'vmLxc.ociUpdates.terminalTitleModify'),
'registration and editing retain their own titles');
}
for(const frame of ['raw','json']) {
const f=fixture(handlers[0],'sk'),timers=[];
const old={readyState:3,onclose:()=>f.callbacks.push(-1),onerror:()=>{},onmessage:()=>{},close(){this.onclose?.({})}};
const ref={current:old};
class Socket{static OPEN=1;constructor(){this.readyState=1}close(){this.onclose?.({})}}
const scope={...f.scope,isOpen:true,isComplete:false,reconnectAttemptsRef:{current:0},reconnectTimeoutRef:{current:null},
wsRef:ref,WebSocket:Socket,sessionIdRef:{current:'fixture'},paramsRef:{current:{}},keepAliveIntervalRef:{current:null},
scriptPath:'/fixture/oci',setConnectionStatus:()=>{},getScriptWebSocketUrl:()=> 'ws://fixture',getTicketedWsUrl:async url=>url,
setTimeout:fn=>{timers.push(fn);return 1},clearTimeout:()=>{}};
executable(reconnectCallback,source,scope)();await timers[0]();
assert.equal(old.onclose,null,'replaced connection cannot emit an unknown result');
assert.equal(old.onmessage,null,'late result from replaced connection cannot win');
ref.current.onmessage({data:frame==='raw'?'\r\n[Script exited with code 6]\r\n':JSON.stringify({type:'script_complete',exit_code:6})});
assert.deepEqual(f.callbacks,[6]);
}
for(const action of ['update','modify']) for(const code of [6,0,1,undefined]) {
const calls=[],row={vmid:110,action},saved=JSON.stringify(row),ref={current:undefined};
const scope={...completion,ociAction:row,ociCompletionCodeRef:ref,
setOciAction:value=>calls.push(['close',value]),fetchApi:(url,options)=>{calls.push(['fetch',url,options]);return Promise.resolve({});},
invalidateLxcApps:vmid=>calls.push(['invalidate',vmid]),vmModalCacheRef:{current:{mountPoints:new Map()}},
fetchOciInstance:vmid=>calls.push(['instance',vmid]),fetchMountPoints:vmid=>calls.push(['mounts',vmid]),mutate:()=>calls.push(['mutate'])};
executable(attribute('onComplete'),parent,scope)(code);
executable(attribute('onClose'),parent,scope)();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(JSON.stringify(row),saved);
if(code===6) assert.deepEqual(calls,[['close',null]],'cancel has zero registry/API/cache calls');
else assert.equal(calls.filter(c=>c[0]==='fetch'&&c[2].method==='POST').length,1,'preserve refresh after non-cancel');
executable(reset,parent,scope)();assert.equal(ref.current,undefined,'next modal cannot inherit cancellation');
}
console.log(`PASS: ${scenarios} real terminal frame scenarios; actual OCI close callbacks, code6 scoping, reset and zero cancel probes`);
})().catch(error=>{console.error(error);process.exitCode=1});