mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 02:12:12 +00:00
Merge commit 'c471bccdab95' into HEAD
# Conflicts: # .github/scripts/run_offline_qualified.py
This commit is contained in:
@@ -13,6 +13,9 @@ import sys
|
||||
import tempfile
|
||||
|
||||
PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_pbs_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links.py",
|
||||
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
|
||||
"AppImage/scripts/tests/test_local_app_icons.py",
|
||||
"AppImage/scripts/tests/test_docker_app_discovery.py",
|
||||
@@ -29,6 +32,10 @@ PYTHON_FILES = (
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/lxc_updates/test_app_editor_errors.cjs",
|
||||
"tests/test_oci_update_cancellation.cjs",
|
||||
"tests/test_host_backup_messages.cjs",
|
||||
"tests/test_pbs_i18n.cjs",
|
||||
"tests/test_monitor_links_i18n.cjs",
|
||||
"tests/lxc_updates/test_local_app_icons_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_native_app_discovery_ui.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.read = lambda root, vmid: self.primary
|
||||
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),
|
||||
'_remove': lambda *args: self.fail('remove must not run'),
|
||||
'_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'
|
||||
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.')
|
||||
PREVIEW = ('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.')
|
||||
PREVIEW = ('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\n'
|
||||
'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):
|
||||
@@ -130,7 +133,7 @@ class UpdateWording(unittest.TestCase):
|
||||
reconcile = ModuleType('oci_instance_reconcile')
|
||||
reconcile.propose = lambda *args: None
|
||||
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,
|
||||
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
|
||||
@@ -121,7 +121,7 @@ class OciWordingTests(TestCase):
|
||||
|
||||
def test_failed_registry_inspection_points_to_registry_accounts(self):
|
||||
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 _: {
|
||||
'container_contract': {'image': {'reference': 'example:latest'}}},
|
||||
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
|
||||
|
||||
@@ -20,7 +20,11 @@ class OutcomePromises(TestCase):
|
||||
texts = oci_menu_texts()
|
||||
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]
|
||||
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):
|
||||
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""PBS CLI language and no-effect boundaries using real functions, isolated files."""
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def function(path, name):
|
||||
text = (ROOT/path).read_text()
|
||||
start = text.index(name+'() {')
|
||||
return text[start:text.index('\n}\n', start)+3]
|
||||
|
||||
|
||||
def repository(components, suite='trixie'):
|
||||
return dict(Types=['deb'], URIs=['https://credential.invalid/private'], Suites=[suite],
|
||||
Components=components.split(), Enabled=True)
|
||||
|
||||
|
||||
class RepositoryCliTests(unittest.TestCase):
|
||||
def run_policy(self, case, language='sk', mode='plan', malformed=None, product='pbs'):
|
||||
spec = importlib.util.spec_from_file_location('policy_fixture', ROOT/'scripts/global/repository_policy.py')
|
||||
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
state = dict(files=[dict(path='/etc/apt/sources.list.d/pbs.sources', repositories=[repository('pbs-enterprise')]),
|
||||
dict(path='/etc/apt/sources.list.d/debian.sources', repositories=[repository('main')])],
|
||||
errors=[], digest='fixture', **{'standard-repos':[{'handle':'no-subscription'}]})
|
||||
status='notfound'; calls=[]
|
||||
if case=='inventory': state['errors']=['PRIVATE RAW DIAGNOSTIC']
|
||||
if case=='status': status='unknown'
|
||||
if case=='suite': state['files'][0]['repositories'][0]['Suites']=['bookworm']
|
||||
if case=='active': status='active'; state['files']=state['files'][1:]
|
||||
if case=='debian': state['files']=state['files'][:1]
|
||||
if case=='mixed': state['files'][0]['repositories'][0]['Components'].append('main')
|
||||
if case=='handle': state['standard-repos']=[]
|
||||
if case=='preserve': state['files'][0]['repositories'][0]['Components']=['pbs-no-subscription']
|
||||
original=copy.deepcopy(state)
|
||||
def run(args):
|
||||
calls.append(args)
|
||||
if args[0]=='proxmox-backup-manager': return json.dumps(dict(status=status, serverid='PRIVATE SERVER ID'))
|
||||
if 'create' in args:
|
||||
state['files'][0]['repositories'][0]['Enabled']=False
|
||||
if case=='partial': state['errors']=['PRIVATE RAW DIAGNOSTIC']
|
||||
return ''
|
||||
if 'set' in args:
|
||||
state['files'].append(dict(path='/etc/apt/sources.list.d/public.sources',repositories=[repository('pbs-no-subscription')]))
|
||||
return ''
|
||||
return json.dumps(state)
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-policy-i18n-') as folder:
|
||||
module.BASE=Path(folder)
|
||||
(module.BASE/'lang').mkdir()
|
||||
(module.BASE/'config.json').write_text(json.dumps({'language':language}))
|
||||
catalog=(ROOT/'lang/sk.json').read_text() if malformed is None else malformed
|
||||
(module.BASE/'lang/sk.json').write_text(catalog)
|
||||
stdout,stderr=io.StringIO(),io.StringIO()
|
||||
suite='sid' if case=='unsupported' else 'trixie'
|
||||
with patch.object(module,'run',side_effect=run), patch.object(module.sys,'argv',['repository_policy.py',mode,suite,product]), contextlib.redirect_stdout(stdout),contextlib.redirect_stderr(stderr):
|
||||
result=module.main()
|
||||
return result,stdout.getvalue(),stderr.getvalue(),calls,state,original
|
||||
|
||||
def test_pbs_errors_are_localized_without_changing_decisions_or_exposing_raw_data(self):
|
||||
expectations={'inventory':'Zoznam repozitárov APT', 'status':'Stav predplatného',
|
||||
'suite':'iné vydanie', 'active':'aktívne predplatné', 'debian':'základný repozitár Debianu',
|
||||
'mixed':'spoločný záznam APT', 'handle':'no-subscription', 'unsupported':'Nepodporované vydanie',
|
||||
'partial':'stav repozitárov je neznámy'}
|
||||
for case,expected in expectations.items():
|
||||
with self.subTest(case=case):
|
||||
result,out,error,calls,state,original=self.run_policy(case,mode='apply' if case=='partial' else 'plan')
|
||||
self.assertEqual(result,1); self.assertEqual(out,''); self.assertIn(expected,error)
|
||||
self.assertNotIn('PRIVATE',error); self.assertNotIn('credential.invalid',error)
|
||||
writes=[c for c in calls if 'create' in c or 'set' in c]
|
||||
if case=='partial':
|
||||
self.assertEqual(len(writes),1)
|
||||
self.assertFalse(state['files'][0]['repositories'][0]['Enabled'])
|
||||
self.assertIn('Automatický návrat',error)
|
||||
else:
|
||||
self.assertEqual(writes,[]); self.assertEqual(state,original)
|
||||
if case=='unsupported': self.assertEqual(calls,[])
|
||||
|
||||
def test_english_error_and_stdout_tokens_remain_compatible(self):
|
||||
en=self.run_policy('unsupported',language='en')
|
||||
self.assertEqual(en[2],'Unsupported Proxmox suite; no repository changed.\n')
|
||||
pve=self.run_policy('unsupported',product='pve')
|
||||
self.assertEqual(pve[2],en[2])
|
||||
for mode,expected in [('plan','offer\n'),('apply','changed\n')]:
|
||||
result,out,error,*_=self.run_policy('offer',mode=mode)
|
||||
self.assertEqual((result,out,error),(0,expected,''))
|
||||
result,out,error,calls,*_=self.run_policy('preserve',mode='apply')
|
||||
self.assertEqual((result,out,error),(0,'preserve\n',''))
|
||||
self.assertFalse(any(c[0]=='proxmox-backup-manager' or 'set' in c or 'create' in c for c in calls))
|
||||
|
||||
def test_bad_or_missing_translation_falls_back_to_original_english(self):
|
||||
for catalog in ('{}','not json','[]',json.dumps({'Unsupported Proxmox suite; no repository changed.':42}),
|
||||
json.dumps({'Unsupported Proxmox suite; no repository changed.':'{unknown}'})):
|
||||
with self.subTest(catalog=catalog):
|
||||
result,out,error,calls,*_=self.run_policy('unsupported',malformed=catalog)
|
||||
self.assertEqual(result,1);self.assertEqual(out,'');self.assertEqual(calls,[])
|
||||
self.assertEqual(error,'Unsupported Proxmox suite; no repository changed.\n')
|
||||
|
||||
def test_translation_cannot_drop_actual_repository_details(self):
|
||||
spec=importlib.util.spec_from_file_location('policy_format_fixture',ROOT/'scripts/global/repository_policy.py')
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-policy-format-') as folder:
|
||||
module.BASE=Path(folder);(module.BASE/'lang').mkdir()
|
||||
(module.BASE/'config.json').write_text('{"language":"sk"}')
|
||||
error=module.PolicyError('Inspect {ui}; no repository changed.',ui='Administration > Repositories')
|
||||
for invalid in ('Skontrolujte repozitáre.','{ui.__class__}','{other}','{ui:.0}','{ui!r}'):
|
||||
(module.BASE/'lang/sk.json').write_text(json.dumps({error.template:invalid}))
|
||||
self.assertEqual(module.cli_error(error,'pbs'),str(error))
|
||||
for invalid in ('not json','[]','{"language":"../../private"}'):
|
||||
(module.BASE/'config.json').write_text(invalid)
|
||||
self.assertEqual(module.cli_error(error,'pbs'),str(error))
|
||||
|
||||
|
||||
UPDATE_HARNESS=r'''
|
||||
BASE_DIR="$FIXTURE"
|
||||
LANG_DIR="$REPO/lang"
|
||||
SCRIPT_TITLE='fixture'
|
||||
TAB='' BGN='' CL='' GN='' BL=''
|
||||
mkdir -p "$FIXTURE/log" "$FIXTURE/tmp" "$FIXTURE/boot" "$FIXTURE/run"
|
||||
: > "$FIXTURE/calls"
|
||||
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
|
||||
pmx_journal_context() { :; }
|
||||
pmx_is_pbs() { [[ "$CASE" != pve ]]; }
|
||||
pmx_platform_field() { [[ "$1" != supported ]] && printf '%s' "$SUITE" || printf true; }
|
||||
ensure_pbs_repositories() { record repository-plan; return 0; }
|
||||
dpkg-query() { [[ "$SUITE" == bookworm ]] && printf 3.4.0 || printf 4.0.1; }
|
||||
apt-cache() { record apt-cache "$@"; printf ' Candidate: 4.0.2\n'; }
|
||||
df() { printf 'Filesystem 1K-blocks Used Available Use%% Mounted\nfixture 10000 1000 %s 10%% /\n' "$([[ "$CASE" == disk ]] && echo 512000 || echo 4096000)"; }
|
||||
apt-get() { record apt-get "$@"; [[ "$CASE" != refresh ]]; }
|
||||
apt() {
|
||||
record apt "$@"
|
||||
if [[ "$*" == 'list --upgradable' ]]; then
|
||||
printf 'Listing...\n'
|
||||
[[ "$CASE" == current ]] || printf 'fixture/trixie 1.2 amd64 [upgradable from: 1.1]\n'
|
||||
return 0
|
||||
fi
|
||||
[[ "$CASE" != failure ]]
|
||||
}
|
||||
fixture_env() { "$@"; }
|
||||
APT_ENV='fixture_env'
|
||||
pmx_format_upgradable() { cat; }
|
||||
pmx_record_execution() { record journal "$1"; }
|
||||
pmx_dpkg_changes_since() { :; }
|
||||
msg_error() { printf '%s\n' "$*"; }
|
||||
msg_ok() { printf '%s\n' "$*"; }
|
||||
msg_info2() { printf '%s\n' "$*"; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
msg_title() { :; }
|
||||
clear() { :; }
|
||||
show_proxmenux_logo() { :; }
|
||||
whiptail() { printf '%s\n' "$*" >> "$FIXTURE/dialog"; [[ "$CASE" != cancel ]]; }
|
||||
uname() { printf old-kernel; }
|
||||
touch "$FIXTURE/boot/vmlinuz-new-kernel"
|
||||
'''
|
||||
|
||||
|
||||
class UpdateCliTests(unittest.TestCase):
|
||||
def run_update(self,case,language='sk',suite='trixie'):
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-update-i18n-') as folder:
|
||||
body=function('scripts/global/update-pbs.sh','update_pbs')
|
||||
for old,new in [('/var/log/',folder+'/log/'),('/tmp/proxmenux_screen_capture_',folder+'/tmp/proxmenux_screen_capture_'),('/var/run/',folder+'/run/'),('/boot/',folder+'/boot/')]: body=body.replace(old,new)
|
||||
script=UPDATE_HARNESS+function('scripts/utils.sh','translate')+body+'\nupdate_pbs\n'
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'REPO':str(ROOT),'FIXTURE':folder,'CASE':case,'LANGUAGE':language,'SUITE':suite})
|
||||
files={str(p.relative_to(folder)):p.read_text() for p in Path(folder).rglob('*') if p.is_file()}
|
||||
return result,files
|
||||
|
||||
def test_pve_guard_cannot_reach_repository_or_apt_calls(self):
|
||||
result,files=self.run_update('pve')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertEqual(files['calls'],'')
|
||||
self.assertFalse(any(name.startswith('log/') for name in files))
|
||||
|
||||
def test_disk_and_upgrade_errors_keep_their_actual_parameters(self):
|
||||
result,files=self.run_update('disk')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertIn('Nedostatok miesta na disku. Dostupné:',result.stdout)
|
||||
self.assertIn('500MB',result.stdout)
|
||||
self.assertNotIn('apt',files['calls'])
|
||||
result,files=self.run_update('failure')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertIn('Aktualizácia systému zlyhala. Skontrolujte denník:',result.stdout)
|
||||
self.assertIn('/log/proxmox-update-',result.stdout)
|
||||
self.assertNotIn('autoremove',files['calls'])
|
||||
|
||||
def test_update_status_cancel_refresh_error_and_reboot_notice(self):
|
||||
for suite in ('bookworm','trixie'):
|
||||
for case in ('current','cancel','refresh','success'):
|
||||
with self.subTest(suite=suite,case=case):
|
||||
result,files=self.run_update(case,suite=suite)
|
||||
self.assertEqual(result.returncode,1 if case=='refresh' else 0,result.stderr)
|
||||
if case!='success': self.assertNotIn('full-upgrade',files['calls'])
|
||||
if case=='success': self.assertIn('reštartujte server',result.stdout)
|
||||
if case in ('current','cancel'): self.assertIn('Aktuálna verzia PBS',files['dialog'])
|
||||
|
||||
|
||||
class DialogTests(unittest.TestCase):
|
||||
def test_automated_confirmation_and_banner_cancel_do_not_run_actions(self):
|
||||
cases=[('scripts/menus/menu_post_install.sh','confirm_automated_script',
|
||||
['repozitár no-subscription','upozornenie na predplatné','pamäť','jadro','sieť']),
|
||||
('scripts/post_install/auto_post_install.sh','remove_subscription_banner',
|
||||
['Proxmox Backup Server – odstránenie upozornenia na predplatné'])]
|
||||
for path,name,expected in cases:
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-dialog-') as folder:
|
||||
script=function('scripts/utils.sh','translate')+function(path,name)+r'''
|
||||
PMX_ON_PBS=true
|
||||
LOCAL_SCRIPTS='/fixture/scripts'
|
||||
clear() { :; }; sleep() { :; }; stop_spinner() { :; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
bash() { printf forbidden >> "$CAPTURE/actions"; }
|
||||
register_tool() { printf forbidden >> "$CAPTURE/actions"; }
|
||||
dialog() { printf '%s\n' "$*" >> "$CAPTURE/dialog"; return 1; }
|
||||
whiptail() { dialog "$@"; }
|
||||
'''+name+'\n'
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'CAPTURE':folder})
|
||||
self.assertEqual(result.returncode,1 if name=='remove_subscription_banner' else 0,result.stderr)
|
||||
dialog=(Path(folder)/'dialog').read_text()
|
||||
for text in expected:self.assertIn(text,dialog)
|
||||
self.assertFalse((Path(folder)/'actions').exists())
|
||||
if name=='remove_subscription_banner':self.assertIn('bolo zrušené používateľom',result.stdout)
|
||||
|
||||
def test_community_labels_preserve_dispatch_names_and_translate_display(self):
|
||||
script=function('scripts/utils.sh','translate')+function('scripts/menus/menu_post_install.sh','post_install_label')+'\npost_install_label "$1"\n'
|
||||
for text,expected in [('Proxmox Backup Server Post Install','nastavenie po inštalácii'),
|
||||
('Proxmox Backup Server Microcode','mikrokód procesora')]:
|
||||
result=subprocess.run(['bash','-c',script,'bash',text],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang')})
|
||||
self.assertEqual(result.returncode,0,result.stderr);self.assertIn(expected,result.stdout)
|
||||
|
||||
def test_failed_apt_hook_check_removes_only_fixture_hook_with_localized_warning(self):
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-hook-') as folder:
|
||||
script=function('scripts/utils.sh','translate')+function('scripts/global/remove-banner-pve-v3.sh','create_apt_hook')+r'''
|
||||
apt-config() { return 1; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
create_apt_hook
|
||||
'''
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'APT_HOOK':folder+'/hook'})
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
self.assertIn('Nesprávna syntax hooku APT',result.stdout)
|
||||
self.assertFalse((Path(folder)/'hook').exists())
|
||||
@@ -69,13 +69,43 @@ class TerminalCatalogsTests(unittest.TestCase):
|
||||
def test_shipped_active_catalogs_keep_literal_contracts(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--require-complete'],
|
||||
'--root', str(ROOT)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
for locale in MODULE.DEFAULT_LANGUAGES:
|
||||
self.assertIn(f'[{locale}] active=', result.stdout)
|
||||
self.assertNotIn('missing=1', result.stdout)
|
||||
# New hand-reviewed Slovak keys ship with their source changes;
|
||||
# other locales may use the runtime English fallback until translated.
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--languages', 'sk', '--require-complete'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn('missing=0', result.stdout)
|
||||
|
||||
def test_optional_completeness_still_rejects_invalid_present_translations(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / 'scripts').mkdir()
|
||||
(root / 'lang').mkdir()
|
||||
(root / 'scripts/check.sh').write_text('translate "Check /etc/pve/storage.cfg"')
|
||||
(root / 'lang/sk.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Skontrolujte /etc/pve/storage.cfg'}))
|
||||
(root / 'lang/es.json').write_text('{}')
|
||||
|
||||
def check(languages, *flags):
|
||||
return subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(root), '--languages', languages, *flags],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
|
||||
self.assertEqual(check('es,sk').returncode, 0)
|
||||
self.assertEqual(check('sk', '--require-complete').returncode, 0)
|
||||
self.assertNotEqual(check('es', '--require-complete').returncode, 0)
|
||||
(root / 'lang/es.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Compruebe /etc/PVE/storage.cfg'}))
|
||||
self.assertNotEqual(check('es,sk').returncode, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Render the real ZRAM CLI paths in isolated files with host effects stubbed."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
DETAILS = {
|
||||
"version": ("Cannot determine installed systemd-zram-generator version", "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator"),
|
||||
"mismatch": ("Existing ZRAM profile is active but does not match its configuration", "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii"),
|
||||
"stop": ("Could not stop existing dev-zram0.swap", "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap"),
|
||||
"directory": ("Could not create ZRAM generator directory", "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM"),
|
||||
"config": ("Could not write ZRAM generator configuration", "Nepodarilo sa zapísať konfiguráciu generátora ZRAM"),
|
||||
"marker": ("Could not write ZRAM package ownership state", "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux"),
|
||||
"start": ("Could not activate dev-zram0.swap", "Nepodarilo sa aktivovať jednotku dev-zram0.swap"),
|
||||
"verify": ("Active ZRAM algorithm, capacity or swap sources did not match the profile", "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu"),
|
||||
}
|
||||
REFUSAL = "Another ZRAM generator configuration exists; refusing to overwrite it:"
|
||||
REFUSAL_SK = "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:"
|
||||
|
||||
|
||||
def function(path, name):
|
||||
text = path.read_text()
|
||||
start = text.index(name + "() {")
|
||||
return text[start:text.index("\n}\n", start) + 3]
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
set -u
|
||||
BASE_DIR="$FIXTURE/state"
|
||||
LANG_DIR="$REPO/lang"
|
||||
mkdir -p "$BASE_DIR" "$FIXTURE/etc/systemd/zram-generator.conf.d" "$FIXTURE/proc"
|
||||
CONFIG="$FIXTURE/etc/systemd/zram-generator.conf.d/90-proxmenux.conf"
|
||||
MARKER="$BASE_DIR/zram-swap.state"
|
||||
printf 'Filename\tType\tSize\tUsed\tPriority\n' > "$FIXTURE/proc/swaps"
|
||||
printf '/swapfile none swap sw 0 0\n' > "$FIXTURE/etc/fstab"
|
||||
: > "$FIXTURE/calls"
|
||||
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
|
||||
pmx_journal_context() { :; }
|
||||
msg_error() { printf '%s\n' "$*"; }
|
||||
msg_success() { printf '%s\n' "$*"; }
|
||||
register_tool() { record register "$@"; }
|
||||
pmx_install_pkg() { record package-install; return 1; }
|
||||
apt-get() { record apt "$@"; return 1; }
|
||||
dpkg-query() {
|
||||
record dpkg-query
|
||||
case "$*" in
|
||||
*'${Status}'*) printf 'install ok installed\n';;
|
||||
*'${Version}'*) [[ "$CASE" == version ]] || printf '1.2.0-1\n';;
|
||||
esac
|
||||
}
|
||||
dpkg() { return 0; }
|
||||
install() { record directory; [[ "$CASE" != directory ]]; }
|
||||
pmx_write_file() {
|
||||
record write "$1"
|
||||
if [[ "$CASE" == config && "$1" == "$CONFIG" ]] ||
|
||||
[[ "$CASE" == marker && "$1" == "$MARKER" ]]; then
|
||||
cat >/dev/null
|
||||
return 1
|
||||
fi
|
||||
cat > "$1"
|
||||
}
|
||||
pmx_remove_file() { record remove "$1"; rm -f -- "$1"; }
|
||||
pmx_record_uninstall() { record record-uninstall; }
|
||||
swapoff() { record swapoff; return 0; }
|
||||
systemctl() {
|
||||
record systemctl "$@"
|
||||
[[ "$CASE" != start || "$1" != start ]] &&
|
||||
[[ "$CASE" != stop || "$1" != stop ]]
|
||||
}
|
||||
source "$REPO/scripts/post_install/zram_profile_helpers.sh"
|
||||
zram_profile_verify_active() { [[ "$CASE" != verify && "$CASE" != mismatch ]]; }
|
||||
if [[ "$CASE" == mismatch || "$CASE" == stop ]]; then
|
||||
printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
|
||||
[[ "$CASE" != mismatch ]] || zram_render_generator_config true > "$CONFIG"
|
||||
printf 'installed_by_proxmenux=false\n' > "$MARKER"
|
||||
printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$FIXTURE/proc/swaps"
|
||||
fi
|
||||
if [[ "$CASE" == foreign ]]; then
|
||||
printf '[zram1]\nzram-size = ram / 5\n' > "$FIXTURE/etc/systemd/zram-generator.conf"
|
||||
fi
|
||||
if [[ "$CASE" == unreadable ]]; then
|
||||
zram_foreign_generator_config() { return 2; }
|
||||
fi
|
||||
'''
|
||||
|
||||
|
||||
class ZramLocalizationTests(unittest.TestCase):
|
||||
def test_missing_optional_translation_uses_original_english(self):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-fallback-") as folder:
|
||||
(Path(folder) / 'xx.json').write_text('{}')
|
||||
script = function(ROOT / "scripts/utils.sh", "translate") + '\ntranslate "$1"\n'
|
||||
result = subprocess.run(["bash", "-c", script, "bash", DETAILS['version'][0]],
|
||||
text=True, capture_output=True, timeout=15,
|
||||
env={**os.environ, 'LANGUAGE': 'xx', 'LANG_DIR': folder})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), DETAILS['version'][0])
|
||||
|
||||
def run_case(self, case, language="sk"):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-i18n-") as folder:
|
||||
directory = Path(folder)
|
||||
installer = function(ROOT / "scripts/post_install/customizable_post_install.sh", "configure_zram_swap")
|
||||
installer = installer.replace("/etc/systemd/zram-generator.conf.d/90-proxmenux.conf", folder + "/etc/systemd/zram-generator.conf.d/90-proxmenux.conf")
|
||||
installer = installer.replace("/proc/swaps", folder + "/proc/swaps").replace("/etc/fstab", folder + "/etc/fstab")
|
||||
installer = installer.replace('zram_foreign_generator_config "$config"', 'zram_foreign_generator_config "$config" "$FIXTURE"')
|
||||
installer = installer.replace('zram_list_foreign_systemd_zram_units)', 'zram_list_foreign_systemd_zram_units "$FIXTURE")')
|
||||
script = HARNESS + function(ROOT / "scripts/utils.sh", "translate") + installer + "\nconfigure_zram_swap\n"
|
||||
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
|
||||
env={**os.environ, "REPO": str(ROOT), "FIXTURE": folder, "CASE": case, "LANGUAGE": language}, timeout=15)
|
||||
calls = (directory / "calls").read_text().splitlines()
|
||||
files = {str(p.relative_to(directory)): p.read_text() for p in directory.rglob("*") if p.is_file()}
|
||||
return result, calls, files
|
||||
|
||||
def test_all_activation_details_are_localized_and_english_is_preserved(self):
|
||||
for case, (english, slovak) in DETAILS.items():
|
||||
for language, expected in (("sk", slovak), ("en", english)):
|
||||
with self.subTest(case=case, language=language):
|
||||
result, calls, files = self.run_case(case, language)
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn(expected, result.stdout)
|
||||
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
|
||||
if case in ("version", "mismatch"):
|
||||
self.assertTrue(all(call == "dpkg-query" for call in calls), calls)
|
||||
elif case == "stop":
|
||||
self.assertEqual(files["state/zram-swap.state"], "installed_by_proxmenux=false\n")
|
||||
self.assertIn("# ProxMenux ZRAM profile", files["etc/systemd/zram-generator.conf.d/90-proxmenux.conf"])
|
||||
else:
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
self.assertFalse(any(call.startswith(("apt ", "package-install", "register ")) for call in calls), calls)
|
||||
|
||||
def test_foreign_generator_is_refused_in_both_languages_without_any_effect(self):
|
||||
for language, expected in (("sk", REFUSAL_SK), ("en", REFUSAL)):
|
||||
result, calls, files = self.run_case("foreign", language)
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn(expected, result.stdout)
|
||||
self.assertIn("/etc/systemd/zram-generator.conf", result.stdout)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(files["etc/systemd/zram-generator.conf"], "[zram1]\nzram-size = ram / 5\n")
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
|
||||
self.assertEqual(files["proc/swaps"], "Filename\tType\tSize\tUsed\tPriority\n")
|
||||
|
||||
def test_failed_inspection_is_not_mistaken_for_no_conflict(self):
|
||||
result, calls, files = self.run_case("unreadable")
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn("Nie je možné skontrolovať existujúcu konfiguráciu generátora ZRAM", result.stdout)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
|
||||
def test_uninstall_label_and_cancel_do_not_call_uninstall(self):
|
||||
for language, label in (("sk", "Profil swapu ZRAM"), ("en", "ZRAM swap profile")):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-menu-") as folder:
|
||||
tools = Path(folder) / "tools.json"
|
||||
tools.write_text('{"zram_swap":true}')
|
||||
script = function(ROOT / "scripts/utils.sh", "translate")
|
||||
script += function(ROOT / "scripts/post_install/uninstall-tools.sh", "show_uninstall_menu")
|
||||
script += '\nensure_tools_json() { :; }; migrate_installed_tools() { :; }\nuninstall_zram_swap() { printf forbidden; return 1; }\ndialog() { printf "%s\\n" "$@" > "$MENU_CAPTURE"; return 1; }\nshow_uninstall_menu\n'
|
||||
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
|
||||
env={**os.environ, "LANGUAGE": language, "LANG_DIR": str(ROOT / "lang"), "TOOLS_JSON": str(tools), "MENU_CAPTURE": folder + "/dialog.txt"}, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(label, (Path(folder) / "dialog.txt").read_text())
|
||||
self.assertNotIn("forbidden", result.stdout + result.stderr)
|
||||
self.assertEqual(json.loads(tools.read_text()), {"zram_swap": True})
|
||||
@@ -71,8 +71,8 @@ jobs:
|
||||
run: |
|
||||
test -x /bin/bash
|
||||
test -x /usr/bin/jq
|
||||
- name: Install OCI wording test dependency
|
||||
run: python3 -m pip install 'PyYAML>=6,<7'
|
||||
- name: Install OCI wording test dependencies
|
||||
run: python3 -m pip install 'PyYAML>=6,<7' 'jsonschema==4.25.1'
|
||||
- name: Run offline i18n and script regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
|
||||
@@ -16,6 +16,7 @@ on:
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
@@ -41,6 +42,7 @@ on:
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- '.github/workflows/test-pbs.yml'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'AppImage/scripts/tests/test_pbs.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/auth_manager.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
@@ -51,6 +52,7 @@ jobs:
|
||||
- name: Platform, real TLS, APIs, health and AppImage packaging
|
||||
run: |
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
|
||||
bash -n "$script"
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { RestoreProgressCard } from "./restore-progress-card"
|
||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||
import { hostBackupErrorText } from "../lib/host-backup-messages"
|
||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
|
||||
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
||||
@@ -525,7 +526,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
||||
await mutateInfo()
|
||||
closeUpload()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -542,7 +543,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
||||
await mutateInfo()
|
||||
closeDelete()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -765,7 +766,7 @@ function KeyfileActionsBar({
|
||||
setPass1("")
|
||||
setPass2("")
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -956,7 +957,7 @@ export function HostBackup() {
|
||||
mutateJobs()
|
||||
setJobToDelete(null)
|
||||
} catch (e) {
|
||||
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` }))
|
||||
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${hostBackupErrorText(e, t)}` }))
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
@@ -1334,7 +1335,7 @@ export function HostBackup() {
|
||||
<div className="font-medium">{t("backup.archives.remoteQueryWarning")}</div>
|
||||
{remoteArchivesResp.errors.map((e, i) => (
|
||||
<div key={i} className="font-mono break-all">
|
||||
{e.backend}/{e.repo_name}: {e.error}
|
||||
{e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1761,7 +1762,7 @@ function InspectModal({
|
||||
setImportFile(null)
|
||||
setImportPath("")
|
||||
} catch (e) {
|
||||
setImportError(e instanceof Error ? e.message : String(e))
|
||||
setImportError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
@@ -1812,7 +1813,7 @@ function InspectModal({
|
||||
: undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
setRestoreError(e instanceof Error ? e.message : String(e))
|
||||
setRestoreError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setRestorePreparing(false)
|
||||
}
|
||||
@@ -1841,7 +1842,7 @@ function InspectModal({
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
// The download itself runs in the browser's network stack;
|
||||
// we just initiated it. Clear the spinner immediately.
|
||||
@@ -1914,7 +1915,7 @@ function InspectModal({
|
||||
document.body.removeChild(a)
|
||||
setExportTask(null)
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
@@ -1962,7 +1963,7 @@ function InspectModal({
|
||||
setShowDeleteArchiveConfirm(false)
|
||||
if (onDeleted) onDeleted(); else onClose()
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.deleteFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setDeletingArchive(false)
|
||||
}
|
||||
@@ -1989,7 +1990,7 @@ function InspectModal({
|
||||
) as NonNullable<typeof instructions>
|
||||
setInstructions(res)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setFetchingInstructions(false)
|
||||
}
|
||||
@@ -2021,7 +2022,7 @@ function InspectModal({
|
||||
)
|
||||
setReport(res)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("backup.errors.preflightFailed"))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
@@ -2164,7 +2165,7 @@ function InspectModal({
|
||||
<span className="text-muted-foreground">— {exportTask.message}</span>
|
||||
</div>
|
||||
{exportTask.state === "failed" && exportTask.error && (
|
||||
<div className="text-red-500 mt-1">{exportTask.error}</div>
|
||||
<div className="text-red-500 mt-1">{hostBackupErrorText(exportTask.error, t)}</div>
|
||||
)}
|
||||
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
|
||||
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
|
||||
@@ -2706,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
|
||||
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`font-mono ${color}`}>{c.id}</span>
|
||||
<span className="text-muted-foreground ml-2">{c.message}</span>
|
||||
<span className="text-muted-foreground ml-2">{hostBackupErrorText(c.message, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -3326,9 +3327,9 @@ function CreateJobDialog({
|
||||
} catch (e) {
|
||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||
const detail = err.body?.tool_output
|
||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: err.message
|
||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
||||
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: hostBackupErrorText(err, t)
|
||||
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
@@ -3391,7 +3392,7 @@ function CreateJobDialog({
|
||||
})
|
||||
onCreated()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -3764,7 +3765,7 @@ function CreateJobDialog({
|
||||
</>
|
||||
) : (
|
||||
<div className="text-red-500">
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {calendarPreview.error}
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {hostBackupErrorText(calendarPreview.error, t)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -4696,9 +4697,9 @@ function ManualBackupDialog({
|
||||
} catch (e) {
|
||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||
const detail = err.body?.tool_output
|
||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: err.message
|
||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
||||
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: hostBackupErrorText(err, t)
|
||||
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
@@ -4738,7 +4739,7 @@ function ManualBackupDialog({
|
||||
)
|
||||
onLaunched(resp.job_id)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -5542,7 +5543,7 @@ function DestinationsSection({
|
||||
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5554,7 +5555,7 @@ function DestinationsSection({
|
||||
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5571,7 +5572,7 @@ function DestinationsSection({
|
||||
)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5599,7 +5600,7 @@ function DestinationsSection({
|
||||
})
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -6042,7 +6043,7 @@ function DestinationRow({
|
||||
|
||||
{capacity?.error && (
|
||||
<p className="mt-3 text-[11px] text-muted-foreground italic">
|
||||
{t("backup.destinations.capacityUnavailable", { error: capacity.error })}
|
||||
{t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}
|
||||
</p>
|
||||
)}
|
||||
{!capacity && (
|
||||
@@ -6341,7 +6342,7 @@ function AddDestinationDialog({
|
||||
)
|
||||
setGeneratedKey(resp)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setGeneratingKey(false)
|
||||
}
|
||||
@@ -6405,7 +6406,7 @@ function AddDestinationDialog({
|
||||
}
|
||||
onSaved(savedRepo)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -6756,7 +6757,7 @@ function UsbPicker({
|
||||
mutate()
|
||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -6784,7 +6785,7 @@ function UsbPicker({
|
||||
setFormatTyped("")
|
||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7001,7 +7002,7 @@ function ExtraPathsSection() {
|
||||
setNewPath("")
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -7014,7 +7015,7 @@ function ExtraPathsSection() {
|
||||
await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" })
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyPath(null)
|
||||
}
|
||||
@@ -7180,7 +7181,7 @@ function UsbDrivesSection() {
|
||||
})
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7198,7 +7199,7 @@ function UsbDrivesSection() {
|
||||
})
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7222,7 +7223,7 @@ function UsbDrivesSection() {
|
||||
setFormatTarget(null)
|
||||
setFormatTyped("")
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7508,7 +7509,7 @@ function JobDetailModal({
|
||||
setRunning(false)
|
||||
setRunBaseline(null)
|
||||
setRunBaselineLogPath(null)
|
||||
setActionError(t("backup.errors.runFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setActionError(t("backup.errors.runFailed", { error: hostBackupErrorText(e, t) }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7522,7 +7523,7 @@ function JobDetailModal({
|
||||
await refetch()
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setActionError(t("backup.errors.toggleFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setActionError(t("backup.errors.toggleFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setBusy("")
|
||||
}
|
||||
@@ -8155,7 +8156,7 @@ function PbsKeyfileRecoveryDialog({
|
||||
})
|
||||
onRecovered()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -8342,7 +8343,7 @@ function ArchiveContentsModal({
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((r: any) => setData(r))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.catch((e) => setError(hostBackupErrorText(e, t)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, source, repo_name, snapshot, path])
|
||||
|
||||
@@ -8571,7 +8572,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.blockers")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{blockers.map((b, i) => (
|
||||
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
|
||||
<li key={i} className="text-red-400">{hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8581,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">{t("backup.plan.warnings")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
|
||||
<li key={i} className="text-amber-400">{hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8611,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
|
||||
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{hostBackupErrorText(e.message, t)}</div>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { monitorLinkError } from "../lib/monitor-link-errors"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface MonitorLink {
|
||||
@@ -185,8 +186,9 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
await refreshCache("/api/monitor-links")
|
||||
setOfferOrigin(false)
|
||||
} catch (err) {
|
||||
await refreshCache("/api/monitor-links")
|
||||
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
setAddError(monitorLinkError(err, t))
|
||||
try { await refreshCache("/api/monitor-links") }
|
||||
catch { /* SWR shows the load error; keep the localized mutation error. */ }
|
||||
} finally { setAdding(false) }
|
||||
}
|
||||
const notNow = () => {
|
||||
@@ -260,7 +262,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
</DropdownMenu>
|
||||
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
|
||||
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
|
||||
@@ -290,7 +292,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
|
||||
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const t = useT()
|
||||
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const { data, mutate, error: loadError, isLoading } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
|
||||
const [addresses, setAddresses] = useState<Record<string, string>>({})
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
@@ -304,14 +306,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
await mutate()
|
||||
await refreshCache("/api/monitor-links")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
setError(monitorLinkError(err, t))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const send = (path: string, method: string, body?: unknown) =>
|
||||
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
||||
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||
@@ -320,7 +322,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
|
||||
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
|
||||
@@ -350,6 +352,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data?.links.map(link => {
|
||||
const own = link.manual || link.custom_url
|
||||
@@ -378,8 +382,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
|
||||
</div>
|
||||
: isEditing
|
||||
? <div className="flex gap-2">
|
||||
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
? <div className="grid grid-cols-2 gap-2 sm:flex">
|
||||
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
|
||||
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
|
||||
|
||||
@@ -4,7 +4,8 @@ import { CalendarClock, Database } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
|
||||
export interface PBSDatastore {
|
||||
name: string
|
||||
@@ -13,6 +14,7 @@ export interface PBSDatastore {
|
||||
used?: number | null
|
||||
avail?: number | null
|
||||
error?: string
|
||||
error_code?: string
|
||||
"backend-type"?: string
|
||||
"estimated-full-date"?: number
|
||||
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
|
||||
@@ -42,7 +44,7 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-500">{store.error || t("pbs.capacityUnavailable")}</p>
|
||||
<PBSProblemMessage className="text-xs text-amber-500" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
</div>
|
||||
}
|
||||
const tone = usageTone(percent)
|
||||
@@ -62,10 +64,11 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
|
||||
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
|
||||
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const percent = datastorePercent(store)
|
||||
const tone = percent == null ? null : usageTone(percent)
|
||||
const fullDate = store["estimated-full-date"]
|
||||
const type = store["backend-type"] || "datastore"
|
||||
const type = store["backend-type"] || t("pbs.datastoreType")
|
||||
return <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||
@@ -99,13 +102,13 @@ export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
|
||||
<PBSProblemMessage className="text-sm text-muted-foreground" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
)}
|
||||
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
|
||||
{fullDate !== undefined && <span className="flex items-center">
|
||||
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}</span>
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString(language) : t("pbs.estimatedFullUnknown")}</span>
|
||||
</span>}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,10 @@ import { Label } from "./ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic, pbsResultText, pbsServiceState, pbsTaskType, formatPBSDuration } from "../lib/pbs-messages"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
|
||||
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
|
||||
interface Detected { name: string; url: string; fingerprint: string }
|
||||
@@ -26,6 +28,7 @@ interface PBSStatus {
|
||||
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
|
||||
datastores: PBSDatastore[] | null; tasks: Task[] | null;
|
||||
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
|
||||
error_details?: Record<string, { code: string; params?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" }
|
||||
@@ -46,9 +49,10 @@ const TASK_ICONS: Record<string, typeof Activity> = {
|
||||
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
|
||||
}
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
|
||||
// PBS reports "unknown", "OK", "WARNINGS: n" or an error; no state is still running.
|
||||
function taskState(task: Task): keyof typeof badgeTone {
|
||||
if (!task.status) return task.endtime ? "muted" : "running"
|
||||
if (task.status === "unknown") return "muted"
|
||||
if (task.status === "OK") return "ok"
|
||||
if (task.status.startsWith("WARNINGS")) return "warn"
|
||||
return "error"
|
||||
@@ -56,27 +60,19 @@ function taskState(task: Task): keyof typeof badgeTone {
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text of the last run.
|
||||
function jobState(state?: string): keyof typeof badgeTone {
|
||||
if (!state) return "muted"
|
||||
if (!state || state === "unknown") return "muted"
|
||||
if (state.toLowerCase() === "ok") return "ok"
|
||||
if (state.toLowerCase().startsWith("warn")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "—"
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
|
||||
}
|
||||
|
||||
// `localOnly` is the PBS host's own Monitor: no connection picker, the
|
||||
// header Refresh already reloads the page data. Its tabs show one `view` each.
|
||||
export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: boolean; view?: "all" | "datastores" | "tasks" } = {}) {
|
||||
const showStores = view !== "tasks"
|
||||
const showTasks = view !== "datastores"
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
|
||||
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
|
||||
const [selected, setSelected] = useState("")
|
||||
@@ -85,7 +81,8 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
const [method, setMethod] = useState<Method>("password")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [errorFallback, setErrorFallback] = useState("pbs.saveFailed")
|
||||
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
|
||||
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
|
||||
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
|
||||
@@ -102,7 +99,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setErrorFallback("pbs.saveFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let saved = false
|
||||
try {
|
||||
// A password is sent once to create a read-only token; only the token is kept.
|
||||
const { username, password, ...connection } = form
|
||||
@@ -113,22 +112,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
: await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
|
||||
...connection, id: form.id || undefined, token_secret: form.token_secret || undefined,
|
||||
}) })
|
||||
saved = true
|
||||
await reloadConnections()
|
||||
setSelected(result.id)
|
||||
setForm(emptyForm)
|
||||
setManage(false)
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
} catch (err) { setError(saved ? { body: { code: "pbs_saved_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
|
||||
setErrorFallback("pbs.removeFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let removed = false
|
||||
try {
|
||||
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
|
||||
removed = true
|
||||
setForm(emptyForm)
|
||||
await reloadConnections()
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
} catch (err) { setError(removed ? { body: { code: "pbs_removed_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const now = Date.now() / 1000
|
||||
@@ -150,8 +153,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
|
||||
</Button>
|
||||
</div>}
|
||||
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
|
||||
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
|
||||
{(connectionError || statusError) && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={connectionError || statusError} raw={pbsDiagnostic(connectionError || statusError)} />
|
||||
</div>}
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
|
||||
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
|
||||
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
|
||||
@@ -171,7 +175,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{!localOnly && <Dialog open={manage} onOpenChange={open => { if (!busy) setManage(open) }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pbs.manage")}</DialogTitle>
|
||||
<DialogDescription>{method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}</DialogDescription>
|
||||
@@ -201,19 +205,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
|
||||
const secret = field === "token_secret" || field === "password"
|
||||
return <div key={field} className="space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
|
||||
const help = field === "url" ? "pbs.urlHelp" : field === "username" ? "pbs.usernameHelp"
|
||||
: field === "token_id" ? "pbs.tokenIdHelp" : field === "token_secret"
|
||||
? form.id ? "pbs.tokenSecretKeepHelp" : "pbs.tokenSecretHelp" : undefined
|
||||
return <div key={field} className="min-w-0 space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`} className="block leading-5">{t(`pbs.${field}`)}</Label>
|
||||
<Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
|
||||
aria-describedby={help ? `pbs-${field}-help` : undefined}
|
||||
autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
|
||||
value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||
{help && <p id={`pbs-${field}-help`} className="text-xs text-muted-foreground">{t(help)}</p>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
{!!error && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={error} raw={pbsDiagnostic(error)} fallback={errorFallback} />
|
||||
</div>}
|
||||
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{form.id && <Button variant="outline" disabled={busy} className="mr-auto text-red-500 hover:text-red-500" onClick={remove}>
|
||||
@@ -230,14 +241,17 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</Dialog>}
|
||||
{data && <>
|
||||
{!localOnly && <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString(language) })}</span>
|
||||
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
|
||||
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
|
||||
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {pbsServiceState(state, t)}</Badge>)}
|
||||
</div>}
|
||||
{Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks)
|
||||
.map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
.map(([section, detail]) => <div key={section} role="alert" className="text-sm text-amber-500">
|
||||
<p className="font-medium">{t(`pbs.${["datastores", "tasks", "sync", "verify", "prune"].includes(section) ? section : "title"}`)}</p>
|
||||
<PBSProblemMessage problem={data.error_details?.[section]} raw={detail} />
|
||||
</div>)}
|
||||
|
||||
{showStores && <Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
|
||||
@@ -268,14 +282,14 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
return <tr key={row.upid} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-foreground">{row.worker_type}</span>
|
||||
<span className="text-foreground" title={row.worker_type}>{pbsTaskType(row.worker_type, t)}</span>
|
||||
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
|
||||
</span></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatDuration(end - row.starttime) : "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={`${badgeTone[state]} max-w-[16rem] truncate`} title={row.status}>
|
||||
{state === "running" ? t("pbs.running") : row.status || "—"}
|
||||
</Badge></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString(language) : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatPBSDuration(end - row.starttime, t) : "—"}</td>
|
||||
<td className="py-2">
|
||||
<PBSResultBadge status={row.status} running={state === "running"} tone={badgeTone[state]} />
|
||||
</td>
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
@@ -299,7 +313,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
|
||||
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={badgeTone[jobState(row["last-run-state"])]}>{row["last-run-state"] || "—"}</Badge></td>
|
||||
<td className="py-2"><PBSResultBadge status={row["last-run-state"]} tone={badgeTone[jobState(row["last-run-state"])]} /></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
})()}
|
||||
@@ -309,3 +323,15 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function PBSResultBadge({ status, running = false, tone }: { status?: string; running?: boolean; tone: string }) {
|
||||
const t = useT()
|
||||
const badge = <Badge variant="outline" className={`${tone} max-w-[16rem] truncate`}>
|
||||
{running ? t("pbs.running") : pbsResultText(status, t)}
|
||||
</Badge>
|
||||
if (running || !status || status === "unknown" || /^ok$/i.test(status) || /^WARNINGS:\s*\d+\s*$/i.test(status)) return badge
|
||||
return <details className="max-w-xs">
|
||||
<summary className="cursor-pointer" aria-label={t("pbs.technicalDetails")}>{badge}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">{status}</pre>
|
||||
</details>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { pbsErrorText, pbsIsKnownError } from "../lib/pbs-messages"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Known application messages are already explained in the active language.
|
||||
// Preserve unrecognized external diagnostics as escaped technical details.
|
||||
export function PBSProblemMessage({ problem, raw, fallback, className = "" }: {
|
||||
problem?: unknown; raw?: string; fallback?: string; className?: string
|
||||
}) {
|
||||
const t = useT()
|
||||
return <div className={`min-w-0 space-y-1 ${className}`}>
|
||||
<p>{pbsErrorText(problem, t, fallback)}</p>
|
||||
{raw && !pbsIsKnownError(problem) && <details className="text-xs">
|
||||
<summary className="cursor-pointer">{t("pbs.technicalDetails")}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{raw}</pre>
|
||||
</details>}
|
||||
</div>
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import { scriptCompletion, scriptCompletionOutput } from "@/lib/script-completion"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface WebInteraction {
|
||||
@@ -52,6 +53,7 @@ interface ScriptTerminalModalProps {
|
||||
params?: Record<string, string>
|
||||
completedSuccessfullyMessage?: string
|
||||
completedWithErrorMessage?: (exitCode: number) => string
|
||||
cancelledExitCode?: number
|
||||
// Optional callback fired when the script's WebSocket closes
|
||||
// (script_runner sends an exit code and then closes). Lets the
|
||||
// parent auto-dismiss the modal — used by host-backup's Restore
|
||||
@@ -69,6 +71,7 @@ export function ScriptTerminalModal({
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
completedSuccessfullyMessage,
|
||||
completedWithErrorMessage,
|
||||
cancelledExitCode,
|
||||
onComplete,
|
||||
}: ScriptTerminalModalProps) {
|
||||
const t = useT()
|
||||
@@ -103,10 +106,12 @@ export function ScriptTerminalModal({
|
||||
|
||||
const getCompletionMessage = useCallback(
|
||||
(exitCode: number) =>
|
||||
exitCode === 0
|
||||
scriptCompletion(exitCode, cancelledExitCode).kind === "cancelled"
|
||||
? t("scriptTerminal.cancelled")
|
||||
: exitCode === 0
|
||||
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
|
||||
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
|
||||
[completedSuccessfullyMessage, completedWithErrorMessage, t],
|
||||
[completedSuccessfullyMessage, completedWithErrorMessage, cancelledExitCode, t],
|
||||
)
|
||||
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -139,6 +144,11 @@ export function ScriptTerminalModal({
|
||||
reconnectTimeoutRef.current = setTimeout(async () => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.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()
|
||||
}
|
||||
|
||||
@@ -176,6 +186,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (completionReceivedRef.current) return
|
||||
// Filter out pong responses from heartbeat
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
@@ -189,10 +200,10 @@ const initMessage = {
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
termRef.current?.write(event.data)
|
||||
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") termRef.current?.write(event.data)
|
||||
completionReceivedRef.current = 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)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -223,7 +234,7 @@ const initMessage = {
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = 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)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -261,7 +272,7 @@ const initMessage = {
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
@@ -392,6 +403,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (completionReceivedRef.current) return
|
||||
// Filter out pong responses from heartbeat - don't display in terminal
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
@@ -405,10 +417,10 @@ const initMessage = {
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
term.write(event.data)
|
||||
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") term.write(event.data)
|
||||
completionReceivedRef.current = 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)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -441,7 +453,7 @@ const initMessage = {
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = 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)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -814,7 +826,7 @@ const initMessage = {
|
||||
)}
|
||||
|
||||
{(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
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
|
||||
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic } from "../lib/pbs-messages"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
@@ -187,14 +189,20 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
|
||||
}
|
||||
|
||||
interface PBSOverviewStatus {
|
||||
datastores: PBSDatastore[] | null
|
||||
errors?: Record<string, string>
|
||||
error_details?: Record<string, { code: string }>
|
||||
}
|
||||
|
||||
export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const isPbs = product === "pbs"
|
||||
// On Proxmox Backup Server the datastores take the place of guests and PVE storage.
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>(
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<PBSOverviewStatus>(
|
||||
isPbs ? "/api/pbs/status?server=local" : null,
|
||||
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
|
||||
(url: string) => fetchApi<PBSOverviewStatus>(url),
|
||||
{ refreshInterval: 60000, revalidateOnFocus: false },
|
||||
)
|
||||
const datastores = pbsStatus?.datastores ?? null
|
||||
@@ -545,13 +553,15 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!datastores && !pbsError ? (
|
||||
{!pbsStatus && !pbsError ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : !datastores ? (
|
||||
<p className="text-sm text-amber-500">{t("pbs.loadFailed")}</p>
|
||||
<div role="alert" className="text-sm text-amber-500">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
|
||||
) : (
|
||||
@@ -762,8 +772,12 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
|
||||
{isPbs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
|
||||
{!datastores ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}</div>
|
||||
{!pbsStatus && !pbsError ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.loading")}</div>
|
||||
) : !datastores ? (
|
||||
<div role="alert" className="py-4 text-amber-500 text-sm">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
|
||||
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import useSWR from "swr"
|
||||
import { MetricsView } from "./metrics-dialog"
|
||||
import { LxcTerminalModal } from "./lxc-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 { GuestBackupList } from "./guest-backup-list"
|
||||
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
|
||||
// and has no record on this host until it is recovered from the menu.
|
||||
const [ociRestored, setOciRestored] = useState(false)
|
||||
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover" } | null>(null)
|
||||
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover"; recoverPrevious?: boolean } | null>(null)
|
||||
const ociCompletionCodeRef = useRef<number | undefined>(undefined)
|
||||
useEffect(() => { ociCompletionCodeRef.current = undefined }, [ociAction])
|
||||
|
||||
// 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
|
||||
@@ -5515,7 +5518,7 @@ export function VirtualMachines() {
|
||||
<Button
|
||||
size="sm"
|
||||
className={updateBtnCls}
|
||||
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update" })}
|
||||
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update", recoverPrevious: !!ociInstance.pending })}
|
||||
>
|
||||
{hasUpdate
|
||||
? <ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||
@@ -5744,7 +5747,7 @@ export function VirtualMachines() {
|
||||
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "recover" })}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1.5" />
|
||||
{t("vmLxc.ociUpdates.recover")}
|
||||
{t("vmLxc.ociUpdates.registerRestored")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -8014,6 +8017,7 @@ export function VirtualMachines() {
|
||||
onClose={() => {
|
||||
const vmid = ociAction.vmid
|
||||
setOciAction(null)
|
||||
if (!shouldRefreshOciAfterCompletion(ociCompletionCodeRef.current)) return
|
||||
// The image, the digest and the record changed: read them again.
|
||||
fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
|
||||
.catch(() => undefined)
|
||||
@@ -8027,7 +8031,10 @@ export function VirtualMachines() {
|
||||
}}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/oci/manage_instance.sh"
|
||||
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")
|
||||
: t("vmLxc.ociUpdates.terminalTitleModify")}
|
||||
description={t("vmLxc.ociUpdates.terminalDescription")}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Legacy HostBackup API messages are an English wire contract shared with CLI callers.
|
||||
// Match complete, source-verified messages only; never derive a translation key from input.
|
||||
// This presentation-only adapter keeps unknown tool diagnostics and parameter text intact.
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const fixed = new Map<string, string>([
|
||||
["path must be absolute", "backup.apiErrors.absolutePath"],
|
||||
["this path is already configured", "backup.apiErrors.pathConfigured"],
|
||||
["path is required", "backup.apiErrors.pathRequired"],
|
||||
["path query parameter is required", "backup.apiErrors.pathQuery"],
|
||||
["path not found", "backup.apiErrors.pathNotFound"],
|
||||
["invalid job id", "backup.apiErrors.invalidJobId"],
|
||||
["job not found", "backup.apiErrors.jobNotFound"],
|
||||
["runner script not installed", "backup.apiErrors.runnerMissing"],
|
||||
["no restore state to dismiss", "backup.apiErrors.noRestore"],
|
||||
["invalid file name", "backup.apiErrors.invalidFile"],
|
||||
["not found", "backup.apiErrors.notFound"],
|
||||
["log path outside allowed directory", "backup.apiErrors.logOutside"],
|
||||
["targets must be a list", "backup.apiErrors.targetsList"],
|
||||
["empty expression", "backup.apiErrors.emptyExpression"],
|
||||
["invalid id (use letters, digits, _ or -)", "backup.apiErrors.invalidId"],
|
||||
["backend must be pbs, local or borg", "backup.apiErrors.backend"],
|
||||
["backend must be pbs or borg", "backup.apiErrors.remoteBackend"],
|
||||
["backend must be 'pbs' or 'borg'", "backup.apiErrors.remoteBackendQuoted"],
|
||||
["profile_mode must be default or custom", "backup.apiErrors.profileMode"],
|
||||
["borg cannot be attached (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttached"],
|
||||
["borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttachedLong"],
|
||||
["pve_parent_job_id and pve_storage are required for attached", "backup.apiErrors.parentRequired"],
|
||||
["paths must be a list of absolute paths", "backup.apiErrors.absolutePaths"],
|
||||
["custom profile needs at least one path", "backup.apiErrors.customPath"],
|
||||
["could not resolve default profile paths", "backup.apiErrors.defaultPaths"],
|
||||
["retention must be an object", "backup.apiErrors.retentionObject"],
|
||||
["pbs_repository is required", "backup.apiErrors.pbsRepository"],
|
||||
["pbs_encrypt_mode must be 'none', 'new' or 'existing'", "backup.apiErrors.pbsEncryption"],
|
||||
["failed to create PBS encryption keyfile", "backup.apiErrors.pbsKeyFailed"],
|
||||
["failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", "backup.apiErrors.pbsClientMissing"],
|
||||
["borg_repo is required", "backup.apiErrors.borgRepository"],
|
||||
["borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.borgEncryption"],
|
||||
["encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.encryption"],
|
||||
["borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", "backup.apiErrors.borgPassphrase"],
|
||||
["passphrase is required when encryption is enabled", "backup.apiErrors.passphrase"],
|
||||
["password is required", "backup.apiErrors.password"],
|
||||
["confirm_device must match device exactly (typo / safety check)", "backup.apiErrors.deviceConfirmation"],
|
||||
["invalid device path", "backup.apiErrors.invalidDevice"],
|
||||
["absolute mountpoint required", "backup.apiErrors.absoluteMountpoint"],
|
||||
["archive layout not recognized — no rootfs/ found", "backup.apiErrors.archiveLayout"],
|
||||
["archive not found", "backup.apiErrors.archiveMissing"],
|
||||
["archive path is not a regular file", "backup.apiErrors.archiveNotFile"],
|
||||
["invalid name", "backup.apiErrors.invalidName"],
|
||||
["invalid staging_path", "backup.apiErrors.invalidStaging"],
|
||||
["job name cannot be changed (delete + recreate to rename)", "backup.apiErrors.jobRename"],
|
||||
["key_path must be absolute", "backup.apiErrors.absoluteKey"],
|
||||
["mode must be \"local\" or \"ssh\"", "backup.apiErrors.mode"],
|
||||
["name is required (letters, digits, _ or -)", "backup.apiErrors.nameRequired"],
|
||||
["no log available for this job yet", "backup.apiErrors.noLog"],
|
||||
["no manual PBS configs file", "backup.apiErrors.noPbsConfig"],
|
||||
["output file missing", "backup.apiErrors.outputMissing"],
|
||||
["parser output was not valid JSON", "backup.apiErrors.parserJson"],
|
||||
["run_restore output was not valid JSON", "backup.apiErrors.restoreJson"],
|
||||
["repo is required", "backup.apiErrors.repoRequired"],
|
||||
["repo_name and snapshot are required", "backup.apiErrors.repoSnapshot"],
|
||||
["repo_name and snapshot are required for borg", "backup.apiErrors.borgRepoSnapshot"],
|
||||
["repo_name and snapshot are required for pbs", "backup.apiErrors.pbsRepoSnapshot"],
|
||||
["repo_name is required", "backup.apiErrors.repoName"],
|
||||
["restore tooling not installed on this host", "backup.apiErrors.restoreMissing"],
|
||||
["server and datastore are required", "backup.apiErrors.serverDatastore"],
|
||||
["snapshot is required", "backup.apiErrors.snapshotRequired"],
|
||||
["source must be pbs, borg, or local", "backup.apiErrors.source"],
|
||||
["ssh_port must be an integer between 1 and 65535", "backup.apiErrors.sshPort"],
|
||||
["ssh_user, ssh_host and ssh_remote_path are required for ssh mode", "backup.apiErrors.sshRequired"],
|
||||
["target has no repository path", "backup.apiErrors.repositoryPath"],
|
||||
["task not found or expired", "backup.apiErrors.taskExpired"],
|
||||
["invalid expression", "backup.apiErrors.invalidExpression"],
|
||||
["no report emitted", "backup.apiErrors.noReport"],
|
||||
["parse_manifest exited non-zero", "backup.apiErrors.parseManifestFailed"],
|
||||
])
|
||||
|
||||
const parameterized: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[new RegExp("^job already exists: ([\\s\\S]+)$"), "backup.apiErrors.jobExists"],
|
||||
[new RegExp("^path does not exist on this host: ([\\s\\S]+)$"), "backup.apiErrors.pathMissing"],
|
||||
[new RegExp("^device does not exist: ([\\s\\S]+)$"), "backup.apiErrors.deviceMissing"],
|
||||
[new RegExp("^not a mountpoint: ([\\s\\S]+)$"), "backup.apiErrors.mountpoint"],
|
||||
[new RegExp("^local archive not found: ([\\s\\S]+)$"), "backup.apiErrors.localArchiveMissing"],
|
||||
[new RegExp("^Borg destination '([\\s\\S]+)' not found$"), "backup.apiErrors.borgDestination"],
|
||||
[new RegExp("^PBS destination '([\\s\\S]+)' not found$"), "backup.apiErrors.pbsDestination"],
|
||||
[new RegExp("^no password available for PBS repo '([\\s\\S]+)'$"), "backup.apiErrors.pbsPassword"],
|
||||
[new RegExp("^Borg repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.borgRepoMissing"],
|
||||
[new RegExp("^PBS repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.pbsRepoMissing"],
|
||||
[new RegExp("^Borg target not found: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetMissing"],
|
||||
[new RegExp("^PBS repo not found: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetMissing"],
|
||||
[new RegExp("^PVE vzdump job not found: ([\\s\\S]+)$"), "backup.apiErrors.pveJobMissing"],
|
||||
[new RegExp("^cannot persist state: ([\\s\\S]+)$"), "backup.apiErrors.statePersist"],
|
||||
[new RegExp("^cannot prepare ssh dir: ([\\s\\S]+)$"), "backup.apiErrors.sshDirectory"],
|
||||
[new RegExp("^cannot read log: ([\\s\\S]+)$"), "backup.apiErrors.logRead"],
|
||||
[new RegExp("^could not update \\.env: ([\\s\\S]+)$"), "backup.apiErrors.envUpdate"],
|
||||
[new RegExp("^failed to persist PBS config: ([\\s\\S]+)$"), "backup.apiErrors.pbsConfigWrite"],
|
||||
[new RegExp("^failed to persist: ([\\s\\S]+)$"), "backup.apiErrors.persist"],
|
||||
[new RegExp("^failed to remove archive: ([\\s\\S]+)$"), "backup.apiErrors.archiveRemove"],
|
||||
[new RegExp("^failed to start: ([\\s\\S]+)$"), "backup.apiErrors.start"],
|
||||
[new RegExp("^failed to update borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsUpdate"],
|
||||
[new RegExp("^failed to update pbs-manual-configs\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetsUpdate"],
|
||||
[new RegExp("^failed to update systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsUpdate"],
|
||||
[new RegExp("^failed to write borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsWrite"],
|
||||
[new RegExp("^failed to write job files: ([\\s\\S]+)$"), "backup.apiErrors.jobWrite"],
|
||||
[new RegExp("^failed to write local-target\\.conf: ([\\s\\S]+)$"), "backup.apiErrors.localTargetWrite"],
|
||||
[new RegExp("^failed to write systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsWrite"],
|
||||
[new RegExp("^format failed: ([\\s\\S]+)$"), "backup.apiErrors.format"],
|
||||
[new RegExp("^invalid on_calendar: ([\\s\\S]+)$"), "backup.apiErrors.calendar"],
|
||||
[new RegExp("^mount failed: ([\\s\\S]+)$"), "backup.apiErrors.mount"],
|
||||
[new RegExp("^parser invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.parserInvocation"],
|
||||
[new RegExp("^preflight invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.preflightInvocation"],
|
||||
[new RegExp("^public key not found after generation: ([\\s\\S]+)$"), "backup.apiErrors.publicKeyMissing"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be an integer$"), "backup.apiErrors.retentionInteger"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be non-negative$"), "backup.apiErrors.retentionNegative"],
|
||||
[new RegExp("^ssh-keygen failed: ([\\s\\S]+)$"), "backup.apiErrors.keygen"],
|
||||
[new RegExp("^systemctl failed: ([\\s\\S]+)$"), "backup.apiErrors.systemctl"],
|
||||
[new RegExp("^umount failed: ([\\s\\S]+)$"), "backup.apiErrors.unmount"],
|
||||
[new RegExp("^task is in state \"([\\s\\S]+)\", not completed$"), "backup.apiErrors.taskIncomplete"],
|
||||
[new RegExp("^unknown kind: ([\\s\\S]+)$"), "backup.apiErrors.unknownKind"],
|
||||
[new RegExp("^unknown mode \"([\\s\\S]+)\"$"), "backup.apiErrors.unknownMode"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default and cannot be removed$"), "backup.apiErrors.defaultRemove"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default; no need to add it$"), "backup.apiErrors.defaultAdd"],
|
||||
[new RegExp("^pbs_encrypt_mode='existing' but no keyfile is installed at ([\\s\\S]+) — import one first with POST /api/host-backups/pbs-encryption/import$"), "backup.apiErrors.keyImport"],
|
||||
[new RegExp("^PVE job storage mismatch \\(job uses ([\\s\\S]+), payload says ([\\s\\S]+)\\)$"), "backup.apiErrors.storageMismatch"],
|
||||
]
|
||||
|
||||
export function hostBackupErrorText(problem: unknown, t: Translate): string {
|
||||
const message = typeof problem === "string" ? problem
|
||||
: problem && typeof problem === "object" && "message" in problem && typeof problem.message === "string"
|
||||
? problem.message : ""
|
||||
if (!message) return t("backup.apiErrors.requestFailed")
|
||||
const key = fixed.get(message)
|
||||
if (key) return t(key)
|
||||
// Keep unusually large tool output verbatim, without running pattern matching over it.
|
||||
if (message.length > 16384) return message
|
||||
for (const [pattern, patternKey] of parameterized) {
|
||||
const match = pattern.exec(message)
|
||||
if (match) return t(patternKey, { value: match[1], other: match[2] ?? "" })
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
monitor_link_address_required: "addressRequired",
|
||||
monitor_link_invalid_address: "invalidAddress",
|
||||
monitor_link_address_credentials: "addressCredentials",
|
||||
monitor_link_invalid_port: "invalidPort",
|
||||
monitor_link_invalid_request: "invalidRequest",
|
||||
monitor_link_duplicate: "duplicate",
|
||||
monitor_link_not_found: "notFound",
|
||||
monitor_link_detected_remove: "detectedRemove",
|
||||
}
|
||||
|
||||
// fetchApi preserves the structured response on Error.body. Never expose raw
|
||||
// server/network messages or treat arbitrary error codes as translation keys.
|
||||
export function monitorLinkError(error: unknown, t: Translate): string {
|
||||
const fallback = () => t("monitorLinks.saveFailed")
|
||||
if (!error || typeof error !== "object" || !("body" in error)) return fallback()
|
||||
const body = error.body
|
||||
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return fallback()
|
||||
if (body.code === "monitor_link_limit") {
|
||||
const params = "params" in body ? body.params : null
|
||||
const count = params && typeof params === "object" && "count" in params ? params.count : null
|
||||
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
? t("monitorLinks.errors.limit", { count }) : fallback()
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code)
|
||||
? t(`monitorLinks.errors.${ERROR_KEYS[body.code]}`) : fallback()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
pbs_config_read: "pbs.errors.configRead", pbs_invalid_config: "pbs.errors.invalidConfig",
|
||||
pbs_invalid_field: "pbs.errors.invalidField", pbs_invalid_url: "pbs.errors.invalidUrl",
|
||||
pbs_name_required: "pbs.errors.nameRequired", pbs_token_id_format: "pbs.errors.tokenIdFormat",
|
||||
pbs_token_secret: "pbs.errors.tokenSecret", pbs_invalid_token_id: "pbs.errors.invalidTokenId",
|
||||
pbs_invalid_fingerprint: "pbs.errors.invalidFingerprint", pbs_fingerprint_mismatch: "pbs.errors.fingerprintMismatch",
|
||||
pbs_token_rejected: "pbs.errors.tokenRejected", pbs_invalid_response: "pbs.errors.invalidResponse",
|
||||
pbs_invalid_json: "pbs.errors.invalidJson", pbs_connect_failed: "pbs.errors.connectFailed",
|
||||
pbs_invalid_id: "pbs.errors.invalidId", pbs_invalid_datastores: "pbs.errors.invalidDatastores",
|
||||
pbs_username: "pbs.errors.username", pbs_password: "pbs.errors.password",
|
||||
pbs_login_rejected: "pbs.errors.loginRejected", pbs_invalid_login: "pbs.errors.invalidLogin",
|
||||
pbs_token_create_denied: "pbs.errors.tokenCreateDenied", pbs_invalid_token: "pbs.errors.invalidToken",
|
||||
pbs_audit_role_denied: "pbs.errors.auditRoleDenied", pbs_not_found: "pbs.errors.notFound",
|
||||
pbs_read_failed: "pbs.errors.readFailed", pbs_invalid_list: "pbs.errors.invalidList",
|
||||
pbs_local_unavailable: "pbs.errors.localUnavailable", pbs_auth_required: "pbs.errors.authRequired",
|
||||
pbs_save_failed: "pbs.saveFailed", pbs_remove_failed: "pbs.removeFailed",
|
||||
pbs_saved_refresh_failed: "pbs.savedRefreshFailed", pbs_removed_refresh_failed: "pbs.removedRefreshFailed",
|
||||
pbs_datastore_offline: "pbs.errors.datastoreOffline", pbs_datastore_unavailable: "pbs.errors.datastoreUnavailable",
|
||||
}
|
||||
|
||||
// Accept structured codes, never arbitrary error text as a translation key.
|
||||
function pbsErrorTranslation(error: unknown): { key: string; params?: { count: number } } | null {
|
||||
const body = error && typeof error === "object" && "body" in error ? error.body : error
|
||||
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return null
|
||||
if (body.code === "pbs_connection_limit") {
|
||||
const params = "params" in body ? body.params : null
|
||||
const count = params && typeof params === "object" && "count" in params ? params.count : null
|
||||
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
? { key: "pbs.errors.connectionLimit", params: { count } } : null
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) ? { key: ERROR_KEYS[body.code] } : null
|
||||
}
|
||||
|
||||
export function pbsIsKnownError(error: unknown): boolean {
|
||||
return pbsErrorTranslation(error) !== null
|
||||
}
|
||||
|
||||
export function pbsErrorText(error: unknown, t: Translate, fallback = "pbs.loadFailed"): string {
|
||||
const translated = pbsErrorTranslation(error)
|
||||
return translated ? t(translated.key, translated.params) : t(fallback)
|
||||
}
|
||||
|
||||
export function pbsDiagnostic(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined
|
||||
const body = "body" in error ? error.body : null
|
||||
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") return body.error
|
||||
return "message" in error && typeof error.message === "string" ? error.message : undefined
|
||||
}
|
||||
|
||||
const TASK_TYPES: Record<string, string> = {
|
||||
backup: "backup", reader: "restore", verify: "verify", verificationjob: "verify",
|
||||
verify_group: "verifyGroup", verify_snapshot: "verifySnapshot", prune: "prune", prunejob: "prune",
|
||||
garbage_collection: "garbageCollection", sync: "sync", syncjob: "sync", aptupdate: "updateCheck",
|
||||
termproxy: "terminal", dircreate: "createDatastore", "create-datastore": "createDatastore",
|
||||
}
|
||||
export function pbsTaskType(type: string | undefined, t: Translate): string {
|
||||
return type && Object.prototype.hasOwnProperty.call(TASK_TYPES, type)
|
||||
? t(`pbs.taskTypes.${TASK_TYPES[type]}`) : t("pbs.taskTypes.other", { type: type || "—" })
|
||||
}
|
||||
|
||||
export function pbsResultText(status: string | undefined, t: Translate): string {
|
||||
if (!status || status === "unknown") return t("pbs.states.unknown")
|
||||
if (status.toLowerCase() === "ok") return t("pbs.states.ok")
|
||||
const warnings = /^WARNINGS:\s*(\d+)\s*$/i.exec(status)
|
||||
if (warnings && Number.isSafeInteger(Number(warnings[1]))) return t("pbs.states.warnings", { count: Number(warnings[1]) })
|
||||
return /^warn/i.test(status) ? t("pbs.states.warning") : t("pbs.states.error")
|
||||
}
|
||||
|
||||
export function pbsServiceState(state: string, t: Translate): string {
|
||||
const known = ["active", "inactive", "failed", "activating", "deactivating", "unknown"]
|
||||
return t(`pbs.serviceStates.${known.includes(state) ? state : "unknown"}`)
|
||||
}
|
||||
|
||||
export function formatPBSDuration(seconds: number, t: Translate): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "—"
|
||||
const unit = (key: string, count: number) => t(`pbs.durationUnits.${key}`, { count })
|
||||
if (seconds < 60) return unit("seconds", Math.round(seconds))
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${unit("minutes", minutes)} ${unit("seconds", Math.round(seconds % 60))}`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours < 24 ? `${unit("hours", hours)} ${unit("minutes", minutes % 60)}`
|
||||
: `${unit("days", Math.floor(hours / 24))} ${unit("hours", hours % 24)}`
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"signInWith": "Sign in with",
|
||||
"methodPassword": "User and password",
|
||||
"methodToken": "API token",
|
||||
"username": "User (user@realm)",
|
||||
"username": "User",
|
||||
"password": "Password",
|
||||
"passwordHelp": "The password is used once to create a read-only API token (Audit role) for this Monitor. Only that token is saved, never the password. Enable Monitor authentication first.",
|
||||
"cancel": "Cancel",
|
||||
@@ -27,10 +27,15 @@
|
||||
"editConnection": "Connection to edit",
|
||||
"add": "Add a new connection",
|
||||
"name": "Name",
|
||||
"url": "HTTPS URL (include :8007 for the native PBS port)",
|
||||
"token_id": "API token ID (user@realm!token)",
|
||||
"token_secret": "API token secret (leave empty to keep the stored secret)",
|
||||
"fingerprint": "SHA256 certificate fingerprint (optional)",
|
||||
"url": "HTTPS address",
|
||||
"urlHelp": "Include :8007 for the default PBS port, for example https://pbs.example:8007.",
|
||||
"usernameHelp": "Use user@realm, for example root@pam.",
|
||||
"tokenIdHelp": "Use user@realm!token, for example monitor@pbs!proxmenux.",
|
||||
"tokenSecretHelp": "Enter the secret of this API token.",
|
||||
"tokenSecretKeepHelp": "Leave empty to keep the saved token secret.",
|
||||
"token_id": "API token ID",
|
||||
"token_secret": "API token secret",
|
||||
"fingerprint": "SHA256 fingerprint (optional)",
|
||||
"tlsHelp": "Leave the fingerprint empty for normal CA and hostname verification. For a self-signed certificate, copy its SHA256 fingerprint from the PBS console or another trusted channel. The pin is checked before sending credentials.",
|
||||
"saved": "PBS connection verified and saved.",
|
||||
"saveFailed": "Could not save the PBS connection.",
|
||||
@@ -72,7 +77,55 @@
|
||||
"estimatedFull": "Estimated full",
|
||||
"estimatedFullUnknown": "Not enough data yet",
|
||||
"duration": "Duration",
|
||||
"noJobs": "No sync, verification or prune schedules are configured."
|
||||
"noJobs": "No sync, verification or prune schedules are configured.",
|
||||
"removeFailed": "Could not remove the PBS connection.",
|
||||
"savedRefreshFailed": "The PBS connection was saved, but the connection list could not be refreshed. Reload the page before trying another change.",
|
||||
"removedRefreshFailed": "The PBS connection was removed, but the connection list could not be refreshed. Reload the page.",
|
||||
"technicalDetails": "Technical details",
|
||||
"datastoreType": "Datastore",
|
||||
"errors": {
|
||||
"configRead": "Could not read the saved PBS connections. Check the configuration file and permissions.",
|
||||
"invalidConfig": "The PBS connection settings are not valid. Check the form and try again.",
|
||||
"invalidField": "A connection field is not valid or is too long.",
|
||||
"invalidUrl": "Use an HTTPS PBS address without embedded credentials, query parameters or a fragment.",
|
||||
"nameRequired": "Enter a server name of at most 128 characters.",
|
||||
"tokenIdFormat": "Enter the PBS API token ID in user@realm!token format.",
|
||||
"tokenSecret": "Enter a valid API token secret without spaces or control characters.",
|
||||
"invalidTokenId": "The API token ID must contain only ASCII characters.",
|
||||
"invalidFingerprint": "Enter the certificate's SHA256 fingerprint: 64 hexadecimal characters, optionally separated by colons.",
|
||||
"fingerprintMismatch": "The PBS certificate does not match the fingerprint. No credentials were sent.",
|
||||
"tokenRejected": "PBS rejected the API token or its permissions. Check the ACLs of both the user and the token.",
|
||||
"invalidResponse": "PBS returned an unexpected response. Redirects are not followed.",
|
||||
"invalidJson": "PBS returned an invalid JSON document.",
|
||||
"connectFailed": "Could not connect to PBS over verified HTTPS. Check the address, certificate and service.",
|
||||
"invalidId": "The PBS connection ID is not valid. Reload the connection list.",
|
||||
"invalidDatastores": "PBS returned an invalid datastore list.",
|
||||
"connectionLimit": "At most {count} PBS connections may be configured.",
|
||||
"username": "Enter the PBS user in user@realm format, for example root@pam.",
|
||||
"password": "Enter the PBS user's password.",
|
||||
"loginRejected": "PBS rejected the username or password.",
|
||||
"invalidLogin": "PBS returned an invalid sign-in response.",
|
||||
"tokenCreateDenied": "This PBS user cannot create API tokens.",
|
||||
"invalidToken": "PBS returned an invalid API token.",
|
||||
"auditRoleDenied": "This PBS user cannot grant the Audit role. Sign in as root@pam or another administrator.",
|
||||
"notFound": "The PBS connection was not found. Reload the connection list.",
|
||||
"readFailed": "Could not read PBS data. Check its service and permissions.",
|
||||
"invalidList": "PBS returned an invalid data list.",
|
||||
"localUnavailable": "PBS is not available on this host.",
|
||||
"authRequired": "Enable local authentication in Monitor before managing remote PBS connections.",
|
||||
"datastoreOffline": "The datastore is offline, in maintenance mode or its backing device is not mounted.",
|
||||
"datastoreUnavailable": "The datastore filesystem is not available."
|
||||
},
|
||||
"taskTypes": {
|
||||
"backup": "Backup", "restore": "Restore", "verify": "Verification",
|
||||
"verifyGroup": "Backup group verification", "verifySnapshot": "Snapshot verification",
|
||||
"prune": "Prune", "garbageCollection": "Garbage collection", "sync": "Synchronization",
|
||||
"updateCheck": "Check for updates", "terminal": "Terminal", "createDatastore": "Create datastore",
|
||||
"other": "Other task ({type})"
|
||||
},
|
||||
"states": { "ok": "OK", "unknown": "Unknown", "warnings": "Warnings: {count}", "warning": "Warning", "error": "Error" },
|
||||
"serviceStates": { "active": "Active", "inactive": "Inactive", "failed": "Failed", "activating": "Starting", "deactivating": "Stopping", "unknown": "Unknown" },
|
||||
"durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" }
|
||||
},
|
||||
"app": {
|
||||
"title": "ProxMenux Monitor",
|
||||
@@ -1327,9 +1380,11 @@
|
||||
"update": "Update",
|
||||
"modify": "Modify",
|
||||
"recover": "Recover",
|
||||
"registerRestored": "Register again",
|
||||
"terminalTitleUpdate": "Update OCI application",
|
||||
"terminalTitleRecoverPrevious": "Recover the previous OCI installation",
|
||||
"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.",
|
||||
"keepBackup": "Keep the backup taken before updating",
|
||||
"noKeepBackup": "The backup taken before updating is not kept",
|
||||
@@ -1560,7 +1615,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Images",
|
||||
@@ -4436,6 +4491,124 @@
|
||||
"unrecoverable": "Without the key, encrypted backups cannot be restored.",
|
||||
"usesSharedKeyfileAt": "Uses shared keyfile at"
|
||||
},
|
||||
"apiErrors": {
|
||||
"requestFailed": "The request failed.",
|
||||
"absolutePath": "path must be absolute",
|
||||
"pathConfigured": "this path is already configured",
|
||||
"pathRequired": "path is required",
|
||||
"pathQuery": "path query parameter is required",
|
||||
"pathNotFound": "path not found",
|
||||
"invalidJobId": "invalid job id",
|
||||
"jobNotFound": "job not found",
|
||||
"runnerMissing": "runner script not installed",
|
||||
"noRestore": "no restore state to dismiss",
|
||||
"invalidFile": "invalid file name",
|
||||
"notFound": "not found",
|
||||
"logOutside": "log path outside allowed directory",
|
||||
"targetsList": "targets must be a list",
|
||||
"emptyExpression": "empty expression",
|
||||
"invalidId": "invalid id (use letters, digits, _ or -)",
|
||||
"backend": "backend must be pbs, local or borg",
|
||||
"remoteBackend": "backend must be pbs or borg",
|
||||
"remoteBackendQuoted": "backend must be 'pbs' or 'borg'",
|
||||
"profileMode": "profile_mode must be default or custom",
|
||||
"borgAttached": "borg cannot be attached (PVE vzdump only writes to pbs/local storages)",
|
||||
"borgAttachedLong": "borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)",
|
||||
"parentRequired": "pve_parent_job_id and pve_storage are required for attached",
|
||||
"absolutePaths": "paths must be a list of absolute paths",
|
||||
"customPath": "custom profile needs at least one path",
|
||||
"defaultPaths": "could not resolve default profile paths",
|
||||
"retentionObject": "retention must be an object",
|
||||
"pbsRepository": "pbs_repository is required",
|
||||
"pbsEncryption": "pbs_encrypt_mode must be 'none', 'new' or 'existing'",
|
||||
"pbsKeyFailed": "failed to create PBS encryption keyfile",
|
||||
"pbsClientMissing": "failed to create PBS encryption keyfile (is proxmox-backup-client installed?)",
|
||||
"borgRepository": "borg_repo is required",
|
||||
"borgEncryption": "borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated",
|
||||
"encryption": "encrypt_mode must be one of: none, repokey, keyfile, authenticated",
|
||||
"borgPassphrase": "borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)",
|
||||
"passphrase": "passphrase is required when encryption is enabled",
|
||||
"password": "password is required",
|
||||
"deviceConfirmation": "confirm_device must match device exactly (typo / safety check)",
|
||||
"invalidDevice": "invalid device path",
|
||||
"absoluteMountpoint": "absolute mountpoint required",
|
||||
"archiveLayout": "archive layout not recognized — no rootfs/ found",
|
||||
"archiveMissing": "archive not found",
|
||||
"archiveNotFile": "archive path is not a regular file",
|
||||
"invalidName": "invalid name",
|
||||
"invalidStaging": "invalid staging_path",
|
||||
"jobRename": "job name cannot be changed (delete + recreate to rename)",
|
||||
"absoluteKey": "key_path must be absolute",
|
||||
"mode": "mode must be \"local\" or \"ssh\"",
|
||||
"nameRequired": "name is required (letters, digits, _ or -)",
|
||||
"noLog": "no log available for this job yet",
|
||||
"noPbsConfig": "no manual PBS configs file",
|
||||
"outputMissing": "output file missing",
|
||||
"parserJson": "parser output was not valid JSON",
|
||||
"restoreJson": "run_restore output was not valid JSON",
|
||||
"repoRequired": "repo is required",
|
||||
"repoSnapshot": "repo_name and snapshot are required",
|
||||
"borgRepoSnapshot": "repo_name and snapshot are required for borg",
|
||||
"pbsRepoSnapshot": "repo_name and snapshot are required for pbs",
|
||||
"repoName": "repo_name is required",
|
||||
"restoreMissing": "restore tooling not installed on this host",
|
||||
"serverDatastore": "server and datastore are required",
|
||||
"snapshotRequired": "snapshot is required",
|
||||
"source": "source must be pbs, borg, or local",
|
||||
"sshPort": "ssh_port must be an integer between 1 and 65535",
|
||||
"sshRequired": "ssh_user, ssh_host and ssh_remote_path are required for ssh mode",
|
||||
"repositoryPath": "target has no repository path",
|
||||
"taskExpired": "task not found or expired",
|
||||
"invalidExpression": "invalid expression",
|
||||
"noReport": "no report emitted",
|
||||
"parseManifestFailed": "parse_manifest exited non-zero",
|
||||
"jobExists": "job already exists: {value}",
|
||||
"pathMissing": "path does not exist on this host: {value}",
|
||||
"deviceMissing": "device does not exist: {value}",
|
||||
"mountpoint": "not a mountpoint: {value}",
|
||||
"localArchiveMissing": "local archive not found: {value}",
|
||||
"borgDestination": "Borg destination '{value}' not found",
|
||||
"pbsDestination": "PBS destination '{value}' not found",
|
||||
"pbsPassword": "no password available for PBS repo '{value}'",
|
||||
"borgRepoMissing": "Borg repo \"{value}\" not configured",
|
||||
"pbsRepoMissing": "PBS repo \"{value}\" not configured",
|
||||
"borgTargetMissing": "Borg target not found: {value}",
|
||||
"pbsTargetMissing": "PBS repo not found: {value}",
|
||||
"pveJobMissing": "PVE vzdump job not found: {value}",
|
||||
"statePersist": "cannot persist state: {value}",
|
||||
"sshDirectory": "cannot prepare ssh dir: {value}",
|
||||
"logRead": "cannot read log: {value}",
|
||||
"envUpdate": "could not update .env: {value}",
|
||||
"pbsConfigWrite": "failed to persist PBS config: {value}",
|
||||
"persist": "failed to persist: {value}",
|
||||
"archiveRemove": "failed to remove archive: {value}",
|
||||
"start": "failed to start: {value}",
|
||||
"borgTargetsUpdate": "failed to update borg-targets.txt: {value}",
|
||||
"pbsTargetsUpdate": "failed to update pbs-manual-configs.txt: {value}",
|
||||
"unitsUpdate": "failed to update systemd units: {value}",
|
||||
"borgTargetsWrite": "failed to write borg-targets.txt: {value}",
|
||||
"jobWrite": "failed to write job files: {value}",
|
||||
"localTargetWrite": "failed to write local-target.conf: {value}",
|
||||
"unitsWrite": "failed to write systemd units: {value}",
|
||||
"format": "format failed: {value}",
|
||||
"calendar": "invalid on_calendar: {value}",
|
||||
"mount": "mount failed: {value}",
|
||||
"parserInvocation": "parser invocation failed: {value}",
|
||||
"preflightInvocation": "preflight invocation failed: {value}",
|
||||
"publicKeyMissing": "public key not found after generation: {value}",
|
||||
"retentionInteger": "retention.{value} must be an integer",
|
||||
"retentionNegative": "retention.{value} must be non-negative",
|
||||
"keygen": "ssh-keygen failed: {value}",
|
||||
"systemctl": "systemctl failed: {value}",
|
||||
"unmount": "umount failed: {value}",
|
||||
"taskIncomplete": "task is in state \"{value}\", not completed",
|
||||
"unknownKind": "unknown kind: {value}",
|
||||
"unknownMode": "unknown mode \"{value}\"",
|
||||
"defaultRemove": "{value} is the built-in default and cannot be removed",
|
||||
"defaultAdd": "{value} is the built-in default; no need to add it",
|
||||
"keyImport": "No PBS keyfile is installed at {value}. Import the keyfile first.",
|
||||
"storageMismatch": "PVE job storage mismatch (job uses {value}, payload says {other})"
|
||||
},
|
||||
"errors": {
|
||||
"backendNoStagingPath": "Backend did not return a staging path.",
|
||||
"deleteFailed": "Delete failed: {error}",
|
||||
@@ -5516,6 +5689,7 @@
|
||||
"websocketError": "WebSocket error occurred",
|
||||
"connectionClosed": "Connection closed",
|
||||
"completedSuccessfully": "Script completed successfully",
|
||||
"cancelled": "Operation cancelled.",
|
||||
"completedWithError": "Script finished with error code {code}",
|
||||
"yes": "Yes",
|
||||
"yourInput": "Your input:",
|
||||
@@ -6737,6 +6911,17 @@
|
||||
"addSelected": "Add selected",
|
||||
"pbsWebInterface": "PBS web interface",
|
||||
"changeAddress": "Change address",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"errors": {
|
||||
"addressRequired": "Enter the address of the Monitor.",
|
||||
"invalidAddress": "The address must start with http:// or https:// and name a host.",
|
||||
"addressCredentials": "The address must not contain a user or password.",
|
||||
"invalidPort": "The port of the address is not valid.",
|
||||
"invalidRequest": "The request is not valid. Reload the page and try again.",
|
||||
"limit": "At most {count} nodes can be added.",
|
||||
"duplicate": "This address is already in the list.",
|
||||
"notFound": "The node was not found. Reload the list and try again.",
|
||||
"detectedRemove": "Only nodes added by hand can be removed. Detected nodes can be hidden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"notAvailable": "Nedostupné",
|
||||
"serverOffline": "Server je offline",
|
||||
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard"
|
||||
"descriptionPbs": "Prehľad systému Proxmox Backup Server"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "Nedostupné",
|
||||
@@ -1032,7 +1032,7 @@
|
||||
"pciPassthrough": "PCI Passthrough",
|
||||
"usbDevices": "USB zariadenia",
|
||||
"serialPorts": "Sériové porty",
|
||||
"bridge": "Sieťový bridge",
|
||||
"bridge": "Sieťový most (bridge)",
|
||||
"enabled": "Zapnuté",
|
||||
"disabled": "Vypnuté",
|
||||
"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.",
|
||||
"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",
|
||||
"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ť",
|
||||
"modify": "Upraviť",
|
||||
"recover": "Obnoviť",
|
||||
"registerRestored": "Znova zaregistrovať",
|
||||
"terminalTitleUpdate": "Aktualizovať OCI aplikáciu",
|
||||
"terminalTitleRecoverPrevious": "Obnoviť predchádzajúcu OCI inštalá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.",
|
||||
"keepBackup": "Ponechať zálohu vytvorenú pred aktualizáciou",
|
||||
"noKeepBackup": "Záloha vytvorená pred aktualizáciou sa neponecháva",
|
||||
@@ -1485,7 +1487,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
"dockerAppTitle": "Docker",
|
||||
"dockerImagesSubheading": "Obrazy",
|
||||
@@ -1993,7 +1995,7 @@
|
||||
},
|
||||
"networkInterfaces": {
|
||||
"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",
|
||||
"interface": "Rozhranie",
|
||||
"down": "VYPNUTÉ",
|
||||
@@ -3625,7 +3627,7 @@
|
||||
},
|
||||
"loading": {
|
||||
"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": {
|
||||
"serverUnavailableTitle": "Server nie je dostupný",
|
||||
@@ -3639,7 +3641,7 @@
|
||||
},
|
||||
"sections": {
|
||||
"physicalInterfaces": "Fyzické rozhrania",
|
||||
"bridgeInterfaces": "Sieťové mosty",
|
||||
"bridgeInterfaces": "Sieťové mosty (bridge)",
|
||||
"vmLxcInterfaces": "Sieťové rozhrania VM a LXC"
|
||||
},
|
||||
"summary": {
|
||||
@@ -3659,8 +3661,8 @@
|
||||
},
|
||||
"interfaceTypes": {
|
||||
"physical": "Fyzické",
|
||||
"bridge": "Most",
|
||||
"bridges": "Mosty",
|
||||
"bridge": "Sieťový most (bridge)",
|
||||
"bridges": "Sieťové mosty (bridge)",
|
||||
"bond": "Bond",
|
||||
"vlan": "VLAN",
|
||||
"virtual": "Virtuálne",
|
||||
@@ -3754,7 +3756,7 @@
|
||||
"inactiveTitle": "Rozhranie je neaktívne",
|
||||
"inactiveDescription": "Toto rozhranie je momentálne vypnuté. Štatistiky sieťovej premávky nie sú dostupné.",
|
||||
"bondConfiguration": "Nastavenie bondu",
|
||||
"bridgeConfiguration": "Nastavenie mosta"
|
||||
"bridgeConfiguration": "Nastavenie sieťového mosta (bridge)"
|
||||
},
|
||||
"flow": {
|
||||
"title": "Tok siete"
|
||||
@@ -4299,7 +4301,7 @@
|
||||
"unmountTitle": "Odpojiť tento cieľ",
|
||||
"whereIsBorgRepo": "Kde je Borg repozitár?",
|
||||
"backupsKeptMessage": "Existujúce zálohy sa neodstránia.",
|
||||
"localAttachHelpMessage": "Najprv pripojte jednotky USB.Ak chcete použiť cestu k úložisku PVE + /dump, nechajte pole prázdne (záložný: /var/lib/vz/dump).",
|
||||
"localAttachHelpMessage": "Najprv pripoj USB disk. Ak pole necháš prázdne, použije sa cesta úložiska PVE doplnená o /dump; náhradná cesta je /var/lib/vz/dump.",
|
||||
"sshAuthorizedKeyHelp": "Pred pridaním do ~/.ssh/authorized_keys pre {user} na vzdialenom hostiteľovi skontrolujte riadok nižšie.",
|
||||
"sshKeyHelpMessage": "Cesta súkromného kľúča na tomto hostiteľovi.Použite {action}, potom skontrolujte a pridajte zobrazený riadok verejného kľúča do ~/.ssh/authorized_keys vzdialeného používateľa.",
|
||||
"sshKeySetupHelp": "Vytvorí kľúč na zadanej lokálnej ceste, ak žiadny neexistuje;inak prečíta svoj verejný kľúč.Na vzdialenom hostiteľovi nie je nainštalovaný žiadny kľúč.",
|
||||
@@ -4343,6 +4345,124 @@
|
||||
"usesSharedKeyfileAt": "Používa spoločný súbor s kľúčom v",
|
||||
"pbsHelp": "Monitor používa {keyfile} s {option} na šifrované zálohy a obnovy PBS.Súbor kľúča je uložený v režime {mode}.Uschovajte si bezpečnú kópiu mimo tohto hostiteľa."
|
||||
},
|
||||
"apiErrors": {
|
||||
"requestFailed": "Požiadavka zlyhala.",
|
||||
"absolutePath": "Cesta musí začínať znakom /.",
|
||||
"pathConfigured": "Táto cesta už je nastavená.",
|
||||
"pathRequired": "Zadaj cestu.",
|
||||
"pathQuery": "V požiadavke chýba cesta.",
|
||||
"pathNotFound": "Cesta sa nenašla.",
|
||||
"invalidJobId": "Neplatný identifikátor úlohy.",
|
||||
"jobNotFound": "Úloha sa nenašla.",
|
||||
"runnerMissing": "Na serveri chýba skript na spustenie zálohy.",
|
||||
"noRestore": "Nie je tu žiadny záznam obnovy na zatvorenie.",
|
||||
"invalidFile": "Neplatný názov súboru.",
|
||||
"notFound": "Požadovaný záznam sa nenašiel.",
|
||||
"logOutside": "Cesta k záznamu je mimo povoleného priečinka.",
|
||||
"targetsList": "Ciele musia byť zadané ako zoznam.",
|
||||
"emptyExpression": "Zadaj časový plán.",
|
||||
"invalidId": "V identifikátore použi iba písmená, číslice, _ alebo -.",
|
||||
"backend": "Vyber cieľ PBS, miestny archív alebo Borg.",
|
||||
"remoteBackend": "Vyber cieľ PBS alebo Borg.",
|
||||
"remoteBackendQuoted": "Vyber cieľ PBS alebo Borg.",
|
||||
"profileMode": "Vyber predvolený alebo vlastný profil.",
|
||||
"borgAttached": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.",
|
||||
"borgAttachedLong": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.",
|
||||
"parentRequired": "Vyber nadradenú úlohu PVE aj jej úložisko.",
|
||||
"absolutePaths": "Zadaj zoznam ciest, ktoré začínajú znakom /.",
|
||||
"customPath": "Do vlastného profilu pridaj aspoň jednu cestu.",
|
||||
"defaultPaths": "Cesty predvoleného profilu sa nepodarilo zistiť.",
|
||||
"retentionObject": "Pravidlá uchovávania záloh majú neplatný formát.",
|
||||
"pbsRepository": "Zadaj repozitár PBS.",
|
||||
"pbsEncryption": "Vyber režim šifrovania PBS: bez šifrovania, nový kľúč alebo existujúci kľúč.",
|
||||
"pbsKeyFailed": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť.",
|
||||
"pbsClientMissing": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť. Skontroluj, či je nainštalovaný proxmox-backup-client.",
|
||||
"borgRepository": "Zadaj repozitár Borg.",
|
||||
"borgEncryption": "Vyber podporovaný režim šifrovania Borg: none, repokey, keyfile alebo authenticated.",
|
||||
"encryption": "Vyber podporovaný režim šifrovania: none, repokey, keyfile alebo authenticated.",
|
||||
"borgPassphrase": "Šifrovaný repozitár Borg potrebuje heslo. Ulož ho pri cieli alebo ho zadaj v požiadavke.",
|
||||
"passphrase": "Pri zapnutom šifrovaní zadaj heslo.",
|
||||
"password": "Zadaj heslo.",
|
||||
"deviceConfirmation": "Na potvrdenie zadaj presný názov zariadenia.",
|
||||
"invalidDevice": "Neplatná cesta k zariadeniu.",
|
||||
"absoluteMountpoint": "Prípojný bod musí byť cesta začínajúca znakom /.",
|
||||
"archiveLayout": "Archív nemá očakávanú štruktúru: chýba priečinok rootfs/.",
|
||||
"archiveMissing": "Archív sa nenašiel.",
|
||||
"archiveNotFile": "Cesta k archívu neoznačuje bežný súbor.",
|
||||
"invalidName": "Neplatný názov.",
|
||||
"invalidStaging": "Neplatná cesta k dočasným súborom.",
|
||||
"jobRename": "Názov existujúcej úlohy sa nedá zmeniť. Ak ju chceš premenovať, odstráň ju a vytvor novú.",
|
||||
"absoluteKey": "Cesta k súboru s kľúčom musí začínať znakom /.",
|
||||
"mode": "Vyber miestny cieľ alebo pripojenie cez SSH.",
|
||||
"nameRequired": "Zadaj názov; použi iba písmená, číslice, _ alebo -.",
|
||||
"noLog": "Pre túto úlohu zatiaľ nie je dostupný záznam.",
|
||||
"noPbsConfig": "Súbor s ručne nastavenými cieľmi PBS sa nenašiel.",
|
||||
"outputMissing": "Chýba výstupný súbor.",
|
||||
"parserJson": "Výstup spracovania archívu nemá platný formát JSON.",
|
||||
"restoreJson": "Výstup prípravy obnovy nemá platný formát JSON.",
|
||||
"repoRequired": "Zadaj repozitár.",
|
||||
"repoSnapshot": "Vyber repozitár aj zálohu.",
|
||||
"borgRepoSnapshot": "Pre Borg vyber repozitár aj zálohu.",
|
||||
"pbsRepoSnapshot": "Pre PBS vyber repozitár aj zálohu.",
|
||||
"repoName": "Zadaj názov repozitára.",
|
||||
"restoreMissing": "Na tomto serveri chýbajú nástroje na obnovu.",
|
||||
"serverDatastore": "Zadaj server aj dátové úložisko.",
|
||||
"snapshotRequired": "Vyber zálohu.",
|
||||
"source": "Vyber zdroj PBS, Borg alebo miestny archív.",
|
||||
"sshPort": "Port SSH musí byť celé číslo od 1 do 65535.",
|
||||
"sshRequired": "Pre SSH zadaj používateľa, hostiteľa a vzdialenú cestu.",
|
||||
"repositoryPath": "Cieľ nemá nastavenú cestu k repozitáru.",
|
||||
"taskExpired": "Úloha sa nenašla alebo už vypršala.",
|
||||
"invalidExpression": "Neplatný časový plán.",
|
||||
"noReport": "Kontrola nevrátila žiadnu správu.",
|
||||
"parseManifestFailed": "Spracovanie manifestu skončilo chybou.",
|
||||
"jobExists": "Úloha s názvom {value} už existuje.",
|
||||
"pathMissing": "Na tomto serveri sa nenašla cesta: {value}",
|
||||
"deviceMissing": "Zariadenie neexistuje: {value}",
|
||||
"mountpoint": "Táto cesta nie je prípojný bod: {value}",
|
||||
"localArchiveMissing": "Miestny archív sa nenašiel: {value}",
|
||||
"borgDestination": "Cieľ Borg „{value}“ sa nenašiel.",
|
||||
"pbsDestination": "Cieľ PBS „{value}“ sa nenašiel.",
|
||||
"pbsPassword": "Pre repozitár PBS „{value}“ nie je dostupné heslo.",
|
||||
"borgRepoMissing": "Repozitár Borg „{value}“ nie je nastavený.",
|
||||
"pbsRepoMissing": "Repozitár PBS „{value}“ nie je nastavený.",
|
||||
"borgTargetMissing": "Cieľ Borg sa nenašiel: {value}",
|
||||
"pbsTargetMissing": "Repozitár PBS sa nenašiel: {value}",
|
||||
"pveJobMissing": "Úloha zálohovania PVE sa nenašla: {value}",
|
||||
"statePersist": "Stav sa nepodarilo uložiť: {value}",
|
||||
"sshDirectory": "Priečinok SSH sa nepodarilo pripraviť: {value}",
|
||||
"logRead": "Záznam sa nepodarilo prečítať: {value}",
|
||||
"envUpdate": "Súbor .env sa nepodarilo aktualizovať: {value}",
|
||||
"pbsConfigWrite": "Nastavenia PBS sa nepodarilo uložiť: {value}",
|
||||
"persist": "Údaje sa nepodarilo uložiť: {value}",
|
||||
"archiveRemove": "Archív sa nepodarilo odstrániť: {value}",
|
||||
"start": "Úlohu sa nepodarilo spustiť: {value}",
|
||||
"borgTargetsUpdate": "Súbor borg-targets.txt sa nepodarilo aktualizovať: {value}",
|
||||
"pbsTargetsUpdate": "Súbor pbs-manual-configs.txt sa nepodarilo aktualizovať: {value}",
|
||||
"unitsUpdate": "Jednotky systemd sa nepodarilo aktualizovať: {value}",
|
||||
"borgTargetsWrite": "Súbor borg-targets.txt sa nepodarilo uložiť: {value}",
|
||||
"jobWrite": "Súbory úlohy sa nepodarilo uložiť: {value}",
|
||||
"localTargetWrite": "Súbor local-target.conf sa nepodarilo uložiť: {value}",
|
||||
"unitsWrite": "Jednotky systemd sa nepodarilo uložiť: {value}",
|
||||
"format": "Formátovanie zlyhalo: {value}",
|
||||
"calendar": "Neplatný časový plán: {value}",
|
||||
"mount": "Disk sa nepodarilo pripojiť: {value}",
|
||||
"parserInvocation": "Spracovanie archívu sa nepodarilo spustiť: {value}",
|
||||
"preflightInvocation": "Kontrolu pred obnovou sa nepodarilo spustiť: {value}",
|
||||
"publicKeyMissing": "Po vytvorení kľúča sa nenašiel verejný kľúč: {value}",
|
||||
"retentionInteger": "Pravidlo uchovávania „{value}“ musí byť celé číslo.",
|
||||
"retentionNegative": "Pravidlo uchovávania „{value}“ nesmie byť záporné.",
|
||||
"keygen": "Vytvorenie kľúča SSH zlyhalo: {value}",
|
||||
"systemctl": "Správa služby cez systemctl zlyhala: {value}",
|
||||
"unmount": "Disk sa nepodarilo odpojiť: {value}",
|
||||
"taskIncomplete": "Úloha ešte nie je dokončená. Jej stav: {value}",
|
||||
"unknownKind": "Neznámy typ: {value}",
|
||||
"unknownMode": "Neznámy režim: {value}",
|
||||
"defaultRemove": "Cesta {value} je predvolená a nedá sa odstrániť.",
|
||||
"defaultAdd": "Cesta {value} je predvolená; nemusíš ju pridávať.",
|
||||
"keyImport": "V {value} nie je uložený súbor s kľúčom PBS. Najprv ho importuj.",
|
||||
"storageMismatch": "Úložisko sa nezhoduje: úloha PVE používa {value}, požiadavka uvádza {other}."
|
||||
},
|
||||
"errors": {
|
||||
"backendNoStagingPath": "Cieľ nevrátil cestu k dočasným súborom.",
|
||||
"deleteFailed": "Mazanie zlyhalo: {error}",
|
||||
@@ -4928,7 +5048,7 @@
|
||||
"keepAll": "Ponechať všetky zálohy",
|
||||
"description": "Predvolená politika uchovávania záloh uložená v Proxmoxe. Zálohovacie úlohy s vlastnou politikou majú pred týmto predvoleným nastavením prednosť.",
|
||||
"refresh": "Obnoviť",
|
||||
"saved": "Politika uchovávania záloh úložiska bola uložená. Prerezávanie sa nevykonalo.",
|
||||
"saved": "Pravidlá uchovávania záloh na úložisku boli uložené. Žiadne zálohy sa teraz neodstránili.",
|
||||
"choosePolicy": "Vyberte Ponechať všetky alebo aspoň jeden počet uchovávaných záloh väčší ako nula.",
|
||||
"saveFailed": "Nepodarilo sa uložiť politiku uchovávania záloh úložiska. Ak sa konfigurácia zmenila, obnovte zoznam.",
|
||||
"loadFailed": "Nepodarilo sa načítať politiku uchovávania záloh úložiska.",
|
||||
@@ -5440,6 +5560,7 @@
|
||||
"websocketError": "Chyba WebSocket spojenia",
|
||||
"connectionClosed": "Spojenie bolo zatvorené",
|
||||
"completedSuccessfully": "Skript sa úspešne dokončil",
|
||||
"cancelled": "Operácia bola zrušená.",
|
||||
"completedWithError": "Skript skončil s chybovým kódom {code}",
|
||||
"yes": "Áno",
|
||||
"yourInput": "Tvoj vstup:",
|
||||
@@ -5762,7 +5883,7 @@
|
||||
"nextStep": {
|
||||
"missing": "Nastavte si držanie na týchto pracovných miestach, alebo na sklade, na ktorý píšu. Proxmox berie nastavenie úlohy ako prvý, potom úložisko je, potom uzol predvolený.",
|
||||
"notDeclared": "Tieto práce uchovávajú všetky kópie, ktoré robia. To je zámerná voľba pre niektoré destinácie, ale potvrďte, že cieľ má priestor na rast.",
|
||||
"onServer": "Tieto úlohy píšu na záložný server, ktorý ich prerezáva podľa vlastných pravidiel. Z tohto uzla sa nedá čítať to, čo drží, takže skontrolujte, či tam nie je."
|
||||
"onServer": "Tieto úlohy ukladajú zálohy na zálohovací server. Ten odstraňuje staré zálohy podľa vlastných pravidiel. Z tohto uzla sa nedá zistiť, ktoré zálohy uchováva; skontroluj ich priamo na zálohovacom serveri."
|
||||
},
|
||||
"explanation": "O tom, koľko výtlačkov sa uchováva a ako dlho sa uchováva. Bez nej sa cieľ naplní, kým neprestane prijímať nové kópie, a záloha, ktorú nemožno napísať, je tá, o ktorej sa dozviete v deň, keď ju potrebujete."
|
||||
},
|
||||
@@ -6172,11 +6293,11 @@
|
||||
}
|
||||
},
|
||||
"bridge_without_ports": {
|
||||
"title": "Porty bridgeov",
|
||||
"rationale": "Konfigurácia portov každého bridge. Bridge bez fyzického portu obsluhuje internú alebo smerovanú sieť.",
|
||||
"title": "Porty sieťových mostov (bridge)",
|
||||
"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": {
|
||||
"allConnected": "Všetkých {total} bridgeov má port",
|
||||
"isolated": "{count} z {total} bridgeov nemá port",
|
||||
"allConnected": "Všetkých {total} sieťových mostov (bridge) má port",
|
||||
"isolated": "{count} z {total} sieťových mostov (bridge) nemá port",
|
||||
"evaluationFailed": "Kontrolu sa nepodarilo vyhodnotiť"
|
||||
}
|
||||
}
|
||||
@@ -6392,13 +6513,13 @@
|
||||
"network": "Sieť",
|
||||
"adapters": "Adaptéry",
|
||||
"bond": "Bond",
|
||||
"bridge": "Bridge",
|
||||
"bridges": "Bridge",
|
||||
"bridge": "Sieťový most (bridge)",
|
||||
"bridges": "Sieťové mosty (bridge)",
|
||||
"physicalAdapters": "Fyzické adaptéry",
|
||||
"interface": "Rozhranie",
|
||||
"driver": "Ovládač",
|
||||
"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",
|
||||
"storage": "Úložisko",
|
||||
"content": "Obsah",
|
||||
@@ -7420,35 +7541,40 @@
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "Server PBS",
|
||||
"chooseServer": "Vybrať server PBS",
|
||||
"local": "Miestny PBS",
|
||||
"local": "Lokálny PBS",
|
||||
"thisNode": "PBS na tomto uzle",
|
||||
"updatedAt": "Aktualizované o {time}",
|
||||
"servicesActive": "Služby sú aktívne",
|
||||
"detectedTitle": "PBS zistené v úložisku Proxmox",
|
||||
"detectedHelp": "Tieto Proxmox Backup Servery sú nastavené ako úložisko záloh. Pripojte každý z nich API tokenom len na čítanie, aby ste tu videli jeho datastory, úlohy a joby.",
|
||||
"detectedTitle": "PBS zistené v nastaveniach úložísk Proxmoxu",
|
||||
"detectedHelp": "Tieto servery Proxmox Backup Server sú nastavené ako úložiská záloh. Pripoj ich pomocou API tokenu iba na čítanie a zobrazia sa tu ich dátové úložiská, úlohy a plány údržby.",
|
||||
"connect": "Pripojiť",
|
||||
"tasksTab": "Úlohy",
|
||||
"signInWith": "Prihlásiť sa pomocou",
|
||||
"methodPassword": "Používateľ a heslo",
|
||||
"methodToken": "API token",
|
||||
"username": "Používateľ (pouzivatel@realm)",
|
||||
"username": "Používateľ",
|
||||
"password": "Heslo",
|
||||
"passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu len na čítanie (rola Audit) pre tento Monitor. Uloží sa iba tento token, nikdy nie heslo. Najprv zapnite overovanie v Monitore.",
|
||||
"passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu iba na čítanie s rolou Audit pre tento Monitor. Uloží sa iba token, heslo sa neukladá. Najprv zapni overovanie v Monitore.",
|
||||
"cancel": "Zrušiť",
|
||||
"refresh": "Obnoviť",
|
||||
"manage": "Spravovať pripojenia PBS",
|
||||
"loadFailed": "Nepodarilo sa načítať stav PBS.",
|
||||
"loading": "Načítavajú sa údaje PBS…",
|
||||
"noServer": "Pridajte pripojenie PBS na zobrazenie dátových úložísk, úloh a plánov.",
|
||||
"permissionsHelp": "Pred uložením prihlasovacích údajov povoľte overovanie v Monitore. Použite vyhradený API token PBS s rolou Audit na / v ACL používateľa aj tokenu, so zapnutým dedením. Tento panel údaje PBS iba číta.",
|
||||
"noServer": "Pridaj pripojenie PBS a zobrazia sa jeho dátové úložiská, úlohy a plány údržby.",
|
||||
"permissionsHelp": "Pred uložením prihlasovacích údajov zapni overovanie v Monitore. Použi vyhradený API token PBS. Používateľ aj token musia mať v zozname prístupových oprávnení (ACL) rolu Audit na ceste / so zapnutým dedením. Tento panel údaje PBS iba číta.",
|
||||
"editConnection": "Pripojenie na úpravu",
|
||||
"add": "Pridať nové pripojenie",
|
||||
"name": "Názov",
|
||||
"url": "HTTPS URL (pre pôvodný port PBS uveďte :8007)",
|
||||
"token_id": "ID API tokenu (user@realm!token)",
|
||||
"token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať uložený kľúč)",
|
||||
"fingerprint": "Odtlačok SHA256 certifikátu (voliteľný)",
|
||||
"tlsHelp": "Pre bežné overenie certifikačnej autority a názvu hostiteľa ponechajte odtlačok prázdny. Pri certifikáte s vlastným podpisom skopírujte jeho odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného kanála. Odtlačok sa kontroluje pred odoslaním prihlasovacích údajov.",
|
||||
"url": "Adresa HTTPS",
|
||||
"urlHelp": "Štandardný port PBS je 8007, napríklad https://pbs.example:8007.",
|
||||
"usernameHelp": "Použi meno a overovaciu oblasť oddelené znakom @, napríklad root@pam.",
|
||||
"tokenIdHelp": "Použi formát meno@oblasť!token, napríklad monitor@pbs!proxmenux.",
|
||||
"tokenSecretHelp": "Zadaj tajný kľúč k tomuto API tokenu.",
|
||||
"tokenSecretKeepHelp": "Prázdne pole zachová uložený tajný kľúč tokenu.",
|
||||
"token_id": "ID API tokenu",
|
||||
"token_secret": "Tajný kľúč API tokenu",
|
||||
"fingerprint": "Odtlačok SHA256 (voliteľný)",
|
||||
"tlsHelp": "Pri bežnom overovaní certifikačnej autority a názvu hostiteľa nechaj odtlačok prázdny. Pri certifikáte s vlastným podpisom skopíruj odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného zdroja. Kontroluje sa ešte pred odoslaním prihlasovacích údajov.",
|
||||
"saved": "Pripojenie PBS bolo overené a uložené.",
|
||||
"saveFailed": "Nepodarilo sa uložiť pripojenie PBS.",
|
||||
"testing": "Overuje sa pripojenie…",
|
||||
@@ -7460,9 +7586,9 @@
|
||||
"total": "Kapacita súborového systému",
|
||||
"used": "Použité",
|
||||
"free": "Dostupné",
|
||||
"noDatastores": "Žiadne dostupné dátové úložiská. Skontrolujte konfiguráciu úložísk a oprávnenia používateľa a tokenu.",
|
||||
"noDatastores": "Nie sú dostupné žiadne dátové úložiská. Skontroluj ich nastavenia a oprávnenia používateľa aj tokenu.",
|
||||
"tasks": "Nedávne úlohy",
|
||||
"noTasks": "Neboli vrátené žiadne nedávne úlohy.",
|
||||
"noTasks": "PBS nevrátil žiadne nedávne úlohy.",
|
||||
"task": "Úloha",
|
||||
"started": "Spustené",
|
||||
"status": "Stav",
|
||||
@@ -7472,7 +7598,7 @@
|
||||
"schedule": "Plán",
|
||||
"sync": "Synchronizácia",
|
||||
"verify": "Overenie",
|
||||
"prune": "Prerezávanie",
|
||||
"prune": "Odstraňovanie starých záloh",
|
||||
"overview": "Prehľad",
|
||||
"logs": "Systémové záznamy",
|
||||
"terminal": "Terminál",
|
||||
@@ -7483,51 +7609,110 @@
|
||||
"memory": "Pamäť",
|
||||
"uptime": "Čas prevádzky",
|
||||
"capacityUnavailable": "Kapacita nie je k dispozícii.",
|
||||
"datastoresUnavailable": "{count} nie je k dispozícii",
|
||||
"datastoresUnavailable": "Nedostupné úložiská: {count}",
|
||||
"datastoresOnline": "Všetky dostupné",
|
||||
"moreDatastores": "+{count} ďalších dátových úložísk",
|
||||
"estimatedFull": "Odhadovaný plný",
|
||||
"estimatedFullUnknown": "Zatiaľ nie je dostatok údajov",
|
||||
"estimatedFull": "Odhadované zaplnenie",
|
||||
"estimatedFullUnknown": "Zatiaľ sa nedá odhadnúť",
|
||||
"duration": "Trvanie",
|
||||
"noJobs": "Nie sú nakonfigurované žiadne plány synchronizácie, overovania ani odstraňovania."
|
||||
"noJobs": "Nie sú nastavené žiadne plány synchronizácie, overovania ani odstraňovania starých záloh.",
|
||||
"removeFailed": "Nepodarilo sa odstrániť pripojenie PBS.",
|
||||
"savedRefreshFailed": "Pripojenie PBS sa uložilo, ale nepodarilo sa obnoviť zoznam pripojení. Pred ďalšou zmenou obnov stránku.",
|
||||
"removedRefreshFailed": "Pripojenie PBS sa odstránilo, ale nepodarilo sa obnoviť zoznam pripojení. Obnov stránku.",
|
||||
"technicalDetails": "Technické podrobnosti",
|
||||
"datastoreType": "Dátové úložisko",
|
||||
"errors": {
|
||||
"configRead": "Nepodarilo sa načítať uložené pripojenia PBS. Skontroluj konfiguračný súbor a oprávnenia.",
|
||||
"invalidConfig": "Nastavenia pripojenia PBS nie sú platné. Skontroluj formulár a skús to znova.",
|
||||
"invalidField": "Niektoré pole pripojenia nie je platné alebo je príliš dlhé.",
|
||||
"invalidUrl": "Použi adresu HTTPS bez vložených prihlasovacích údajov, parametrov za otáznikom a časti za znakom #.",
|
||||
"nameRequired": "Zadaj názov servera s najviac 128 znakmi.",
|
||||
"tokenIdFormat": "Zadaj ID API tokenu PBS vo formáte user@realm!token.",
|
||||
"tokenSecret": "Zadaj platný tajný kľúč API tokenu bez medzier a riadiacich znakov.",
|
||||
"invalidTokenId": "ID API tokenu môže obsahovať iba znaky ASCII.",
|
||||
"invalidFingerprint": "Zadaj odtlačok SHA256 certifikátu: 64 šestnástkových znakov, prípadne oddelených dvojbodkami.",
|
||||
"fingerprintMismatch": "Certifikát PBS sa nezhoduje s odtlačkom. Prihlasovacie údaje sa neodoslali.",
|
||||
"tokenRejected": "PBS odmietol API token alebo jeho oprávnenia. Skontroluj zoznam prístupových oprávnení (ACL) používateľa aj tokenu.",
|
||||
"invalidResponse": "PBS vrátil neočakávanú odpoveď. Presmerovania sa nenasledujú.",
|
||||
"invalidJson": "PBS vrátil neplatný dokument JSON.",
|
||||
"connectFailed": "Nepodarilo sa pripojiť k PBS cez overené HTTPS. Skontroluj adresu, certifikát a službu.",
|
||||
"invalidId": "ID pripojenia PBS nie je platné. Obnov zoznam pripojení.",
|
||||
"invalidDatastores": "PBS vrátil neplatný zoznam dátových úložísk.",
|
||||
"connectionLimit": "Môžeš nastaviť najviac {count} pripojení PBS.",
|
||||
"username": "Zadaj používateľa PBS vo formáte user@realm, napríklad root@pam.",
|
||||
"password": "Zadaj heslo používateľa PBS.",
|
||||
"loginRejected": "PBS odmietol používateľské meno alebo heslo.",
|
||||
"invalidLogin": "PBS vrátil neplatnú odpoveď na prihlásenie.",
|
||||
"tokenCreateDenied": "Tento používateľ PBS nemôže vytvárať API tokeny.",
|
||||
"invalidToken": "PBS vrátil neplatný API token.",
|
||||
"auditRoleDenied": "Tento používateľ PBS nemôže prideliť rolu Audit. Prihlás sa ako root@pam alebo iný správca.",
|
||||
"notFound": "Pripojenie PBS sa nenašlo. Obnov zoznam pripojení.",
|
||||
"readFailed": "Nepodarilo sa načítať údaje PBS. Skontroluj službu a oprávnenia.",
|
||||
"invalidList": "PBS vrátil neplatný zoznam údajov.",
|
||||
"localUnavailable": "PBS na tomto hostiteľovi nie je dostupný.",
|
||||
"authRequired": "Pred správou vzdialených pripojení PBS zapni lokálne overovanie v Monitore.",
|
||||
"datastoreOffline": "Dátové úložisko je odpojené, v režime údržby alebo jeho zariadenie nie je pripojené.",
|
||||
"datastoreUnavailable": "Súborový systém dátového úložiska nie je dostupný."
|
||||
},
|
||||
"taskTypes": {
|
||||
"backup": "Zálohovanie", "restore": "Obnova", "verify": "Overovanie",
|
||||
"verifyGroup": "Overovanie skupiny záloh", "verifySnapshot": "Overovanie snímky",
|
||||
"prune": "Odstraňovanie starých záloh", "garbageCollection": "Čistenie nepoužívaných dát", "sync": "Synchronizácia",
|
||||
"updateCheck": "Kontrola aktualizácií", "terminal": "Terminál", "createDatastore": "Vytvorenie dátového úložiska",
|
||||
"other": "Iná úloha ({type})"
|
||||
},
|
||||
"states": { "ok": "V poriadku", "unknown": "Neznámy", "warnings": "Upozornenia: {count}", "warning": "Upozornenie", "error": "Chyba" },
|
||||
"serviceStates": { "active": "Aktívna", "inactive": "Neaktívna", "failed": "Zlyhala", "activating": "Spúšťa sa", "deactivating": "Zastavuje sa", "unknown": "Neznámy stav" },
|
||||
"durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" }
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"cluster": "Klaster",
|
||||
"standalone": "Samostatné uzly",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Tento uzol",
|
||||
"checking": "Kontrolujú sa uzly…",
|
||||
"loadFailed": "Nepodarilo sa načítať zoznam uzlov.",
|
||||
"empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.",
|
||||
"empty": "Zatiaľ tu nie sú ďalšie uzly. Pridaj uzol a jeho Monitor otvoríš priamo odtiaľto.",
|
||||
"noResponse": "Žiadna odpoveď",
|
||||
"pbsInterface": "rozhranie PBS",
|
||||
"openNewTab": "Otvoriť na novej karte",
|
||||
"manage": "Pridať alebo spravovať uzly…",
|
||||
"dialogTitle": "Prepojené uzly",
|
||||
"dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.",
|
||||
"dialogDescription": "Uzly klastra a nastavené servery Proxmox Backup Server sa zisťujú automaticky. Do Monitora každého uzla sa prihlasuješ samostatne.",
|
||||
"addTitle": "Pridať uzol",
|
||||
"name": "Meno",
|
||||
"address": "Adresa monitora",
|
||||
"name": "Názov uzla",
|
||||
"address": "Adresa Monitora",
|
||||
"type": "Typ",
|
||||
"add": "Pridať",
|
||||
"save": "Uložiť",
|
||||
"remove": "Odstrániť",
|
||||
"visible": "Viditeľné",
|
||||
"visible": "Zobraziť",
|
||||
"autoAddress": "Automaticky",
|
||||
"detected": "Zistené",
|
||||
"added": "Pridané",
|
||||
"detected": "Zistený",
|
||||
"added": "Pridaný",
|
||||
"saveFailed": "Nepodarilo sa uložiť zoznam uzlov.",
|
||||
"backTo": "Späť na {name}",
|
||||
"cameFromTitle": "Pridať uzol monitora?",
|
||||
"cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.",
|
||||
"cameFromTitle": "Pridať uzol Monitora?",
|
||||
"cameFromBody": "Pridaj {name} medzi prepojené uzly tohto Monitora.",
|
||||
"notNow": "Teraz nie",
|
||||
"knownTitle": "Pridať prepojené uzly?",
|
||||
"knownBody": "{name} prepája tieto monitory. Vyberte tie, ktoré chcete pridať do tohto monitora.",
|
||||
"knownBody": "Uzol {name} má prepojené tieto uzly. Vyber tie, ktoré chceš pridať aj sem.",
|
||||
"addSelected": "Pridať vybrané",
|
||||
"pbsWebInterface": "Webové rozhranie PBS",
|
||||
"changeAddress": "Zmeniť adresu",
|
||||
"cancel": "Zrušiť"
|
||||
"cancel": "Zrušiť",
|
||||
"errors": {
|
||||
"addressRequired": "Zadaj adresu Monitora.",
|
||||
"invalidAddress": "Adresa musí začínať na http:// alebo https:// a obsahovať názov hostiteľa alebo IP adresu.",
|
||||
"addressCredentials": "Adresa nesmie obsahovať používateľské meno ani heslo.",
|
||||
"invalidPort": "Port v adrese nie je platný. Zadaj číslo od 1 do 65535.",
|
||||
"invalidRequest": "Požiadavka nie je platná. Obnov stránku a skús to znova.",
|
||||
"limit": "Môžeš pridať najviac {count} uzlov.",
|
||||
"duplicate": "Táto adresa už je v zozname.",
|
||||
"notFound": "Uzol sa nenašiel. Obnov zoznam a skús to znova.",
|
||||
"detectedRemove": "Odstrániť môžeš iba ručne pridané uzly. Automaticky zistené uzly môžeš skryť."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ from jwt_middleware import require_auth, require_admin_scope
|
||||
monitor_links_bp = Blueprint("monitor_links", __name__)
|
||||
|
||||
|
||||
def link_error(exc):
|
||||
payload = {"error": str(exc), "code": exc.code}
|
||||
if exc.params:
|
||||
payload["params"] = exc.params
|
||||
return jsonify(payload), 400
|
||||
|
||||
|
||||
@monitor_links_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -27,9 +34,9 @@ def add_link():
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True, key=key)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
|
||||
@@ -40,9 +47,9 @@ def update_link(key):
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
|
||||
@@ -53,6 +60,6 @@ def delete_link(key):
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
@@ -8,6 +8,10 @@ from jwt_middleware import require_auth, require_admin_scope
|
||||
pbs_bp = Blueprint("pbs", __name__)
|
||||
|
||||
|
||||
def pbs_error(exc, status):
|
||||
return jsonify(error=str(exc), **exc.problem()), status
|
||||
|
||||
|
||||
@pbs_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -27,7 +31,7 @@ def servers():
|
||||
servers = pbs.public_servers()
|
||||
return jsonify(servers=servers, local_available=pbs.local_available(), detected=_detected(servers))
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 500
|
||||
return pbs_error(exc, 500)
|
||||
|
||||
|
||||
def _detected(servers):
|
||||
@@ -57,42 +61,42 @@ def _protected_configuration():
|
||||
@require_admin_scope
|
||||
def save_server():
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 409
|
||||
try:
|
||||
identifier = pbs.save_server(request.get_json(silent=True))
|
||||
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return pbs_error(exc, 400)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save PBS connection."), 500
|
||||
return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/servers/login", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def connect_with_password():
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 409
|
||||
try:
|
||||
identifier = pbs.connect_with_password(request.get_json(silent=True))
|
||||
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return pbs_error(exc, 400)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save PBS connection."), 500
|
||||
return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/servers/<identifier>", methods=["DELETE"])
|
||||
@require_admin_scope
|
||||
def delete_server(identifier):
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before changing PBS connections."), 409
|
||||
return jsonify(error="Enable local authentication before changing PBS connections.", code="pbs_auth_required"), 409
|
||||
try:
|
||||
pbs.delete_server(identifier)
|
||||
return jsonify(success=True)
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return pbs_error(exc, 400)
|
||||
except OSError:
|
||||
return jsonify(error="Could not remove PBS connection."), 500
|
||||
return jsonify(error="Could not remove PBS connection.", code="pbs_remove_failed"), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/status")
|
||||
@@ -101,4 +105,4 @@ def status():
|
||||
try:
|
||||
return jsonify(pbs.get_status(request.args.get("server", "local")))
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 502
|
||||
return pbs_error(exc, 502)
|
||||
|
||||
@@ -39,7 +39,10 @@ _refresh_lock = threading.Lock()
|
||||
|
||||
|
||||
class LinkError(ValueError):
|
||||
pass
|
||||
def __init__(self, message, *, code="monitor_links_save_failed", params=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.params = params
|
||||
|
||||
|
||||
def _load():
|
||||
@@ -63,19 +66,19 @@ def _save(data):
|
||||
def normalize_url(value):
|
||||
"""Return the origin of an http(s) address, or raise LinkError."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise LinkError("Enter the address of the Monitor.")
|
||||
raise LinkError("Enter the address of the Monitor.", code="monitor_link_address_required")
|
||||
text = value.strip()
|
||||
if "://" not in text:
|
||||
text = "https://" + text
|
||||
parsed = urllib.parse.urlsplit(text)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise LinkError("The address must start with http:// or https:// and name a host.")
|
||||
raise LinkError("The address must start with http:// or https:// and name a host.", code="monitor_link_invalid_address")
|
||||
if parsed.username or parsed.password:
|
||||
raise LinkError("The address must not contain a user or password.")
|
||||
raise LinkError("The address must not contain a user or password.", code="monitor_link_address_credentials")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise LinkError("The port of the address is not valid.") from exc
|
||||
raise LinkError("The port of the address is not valid.", code="monitor_link_invalid_port") from exc
|
||||
host = parsed.hostname
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
@@ -301,16 +304,16 @@ def start_prewarmer():
|
||||
|
||||
def add_link(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
|
||||
name = str(payload.get("name") or "").strip()[:64]
|
||||
url = normalize_url(payload.get("url"))
|
||||
product = payload.get("product") if payload.get("product") in PRODUCTS else "pve"
|
||||
with _lock:
|
||||
data = _load()
|
||||
if len(data["manual"]) >= MAX_MANUAL:
|
||||
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.")
|
||||
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.", code="monitor_link_limit", params={"count": MAX_MANUAL})
|
||||
if any(row.get("url") == url for row in data["manual"]):
|
||||
raise LinkError("This address is already in the list.")
|
||||
raise LinkError("This address is already in the list.", code="monitor_link_duplicate")
|
||||
identifier = secrets.token_hex(6)
|
||||
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
|
||||
"url": url, "product": product})
|
||||
@@ -320,13 +323,13 @@ def add_link(payload):
|
||||
|
||||
def update_link(key, payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
|
||||
with _lock:
|
||||
data = _load()
|
||||
if key.startswith("manual:"):
|
||||
row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None)
|
||||
if row is None:
|
||||
raise LinkError("Node not found.")
|
||||
raise LinkError("Node not found.", code="monitor_link_not_found")
|
||||
if "name" in payload:
|
||||
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
|
||||
if payload.get("url"):
|
||||
@@ -345,7 +348,7 @@ def update_link(key, payload):
|
||||
if not override:
|
||||
data["overrides"].pop(key, None)
|
||||
else:
|
||||
raise LinkError("Node not found.")
|
||||
raise LinkError("Node not found.", code="monitor_link_not_found")
|
||||
_save(data)
|
||||
|
||||
|
||||
@@ -354,6 +357,6 @@ def delete_link(key):
|
||||
data = _load()
|
||||
remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key]
|
||||
if len(remaining) == len(data["manual"]):
|
||||
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.")
|
||||
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.", code="monitor_link_detected_remove")
|
||||
data["manual"] = remaining
|
||||
_save(data)
|
||||
|
||||
@@ -39,7 +39,13 @@ MAX_RESPONSE = 4 * 1024 * 1024
|
||||
|
||||
|
||||
class PBSError(ValueError):
|
||||
pass
|
||||
def __init__(self, message, *, code="pbs_unknown", params=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.params = params
|
||||
|
||||
def problem(self):
|
||||
return {"code": self.code, **({"params": self.params} if self.params else {})}
|
||||
|
||||
|
||||
def get_platform():
|
||||
@@ -87,7 +93,7 @@ def _load_servers():
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, ValueError) as exc:
|
||||
raise PBSError("Could not read the PBS server configuration.") from exc
|
||||
raise PBSError("Could not read the PBS server configuration.", code="pbs_config_read") from exc
|
||||
|
||||
|
||||
def _save_servers(servers):
|
||||
@@ -113,11 +119,11 @@ def public_servers():
|
||||
|
||||
def _validate_server(data, previous=None, fields=("name", "url", "token_id", "token_secret", "fingerprint")):
|
||||
if not isinstance(data, dict):
|
||||
raise PBSError("Invalid PBS server configuration.")
|
||||
raise PBSError("Invalid PBS server configuration.", code="pbs_invalid_config")
|
||||
previous = previous or {}
|
||||
row = {field: data.get(field, previous.get(field, "")) for field in fields}
|
||||
if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
|
||||
raise PBSError("Invalid PBS connection field.")
|
||||
raise PBSError("Invalid PBS connection field.", code="pbs_invalid_field")
|
||||
row["name"] = row["name"].strip()
|
||||
row["url"] = row["url"].strip().rstrip("/")
|
||||
try:
|
||||
@@ -127,26 +133,26 @@ def _validate_server(data, previous=None, fields=("name", "url", "token_id", "to
|
||||
or parts.query or parts.fragment):
|
||||
raise ValueError()
|
||||
except ValueError as exc:
|
||||
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from exc
|
||||
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.", code="pbs_invalid_url") from exc
|
||||
if not row["name"] or len(row["name"]) > 128:
|
||||
raise PBSError("A server name of at most 128 characters is required.")
|
||||
raise PBSError("A server name of at most 128 characters is required.", code="pbs_name_required")
|
||||
if "token_id" not in row:
|
||||
return _validated_fingerprint(row)
|
||||
row["token_id"] = row["token_id"].strip()
|
||||
if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
|
||||
raise PBSError("Enter a PBS API token ID in user@realm!token format.")
|
||||
raise PBSError("Enter a PBS API token ID in user@realm!token format.", code="pbs_token_id_format")
|
||||
if (not row["token_secret"] or not row["token_secret"].isascii()
|
||||
or any(char.isspace() or ord(char) < 32 for char in row["token_secret"])):
|
||||
raise PBSError("A valid API token secret is required.")
|
||||
raise PBSError("A valid API token secret is required.", code="pbs_token_secret")
|
||||
if not row["token_id"].isascii():
|
||||
raise PBSError("Invalid API token ID.")
|
||||
raise PBSError("Invalid API token ID.", code="pbs_invalid_token_id")
|
||||
return _validated_fingerprint(row)
|
||||
|
||||
|
||||
def _validated_fingerprint(row):
|
||||
fingerprint = row["fingerprint"].strip().lower().replace(":", "")
|
||||
if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint):
|
||||
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).")
|
||||
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).", code="pbs_invalid_fingerprint")
|
||||
row["fingerprint"] = fingerprint
|
||||
return row
|
||||
|
||||
@@ -158,7 +164,8 @@ def _remote_get(server, endpoint):
|
||||
|
||||
|
||||
def _remote_request(server, method, endpoint, headers, form=None,
|
||||
rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs."):
|
||||
rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs.",
|
||||
*, rejected_code="pbs_token_rejected"):
|
||||
"""One request to PBS; credentials leave only after the certificate is checked."""
|
||||
parts = urlsplit(server["url"])
|
||||
fingerprint = server.get("fingerprint")
|
||||
@@ -175,7 +182,7 @@ def _remote_request(server, method, endpoint, headers, form=None,
|
||||
if fingerprint:
|
||||
actual = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).hexdigest()
|
||||
if not hmac.compare_digest(actual, fingerprint):
|
||||
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.")
|
||||
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.", code="pbs_fingerprint_mismatch")
|
||||
path = parts.path.rstrip("/") + "/api2/json" + endpoint
|
||||
body = urlencode(form) if form is not None else None
|
||||
request_headers = {"Accept": "application/json", **headers}
|
||||
@@ -185,17 +192,17 @@ def _remote_request(server, method, endpoint, headers, form=None,
|
||||
response = connection.getresponse()
|
||||
content = response.read(MAX_RESPONSE + 1)
|
||||
if response.status in (401, 403):
|
||||
raise PBSError(rejected)
|
||||
raise PBSError(rejected, code=rejected_code)
|
||||
if response.status != 200 or len(content) > MAX_RESPONSE:
|
||||
raise PBSError("PBS returned an invalid response. Redirects are not followed.")
|
||||
raise PBSError("PBS returned an invalid response. Redirects are not followed.", code="pbs_invalid_response")
|
||||
document = json.loads(content)
|
||||
if not isinstance(document, dict) or "data" not in document:
|
||||
raise PBSError("PBS returned an invalid JSON document.")
|
||||
raise PBSError("PBS returned an invalid JSON document.", code="pbs_invalid_json")
|
||||
return document["data"]
|
||||
except PBSError:
|
||||
raise
|
||||
except (OSError, http.client.HTTPException, ValueError) as exc:
|
||||
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.") from exc
|
||||
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.", code="pbs_connect_failed") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@@ -203,19 +210,19 @@ def _remote_request(server, method, endpoint, headers, form=None,
|
||||
def save_server(data):
|
||||
identifier = data.get("id") if isinstance(data, dict) else None
|
||||
if identifier is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(identifier)):
|
||||
raise PBSError("Invalid PBS server ID.")
|
||||
raise PBSError("Invalid PBS server ID.", code="pbs_invalid_id")
|
||||
with _lock:
|
||||
previous = next((row for row in _load_servers() if row["id"] == identifier), None)
|
||||
row = _validate_server(data, previous)
|
||||
# /version may be public; validate credentials on the protected status API.
|
||||
usage = _remote_get(row, "/status/datastore-usage")
|
||||
if not isinstance(usage, list):
|
||||
raise PBSError("PBS returned an invalid datastore list.")
|
||||
raise PBSError("PBS returned an invalid datastore list.", code="pbs_invalid_datastores")
|
||||
row.update(id=identifier or "pbs-" + secrets.token_hex(6), revision=secrets.token_hex(16))
|
||||
with _lock:
|
||||
servers = _load_servers()
|
||||
if previous is None and len(servers) >= 32:
|
||||
raise PBSError("At most 32 PBS connections may be configured.")
|
||||
raise PBSError("At most 32 PBS connections may be configured.", code="pbs_connection_limit", params={"count": 32})
|
||||
servers = [existing for existing in servers if existing["id"] != row["id"]]
|
||||
_save_servers([*servers, row])
|
||||
return row["id"]
|
||||
@@ -228,27 +235,27 @@ def connect_with_password(data):
|
||||
username = data.get("username") if isinstance(data, dict) else None
|
||||
password = data.get("password") if isinstance(data, dict) else None
|
||||
if not isinstance(username, str) or not re.fullmatch(r"[^\s:!@]+@[^\s:!@]+", username.strip()):
|
||||
raise PBSError("Enter the PBS user as user@realm, for example root@pam.")
|
||||
raise PBSError("Enter the PBS user as user@realm, for example root@pam.", code="pbs_username")
|
||||
if not isinstance(password, str) or not password or len(password) > 4096:
|
||||
raise PBSError("Enter the password of the PBS user.")
|
||||
raise PBSError("Enter the password of the PBS user.", code="pbs_password")
|
||||
ticket = _remote_request(endpoint, "POST", "/access/ticket", {},
|
||||
{"username": username.strip(), "password": password},
|
||||
rejected="PBS rejected the user name or password.")
|
||||
rejected="PBS rejected the user name or password.", rejected_code="pbs_login_rejected")
|
||||
if not isinstance(ticket, dict) or not ticket.get("ticket") or not ticket.get("CSRFPreventionToken"):
|
||||
raise PBSError("PBS returned an invalid sign-in response.")
|
||||
raise PBSError("PBS returned an invalid sign-in response.", code="pbs_invalid_login")
|
||||
session = {"Cookie": "PBSAuthCookie=" + quote(ticket["ticket"], safe=""),
|
||||
"CSRFPreventionToken": ticket["CSRFPreventionToken"]}
|
||||
user = str(ticket.get("username") or username.strip())
|
||||
name = "proxmenux-" + secrets.token_hex(4)
|
||||
token_path = f"/access/users/{quote(user, safe='')}/token/{name}"
|
||||
created = _remote_request(endpoint, "POST", token_path, session, {"comment": "ProxMenux Monitor, read-only"},
|
||||
rejected="This PBS user cannot create API tokens.")
|
||||
rejected="This PBS user cannot create API tokens.", rejected_code="pbs_token_create_denied")
|
||||
if not isinstance(created, dict) or not created.get("tokenid") or not created.get("value"):
|
||||
raise PBSError("PBS returned an invalid API token.")
|
||||
raise PBSError("PBS returned an invalid API token.", code="pbs_invalid_token")
|
||||
try:
|
||||
_remote_request(endpoint, "PUT", "/access/acl", session,
|
||||
{"path": "/", "role": "Audit", "auth-id": created["tokenid"], "propagate": "1"},
|
||||
rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.")
|
||||
rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.", rejected_code="pbs_audit_role_denied")
|
||||
return save_server({**endpoint, "token_id": created["tokenid"], "token_secret": created["value"]})
|
||||
except PBSError:
|
||||
try:
|
||||
@@ -263,7 +270,7 @@ def delete_server(identifier):
|
||||
servers = _load_servers()
|
||||
remaining = [row for row in servers if row["id"] != identifier]
|
||||
if len(remaining) == len(servers):
|
||||
raise PBSError("PBS server not found.")
|
||||
raise PBSError("PBS server not found.", code="pbs_not_found")
|
||||
_save_servers(remaining)
|
||||
|
||||
|
||||
@@ -276,7 +283,7 @@ def _cli(*arguments):
|
||||
raise ValueError()
|
||||
return data
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as exc:
|
||||
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.") from exc
|
||||
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.", code="pbs_read_failed") from exc
|
||||
|
||||
|
||||
def _local_api(path):
|
||||
@@ -290,7 +297,7 @@ def _local_api(path):
|
||||
raise ValueError()
|
||||
return data
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as exc:
|
||||
raise PBSError("Could not read PBS " + path + ". Check its service and permissions.") from exc
|
||||
raise PBSError("Could not read PBS " + path + ". Check its service and permissions.", code="pbs_read_failed") from exc
|
||||
|
||||
|
||||
def _local_datastores():
|
||||
@@ -305,12 +312,14 @@ def _local_datastores():
|
||||
inactive = store.get("backing-device") and not (path and os.path.ismount(path))
|
||||
if not path or inactive or store.get("maintenance-mode"):
|
||||
row["error"] = "Datastore is offline, in maintenance or has no mounted backing device."
|
||||
row["error_code"] = "pbs_datastore_offline"
|
||||
else:
|
||||
try:
|
||||
capacity = shutil.disk_usage(path)
|
||||
row.update(total=capacity.total, used=capacity.used, avail=capacity.free)
|
||||
except OSError:
|
||||
row["error"] = "Datastore filesystem is not available."
|
||||
row["error_code"] = "pbs_datastore_unavailable"
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
@@ -331,10 +340,10 @@ def get_status(identifier):
|
||||
cached = _status_cache.get(identifier)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
return cached[1]
|
||||
errors = {}
|
||||
errors, error_details = {}, {}
|
||||
if identifier == "local":
|
||||
if not local_available():
|
||||
raise PBSError("The local host is not a PBS server.")
|
||||
raise PBSError("The local host is not a PBS server.", code="pbs_local_unavailable")
|
||||
name, version = get_platform().get("hostname") or socket.gethostname(), _local_version()
|
||||
services = _local_services()
|
||||
operations = {"datastores": _local_datastores,
|
||||
@@ -346,7 +355,7 @@ def get_status(identifier):
|
||||
with _lock:
|
||||
server = next((row for row in _load_servers() if row["id"] == identifier), None)
|
||||
if not server:
|
||||
raise PBSError("PBS server not found.")
|
||||
raise PBSError("PBS server not found.", code="pbs_not_found")
|
||||
metadata = _remote_get(server, "/version")
|
||||
name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None
|
||||
services = {}
|
||||
@@ -354,18 +363,19 @@ def get_status(identifier):
|
||||
"sync": "/admin/sync", "verify": "/admin/verify", "prune": "/admin/prune"}
|
||||
operations = {key: (lambda endpoint=path: _remote_get(server, endpoint)) for key, path in endpoints.items()}
|
||||
result = {"server": identifier, "name": name, "version": version, "services": services,
|
||||
"fetched_at": int(time.time()), "errors": errors}
|
||||
"fetched_at": int(time.time()), "errors": errors, "error_details": error_details}
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
futures = {key: executor.submit(operation) for key, operation in operations.items()}
|
||||
for key, future in futures.items():
|
||||
try:
|
||||
data = future.result()
|
||||
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
|
||||
raise PBSError("PBS returned an invalid list.")
|
||||
raise PBSError("PBS returned an invalid list.", code="pbs_invalid_list")
|
||||
result[key] = [{**row, "name": row.get("store", row.get("name"))} for row in data] if key == "datastores" else data
|
||||
except PBSError as exc:
|
||||
result[key] = None # Unknown/forbidden is never presented as zero.
|
||||
errors[key] = str(exc)
|
||||
error_details[key] = exc.problem()
|
||||
with _lock:
|
||||
if len(_status_cache) >= 33:
|
||||
_status_cache.clear()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Exercise the shipped routes and persistence; localization is additive."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from flask import Flask
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
import monitor_links as links
|
||||
|
||||
|
||||
class MonitorLinksI18nTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.path = Path(temporary.name) / "links.json"
|
||||
self.previous = {"manual": [{"id": "saved", "name": "Saved", "url": "https://saved.test", "product": "pve"}], "overrides": {}}
|
||||
self.path.write_text(json.dumps(self.previous))
|
||||
for patch in [mock.patch.object(links, "LINKS_FILE", self.path),
|
||||
mock.patch.object(links, "refresh"), mock.patch.object(links, "_probe")]:
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
auth_provider = types.SimpleNamespace(
|
||||
load_auth_config=lambda: {"enabled": True},
|
||||
verify_token=lambda token: "fixture" if token in ("admin", "reader") else None,
|
||||
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only")
|
||||
if token in ("admin", "reader") else (None, None))
|
||||
middleware_spec = importlib.util.spec_from_file_location("fixture_jwt_middleware", ROOT / "AppImage/scripts/jwt_middleware.py")
|
||||
middleware = importlib.util.module_from_spec(middleware_spec)
|
||||
# Execute the shipped decorators; only credential verification is faked.
|
||||
with mock.patch.dict(sys.modules, {"auth_manager": auth_provider}):
|
||||
middleware_spec.loader.exec_module(middleware)
|
||||
spec = importlib.util.spec_from_file_location("fixture_monitor_links_routes", ROOT / "AppImage/scripts/flask_monitor_links_routes.py")
|
||||
routes = importlib.util.module_from_spec(spec)
|
||||
# Import isolation keeps the real JWT module and other suites untouched.
|
||||
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}):
|
||||
spec.loader.exec_module(routes)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(routes.monitor_links_bp)
|
||||
self.client = app.test_client()
|
||||
|
||||
def send(self, method, path="", payload=None, role="admin"):
|
||||
return self.client.open("/api/monitor-links" + path, method=method,
|
||||
json=payload, headers={"Authorization": "Bearer " + role} if role else {})
|
||||
|
||||
def rejected(self, method, path, payload, message, code, params=None):
|
||||
before = self.path.read_bytes()
|
||||
with mock.patch.object(links, "_save") as save:
|
||||
response = self.send(method, path, payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
expected = {"error": message, "code": code}
|
||||
if params:
|
||||
expected["params"] = params
|
||||
self.assertEqual(response.json, expected)
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-store")
|
||||
save.assert_not_called()
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_validation_errors_preserve_english_status_and_have_stable_codes_without_effects(self):
|
||||
cases = [
|
||||
("POST", "", None, "Invalid request.", "monitor_link_invalid_request"),
|
||||
("PATCH", "/manual:saved", [], "Invalid request.", "monitor_link_invalid_request"),
|
||||
("POST", "", {"url": " "}, "Enter the address of the Monitor.", "monitor_link_address_required"),
|
||||
("POST", "", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
|
||||
("POST", "", {"url": "https://user:pass@example.test"}, "The address must not contain a user or password.", "monitor_link_address_credentials"),
|
||||
("PATCH", "/manual:saved", {"name": "Unsaved", "url": "https://example.test:99999"}, "The port of the address is not valid.", "monitor_link_invalid_port"),
|
||||
("PATCH", "/cluster:node", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
|
||||
("POST", "", {"url": "https://saved.test/path"}, "This address is already in the list.", "monitor_link_duplicate"),
|
||||
("PATCH", "/manual:absent", {"url": "https://new.test"}, "Node not found.", "monitor_link_not_found"),
|
||||
("PATCH", "/other:node", {}, "Node not found.", "monitor_link_not_found"),
|
||||
("DELETE", "/cluster:node", None, "Only nodes added by hand can be removed; detected ones can be hidden.", "monitor_link_detected_remove"),
|
||||
]
|
||||
for case in cases:
|
||||
with self.subTest(case=case):
|
||||
self.rejected(*case)
|
||||
|
||||
def test_limit_uses_actual_count_and_preserves_previous_file(self):
|
||||
with mock.patch.object(links, "MAX_MANUAL", 1):
|
||||
self.rejected("POST", "", {"url": "https://new.test"}, "At most 1 nodes can be added.",
|
||||
"monitor_link_limit", {"count": 1})
|
||||
|
||||
def test_write_failure_is_localizable_without_leaking_filesystem_details(self):
|
||||
for method, path, payload in [("POST", "", {"url": "https://new.test"}),
|
||||
("PATCH", "/manual:saved", {"name": "Unsaved"}),
|
||||
("DELETE", "/manual:saved", None)]:
|
||||
with self.subTest(method=method), mock.patch.object(links.os, "replace", side_effect=OSError("private path details")):
|
||||
before = self.path.read_bytes()
|
||||
response = self.send(method, path, payload)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertEqual(response.json, {"error": "Could not save the node list.", "code": "monitor_links_save_failed"})
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
|
||||
def test_successful_crud_and_detected_hide_reset_keep_existing_contract(self):
|
||||
response = self.send("POST", payload={"name": "New", "url": "new.test:8008/path", "product": "pbs"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(set(response.json), {"success", "key"})
|
||||
key = response.json["key"]
|
||||
self.assertTrue(key.startswith("manual:"))
|
||||
row = links._load()["manual"][-1]
|
||||
self.assertEqual((row["url"], row["product"]), ("https://new.test:8008", "pbs"))
|
||||
self.assertEqual(self.send("PATCH", "/" + key, {"url": "http://changed.test"}).json, {"success": True})
|
||||
self.assertEqual(links._load()["manual"][-1]["url"], "http://changed.test")
|
||||
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "https://override.test", "hidden": True}).json, {"success": True})
|
||||
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "", "hidden": False}).json, {"success": True})
|
||||
self.assertEqual(links._load()["overrides"], {"cluster:node": {"hidden": False}})
|
||||
self.assertEqual(self.send("DELETE", "/" + key).json, {"success": True})
|
||||
self.assertEqual(links._load()["manual"], self.previous["manual"])
|
||||
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(links.refresh.call_count, 5)
|
||||
links._probe.assert_not_called()
|
||||
|
||||
def test_existing_read_and_admin_decorators_still_guard_routes_before_effects(self):
|
||||
with mock.patch.object(links, "cached_links", return_value={"self": {}, "links": []}) as cached, \
|
||||
mock.patch.object(links, "_save") as save:
|
||||
for method, path in [("POST", ""), ("PATCH", "/manual:saved"), ("DELETE", "/manual:saved")]:
|
||||
for role, status in [(None, 401), ("reader", 403)]:
|
||||
self.assertEqual(self.send(method, path, {"url": "https://new.test"}, role).status_code, status)
|
||||
self.assertEqual(self.send("GET", role=None).status_code, 401)
|
||||
self.assertEqual(self.send("GET", "?all=1", role="reader").status_code, 200)
|
||||
cached.assert_called_once_with(include_hidden=True)
|
||||
save.assert_not_called()
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
self.assertEqual(json.loads(self.path.read_text()), self.previous)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -205,7 +205,7 @@ class PBSManagerTests(unittest.TestCase):
|
||||
|
||||
def test_password_sign_in_keeps_only_a_read_only_token(self):
|
||||
calls = []
|
||||
def request(server, method, endpoint, headers, form=None, rejected=""):
|
||||
def request(server, method, endpoint, headers, form=None, rejected="", **kwargs):
|
||||
calls.append((method, endpoint, form))
|
||||
if endpoint == "/access/ticket":
|
||||
return {"ticket": "PBS:root@pam:ticket", "CSRFPreventionToken": "csrf", "username": "root@pam"}
|
||||
@@ -227,7 +227,7 @@ class PBSManagerTests(unittest.TestCase):
|
||||
|
||||
def test_password_sign_in_removes_its_token_when_the_role_cannot_be_granted(self):
|
||||
calls = []
|
||||
def request(server, method, endpoint, headers, form=None, rejected=""):
|
||||
def request(server, method, endpoint, headers, form=None, rejected="", **kwargs):
|
||||
calls.append((method, endpoint))
|
||||
if endpoint == "/access/ticket":
|
||||
return {"ticket": "t", "CSRFPreventionToken": "c"}
|
||||
@@ -351,15 +351,17 @@ class TLSTests(unittest.TestCase):
|
||||
|
||||
def test_bad_pin_and_untrusted_ca_send_no_http_credentials(self):
|
||||
for fingerprint in ('0' * 64, ''):
|
||||
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError):
|
||||
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError) as caught:
|
||||
pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage')
|
||||
self.assertEqual(caught.exception.code, "pbs_fingerprint_mismatch" if fingerprint else "pbs_connect_failed")
|
||||
self.assertEqual(self.requests, [])
|
||||
|
||||
def test_bad_credentials_and_redirects_are_rejected(self):
|
||||
for status in (401, 403, 302):
|
||||
type(self).response_status = status
|
||||
with self.subTest(status=status), self.assertRaises(pbs.PBSError):
|
||||
with self.subTest(status=status), self.assertRaises(pbs.PBSError) as caught:
|
||||
pbs._remote_get(self.connection, '/status/datastore-usage')
|
||||
self.assertEqual(caught.exception.code, "pbs_token_rejected" if status in (401, 403) else "pbs_invalid_response")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""PBS localization metadata must preserve routes, security and persistence."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from flask import Flask
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
import auth_manager
|
||||
import pbs_manager as pbs
|
||||
REAL_REMOTE_REQUEST = pbs._remote_request
|
||||
|
||||
|
||||
class PBSI18nTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup)
|
||||
self.root = Path(temp.name)
|
||||
for patch in [mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json"),
|
||||
mock.patch.object(pbs, "_remote_get", return_value=[]),
|
||||
mock.patch.object(pbs, "_remote_request"),
|
||||
mock.patch.object(pbs, "local_available", return_value=False)]:
|
||||
patch.start(); self.addCleanup(patch.stop)
|
||||
(self.root / "auth.json").write_text(json.dumps({"enabled": True, "password_hash": "fixture"}))
|
||||
self.server = {"name": "Fixture", "url": "https://pbs.invalid:8007", "token_id": "audit@pbs!monitor", "token_secret": "fake-fixture-secret", "fingerprint": ""}
|
||||
self.id = pbs.save_server(self.server)
|
||||
pbs._remote_get.reset_mock(); pbs._status_cache.clear()
|
||||
auth = types.SimpleNamespace(load_auth_config=auth_manager.load_auth_config, verify_token=lambda _: "fixture",
|
||||
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only"))
|
||||
spec = importlib.util.spec_from_file_location("fixture_pbs_jwt", ROOT / "AppImage/scripts/jwt_middleware.py")
|
||||
middleware = importlib.util.module_from_spec(spec)
|
||||
with mock.patch.dict(sys.modules, {"auth_manager": auth}): spec.loader.exec_module(middleware)
|
||||
spec = importlib.util.spec_from_file_location("fixture_pbs_routes", ROOT / "AppImage/scripts/flask_pbs_routes.py")
|
||||
routes = importlib.util.module_from_spec(spec)
|
||||
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}): spec.loader.exec_module(routes)
|
||||
app = Flask(__name__); app.register_blueprint(routes.pbs_bp); self.client = app.test_client()
|
||||
|
||||
def send(self, method="POST", path="/api/pbs/servers", data=None, role="admin"):
|
||||
return self.client.open(path, method=method, json=data, headers={"Authorization": "Bearer " + role} if role else {})
|
||||
|
||||
def reject(self, data, message, code, path="/api/pbs/servers"):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
with mock.patch.object(pbs, "_save_servers") as save:
|
||||
response = self.send(path=path, data=data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json, {"error": message, "code": code})
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-store")
|
||||
save.assert_not_called(); pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_invalid_input_is_localizable_before_any_remote_or_save_effect(self):
|
||||
cases = [
|
||||
(None, "Invalid PBS server configuration.", "pbs_invalid_config"),
|
||||
({**self.server, "name": []}, "Invalid PBS connection field.", "pbs_invalid_field"),
|
||||
({**self.server, "url": "http://pbs.invalid"}, "Use an HTTPS PBS URL without embedded credentials, query or fragment.", "pbs_invalid_url"),
|
||||
({**self.server, "name": ""}, "A server name of at most 128 characters is required.", "pbs_name_required"),
|
||||
({**self.server, "token_id": "bad"}, "Enter a PBS API token ID in user@realm!token format.", "pbs_token_id_format"),
|
||||
({**self.server, "token_secret": "bad\nsecret"}, "A valid API token secret is required.", "pbs_token_secret"),
|
||||
({**self.server, "token_id": "človek@pbs!monitor"}, "Invalid API token ID.", "pbs_invalid_token_id"),
|
||||
({**self.server, "fingerprint": "invalid"}, "The certificate fingerprint must be SHA256 (64 hex characters).", "pbs_invalid_fingerprint"),
|
||||
({**self.server, "id": "../../bad"}, "Invalid PBS server ID.", "pbs_invalid_id"),
|
||||
]
|
||||
for data, message, code in cases:
|
||||
with self.subTest(code=code): self.reject(data, message, code)
|
||||
for data, message, code in [
|
||||
({**self.server, "username": "missing-realm", "password": "fake"}, "Enter the PBS user as user@realm, for example root@pam.", "pbs_username"),
|
||||
({**self.server, "username": "root@pam", "password": ""}, "Enter the password of the PBS user.", "pbs_password"),
|
||||
]:
|
||||
with self.subTest(code=code): self.reject(data, message, code, "/api/pbs/servers/login")
|
||||
|
||||
def test_authentication_and_scope_guards_precede_all_mutations(self):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
save_patch = mock.patch.object(pbs, "_save_servers")
|
||||
save = save_patch.start(); self.addCleanup(save_patch.stop)
|
||||
for path, method in [("/api/pbs/servers", "POST"), ("/api/pbs/servers/login", "POST"), ("/api/pbs/servers/" + self.id, "DELETE")]:
|
||||
for role, status in [(None, 401), ("reader", 403)]:
|
||||
self.assertEqual(self.send(method, path, self.server, role).status_code, status)
|
||||
(self.root / "auth.json").write_text(json.dumps({"enabled": False}))
|
||||
for path in ("/api/pbs/servers", "/api/pbs/servers/login"):
|
||||
response = self.send(path=path, data=self.server)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json, {"error": "Enable local authentication before saving remote PBS credentials.", "code": "pbs_auth_required"})
|
||||
response = self.send("DELETE", "/api/pbs/servers/" + self.id)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json["code"], "pbs_auth_required")
|
||||
pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
|
||||
save.assert_not_called()
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_corrupt_configuration_missing_server_and_local_unavailable_keep_status(self):
|
||||
response = self.send("DELETE", "/api/pbs/servers/absent")
|
||||
self.assertEqual((response.status_code, response.json), (400, {"error": "PBS server not found.", "code": "pbs_not_found"}))
|
||||
for identifier, code in [("absent", "pbs_not_found"), ("local", "pbs_local_unavailable")]:
|
||||
response = self.send("GET", "/api/pbs/status?server=" + identifier)
|
||||
self.assertEqual(response.status_code, 502); self.assertEqual(response.json["code"], code)
|
||||
(self.root / "pbs_servers.json").write_text("invalid json")
|
||||
response = self.send("GET", "/api/pbs/servers")
|
||||
self.assertEqual((response.status_code, response.json), (500, {"error": "Could not read the PBS server configuration.", "code": "pbs_config_read"}))
|
||||
pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
|
||||
|
||||
def test_save_delete_failures_leave_previous_bytes_and_do_not_leak_details(self):
|
||||
for path, method, data, code, message in [
|
||||
("/api/pbs/servers", "POST", {**self.server, "id": self.id}, "pbs_save_failed", "Could not save PBS connection."),
|
||||
("/api/pbs/servers/" + self.id, "DELETE", None, "pbs_remove_failed", "Could not remove PBS connection."),
|
||||
]:
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
with self.subTest(method=method), mock.patch.object(pbs.os, "replace", side_effect=OSError("private filesystem detail")):
|
||||
response = self.send(method, path, data)
|
||||
self.assertEqual((response.status_code, response.json), (500, {"error": message, "code": code}))
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_partial_status_preserves_legacy_error_null_and_cache_with_additive_metadata(self):
|
||||
def remote(server, endpoint):
|
||||
if endpoint == "/version": return {"version": "4.2.5"}
|
||||
if endpoint == "/admin/verify": raise pbs.PBSError("Fixture access denied", code="pbs_token_rejected")
|
||||
if endpoint == "/admin/prune": return {"invalid": "list"}
|
||||
return []
|
||||
with mock.patch.object(pbs, "_remote_get", side_effect=remote) as remote_call:
|
||||
result = self.send("GET", "/api/pbs/status?server=" + self.id)
|
||||
count = remote_call.call_count
|
||||
self.assertEqual(self.send("GET", "/api/pbs/status?server=" + self.id).json, result.json)
|
||||
self.assertEqual(remote_call.call_count, count)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertIsNone(result.json["verify"]); self.assertIsNone(result.json["prune"])
|
||||
self.assertEqual(result.json["sync"], [])
|
||||
self.assertEqual(result.json["errors"]["verify"], "Fixture access denied")
|
||||
self.assertEqual(result.json["error_details"]["verify"], {"code": "pbs_token_rejected"})
|
||||
self.assertEqual(result.json["error_details"]["prune"], {"code": "pbs_invalid_list"})
|
||||
|
||||
def test_local_datastore_errors_and_command_errors_keep_legacy_messages(self):
|
||||
with mock.patch.object(pbs, "_cli", return_value=[{"name": "offline", "path": "/fixture", "backing-device": "fixture"}]), \
|
||||
mock.patch.object(pbs.os.path, "ismount", return_value=False), mock.patch.object(pbs.shutil, "disk_usage") as usage:
|
||||
store = pbs._local_datastores()[0]
|
||||
self.assertEqual(store["error"], "Datastore is offline, in maintenance or has no mounted backing device.")
|
||||
self.assertEqual(store["error_code"], "pbs_datastore_offline"); usage.assert_not_called(); self.assertIsNone(store["total"])
|
||||
with mock.patch.object(pbs.subprocess, "run", side_effect=OSError("fixture")):
|
||||
for fn, args in [(pbs._cli, ("task", "list")), (pbs._local_api, ("/admin/sync",))]:
|
||||
with self.subTest(args=args), self.assertRaises(pbs.PBSError) as caught: fn(*args)
|
||||
self.assertEqual(caught.exception.code, "pbs_read_failed")
|
||||
self.assertIn("Check its service and permissions.", str(caught.exception))
|
||||
|
||||
def test_remote_failure_codes_follow_the_actual_request_and_keep_english(self):
|
||||
cases = [
|
||||
(401, b'{}', "pbs_token_rejected", "PBS rejected the API token or its permissions. Check both the user and token ACLs."),
|
||||
(302, b'{}', "pbs_invalid_response", "PBS returned an invalid response. Redirects are not followed."),
|
||||
(200, b'{}', "pbs_invalid_json", "PBS returned an invalid JSON document."),
|
||||
(200, b'invalid-json', "pbs_connect_failed", "Could not connect to PBS over verified HTTPS. Check the address, certificate and service."),
|
||||
]
|
||||
for status, body, code, message in cases:
|
||||
connection = mock.Mock(); connection.getresponse.return_value.status = status
|
||||
connection.getresponse.return_value.read.return_value = body
|
||||
with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), \
|
||||
self.assertRaises(pbs.PBSError) as caught:
|
||||
REAL_REMOTE_REQUEST(self.server, "GET", "/version", {})
|
||||
self.assertEqual((str(caught.exception), caught.exception.problem()), (message, {"code": code}))
|
||||
connection.close.assert_called_once()
|
||||
for code in ("pbs_login_rejected", "pbs_token_create_denied", "pbs_audit_role_denied"):
|
||||
connection = mock.Mock(); connection.getresponse.return_value.status = 403
|
||||
connection.getresponse.return_value.read.return_value = b'{}'
|
||||
with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), self.assertRaises(pbs.PBSError) as caught:
|
||||
REAL_REMOTE_REQUEST(self.server, "POST", "/access/ticket", {}, rejected="Original context", rejected_code=code)
|
||||
self.assertEqual((str(caught.exception), caught.exception.code), ("Original context", code))
|
||||
|
||||
def test_connection_limit_and_invalid_datastore_response_do_not_save(self):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
with mock.patch.object(pbs, "_save_servers") as save, mock.patch.object(pbs, "_load_servers", return_value=[{"id": str(i)} for i in range(32)]):
|
||||
response = self.send(data=self.server)
|
||||
self.assertEqual(response.json, {"error": "At most 32 PBS connections may be configured.", "code": "pbs_connection_limit", "params": {"count": 32}})
|
||||
self.assertEqual(response.status_code, 400); save.assert_not_called()
|
||||
pbs._remote_get.return_value = {}
|
||||
response = self.send(data=self.server)
|
||||
self.assertEqual(response.json["code"], "pbs_invalid_datastores")
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_login_failures_keep_saved_state_and_cleanup_a_created_token(self):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
login = {"name": "Fixture", "url": "https://pbs.invalid", "username": "root@pam", "password": "fake"}
|
||||
for stage, code in [("ticket", "pbs_invalid_login"), ("token", "pbs_invalid_token"), ("acl", "pbs_audit_role_denied")]:
|
||||
calls = []
|
||||
def request(server, method, endpoint, headers, form=None, rejected="", rejected_code="pbs_token_rejected"):
|
||||
calls.append((method, endpoint))
|
||||
if endpoint == "/access/ticket": return None if stage == "ticket" else {"ticket": "fake", "CSRFPreventionToken": "fake"}
|
||||
if method == "POST": return None if stage == "token" else {"tokenid": "audit@pbs!fixture", "value": "fake"}
|
||||
if method == "PUT": raise pbs.PBSError(rejected, code=rejected_code)
|
||||
pbs._remote_request.side_effect = request
|
||||
response = self.send(path="/api/pbs/servers/login", data=login)
|
||||
self.assertEqual(response.status_code, 400); self.assertEqual(response.json["code"], code)
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
self.assertEqual(sum(method == "DELETE" for method, _ in calls), 1 if stage == "acl" else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
+1244
-1122
File diff suppressed because it is too large
Load Diff
@@ -93,8 +93,8 @@ def main() -> None:
|
||||
rootfs = Path(sys.argv[1])
|
||||
configuration = json.loads(sys.argv[2])
|
||||
path, changed = update_encoding(rootfs, configuration)
|
||||
state = "updated" if changed else "already applied"
|
||||
print(f"Jellyfin configuration {state}: /{path.relative_to(rootfs.resolve())}")
|
||||
state = translate("Jellyfin configuration updated:") if changed else translate("Jellyfin configuration already applied:")
|
||||
print(f"{state} /{path.relative_to(rootfs.resolve())}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -20,6 +20,7 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from oci_ui import translate
|
||||
|
||||
CONFIGS = Path('/etc/pve/lxc')
|
||||
HOOKS = Path('/var/lib/lxc')
|
||||
@@ -121,12 +122,13 @@ def check(vmid, settle=0):
|
||||
except BlockingIOError:
|
||||
return
|
||||
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():
|
||||
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
|
||||
LOCKS.mkdir(parents=True, exist_ok=True)
|
||||
if sys.argv[1] == '--all':
|
||||
|
||||
@@ -10,6 +10,7 @@ import sys
|
||||
import time
|
||||
|
||||
import oci_instances as instances
|
||||
from oci_ui import translate
|
||||
|
||||
# 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}'
|
||||
@@ -82,10 +83,10 @@ def main(arguments):
|
||||
command = arguments[0] if arguments else 'prune'
|
||||
if command == '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
|
||||
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
|
||||
with instances.locked(instances.ROOT):
|
||||
archives = archives_of([int(a) for a in arguments[1:]])
|
||||
@@ -103,5 +104,5 @@ if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
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)
|
||||
|
||||
@@ -284,7 +284,7 @@ def change(root, primary_id, acceleration, render_device=None, gfx_override=None
|
||||
|
||||
def main():
|
||||
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('--render-device')
|
||||
parser.add_argument('--gfx-override')
|
||||
|
||||
@@ -301,7 +301,7 @@ def main(argv=None):
|
||||
diagnose = sub.add_parser('diagnose')
|
||||
diagnose.add_argument('vmid', type=int)
|
||||
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)
|
||||
for flag in ('template', 'deployment', 'archive', 'digest'):
|
||||
record.add_argument('--' + flag, required=True)
|
||||
|
||||
@@ -1029,9 +1029,9 @@ def main():
|
||||
parser.add_argument('--root', type=Path, default=instances.ROOT)
|
||||
parser.add_argument('--start', action='store_true')
|
||||
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',
|
||||
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()
|
||||
if os.geteuid() != 0:
|
||||
parser.error(translate('Root privileges are required'))
|
||||
|
||||
@@ -12,6 +12,7 @@ import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from oci_ui import translate
|
||||
|
||||
# A model of five operators (convolution, pooling and a dense layer), enough
|
||||
# to make ROCm compile and run on the GPU without downloading anything.
|
||||
@@ -72,7 +73,8 @@ def main():
|
||||
try:
|
||||
check(args.vmid)
|
||||
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 0
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import time
|
||||
|
||||
import oci_instances as instances
|
||||
from oci_installation_state import parse_config
|
||||
from oci_ui import translate
|
||||
|
||||
APPS = Path('/etc/proxmenux/apps')
|
||||
SOCKET = Path('/run/proxmenux-monitor/oci-check.sock')
|
||||
@@ -206,7 +207,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root', type=Path, default=instances.ROOT)
|
||||
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()
|
||||
try:
|
||||
if args.progress:
|
||||
|
||||
@@ -19,6 +19,7 @@ import time
|
||||
|
||||
import oci_instances as instances
|
||||
import oci_operation_notice
|
||||
from oci_ui import translate
|
||||
|
||||
UNIT = 'proxmenux-oci-watchdog.service'
|
||||
UNIT_FILE = Path('/etc/systemd/system') / UNIT
|
||||
@@ -191,7 +192,8 @@ def look(states, now):
|
||||
lambda seen: stop_requested(read_tasks(), vmid, seen))
|
||||
data = {'app_name': name, 'vmid': vmid, 'containers': f'CT {vmid}'}
|
||||
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:
|
||||
started = start(vmid)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
@@ -199,7 +201,8 @@ def look(states, now):
|
||||
if started and notice_due(state, now):
|
||||
oci_operation_notice.notify('oci_watchdog_restarted', data)
|
||||
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)
|
||||
|
||||
|
||||
@@ -290,7 +293,7 @@ def main():
|
||||
try:
|
||||
vmids = set_watchdog(instances.ROOT, args.vmid, args.state == 'on')
|
||||
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
|
||||
except (OSError, ValueError, KeyError) as error:
|
||||
print(str(error) or type(error).__name__, file=sys.stderr)
|
||||
|
||||
@@ -4,9 +4,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
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
|
||||
@@ -41,14 +64,16 @@ def fail(step: str) -> None:
|
||||
|
||||
def main() -> int:
|
||||
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
|
||||
machine = platform.machine().lower()
|
||||
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
|
||||
open_tree_nr, move_mount_nr, mount_setattr_nr = SYSCALLS[machine]
|
||||
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.syscall.restype = ctypes.c_long
|
||||
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)
|
||||
try:
|
||||
if libc.setns(ct_ns, CLONE_NEWNS) != 0:
|
||||
fail("enter container namespace")
|
||||
fail(translate("enter container namespace"))
|
||||
os.fchdir(ct_root)
|
||||
os.chroot(".")
|
||||
os.chdir("/")
|
||||
@@ -71,7 +96,7 @@ def main() -> int:
|
||||
OPEN_TREE_CLONE | os.O_CLOEXEC,
|
||||
)
|
||||
if tree < 0:
|
||||
fail("clone source mount tree")
|
||||
fail(translate("clone source mount tree"))
|
||||
try:
|
||||
if mode == "ro":
|
||||
attributes = MountAttr(attr_set=MOUNT_ATTR_RDONLY)
|
||||
@@ -84,9 +109,9 @@ def main() -> int:
|
||||
ctypes.sizeof(attributes),
|
||||
)
|
||||
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:
|
||||
fail("return to host namespace")
|
||||
fail(translate("return to host namespace"))
|
||||
os.fchdir(host_root)
|
||||
os.chroot(".")
|
||||
os.chdir("/")
|
||||
@@ -99,7 +124,7 @@ def main() -> int:
|
||||
MOVE_MOUNT_F_EMPTY_PATH,
|
||||
)
|
||||
if result != 0:
|
||||
fail("publish mount tree")
|
||||
fail(translate("publish mount tree"))
|
||||
finally:
|
||||
os.close(tree)
|
||||
finally:
|
||||
|
||||
@@ -164,7 +164,7 @@ def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("archive", type=Path)
|
||||
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()
|
||||
if args.extracted_mib:
|
||||
try:
|
||||
|
||||
@@ -15,7 +15,7 @@ from . import console, images
|
||||
from .catalog import Catalog
|
||||
from .converter import ConversionError
|
||||
from .github_source import SourceError
|
||||
from .i18n import N_, source_text, translate
|
||||
from .i18n import N_, diagnostic, source_text, translate
|
||||
from .installer import (
|
||||
ADDRESS_SHOWN,
|
||||
ADVANCED_MODE,
|
||||
@@ -38,7 +38,7 @@ PUBLISHERS = {"linuxserver.io": "LinuxServer", "official": N_("Official image")}
|
||||
STACK_LABELS = {
|
||||
"server": N_("Server"),
|
||||
"application": N_("Application"),
|
||||
"machine_learning": "Machine learning",
|
||||
"machine_learning": N_("Machine learning"),
|
||||
"database": "PostgreSQL",
|
||||
"valkey": "Valkey",
|
||||
"cache": "Redis",
|
||||
@@ -133,7 +133,7 @@ def _service_kind(service: dict[str, Any]) -> str:
|
||||
if service.get("is_main"):
|
||||
return translate("Application")
|
||||
if "machine-learning" in name or "machine-learning" in image:
|
||||
return "Machine learning"
|
||||
return translate("Machine learning")
|
||||
for key, label in SERVICE_KINDS:
|
||||
if key in image:
|
||||
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']}"
|
||||
if mount["type"] == "managed-volume" else mount["source"])
|
||||
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)
|
||||
if isinstance(storage, dict) and "mode" in storage:
|
||||
if storage["mode"] == "host-bind":
|
||||
@@ -472,7 +472,7 @@ def _rclone_mount(catalog: Catalog, ui) -> None:
|
||||
if result:
|
||||
from .management import carry_records
|
||||
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-only')}: {result['read_only_path']}")
|
||||
console.wait_for_enter(translate("Press Enter to return to the menu..."))
|
||||
@@ -614,7 +614,7 @@ def interactive(catalog: Catalog) -> int:
|
||||
continue
|
||||
except (ConversionError, InstallError, OSError, ValueError) as exc:
|
||||
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
|
||||
@@ -623,52 +623,52 @@ def _template_summary_text(template: dict[str, Any]) -> str:
|
||||
contract = template["container_contract"]
|
||||
lines = [
|
||||
source_text(template["catalog_ui"]["title"]),
|
||||
f"Image: {contract['image']['reference']}",
|
||||
f"Status: {template['status']}",
|
||||
"Ports: " + (", ".join(f"{p['container_port']}/{p['protocol']}" for p in contract["ports"]) or "none"),
|
||||
"Volumes: " + (", ".join(v["container_path"] for v in contract["volumes"]) or "none"),
|
||||
f"{translate('Image')}: {contract['image']['reference']}",
|
||||
f"{translate('Status')}: {template['status']}",
|
||||
translate("Ports") + ": " + (", ".join(f"{p['container_port']}/{p['protocol']}" for p in contract["ports"]) or translate("none")),
|
||||
translate("Volumes") + ": " + (", ".join(v["container_path"] for v in contract["volumes"]) or translate("none")),
|
||||
]
|
||||
blockers = template["compatibility"]["untranslated_blockers"]
|
||||
if blockers:
|
||||
lines.append(f"Blockers: {', '.join(blockers)}")
|
||||
lines.append(f"{translate('Blockers')}: {', '.join(blockers)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
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.add_parser("sync", help="Refresh the index from the image sources")
|
||||
list_parser = subparsers.add_parser("list", help="List applications")
|
||||
subparsers.add_parser("sync", help=translate("Refresh the index from the image sources"))
|
||||
list_parser = subparsers.add_parser("list", help=translate("List applications"))
|
||||
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_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"],
|
||||
default="all")
|
||||
subparsers.add_parser("apply-icons", help="Resolve catalog icons against the jsdelivr icon sets")
|
||||
show_parser = subparsers.add_parser("show", help="Show the summary of a template")
|
||||
subparsers.add_parser("apply-icons", help=translate("Resolve catalog icons against the jsdelivr icon sets"))
|
||||
show_parser = subparsers.add_parser("show", help=translate("Show the summary of a template"))
|
||||
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")
|
||||
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("--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("--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("--dry-run", action="store_true")
|
||||
subparsers.add_parser("recover", help="Register again the OCI applications restored from a backup")
|
||||
manage_parser = subparsers.add_parser("manage", help="Update, modify or recreate one installed OCI instance")
|
||||
subparsers.add_parser("recover", help=translate("Register again the OCI applications restored from a backup"))
|
||||
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("--action", choices=("update", "modify", "recreate"), required=True)
|
||||
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",
|
||||
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",
|
||||
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)
|
||||
return parser
|
||||
|
||||
@@ -681,15 +681,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return interactive(catalog)
|
||||
if args.command == "sync":
|
||||
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
|
||||
if args.command == "list":
|
||||
query = args.filter.casefold()
|
||||
for item in catalog.load_index()["applications"]:
|
||||
if not item.get("hidden", False) and query in item["id"].casefold():
|
||||
candidate = item.get("automatic_install_candidate")
|
||||
status = ("installable" if candidate else "pending adaptation" if candidate is False
|
||||
else item.get("template_status") or "not generated")
|
||||
status = (translate("installable") if candidate else translate("pending adaptation") if candidate is False
|
||||
else item.get("template_status") or translate("not generated"))
|
||||
print(f"{item['id']:<30} {_display_architectures(item):<12} {publisher(item):<14} {status}")
|
||||
return 0
|
||||
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)
|
||||
|
||||
report = catalog.generate_all(progress=progress, provider=args.provider)
|
||||
print(f"\nGenerated: {report['generated_count']}; failed: {report['failed_count']}; "
|
||||
f"family: {args.provider}")
|
||||
print('\n' + translate('Generated: {generated}; failed: {failed}; family: {provider}').format(
|
||||
generated=report['generated_count'], failed=report['failed_count'], provider=args.provider))
|
||||
return 0 if not report["failed"] else 2
|
||||
if args.command == "apply-icons":
|
||||
report = catalog.apply_icons()
|
||||
print(f"Icons resolved: {report['resolved']}; cleared: {report['cleared']}; "
|
||||
f"unchanged: {report['unchanged']}; with a theme variant: {report['themed']}")
|
||||
print(translate('Icons resolved: {resolved}; cleared: {cleared}; unchanged: {unchanged}; with a theme variant: {themed}').format(**report))
|
||||
return 0
|
||||
if args.command == "show":
|
||||
print(_template_summary_text(catalog.compose(args.app)))
|
||||
@@ -728,14 +727,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.command == "install":
|
||||
template = catalog.compose(args.app)
|
||||
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)
|
||||
if notice:
|
||||
print(notice + "\n")
|
||||
deployment = build_deployment(template, None, ADVANCED_MODE if args.advanced else DEFAULT_MODE)
|
||||
print(_deployment_summary_text(template, deployment))
|
||||
if not args.dry_run and input("\nInstall? [y/N]: ").strip().casefold() not in {"y", "yes"}:
|
||||
print("Installation cancelled.")
|
||||
if not args.dry_run and input('\n' + translate('Install? [y/N]:') + ' ').strip().casefold() not in {"y", "yes"}:
|
||||
print(translate("Installation cancelled."))
|
||||
return 0
|
||||
result = run_remote_install(PROJECT_ROOT, template, deployment, args.host, args.dry_run)
|
||||
if result:
|
||||
@@ -764,7 +763,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(json.dumps(deployment, ensure_ascii=False, indent=2))
|
||||
result = run_remote_rclone_mount(PROJECT_ROOT, template, deployment, args.host, args.dry_run)
|
||||
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 1
|
||||
except (KeyboardInterrupt, UserCancelled):
|
||||
@@ -773,7 +772,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 130
|
||||
except (ConversionError, SourceError, InstallError, OSError, ValueError) as exc:
|
||||
console.stop_spinner()
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
print(f"ERROR: {diagnostic(exc)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from .cli import install_template
|
||||
from .i18n import source_text
|
||||
from .installer import ADVANCED_MODE, DEFAULT_MODE
|
||||
from .converter import ConversionError
|
||||
from .i18n import translate
|
||||
from .i18n import N_, translate
|
||||
from .registry_auth import RegistryError, refuses_access, validate_image
|
||||
from .ui import UserCancelled
|
||||
|
||||
@@ -533,16 +533,16 @@ def read_definition(ui) -> tuple[str, str]:
|
||||
|
||||
|
||||
BLOCKER_TEXTS = {
|
||||
'multi-service-compose': 'it describes more than one image',
|
||||
'native-multi-lxc-orchestrator-not-yet-implemented': 'it describes more than one image',
|
||||
'top-level-configs': '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',
|
||||
'healthcheck-format': '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',
|
||||
'shm-size-format': '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',
|
||||
'mem-limit-format-or-below-proxmox-minimum': 'the memory limit it asks for cannot be applied',
|
||||
'mem-limit-deploy-consistency-review': 'it asks for two different memory limits',
|
||||
'multi-service-compose': N_('it describes more than one image'),
|
||||
'native-multi-lxc-orchestrator-not-yet-implemented': N_('it describes more than one image'),
|
||||
'top-level-configs': N_('it uses Compose configs, which have no equivalent here'),
|
||||
'devices-format': N_('the devices it asks for are 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': N_('its stop timeout 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': N_('the resource limits 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': N_('it asks for two different memory limits'),
|
||||
}
|
||||
|
||||
|
||||
@@ -730,7 +730,7 @@ def _name(ui, template: dict[str, Any]) -> str:
|
||||
return name
|
||||
|
||||
|
||||
def explore(ui) -> None:
|
||||
def _explore(ui) -> None:
|
||||
"""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."""
|
||||
text, origin = read_definition(ui)
|
||||
@@ -784,3 +784,43 @@ def explore(ui) -> None:
|
||||
else:
|
||||
name = _name(ui, template)
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ is the source language: a missing translation returns the English text.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -46,6 +47,68 @@ def translate(text: str) -> str:
|
||||
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:
|
||||
"""The English a catalog field was written in.
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ STATUS_LABELS = {'installed': N_('installed'), 'failed': N_('failed'), 'updating
|
||||
'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):
|
||||
return {'vmid': record['vmid'], 'hostname': record.get('deployment', {}).get('hostname',
|
||||
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"\n\n{translate('Recover them now?')}")
|
||||
if not ui.confirm(text, default=True):
|
||||
return
|
||||
return CANCELLED
|
||||
command = [sys.executable, str(project / 'remote/oci_restore_recovery.py'), 'recover']
|
||||
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(
|
||||
@@ -242,7 +252,9 @@ def direct_recovery(project):
|
||||
if not restored_applications(project):
|
||||
ui.message(translate('No restored OCI application is waiting to be recovered.'), translate('Restored OCI applications'))
|
||||
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
|
||||
|
||||
|
||||
@@ -458,12 +470,14 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=(), heading=No
|
||||
proposal = None
|
||||
if action == 'modify':
|
||||
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:
|
||||
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):
|
||||
return False
|
||||
return CANCELLED
|
||||
# Opening/cancelling a confirmation needs no disk or storage probes.
|
||||
# Both accepted operations retain this check before the backend starts.
|
||||
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')),
|
||||
('recover', translate('Recover the previous installation'))], 'status')
|
||||
if action is None:
|
||||
return False
|
||||
return CANCELLED
|
||||
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('Recover OCI'), question=translate('Recover now?'), default=True):
|
||||
return False
|
||||
return CANCELLED
|
||||
return _run_lifecycle([sys.executable, str(project / 'remote/oci_instance_transaction.py'),
|
||||
action, str(row['vmid'])],
|
||||
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)
|
||||
text = []
|
||||
if len(members) > 1 and vmid == primary_id:
|
||||
text += [f"{application} {translate('runs in')} {len(members)} {translate('containers')}. "
|
||||
f"{translate('All of them are targeted for removal.')}", '']
|
||||
sentence = translate('{application} runs in {count} containers.').format(
|
||||
application=application, count=len(members))
|
||||
text += [f"{sentence} {translate('All of them are targeted for removal.')}", '']
|
||||
elif len(members) > 1:
|
||||
alone = translate('It cannot be removed on its own, because the application would stop '
|
||||
'working: continuing targets the whole application for removal.')
|
||||
text += [f"CT {vmid} {translate('is one of the')} {len(members)} "
|
||||
f"{translate('containers of')} {application}. {alone}", '']
|
||||
sentence = translate('CT {vmid} is one of the {count} containers of {application}.').format(
|
||||
vmid=vmid, count=len(members), application=application)
|
||||
text += [f"{sentence} {alone}", '']
|
||||
text += [translate('Containers targeted for removal:'), *lines, '',
|
||||
translate('Container data targeted for deletion:'), *volumes]
|
||||
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.')
|
||||
.format(count=len(members), vmid=primary_id), translate('Recreate OCI stack'),
|
||||
question=translate('Recreate the whole stack?'), default=False):
|
||||
return False
|
||||
return CANCELLED
|
||||
else:
|
||||
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 '
|
||||
@@ -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 '
|
||||
'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):
|
||||
return False
|
||||
return CANCELLED
|
||||
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'),
|
||||
question=translate('Recover or complete the operation?'), default=True):
|
||||
return False
|
||||
return CANCELLED
|
||||
import json
|
||||
members = json.loads(Path(pending).read_text())['plan']['members']
|
||||
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_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):
|
||||
@@ -765,4 +782,6 @@ def direct_management(project, vmid, action, lifecycle_args=(), unattended=False
|
||||
return EXIT_BUSY
|
||||
if unattended and ui.declined:
|
||||
return EXIT_NEEDS_REVIEW
|
||||
if completed is CANCELLED and not unattended:
|
||||
return EXIT_CANCELLED
|
||||
return EXIT_DONE if completed else EXIT_FAILED
|
||||
|
||||
@@ -3,7 +3,7 @@ import copy
|
||||
import re
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from .i18n import translate
|
||||
from .i18n import N_, translate
|
||||
from .ui import BacktrackUI, RestartWizard, UserCancelled
|
||||
|
||||
|
||||
@@ -212,8 +212,8 @@ def edit_section(candidate, ui, setting):
|
||||
wizard.close()
|
||||
|
||||
|
||||
def edit_recreation(record, ui):
|
||||
"""Keep a draft across independently editable settings; apply it once."""
|
||||
def edit_recreation(record, ui, *, cancelled_result=None):
|
||||
"""Keep a draft; optionally distinguish cancellation from an unchanged review."""
|
||||
from .cli import _deployment_summary_text
|
||||
candidate = copy.deepcopy(record)
|
||||
selected = 'cores'
|
||||
@@ -227,7 +227,7 @@ def edit_recreation(record, ui):
|
||||
title=translate('Modify OCI'), show_tags=False)
|
||||
if selected is None or selected == 'discard':
|
||||
if not pending or ui.confirm(translate('Discard unapplied changes?'), False):
|
||||
return None
|
||||
return cancelled_result
|
||||
selected = 'apply'
|
||||
elif selected == 'apply':
|
||||
if not pending:
|
||||
@@ -243,8 +243,29 @@ def edit_recreation(record, ui):
|
||||
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):
|
||||
from .catalog import Catalog
|
||||
from .converter import ConversionError
|
||||
old = candidate.get('template', {})
|
||||
template_id = old.get('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:
|
||||
return
|
||||
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:
|
||||
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):
|
||||
|
||||
@@ -365,13 +365,13 @@ def build_stack(template, ui, mode='advanced'):
|
||||
service_name = group['service']
|
||||
if service_name not in envs:
|
||||
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
|
||||
for field in group['fields']:
|
||||
if field['name'] not in envs[service_name] or envs[service_name][field['name']] != '':
|
||||
raise StackError(f"{translate('External field not reserved:')} {field['name']}")
|
||||
value = (ui.password(field['label'], required=True) if field.get('sensitive',True)
|
||||
else ui.ask(field['label'], required=True))
|
||||
value = (ui.password(translate(field['label']), required=True) if field.get('sensitive',True)
|
||||
else ui.ask(translate(field['label']), required=True))
|
||||
if not value or any(c in value for c in '\r\n'):
|
||||
raise StackError(translate('External credential is empty or spans multiple lines'))
|
||||
envs[service_name][field['name']] = value
|
||||
|
||||
@@ -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."""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
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, *_):
|
||||
script = (ROOT / "remote/install_immich_stack.sh").read_text()
|
||||
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__":
|
||||
|
||||
@@ -185,7 +185,7 @@ class ModifyDispatchTests(unittest.TestCase):
|
||||
patch.object(management, '_run_lifecycle', side_effect=lifecycle):
|
||||
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()
|
||||
self.assertFalse(self.call_manager(None, lifecycle))
|
||||
lifecycle.assert_not_called()
|
||||
|
||||
@@ -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()
|
||||
@@ -312,7 +312,7 @@ EOFAPT
|
||||
|
||||
# Verify APT hook syntax
|
||||
apt-config dump >/dev/null 2>&1 || {
|
||||
msg_warn "APT hook syntax issue, removing..."
|
||||
msg_warn "$(translate "APT hook syntax issue, removing...")"
|
||||
rm -f "$APT_HOOK"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ repository API responses: they can contain subscription keys or credentials.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import string
|
||||
import sys
|
||||
|
||||
ENDPOINT = '/nodes/localhost/apt/repositories'
|
||||
@@ -26,6 +28,41 @@ PRODUCTS = {
|
||||
'test': {'pbs-no-subscription', 'pbs-test', 'pbstest'},
|
||||
'ui': 'Administration > Repositories', 'ceph': False},
|
||||
}
|
||||
BASE = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def N_(text):
|
||||
"""Mark a CLI message without changing the policy's English exception."""
|
||||
return text
|
||||
|
||||
|
||||
class PolicyError(ValueError):
|
||||
def __init__(self, template, **params):
|
||||
self.template = template
|
||||
self.params = params
|
||||
super().__init__(template.format(**params))
|
||||
|
||||
|
||||
def cli_error(error, product):
|
||||
"""Localize only PBS CLI diagnostics; policy callers retain English errors."""
|
||||
if product != 'pbs' or not isinstance(error, PolicyError):
|
||||
return str(error)
|
||||
try:
|
||||
language = json.loads((BASE / 'config.json').read_text()).get('language', 'en')
|
||||
if language not in ('sk', 'de', 'es', 'fr', 'it', 'pt', 'sv'):
|
||||
return str(error)
|
||||
catalog = json.loads((BASE / 'lang' / f'{language}.json').read_text())
|
||||
message = catalog.get(error.template)
|
||||
if isinstance(message, str) and message.strip():
|
||||
fields = lambda text: {name for _, name, _, _ in string.Formatter().parse(text) if name is not None}
|
||||
if fields(message) != fields(error.template) or any(
|
||||
spec or conversion for _, _, spec, conversion in string.Formatter().parse(message)
|
||||
):
|
||||
return str(error)
|
||||
return message.format(**error.params)
|
||||
except (OSError, ValueError, KeyError, IndexError, TypeError, AttributeError):
|
||||
pass
|
||||
return str(error)
|
||||
|
||||
|
||||
def run(args):
|
||||
@@ -43,7 +80,7 @@ def subscription_status(product='pve'):
|
||||
except (ValueError, AttributeError):
|
||||
status = None
|
||||
if status not in ('active', 'notfound', 'expired'):
|
||||
raise ValueError('Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.')
|
||||
raise PolicyError(N_('Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.'))
|
||||
return status
|
||||
# pvesubscription's CLI get printer emits sorted "key: value" lines,
|
||||
# including a secret key and server ID. Only parse the exact status line.
|
||||
@@ -110,12 +147,12 @@ def repository_state(suite, product='pve'):
|
||||
entries.append((file['path'], index, row))
|
||||
return data, entries
|
||||
except (KeyError, TypeError, ValueError, IndexError) as exc:
|
||||
raise ValueError(f"Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {p['ui']}; no repository changed.") from exc
|
||||
raise PolicyError(N_('Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {ui}; no repository changed.'), ui=p['ui']) from exc
|
||||
|
||||
|
||||
def evaluate(suite, apply=False, product='pve'):
|
||||
if suite not in ('bookworm', 'trixie'):
|
||||
raise ValueError('Unsupported Proxmox suite; no repository changed.')
|
||||
raise PolicyError(N_('Unsupported Proxmox suite; no repository changed.'))
|
||||
p = PRODUCTS[product]
|
||||
data, entries = repository_state(suite, product)
|
||||
pve = []
|
||||
@@ -139,8 +176,8 @@ def evaluate(suite, apply=False, product='pve'):
|
||||
ceph.append((path, index, row))
|
||||
# Name the stale sources, never their URIs: those can carry credentials.
|
||||
if wrong_pve:
|
||||
raise ValueError(f"This host runs {p['name']} on {suite}, but these {p['name']} repositories are for another suite: "
|
||||
f"{'; '.join(wrong_pve)}. Disable or remove them in {p['ui']} and run the update again; no repository changed.")
|
||||
raise PolicyError(N_('This host runs {name} on {suite}, but these {name} repositories are for another suite: {details}. Disable or remove them in {ui} and run the update again; no repository changed.'),
|
||||
name=p['name'], suite=suite, details='; '.join(wrong_pve), ui=p['ui'])
|
||||
if wrong_ceph:
|
||||
raise ValueError(f"This host runs Proxmox VE on {suite}, but these Enterprise Ceph repositories are for another suite: "
|
||||
f"{'; '.join(wrong_ceph)}. Correct them in Node > Updates > Repositories before switching; no repository changed.")
|
||||
@@ -152,20 +189,20 @@ def evaluate(suite, apply=False, product='pve'):
|
||||
status = subscription_status(product) # fail closed before any write
|
||||
if status == 'active':
|
||||
if not pve:
|
||||
raise ValueError(f"Host has an active subscription but no active {p['short']} repository. Configure its Enterprise source in {p['ui']}; no repository changed.")
|
||||
raise PolicyError(N_('Host has an active subscription but no active {short} repository. Configure its Enterprise source in {ui}; no repository changed.'), short=p['short'], ui=p['ui'])
|
||||
return 'preserve'
|
||||
if not debian:
|
||||
raise ValueError('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.')
|
||||
raise PolicyError(N_('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.'))
|
||||
# No active product source or only inaccessible Enterprise. A mixed stanza
|
||||
# cannot be disabled without also disabling an unrelated component.
|
||||
disable = [item for item in pve if p['enterprise'] in item[2]['Components']] + ceph
|
||||
for _, _, row in disable:
|
||||
expected = {p['enterprise']} if p['enterprise'] in row['Components'] else {'enterprise'}
|
||||
if set(row['Components']) != expected:
|
||||
raise ValueError('Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.')
|
||||
raise PolicyError(N_('Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.'))
|
||||
handles = [r.get('handle') for r in data['standard-repos'] if r.get('handle') == 'no-subscription']
|
||||
if len(handles) != 1:
|
||||
raise ValueError('Proxmox no-subscription standard repository handle unavailable; no repository changed.')
|
||||
raise PolicyError(N_('Proxmox no-subscription standard repository handle unavailable; no repository changed.'))
|
||||
if not apply:
|
||||
return 'offer'
|
||||
# Adopt a refreshed digest only after checking the complete expected
|
||||
@@ -192,11 +229,12 @@ def evaluate(suite, apply=False, product='pve'):
|
||||
for path, index, _ in disable for p, i, row in current):
|
||||
raise ValueError('Could not verify the switched repositories; inspect the Proxmox repository UI before retrying.')
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {p['ui']} before retrying; no automatic rollback was attempted.") from exc
|
||||
raise PolicyError(N_('Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {ui} before retrying; no automatic rollback was attempted.'), ui=p['ui']) from exc
|
||||
return 'changed'
|
||||
|
||||
|
||||
def main():
|
||||
product = 'pve'
|
||||
try:
|
||||
if len(sys.argv) not in (3, 4) or sys.argv[1] not in ('plan', 'apply') \
|
||||
or (len(sys.argv) == 4 and sys.argv[3] not in PRODUCTS):
|
||||
@@ -204,7 +242,7 @@ def main():
|
||||
product = sys.argv[3] if len(sys.argv) == 4 else 'pve'
|
||||
print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply', product=product))
|
||||
except ValueError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
print(cli_error(exc, product), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ update_pbs() {
|
||||
local available_space
|
||||
available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
|
||||
msg_error "$(translate "Insufficient disk space. Available:") ${available_space}MB"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -149,7 +149,7 @@ update_pbs() {
|
||||
rm -f "$screen_capture"
|
||||
|
||||
if [ $upgrade_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
msg_error "$(translate "System upgrade failed. Check log:") $log_file"
|
||||
echo "Finished: $(date -Iseconds) — upgrade failed (exit $upgrade_exit_code)" >> "$log_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -63,13 +63,13 @@ confirm_automated_script() {
|
||||
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server"):\n\n"
|
||||
script_info+="• $(translate "Upgrade the system, offering the") \Z4no-subscription repository\Z0 $(translate "when the server has no subscription (you will be asked)")\n"
|
||||
script_info+="• $(translate "Upgrade the system, offering the") \Z4$(translate "no-subscription repository")\Z0 $(translate "when the server has no subscription (you will be asked)")\n"
|
||||
else
|
||||
script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server"):\n\n"
|
||||
script_info+="• $(translate "Configure") \Z4free repositories\Z0 $(translate "and upgrade the system (disables the enterprise repo)")\n"
|
||||
fi
|
||||
script_info+="• $(translate "Optionally remove") \Z4subscription banner\Z0 $(translate "from Proxmox web interface (you will be asked)")\n"
|
||||
script_info+="• $(translate "Optimize") \Z4memory\Z0, \Z4kernel\Z0, $(translate "and") \Z4network\Z0 $(translate "for better performance and stability")\n"
|
||||
script_info+="• $(translate "Optionally remove") \Z4$(translate "subscription banner")\Z0 $(translate "from Proxmox web interface (you will be asked)")\n"
|
||||
script_info+="• $(translate "Optimize") \Z4$(translate "memory")\Z0, \Z4$(translate "kernel")\Z0, $(translate "and") \Z4$(translate "network")\Z0 $(translate "for better performance and stability")\n"
|
||||
script_info+="• $(translate "Install and configure") \Z4Log2RAM\Z0 $(translate "(only on SSD/NVMe) to protect your disk")\n"
|
||||
script_info+="• $(translate "Enable") \Z4ZFS autotrim\Z0 $(translate "on SSD/NVMe pools that support discard")\n"
|
||||
script_info+="• $(translate "Improve log rotation and limit log size to save space and extend disk life")\n"
|
||||
@@ -289,10 +289,10 @@ post_install_label() {
|
||||
echo "Proxmox VE Microcode"
|
||||
;;
|
||||
"Proxmox Backup Server Post Install")
|
||||
echo "Proxmox Backup Server Post Install"
|
||||
translate "Proxmox Backup Server Post Install"
|
||||
;;
|
||||
"Proxmox Backup Server Microcode")
|
||||
echo "Proxmox Backup Server Microcode"
|
||||
translate "Proxmox Backup Server Microcode"
|
||||
;;
|
||||
"Community Scripts")
|
||||
translate "Community Scripts"
|
||||
|
||||
@@ -182,9 +182,9 @@ remove_subscription_banner() {
|
||||
# description: Patch the Proxmox web UI to suppress the subscription dialog and register a successful patch.
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
stop_spinner
|
||||
if ! whiptail --title "Proxmox Backup Server Subscription Banner Removal" \
|
||||
if ! whiptail --title "$(translate "Proxmox Backup Server Subscription Banner Removal")" \
|
||||
--yesno "$(translate "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?")\n\n$(translate "Attention: Removing the subscription banner may cause issues in the web interface after a future update.")\n\n$(translate "If this happens, you can restore the backup from the 'Subscription Banner Removal' option in 'Uninstall optimizations'.")\n\n$(translate "Are you sure you want to continue?")" 14 75; then
|
||||
msg_warn "Banner removal cancelled by user."
|
||||
msg_warn "$(translate "Banner removal cancelled by user.")"
|
||||
return 1
|
||||
fi
|
||||
if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then
|
||||
|
||||
@@ -2574,7 +2574,7 @@ configure_zram_swap() {
|
||||
fi
|
||||
version="$(dpkg-query -W -f='${Version}' systemd-zram-generator 2>/dev/null)"
|
||||
if [[ -z "$version" ]]; then
|
||||
failure="Cannot determine installed systemd-zram-generator version"
|
||||
failure="$(translate "Cannot determine installed systemd-zram-generator version")"
|
||||
else
|
||||
if dpkg --compare-versions "$version" ge '1.2.0'; then
|
||||
supports_params=true
|
||||
@@ -2588,7 +2588,7 @@ configure_zram_swap() {
|
||||
msg_success "$(translate "ZRAM profile is already active")"
|
||||
return 0
|
||||
fi
|
||||
failure="Existing ZRAM profile is active but does not match its configuration"
|
||||
failure="$(translate "Existing ZRAM profile is active but does not match its configuration")"
|
||||
fi
|
||||
if [[ -z "$failure" && "$previous_active" == true ]]; then
|
||||
if ! swapoff /dev/zram0; then
|
||||
@@ -2597,31 +2597,31 @@ configure_zram_swap() {
|
||||
fi
|
||||
state_mutated=true
|
||||
if ! systemctl stop dev-zram0.swap >/dev/null 2>&1; then
|
||||
failure="Could not stop existing dev-zram0.swap"
|
||||
failure="$(translate "Could not stop existing dev-zram0.swap")"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$failure" ]]; then
|
||||
state_mutated=true
|
||||
if ! install -d -m 755 "$(dirname "$config")"; then
|
||||
failure="Could not create ZRAM generator directory"
|
||||
failure="$(translate "Could not create ZRAM generator directory")"
|
||||
elif ! printf '%s\n' "$desired" | pmx_write_file "$config"; then
|
||||
failure="Could not write ZRAM generator configuration"
|
||||
failure="$(translate "Could not write ZRAM generator configuration")"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$failure" ]]; then
|
||||
old_umask="$(umask)"
|
||||
umask 077
|
||||
if ! printf 'installed_by_proxmenux=%s\n' "$package_owned" | pmx_write_file "$marker"; then
|
||||
failure="Could not write ZRAM package ownership state"
|
||||
failure="$(translate "Could not write ZRAM package ownership state")"
|
||||
fi
|
||||
umask "$old_umask"
|
||||
[[ -f "$marker" ]] && chmod 600 "$marker"
|
||||
fi
|
||||
if [[ -z "$failure" ]]; then
|
||||
if ! systemctl daemon-reload || ! systemctl start dev-zram0.swap; then
|
||||
failure="Could not activate dev-zram0.swap"
|
||||
failure="$(translate "Could not activate dev-zram0.swap")"
|
||||
elif ! zram_profile_verify_active "$algorithm"; then
|
||||
failure="Active ZRAM algorithm, capacity or swap sources did not match the profile"
|
||||
failure="$(translate "Active ZRAM algorithm, capacity or swap sources did not match the profile")"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$failure" ]]; then
|
||||
|
||||
@@ -1356,7 +1356,7 @@ show_uninstall_menu() {
|
||||
system_limits) desc="System Limits Increase";;
|
||||
entropy) desc="Entropy Generation (haveged)";;
|
||||
memory_settings) desc="Memory Settings Optimization";;
|
||||
zram_swap) desc="ZRAM swap profile";;
|
||||
zram_swap) desc="$(translate "ZRAM swap profile")";;
|
||||
kernel_panic) desc="Kernel Panic Configuration";;
|
||||
apt_ipv4) desc="APT IPv4 Force";;
|
||||
kexec) desc="kexec for quick reboots";;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Exercise the shipped error adapter and actual HostBackup handlers with a fake transport.
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
|
||||
const { ts, read, compile, translator } = require('./backup_i18n_seam.cjs');
|
||||
const library = { exports: {} };
|
||||
vm.runInNewContext(compile(read('lib/host-backup-messages.ts')), library);
|
||||
const { hostBackupErrorText } = library.exports;
|
||||
// Independently read the real API's literal/f-string contracts without importing
|
||||
// Flask or running any route, host probe, script or persistence operation.
|
||||
const contracts = JSON.parse(require('node:child_process').execFileSync('python3', ['-I', '-B', '-c', `
|
||||
import ast,json,sys
|
||||
tree=ast.parse(open(sys.argv[1]).read())
|
||||
def messages(node):
|
||||
if isinstance(node,ast.Constant) and isinstance(node.value,str): return [node.value]
|
||||
if isinstance(node,ast.JoinedStr): return [''.join(part.value if isinstance(part,ast.Constant) else 'PARAMETER' for part in node.values)]
|
||||
if isinstance(node,ast.BoolOp): return [text for child in node.values for text in messages(child)]
|
||||
return []
|
||||
result=set()
|
||||
for function in tree.body:
|
||||
if isinstance(function,ast.FunctionDef) and function.name.startswith(('api_host_backups_','api_host_restore')):
|
||||
for node in ast.walk(function):
|
||||
if isinstance(node,ast.Dict):
|
||||
for key,value in zip(node.keys,node.values):
|
||||
if isinstance(key,ast.Constant) and key.value=='error': result.update(messages(value))
|
||||
print(json.dumps(sorted(result)))
|
||||
`, 'AppImage/scripts/flask_server.py'], { encoding: 'utf8' }));
|
||||
const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten)
|
||||
: typeof n === 'object' ? [n, ...flatten(n.props?.children)] : [n];
|
||||
const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
|
||||
function fixture(locale) {
|
||||
let cursor = 0, slots = [], tree, failure = 'path must be absolute', mutations = 0;
|
||||
const calls = [], t = translator(locale);
|
||||
const jsx = (type, props) => typeof type === 'function' ? type(props) : { type, props };
|
||||
const hooks = { useState(initial) { const i = cursor++; if (!(i in slots)) slots[i] = initial; return [slots[i], value => { slots[i] = value; }]; } };
|
||||
const requireModule = name => {
|
||||
if (name === 'react') return hooks;
|
||||
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx };
|
||||
if (name === 'swr') return { default: () => ({ data: { paths: [{ path: '/kept' }] }, mutate: () => { mutations++; } }) };
|
||||
if (name.includes('i18n/provider')) return { useT: () => t };
|
||||
if (name.endsWith('host-backup-messages')) return library.exports;
|
||||
if (name.endsWith('api-config')) return { fetchApi: async (url, options) => { calls.push({ url, ...options }); if (failure) throw new Error(failure); return { status: 'ok' }; } };
|
||||
return new Proxy({}, { get: (_, key) => key });
|
||||
};
|
||||
const source = read('components/host-backup.tsx') + '\nexports.ExtraPathsSection = ExtraPathsSection;';
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX } }).outputText,
|
||||
{ exports: module.exports, require: requireModule, Error, console });
|
||||
return { calls, t, render() { cursor = 0; tree = module.exports.ExtraPathsSection(); return tree; }, text: () => text(tree),
|
||||
input: () => flatten(tree).find(n => n.type === 'Input'),
|
||||
add: () => flatten(tree).find(n => n.type === 'Button' && text(n).includes(t('backup.actions.addPath'))),
|
||||
fail: message => { failure = message; }, mutations: () => mutations };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
assert.ok(contracts.length > 100, 'real HostBackup error contracts discovered');
|
||||
for (const message of contracts) {
|
||||
const translated = hostBackupErrorText(message, translator('sk'));
|
||||
assert.notEqual(translated, message, 'application-owned message must be localized: ' + message);
|
||||
assert.ok(!translated.includes('backup.apiErrors.'), 'every mapped key has a real Slovak value');
|
||||
assert.equal(hostBackupErrorText(message, translator('missing')), hostBackupErrorText(message, translator('en')), 'missing locale uses English');
|
||||
}
|
||||
for (const locale of ['en', 'sk', 'missing']) {
|
||||
const t = translator(locale);
|
||||
assert.equal(hostBackupErrorText(new Error('path must be absolute'), t), t('backup.apiErrors.absolutePath'));
|
||||
assert.equal(hostBackupErrorText({ message: 'this path is already configured' }, t), t('backup.apiErrors.pathConfigured'));
|
||||
assert.equal(hostBackupErrorText('job already exists: test_1', t), t('backup.apiErrors.jobExists', { value: 'test_1' }));
|
||||
const raw = 'tool failed: <img src=x onerror=unsafe()> path must be absolute';
|
||||
assert.equal(hostBackupErrorText(raw, t), raw, 'external diagnostics must not be partially matched or discarded');
|
||||
const long = 'path does not exist on this host: ' + 'x'.repeat(20000);
|
||||
assert.equal(hostBackupErrorText(long, t), long, 'large diagnostics preserved without pattern matching');
|
||||
for (const unknown of ['constructor', '__proto__', 'toString', 'future backend message']) assert.equal(hostBackupErrorText(unknown, t), unknown);
|
||||
for (const invalid of [null, undefined, {}, 23]) assert.equal(hostBackupErrorText(invalid, t), t('backup.apiErrors.requestFailed'));
|
||||
const unsafe = '<img src=x onerror=unsafe()>{backup.actions.run}';
|
||||
assert.equal(hostBackupErrorText('path does not exist on this host: ' + unsafe, t), t('backup.apiErrors.pathMissing', { value: unsafe }), 'captured paths remain literal data');
|
||||
const f = fixture(locale); f.render();
|
||||
assert.equal(f.add().props.disabled, true);
|
||||
await f.add().props.onClick(); assert.equal(f.calls.length, 0, 'blank path causes no request');
|
||||
f.input().props.onChange({ target: { value: 'relative-path' } }); f.render(); await f.add().props.onClick(); f.render();
|
||||
assert.equal(f.calls.length, 1); assert.equal(f.calls[0].url, '/api/host-backups/extra-paths');
|
||||
assert.equal(f.calls[0].method, 'POST'); assert.deepEqual(JSON.parse(f.calls[0].body), { path: 'relative-path' });
|
||||
assert.equal(f.input().props.value, 'relative-path'); assert.equal(f.mutations(), 0, 'rejected input preserves state and does not refresh as success');
|
||||
assert.ok(f.text().includes(t('backup.apiErrors.absolutePath'))); assert.ok(f.text().includes('/kept'));
|
||||
f.fail('future backend message'); await f.add().props.onClick(); f.render(); assert.ok(f.text().includes('future backend message'));
|
||||
f.fail(null); await f.add().props.onClick(); f.render(); assert.equal(f.input().props.value, ''); assert.equal(f.mutations(), 1);
|
||||
}
|
||||
// Adapter has no effect on the server's CLI/API/persistence contracts.
|
||||
const host = read('components/host-backup.tsx');
|
||||
const ast = ts.createSourceFile('host-backup.tsx', host, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX), encryptionCatches = [];
|
||||
function visit(node) { if (ts.isCatchClause(node) && node.block.getText(ast).includes('err.body?.tool_output')) encryptionCatches.push(node.block); ts.forEachChild(node, visit); }
|
||||
visit(ast); assert.equal(encryptionCatches.length, 2, 'scheduled and manual backup encryption paths both checked');
|
||||
for (const locale of ['en', 'sk', 'missing']) for (const block of encryptionCatches) {
|
||||
let displayed, effects = 0;
|
||||
const code = ts.transpileModule('function run(e) '+block.getText(ast)+'; run(error);', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const t = translator(locale), error = Object.assign(new Error('failed to create PBS encryption keyfile'), { body: { tool_output: 'EXTERNAL TOOL DIAGNOSTIC' } });
|
||||
vm.runInNewContext(code, { error, t, hostBackupErrorText, setError: value => { displayed = value; }, setPbsImportBusy: () => {}, setSubmitting: () => {}, onCreated: () => effects++ });
|
||||
assert.ok(displayed.includes(hostBackupErrorText(error, t)) && displayed.includes('EXTERNAL TOOL DIAGNOSTIC'));
|
||||
assert.ok(!displayed.includes('{error}') && !displayed.includes('{detail}'), 'error interpolation must not leave an unresolved placeholder');
|
||||
assert.equal(effects, 0);
|
||||
}
|
||||
assert.ok(!host.includes('e instanceof Error ? e.message : String(e)'), 'all HostBackup catch consumers use the adapter');
|
||||
assert.ok(!host.includes('{e.error}') && !host.includes('{calendarPreview.error}'), 'structured error consumers use the adapter too');
|
||||
console.log('PASS: '+contracts.length+' real API contracts, HostBackup actual extra-path handlers, EN/SK/fallback, 0 calls for blank, rejected input/state, success, literal parameters and unknown diagnostics');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,133 @@
|
||||
// Run the shipped translation adapter, transport and both dialog components.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
||||
}}).outputText;
|
||||
const translations = Object.fromEntries(['en', 'sk'].map(locale => [locale, JSON.parse(fs.readFileSync(`AppImage/messages/${locale}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], translations[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (match, key) => params[key] ?? match);
|
||||
};
|
||||
const adapter = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/monitor-link-errors.ts', 'utf8')), adapter);
|
||||
const {monitorLinkError} = adapter.exports;
|
||||
const codes = {
|
||||
monitor_link_address_required: 'addressRequired', monitor_link_invalid_address: 'invalidAddress',
|
||||
monitor_link_address_credentials: 'addressCredentials', monitor_link_invalid_port: 'invalidPort',
|
||||
monitor_link_invalid_request: 'invalidRequest', monitor_link_duplicate: 'duplicate',
|
||||
monitor_link_not_found: 'notFound', monitor_link_detected_remove: 'detectedRemove',
|
||||
};
|
||||
const failure = (code, params) => Object.assign(new Error('RAW ENGLISH SECRET DETAILS'), {body: {error: 'RAW ENGLISH SECRET DETAILS', code, params}});
|
||||
|
||||
const flatten = tree => !tree || typeof tree === 'boolean' ? [] : Array.isArray(tree) ? tree.flatMap(flatten)
|
||||
: typeof tree === 'object' ? [tree, ...flatten(tree.props?.children)] : [tree];
|
||||
const text = tree => flatten(tree).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
const jsx = (type, props) => ({type, props});
|
||||
const source = compile(fs.readFileSync('AppImage/components/monitor-switcher.tsx', 'utf8') + '\nexport {ManageLinksDialog};');
|
||||
|
||||
function fixture({locale = 'sk', links = [], origin = false, loadError = false, loading = false} = {}) {
|
||||
let slots = [], cursor = 0, effects = [], tree, responseError, refreshFailure = false;
|
||||
const calls = [], refreshes = [], storage = new Map();
|
||||
const candidates = [{u: 'https://other.test', n: 'Other', p: 'pve'}];
|
||||
const carried = Buffer.from(JSON.stringify(candidates)).toString('base64url');
|
||||
const location = {origin: 'https://current.test', hostname: 'current.test', pathname: '/', hash: '',
|
||||
search: origin ? `?pmx_from=https%3A%2F%2Fsource.test&pmx_name=Source&pmx_links=${carried}` : ''};
|
||||
const react = {
|
||||
useState(initial) {const slot = cursor++; if (!(slot in slots)) slots[slot] = typeof initial === 'function' ? initial() : initial;
|
||||
return [slots[slot], value => {slots[slot] = typeof value === 'function' ? value(slots[slot]) : value;}];},
|
||||
useEffect(fn, deps) {const slot = cursor++; const old = slots[slot];
|
||||
if (!old || deps.some((dep, i) => dep !== old[i])) effects.push(fn);
|
||||
slots[slot] = deps;
|
||||
},
|
||||
};
|
||||
const exported = {};
|
||||
vm.runInNewContext(source, {exports: exported, URL, URLSearchParams, escape, unescape,
|
||||
atob: value => Buffer.from(value, 'base64').toString('binary'), btoa: value => Buffer.from(value, 'binary').toString('base64'),
|
||||
window: {location, history: {replaceState() {location.search = ''}}},
|
||||
sessionStorage: {getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value)},
|
||||
require(name) {
|
||||
if (name === 'react') return react;
|
||||
if (name === 'react/jsx-runtime') return {jsx, jsxs: jsx, Fragment: 'Fragment'};
|
||||
if (name === 'swr') return {default: () => ({data: {self: {name: 'Current', product: 'pve'}, links}, error: loadError, isLoading: loading,
|
||||
mutate: async () => refreshes.push('all')}), mutate: async key => {refreshes.push(key); if (refreshFailure) throw new Error('RAW REFRESH ERROR');}};
|
||||
if (name.includes('i18n/provider')) return {useT: () => translate(locale)};
|
||||
if (name.includes('monitor-link-errors')) return {monitorLinkError};
|
||||
if (name.includes('api-config')) return {fetchApi: async (path, options) => {calls.push({path, ...options}); if (responseError) throw responseError; return {success: true};}};
|
||||
return new Proxy({}, {get: (_, key) => key});
|
||||
},
|
||||
});
|
||||
return {
|
||||
render(component = 'ManageLinksDialog') {cursor = 0; effects = []; tree = exported[component]({open: true, onOpenChange() {}, nodeName: 'Current'}); effects.forEach(fn => fn()); return tree;},
|
||||
button(label) {const node = flatten(tree).find(n => n.type === 'Button' && (text(n) === label || n.props['aria-label'] === label)); assert.ok(node, label); return node;},
|
||||
input(id) {const node = flatten(tree).find(n => n.type === 'Input' && (n.props.id === id || n.props['aria-label'] === id)); assert.ok(node, id); return node;},
|
||||
nodes: () => flatten(tree), text: () => text(tree), fail: error => {responseError = error;}, failRefresh: () => {refreshFailure = true;}, calls, refreshes, storage,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const locale of ['en', 'sk']) {
|
||||
const t = translate(locale);
|
||||
for (const [code, key] of Object.entries(codes)) assert.equal(monitorLinkError(failure(code), t), t(`monitorLinks.errors.${key}`));
|
||||
assert.equal(monitorLinkError(failure('monitor_link_limit', {count: 64}), t), t('monitorLinks.errors.limit', {count: 64}));
|
||||
for (const error of [null, undefined, 'raw', new Error('raw'), {body: null}, {body: {}},
|
||||
...['constructor', '__proto__', 'toString', 'unknown', 'monitor_links_save_failed'].map(code => failure(code)),
|
||||
...[undefined, null, '64', 0, -1, 1.5, NaN, Infinity, 2 ** 54].map(count => failure('monitor_link_limit', {count}))]) {
|
||||
assert.equal(monitorLinkError(error, t), t('monitorLinks.saveFailed'));
|
||||
}
|
||||
const f = fixture({locale, links: [{key: 'manual:saved', name: 'Saved', url: 'https://saved.test', manual: true}]});
|
||||
f.render(); f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://unsaved.test'}}); f.render();
|
||||
f.button(t('monitorLinks.cancel')).props.onClick(); f.render();
|
||||
assert.equal(f.calls.length, 0, 'Cancel must not write');
|
||||
f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
assert.equal(f.input(t('monitorLinks.address')).props.value, 'https://saved.test', 'Cancel discards address draft');
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://changed.test'}}); f.render();
|
||||
await f.button(t('monitorLinks.save')).props.onClick(); f.render();
|
||||
assert.equal(f.calls[0].method, 'PATCH'); assert.deepEqual(JSON.parse(f.calls[0].body), {url: 'https://changed.test'});
|
||||
assert.equal(f.button(t('monitorLinks.add')).props.disabled, true, 'Blank address cannot be submitted');
|
||||
f.input('monitor-link-url').props.onChange({target: {value: 'ftp://bad.test'}}); f.render();
|
||||
f.fail(failure('monitor_link_invalid_address')); await f.button(t('monitorLinks.add')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.errors.invalidAddress')));
|
||||
assert.equal(f.input('monitor-link-url').props.value, 'ftp://bad.test', 'Failure retains draft');
|
||||
assert.ok(!f.text().includes('RAW ENGLISH'));
|
||||
f.fail(new Error('raw network details')); await f.button(t('monitorLinks.remove')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.saveFailed')));
|
||||
const failedRead = fixture({locale, loadError: new Error('raw')}); failedRead.render();
|
||||
assert.ok(failedRead.text().includes(t('monitorLinks.loadFailed')));
|
||||
const pendingRead = fixture({locale, loading: true}); pendingRead.render();
|
||||
assert.ok(pendingRead.text().includes(t('monitorLinks.checking')));
|
||||
|
||||
const declined = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) declined.render('MonitorSwitcher');
|
||||
declined.button(t('monitorLinks.notNow')).props.onClick(); declined.render('MonitorSwitcher');
|
||||
assert.equal(declined.calls.length, 0, 'Not now must not write');
|
||||
const carried = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) carried.render('MonitorSwitcher');
|
||||
const boxes = carried.nodes().filter(n => n.type === 'Checkbox'); assert.equal(boxes.length, 2);
|
||||
boxes[1].props.onCheckedChange(false); carried.render('MonitorSwitcher');
|
||||
carried.fail(failure('monitor_link_duplicate'));
|
||||
carried.failRefresh();
|
||||
await carried.button(t('monitorLinks.addSelected')).props.onClick(); carried.render('MonitorSwitcher');
|
||||
assert.equal(carried.calls.length, 1, 'Only selected origin is attempted');
|
||||
assert.equal(JSON.parse(carried.calls[0].body).url, 'https://source.test');
|
||||
assert.ok(carried.text().includes(t('monitorLinks.errors.duplicate')));
|
||||
assert.equal(carried.refreshes.length, 1, 'Failed/partially successful batch refreshes the visible list');
|
||||
}
|
||||
// Real authenticated transport preserves additive fields for the adapter.
|
||||
const api = {exports: {}, process: {env: {}}, fetch: async () => new Response(JSON.stringify({error: 'Original English error', code: 'monitor_link_duplicate'}), {status: 400, headers: {'Content-Type': 'application/json'}})};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/api-config.ts', 'utf8')), api);
|
||||
try {await api.exports.fetchApi('/api/monitor-links', {method: 'POST'}); assert.fail('expected rejection');}
|
||||
catch (error) {assert.equal(error.status, 400); assert.equal(error.message, 'Original English error'); assert.equal(monitorLinkError(error, translate('sk')), translations.sk.monitorLinks.errors.duplicate);}
|
||||
const en = translations.en.monitorLinks, sk = translations.sk.monitorLinks;
|
||||
assert.deepEqual(Object.keys(en.errors).sort(), Object.keys(sk.errors).sort());
|
||||
for (const key of Object.keys(en.errors)) {
|
||||
assert.ok(sk.errors[key]);
|
||||
assert.deepEqual(en.errors[key].match(/\{\w+\}/g), sk.errors[key].match(/\{\w+\}/g), key);
|
||||
}
|
||||
console.log('PASS: EN/SK known/unknown errors, real transport, both dialogs, Cancel/Not now zero writes, draft persistence, selected-only batch, localized loading/failure');
|
||||
}
|
||||
main().catch(error => {console.error(error); process.exitCode = 1;});
|
||||
@@ -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});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Shipped PBS code with only transport, hooks and UI primitives replaced.
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX}}).outputText;
|
||||
const catalogs = Object.fromEntries(['en','sk'].map(l => [l, JSON.parse(fs.readFileSync(`AppImage/messages/${l}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], catalogs[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (m,k) => params[k] ?? m);
|
||||
};
|
||||
const library = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/pbs-messages.ts','utf8')), library);
|
||||
const messages = library.exports;
|
||||
const failure = code => Object.assign(new Error('RAW DIAGNOSTIC'), {body: {error:'RAW DIAGNOSTIC',code}});
|
||||
const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten)
|
||||
: typeof n === 'object' ? n.type === 'Dialog' && !n.props.open ? [] : [n,...flatten(n.props?.children)] : [n];
|
||||
const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
|
||||
function fixture(locale='sk', options={}) {
|
||||
let cursor=0, slots=[], effects=[], tree, apiError, reloadError;
|
||||
const calls=[], confirmations=[], reloads=[];
|
||||
const servers={local_available:false,servers:[{id:'saved',name:'Saved PBS',url:'https://saved.invalid:8007',token_id:'audit@pbs!monitor',has_token:true,fingerprint:''}],detected:[]};
|
||||
const status={name:'Fixture',version:'4.2.5',fetched_at:1700000000,services:{'proxmox-backup':'inactive'},datastores:[],tasks:[],sync:[],verify:[],prune:[],errors:{}};
|
||||
const t=translate(locale), loaded={};
|
||||
const jsx=(type,props) => typeof type === 'function' ? type(props) : ({type,props});
|
||||
const react={useState(initial){const i=cursor++;if(!(i in slots))slots[i]=typeof initial==='function'?initial():initial;return [slots[i],value=>{slots[i]=typeof value==='function'?value(slots[i]):value;}];},
|
||||
useEffect(fn,deps){const i=cursor++, old=slots[i];if(!old||deps.some((d,j)=>d!==old[j]))effects.push(fn);slots[i]=deps;}};
|
||||
function requireModule(name) {
|
||||
if(name==='react')return react;
|
||||
if(name==='react/jsx-runtime')return {jsx,jsxs:jsx,Fragment:'Fragment'};
|
||||
if(name==='swr')return {default:key=>({data:key?.startsWith('/api/pbs/status')?status:key?servers:undefined,error:options.loadError,
|
||||
isLoading:false,mutate:async()=>{reloads.push(key);if(reloadError)throw reloadError;}})};
|
||||
if(name.includes('i18n/provider'))return {useT:()=>t,useI18n:()=>({language:locale})};
|
||||
if(name.includes('pbs-messages'))return messages;
|
||||
if(name.includes('api-config'))return {fetchApi:async(path,opts)=>{calls.push({path,...opts});if(apiError)throw apiError;return {id:'new',success:true};}};
|
||||
if(name.endsWith('pbs-problem-message'))return load('AppImage/components/pbs-problem-message.tsx');
|
||||
if(name.endsWith('pbs-datastore-usage'))return load('AppImage/components/pbs-datastore-usage.tsx');
|
||||
if(name.includes('lib/utils'))return {formatStorage:n=>String(n)+' GB'};
|
||||
return new Proxy({}, {get:(_,key)=>key});
|
||||
}
|
||||
function load(path) {if(loaded[path])return loaded[path];const e={};loaded[path]=e;
|
||||
vm.runInNewContext(compile(fs.readFileSync(path,'utf8')), {exports:e,require:requireModule,Date,window:{confirm:message=>{confirmations.push(message);return options.confirm===true;}}});return e;}
|
||||
const panel=load('AppImage/components/pbs-panel.tsx').PBSPanel;
|
||||
return {t,servers,status,calls,confirmations,reloads,jsx,requireModule,
|
||||
render(props){cursor=0;effects=[];tree=panel(props);effects.forEach(fn=>fn());return tree;},
|
||||
nodes:()=>flatten(tree),text:()=>text(tree),
|
||||
button(label){const n=flatten(tree).find(n=>n.type==='Button'&&text(n)===label);assert.ok(n,label);return n;},
|
||||
input(field){const n=flatten(tree).find(n=>n.type==='Input'&&n.props.id==='pbs-'+field);assert.ok(n,field);return n;},
|
||||
select(label){const n=flatten(tree).find(n=>n.type==='Select'&&flatten(n).some(n=>n.props?.['aria-label']===label));assert.ok(n,label);return n;},
|
||||
fail:e=>{apiError=e;},failReload:e=>{reloadError=e;},load,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(locale, sourcePath='AppImage/components/system-overview.tsx') {
|
||||
const source=fs.readFileSync(sourcePath,'utf8'), ast=ts.createSourceFile('overview.tsx',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX), branches=[];
|
||||
function visit(n){if(ts.isConditionalExpression(n)&&n.condition.getText(ast)==='isPbs'&&n.whenTrue.getText(ast).includes('pbs.datastores'))branches.push(n.whenTrue.getText(ast));ts.forEachChild(n,visit);}visit(ast);
|
||||
assert.equal(branches.length,2,'overview summary and storage details both consume PBS');
|
||||
const f=fixture(locale);
|
||||
for(const branch of branches) {
|
||||
for(const state of ['partial','network','empty','loading']) {
|
||||
const pbsStatus=state==='loading'||state==='network'?undefined:{datastores:state==='empty'?[]:null,errors:{datastores:'RAW DIAGNOSTIC'},error_details:{datastores:{code:'pbs_token_rejected'}}};
|
||||
const context={isPbs:true,pbsStatus,pbsError:state==='network'?failure('pbs_connect_failed'):undefined,datastores:pbsStatus?.datastores,
|
||||
t:f.t,require:f.requireModule,pbsDiagnostic:messages.pbsDiagnostic,PBSProblemMessage:f.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage,
|
||||
DatastoreUsageRow:f.load('AppImage/components/pbs-datastore-usage.tsx').DatastoreUsageRow};
|
||||
for(const name of ['Card','CardHeader','CardTitle','CardContent','Database','Badge'])context[name]=name;
|
||||
vm.runInNewContext(compile('exports.result=('+branch+');'),{...context,exports:context});
|
||||
const rendered=text(context.result);
|
||||
if(state==='partial') {assert.ok(rendered.includes(f.t('pbs.errors.tokenRejected')));assert.ok(!rendered.includes(f.t('pbs.loading')));assert.ok(!flatten(context.result).some(n=>n.props?.className?.includes('animate-pulse')));}
|
||||
if(state==='network')assert.ok(rendered.includes(f.t('pbs.errors.connectFailed')));
|
||||
if(state==='empty')assert.ok(rendered.includes(f.t('pbs.noDatastores')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const backend=fs.readFileSync('AppImage/scripts/pbs_manager.py','utf8')+fs.readFileSync('AppImage/scripts/flask_pbs_routes.py','utf8');
|
||||
const codes=new Set([...backend.matchAll(/(?:code|rejected_code)="(pbs_\w+)"/g)].map(m=>m[1]));
|
||||
for(const locale of ['en','sk']) {
|
||||
const t=translate(locale);
|
||||
for(const code of codes) {
|
||||
if(code==='pbs_unknown')continue;
|
||||
const err=failure(code);err.body.params={count:32};
|
||||
assert.notEqual(messages.pbsErrorText(err,t),t('pbs.loadFailed'),code);
|
||||
assert.ok(!messages.pbsErrorText(err,t).includes('RAW'));
|
||||
}
|
||||
for(const err of [null,undefined,'raw',new Error('raw'),{body:null},{code:'constructor'},{code:'__proto__'},{code:'future'},
|
||||
...[null,0,-1,1.2,'32',Infinity,NaN,2**54].map(count=>({code:'pbs_connection_limit',params:{count}}))]) {
|
||||
assert.equal(messages.pbsErrorText(err,t),t('pbs.loadFailed'));
|
||||
}
|
||||
assert.equal(messages.pbsResultText('OK',t),t('pbs.states.ok'));
|
||||
assert.equal(messages.pbsResultText('WARNINGS: 2',t),t('pbs.states.warnings',{count:2}));
|
||||
assert.equal(messages.pbsResultText(undefined,t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('unknown',t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('TASK ERROR: raw',t),t('pbs.states.error'));
|
||||
for(const n of [-1,NaN,Infinity])assert.equal(messages.formatPBSDuration(n,t),'—');
|
||||
for(const [n,label] of [[0,'0 s'],[59,'59 s'],[60,'1 min 0 s'],[3599,'59 min 59 s'],[3600,'1 h 0 min'],[86399,'23 h 59 min'],[86400,'1 d 0 h']])assert.equal(messages.formatPBSDuration(n,t),label);
|
||||
assert.equal(messages.pbsTaskType('garbage_collection',t),t('pbs.taskTypes.garbageCollection'));
|
||||
assert.equal(messages.pbsTaskType('future',t),t('pbs.taskTypes.other',{type:'future'}));
|
||||
assert.equal(messages.pbsServiceState('constructor',t),t('pbs.serviceStates.unknown'));
|
||||
assert.equal(t('pbs.url'),locale==='sk'?'Adresa HTTPS':'HTTPS address');
|
||||
const diagnosticFixture=fixture(locale),problem=diagnosticFixture.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage;
|
||||
for(const code of codes){
|
||||
if(code==='pbs_unknown')continue;
|
||||
const err=failure(code);err.body.params={count:32};
|
||||
const rendered=problem({problem:err,raw:'RAW DIAGNOSTIC'});
|
||||
assert.ok(!text(rendered).includes('RAW DIAGNOSTIC'),'Known '+code+' must not repeat an English application message');
|
||||
assert.ok(!flatten(rendered).some(n=>n.type==='details'));
|
||||
}
|
||||
for(const code of ['future','__proto__','constructor']){
|
||||
const rendered=problem({problem:failure(code),raw:'RAW <img> DIAGNOSTIC'});
|
||||
assert.ok(text(rendered).includes('RAW <img> DIAGNOSTIC'));assert.ok(flatten(rendered).some(n=>n.type==='details'));
|
||||
}
|
||||
const unknown=fixture(locale);unknown.status.tasks=[{upid:'unknown',endtime:1700000000,status:'unknown'}];unknown.status.sync=[{id:'unknown','last-run-state':'unknown'}];
|
||||
unknown.render();unknown.render();
|
||||
const unknownBadges=unknown.nodes().filter(n=>n.type==='Badge'&&text(n)===t('pbs.states.unknown'));
|
||||
assert.equal(unknownBadges.length,2);assert.ok(unknownBadges.every(n=>!n.props.className.includes('red-')));
|
||||
unknown.status.tasks=[{upid:'warn',endtime:1700000000,status:'WARNINGS: 3'},{upid:'raw',endtime:1700000000,status:'RAW EXTERNAL TASK ERROR'}];unknown.status.sync=[];unknown.render();
|
||||
const taskDetails=unknown.nodes().filter(n=>n.type==='details');assert.equal(taskDetails.length,1);assert.ok(text(taskDetails[0]).includes('RAW EXTERNAL TASK ERROR'));
|
||||
const f=fixture(locale);f.render();f.render();f.button(t('pbs.manage')).props.onClick();f.render();
|
||||
assert.equal(f.input('url').props['aria-describedby'],'pbs-url-help');
|
||||
assert.ok(f.text().includes(t('pbs.urlHelp')));assert.ok(f.text().includes(t('pbs.usernameHelp')));
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();
|
||||
f.input('password').props.onChange({target:{value:'fake-one-time-password'}});f.render();
|
||||
f.button(t('pbs.cancel')).props.onClick();f.render();assert.equal(f.calls.length,0);
|
||||
f.button(t('pbs.manage')).props.onClick();f.render();assert.equal(f.input('password').props.value,'');
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();f.input('url').props.onChange({target:{value:'http://bad.invalid'}});f.render();
|
||||
f.fail(failure('pbs_invalid_url'));await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.errors.invalidUrl')));assert.equal(f.input('url').props.value,'http://bad.invalid');
|
||||
assert.equal(f.calls[0].path,'/api/pbs/servers/login');assert.ok(!Object.hasOwn(JSON.parse(f.calls[0].body),'token_secret'));
|
||||
f.button(t('pbs.methodToken')).props.onClick();f.render();await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.tokenSecretHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretKeepHelp')));
|
||||
assert.equal(f.input('token_secret').props['aria-describedby'],'pbs-token_secret-help');
|
||||
assert.equal(f.calls[1].path,'/api/pbs/servers');assert.ok(!Object.hasOwn(JSON.parse(f.calls[1].body),'password'));
|
||||
f.select(t('pbs.editConnection')).props.onValueChange('saved');f.render();await f.button(t('pbs.remove')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.tokenSecretKeepHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretHelp')));
|
||||
assert.deepEqual(f.confirmations,[t('pbs.removeConfirm')]);assert.equal(f.calls.length,2,'Declined remove has no DELETE');
|
||||
const remove=fixture(locale,{confirm:true});remove.render();remove.render();remove.button(t('pbs.manage')).props.onClick();remove.render();remove.select(t('pbs.editConnection')).props.onValueChange('saved');remove.render();
|
||||
await remove.button(t('pbs.remove')).props.onClick();remove.render();assert.equal(remove.calls.length,1);assert.equal(remove.calls[0].method,'DELETE');
|
||||
const failedRemove=fixture(locale,{confirm:true});failedRemove.render();failedRemove.render();failedRemove.button(t('pbs.manage')).props.onClick();failedRemove.render();failedRemove.select(t('pbs.editConnection')).props.onValueChange('saved');failedRemove.render();failedRemove.fail(new Error('network failure'));
|
||||
await failedRemove.button(t('pbs.remove')).props.onClick();failedRemove.render();assert.ok(failedRemove.text().includes(t('pbs.removeFailed')));assert.equal(failedRemove.calls.length,1);
|
||||
const failedReload=fixture(locale);failedReload.render();failedReload.render();failedReload.button(t('pbs.manage')).props.onClick();failedReload.render();failedReload.failReload(new Error('raw reload'));
|
||||
for(const [field,value] of Object.entries({name:'Valid',url:'https://fixture.invalid',username:'root@pam',password:'fake'})){failedReload.input(field).props.onChange({target:{value}});failedReload.render();}
|
||||
assert.equal(failedReload.button(t('pbs.save')).props.disabled,false);
|
||||
await failedReload.button(t('pbs.save')).props.onClick();failedReload.render();assert.equal(failedReload.calls.length,1,'No automatic mutation retry after refresh fails');assert.ok(failedReload.text().includes(t('pbs.savedRefreshFailed')));
|
||||
const removeReload=fixture(locale,{confirm:true});removeReload.render();removeReload.render();removeReload.button(t('pbs.manage')).props.onClick();removeReload.render();removeReload.select(t('pbs.editConnection')).props.onValueChange('saved');removeReload.render();removeReload.failReload(new Error('network'));
|
||||
await removeReload.button(t('pbs.remove')).props.onClick();removeReload.render();assert.equal(removeReload.calls.length,1);assert.ok(removeReload.text().includes(t('pbs.removedRefreshFailed')));
|
||||
const store=f.load('AppImage/components/pbs-datastore-usage.tsx').PBSDatastoreCard({store:{name:'Offline',total:null,used:null,error:'RAW DIAGNOSTIC',error_code:'pbs_datastore_offline'}});
|
||||
assert.ok(text(store).includes(t('pbs.errors.datastoreOffline')));assert.ok(!text(store).includes('0 GB'));
|
||||
overview(locale);
|
||||
}
|
||||
console.log('PASS: all structured PBS error codes, EN/SK/unknown diagnostics, forms/login/token/remove/cancel, no automatic retry, task/service/duration states and both real overview branches');
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1});
|
||||
module.exports={fixture,overview};
|
||||
Reference in New Issue
Block a user