mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 02:12:12 +00:00
i18n: complete OCI runtime and maintenance Slovak diagnostics
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
"""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_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()
|
||||
+343
-280
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:
|
||||
|
||||
@@ -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..."))
|
||||
@@ -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):
|
||||
|
||||
@@ -57,10 +57,55 @@ def diagnostic(error: Any) -> str:
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user