diff --git a/.github/scripts/tests/test_pbs_cli_localization.py b/.github/scripts/tests/test_pbs_cli_localization.py new file mode 100644 index 00000000..bda1064b --- /dev/null +++ b/.github/scripts/tests/test_pbs_cli_localization.py @@ -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()) diff --git a/lang/sk.json b/lang/sk.json index 0834b7f4..5e34a7ec 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -1,4 +1,25 @@ { + "APT hook syntax issue, removing...": "Nesprávna syntax hooku APT; hook sa odstráni...", + "Banner removal cancelled by user.": "Odstránenie upozornenia na predplatné bolo zrušené používateľom.", + "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.": "Nepodarilo sa dokončiť alebo overiť zmenu repozitárov; zmeny môžu byť čiastočné a stav repozitárov je neznámy. Pred ďalším pokusom skontrolujte {ui}. Automatický návrat do pôvodného stavu sa nevykonal.", + "Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.": "Enterprise má spoločný záznam APT s ďalšími komponentmi. Rozdeľte ho v rozhraní repozitárov Proxmoxu; žiadny repozitár sa nezmenil.", + "Host has an active subscription but no active {short} repository. Configure its Enterprise source in {ui}; no repository changed.": "Server má aktívne predplatné, ale nemá aktívny repozitár {short}. Nastavte zdroj Enterprise v {ui}; žiadny repozitár sa nezmenil.", + "Insufficient disk space. Available:": "Nedostatok miesta na disku. Dostupné:", + "No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.": "Pre toto vydanie nie je aktívny základný repozitár Debianu; pred pokračovaním ho nastavte v rozhraní repozitárov Proxmoxu.", + "Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {ui}; no repository changed.": "Zoznam repozitárov APT Proxmoxu je neplatný alebo obsahuje chyby načítania. Opravte zdroje v {ui}; žiadny repozitár sa nezmenil.", + "Proxmox Backup Server Microcode": "Proxmox Backup Server – mikrokód procesora", + "Proxmox Backup Server Post Install": "Proxmox Backup Server – nastavenie po inštalácii", + "Proxmox Backup Server Subscription Banner Removal": "Proxmox Backup Server – odstránenie upozornenia na predplatné", + "Proxmox no-subscription standard repository handle unavailable; no repository changed.": "Štandardný repozitár Proxmoxu no-subscription nie je dostupný; žiadny repozitár sa nezmenil.", + "Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.": "Stav predplatného nie je jednoznačne active, notfound alebo expired. Overte ho lokálne príkazom proxmox-backup-manager subscription get; žiadny repozitár sa nezmenil.", + "System upgrade failed. Check log:": "Aktualizácia systému zlyhala. Skontrolujte denník:", + "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.": "Na serveri beží {name} na vydaní {suite}, ale tieto repozitáre {name} sú pre iné vydanie: {details}. Vypnite alebo odstráňte ich v {ui} a spustite aktualizáciu znova; žiadny repozitár sa nezmenil.", + "Unsupported Proxmox suite; no repository changed.": "Nepodporované vydanie Proxmoxu; žiadny repozitár sa nezmenil.", + "kernel": "jadro", + "memory": "pamäť", + "network": "sieť", + "no-subscription repository": "repozitár no-subscription", + "subscription banner": "upozornenie na predplatné", "# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen", "# Recommended: avoids disconnection during upgrade": "# Odporúčané: zabráni odpojeniu počas aktualizácie", "(Checked entries will be removed. Uncheck to keep in VM.)": "(Označené položky sa odstránia. Zrušte označenie, ak ich chcete ponechať vo VM.)", @@ -88,7 +109,7 @@ "A multi-container application is not recreated: its containers are updated together.": "Aplikácia s viacerými kontajnermi sa nevytvorí znova: jej kontajnery sa aktualizujú spoločne.", "A new ProxMenux version is available:": "Je dostupná nová verzia ProxMenux:", "A new kernel is staged for the next boot:": "Na najbližší štart je pripravený nový kernel:", - "A newer kernel is installed. Reboot before the next backup window to use it.": "Je nainštalované novšie jadro. Aby ste ho mohli použiť, reštartujte ho pred ďalším oknom zálohy.", + "A newer kernel is installed. Reboot before the next backup window to use it.": "Je nainštalované novšie jadro. Pred ďalším plánovaným zálohovaním reštartujte server, aby sa použilo.", "A newer version is available:": "Je dostupná novšia verzia:", "A pending operation exists for": "Čaká na operation existuje", "A pending stack assembly already exists; it is not overwritten": "Nevyriešená zostava stohov už existuje; nie je prepísaná", @@ -4190,7 +4211,7 @@ "No active Proxmox storage accepts OCI template archives.": "Žiadne aktívne úložisko Proxmox neakceptuje archívy šablón OCI.", "No active exports or showmount not available": "Nie sú aktívne exporty alebo príkaz showmount nie je dostupný", "No active session": "Žiadna aktívna relácia", - "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PBS.Neinteraktívny režim nemôže zmeniť zdroje APT;nakonfigurujte ich v Administrácia > Repozitáre.", + "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Server nemá aktívne predplatné ani použiteľný repozitár PBS. Neinteraktívny režim nemôže meniť zdroje APT; nastavte ich v Administration > Repositories.", "No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PVE. Neinteraktívny režim nemôže zmeniť zdroje APT; nakonfigurujte ich v časti Uzol > Aktualizácie > Úložiská.", "No additional GPU can be added.": "Nie je možné pridať ďalšiu GPU.", "No additional device needs to be added.": "Netreba pridávať žiadne ďalšie zariadenie.", @@ -5281,7 +5302,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "Pomocník úložiska nie je k dispozícii. Inštalácia NVIDIA sa zastavila.", "Repository policy returned an unexpected result.": "Politika úložiska vrátila neočakávaný výsledok.", "Repository ready.": "Repozitár je pripravený.", - "Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená;zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Administrácia > Repozitáre a zopakujte apt-get update.", + "Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Zdroje repozitárov sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Operácia sa zastavila; pôvodné zdroje sa neobnovili. Pred pokračovaním skontrolujte Administration > Repositories a zopakujte apt-get update.", "Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená; zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Node > Updates > Repository a zopakujte apt-get update.", "Repository switch declined; no APT source changed.": "Prepnutie úložiska bolo odmietnuté; žiadny zdroj APT sa nezmenil.", "Repository:": "Repozitár:", @@ -7265,7 +7286,7 @@ "Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Asistent aktualizácie: Proxmox VE 8 → 9 (Trixie)", "Upgrade canceled by user": "Aktualizácia zrušená používateľom", "Upgrade one node at a time": "Aktualizujte vždy iba jeden uzol naraz", - "Upgrade the system, offering the": "Aktualizujte systém a ponúknite", + "Upgrade the system, offering the": "Aktualizácia systému a prípadný prechod na", "Upgrade to Proxmox VE 9": "Aktualizovať na Proxmox VE 9", "Upgrading NVIDIA utils (Arch)...": "Aktualizujem NVIDIA nástroje (Arch)...", "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Nahrať šifrovanú kópiu kľúča do PBS, aby ste ju vedeli obnoviť na preinštalovanom hostovi iba pomocou frázy?", diff --git a/scripts/global/remove-banner-pve-v3.sh b/scripts/global/remove-banner-pve-v3.sh index 92df1028..76df07b5 100644 --- a/scripts/global/remove-banner-pve-v3.sh +++ b/scripts/global/remove-banner-pve-v3.sh @@ -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" } } diff --git a/scripts/global/repository_policy.py b/scripts/global/repository_policy.py index 95f40d68..00829730 100644 --- a/scripts/global/repository_policy.py +++ b/scripts/global/repository_policy.py @@ -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 diff --git a/scripts/global/update-pbs.sh b/scripts/global/update-pbs.sh index 54574676..80200ba7 100644 --- a/scripts/global/update-pbs.sh +++ b/scripts/global/update-pbs.sh @@ -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 diff --git a/scripts/menus/menu_post_install.sh b/scripts/menus/menu_post_install.sh index d4e63cda..2f9696e6 100644 --- a/scripts/menus/menu_post_install.sh +++ b/scripts/menus/menu_post_install.sh @@ -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" diff --git a/scripts/post_install/auto_post_install.sh b/scripts/post_install/auto_post_install.sh index e11f131f..1602ad39 100644 --- a/scripts/post_install/auto_post_install.sh +++ b/scripts/post_install/auto_post_install.sh @@ -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