i18n: complete OCI runtime and maintenance Slovak diagnostics

This commit is contained in:
VAIO73
2026-10-21 07:06:04 +00:00
parent 37a6c02111
commit 6c05688196
15 changed files with 746 additions and 337 deletions
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -93,8 +93,8 @@ def main() -> None:
rootfs = Path(sys.argv[1])
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__":
+4 -2
View File
@@ -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':
+4 -3
View File
@@ -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)
+1 -1
View File
@@ -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')
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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'))
+3 -1
View File
@@ -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
+2 -1
View File
@@ -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:
+6 -3
View File
@@ -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)
+32 -7
View File
@@ -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:
+1 -1
View File
@@ -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:
+32 -33
View File
@@ -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):
+45
View File
@@ -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