From 111593bc38d7e25c6a130d0ca4ea30aef7ac1825 Mon Sep 17 00:00:00 2001 From: Jonatan Date: Sat, 17 Oct 2026 12:48:02 +0200 Subject: [PATCH 01/43] Fix OCI management after changing Proxmox tags --- .github/workflows/test-oci-registries.yml | 6 + oci/remote/install_oci.sh | 11 +- oci/remote/oci_instance_transaction.py | 16 +- oci/tests/test_external_tags.py | 189 ++++++++++++++++++++++ 4 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 oci/tests/test_external_tags.py diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 9612c0d3..235bb0c1 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -32,3 +32,9 @@ jobs: env: PYTHONDONTWRITEBYTECODE: '1' run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v + - name: Run external OCI settings regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v + python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 7d1d8e85..3b549001 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -1025,15 +1025,18 @@ apply_extra_hosts() { } # Settings an update gives back to the rebuilt container before it starts: -# the LAN leg of a suite or stack member and its start order. +# the LAN leg of a suite or stack member, its start order and current tags. apply_kept_settings() { local key value while IFS=$'\t' read -r key value; do [[ -n $key ]] || continue - [[ $key == net1 || $key == startup ]] \ + [[ $key == net1 || $key == startup || $key == tags ]] \ || die "$(translate "Unsupported kept setting:") $key" - oci_quiet pct set "$VMID" "--$key" "$value" \ - || die "$(translate "Could not restore the container setting:") $key" + if [[ $key == tags && -z $value ]]; then + oci_quiet pct set "$VMID" --delete tags + else + oci_quiet pct set "$VMID" "--$key" "$value" + fi || die "$(translate "Could not restore the container setting:") $key" done < <(jq -r '.kept_proxmox_settings // {} | to_entries[] | [.key, .value] | @tsv' "$DEPLOYMENT_FILE") } diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index 59579967..cdde917b 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -415,10 +415,9 @@ def candidate_contract(record, operation, proposal=None): return with_default_healthcheck(result) -# Resource settings edited in Proxmox that the new container keeps. # Proxmox settings the rebuilt container gets back exactly as they are: the -# LAN leg ProxMenux adds to a suite or stack member, and the start order. -KEPT_AS_IS = ('net1', 'startup') +# LAN leg ProxMenux adds to a suite or stack member, the start order and tags. +KEPT_AS_IS = ('net1', 'startup', 'tags') ADOPTABLE = {'memory': ('resources', 'memory_mb'), 'swap': ('resources', 'swap_mb'), 'cores': ('resources', 'cores'), 'cpulimit': ('resources', 'cores'), 'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot')} @@ -1051,6 +1050,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i 'original_gpu_devices': original_gpu, 'desired_gpu_devices': desired_gpu, 'was_running': run('pct', 'status', str(vmid)).strip() == b'status: running'} state['kept_config'] = {key: cfg[key] for key in KEPT_AS_IS if key in cfg} + # The installer adds default tags. Preserve an explicit absence too, so + # deleting all tags in Proxmox does not bring them back on the next update. + state['kept_config']['tags'] = cfg.get('tags', '') if not coordinated and state['kept_config']: # Applied by the installer before the new container starts, so the # application has its LAN leg from the first second. @@ -1058,9 +1060,10 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i if coordinated: state['coordinated'] = coordinated state['preserved_stack_config'] = {key: cfg[key] for key in ('net1', 'startup', 'hookscript') if key in cfg} + state['preserved_stack_config']['tags'] = cfg.get('tags', '') if operation == 'update': state['preserved_stack_config'].update({key: cfg[key] for key in - ('net0', 'features', 'cmode', 'console', 'tty', 'cpuunits', 'tags') if key in cfg}) + ('net0', 'features', 'cmode', 'console', 'tty', 'cpuunits') if key in cfg}) checkpoint(journal, state, 'prepared') pending = copy.deepcopy(record) pending.update(status='updating', pending_transaction=str(journal), transaction_id=state['id']) @@ -1141,7 +1144,10 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i restore_firewall(vmid, state) if coordinated: for key, value in state['preserved_stack_config'].items(): - run('pct', 'set', str(vmid), '--' + key, value) + if key == 'tags' and not value: + run('pct', 'set', str(vmid), '--delete', 'tags') + else: + run('pct', 'set', str(vmid), '--' + key, value) check_runtime_mounts(run('pct', 'config', str(vmid)), candidate['deployment']) gpu_devices.check(run('pct', 'config', str(vmid)), candidate['deployment']) state['staged_config_sha256'] = sha(owned(vmid, record['installation_id'])) diff --git a/oci/tests/test_external_tags.py b/oci/tests/test_external_tags.py new file mode 100644 index 00000000..a28dd420 --- /dev/null +++ b/oci/tests/test_external_tags.py @@ -0,0 +1,189 @@ +"""Proxmox tags can change without preventing OCI management or being lost.""" +import copy +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) + +import oci_instance_reconcile as reconcile +import oci_instance_transaction as transaction +from oci_installation_state import sha + +MARKER = '11111111-1111-4111-8111-111111111111' +BASE = (f'description: proxmenux-instance={MARKER}\n' + 'rootfs: local-lvm:vm-200-disk-0,size=8G\n' + 'unprivileged: 1\ncores: 2\nmemory: 512\n' + 'net0: name=eth0,bridge=vmbr0,hwaddr=02:00:00:00:00:01,ip=dhcp\n' + 'net1: name=eth1,bridge=vmbr1,ip=dhcp\nstartup: order=20\n').encode() + + +def config(tags): + return BASE + (f'tags: {tags}\n'.encode() if tags is not None else b'') + + +def record(tags='oci;proxmenux'): + before = config(tags) + return { + 'schema_version': 1, 'vmid': 200, 'status': 'installed', + 'installation_id': MARKER, + 'template': {'id': 'test-app', 'container_contract': {'volumes': []}}, + 'deployment': { + 'vmid': 200, 'rootfs': {'storage': 'local-lvm', 'size_gb': 8}, + 'network': {}, 'resources': {'cores': 2, 'memory_mb': 512}, + 'mounts': [], 'devices': [], + }, + 'observed': { + 'config': before.decode(), 'config_sha256': sha(before), + 'image': {'architecture': 'amd64', 'manifest_digest': 'sha256:old', + 'defaults': {'Volumes': {}}}, + }, + } + + +class ExternalTagsTests(unittest.TestCase): + def test_adding_editing_reordering_and_removing_tags_need_no_adoption(self): + for old, new in ((None, 'homelab'), ('oci;proxmenux', 'homelab;oci;proxmenux'), + ('oci;proxmenux', 'proxmenux;oci'), ('oci;proxmenux', None), + ('oci;proxmenux', '')): + with self.subTest(old=old, new=new): + saved, current = record(old), config(new) + self.assertIsNone(reconcile.propose(saved, current)) + self.assertEqual(transaction.external_changes(saved, current), {}) + + def test_tags_can_change_together_with_cpu_and_memory(self): + current = config('homelab').replace(b'cores: 2', b'cores: 4').replace(b'memory: 512', b'memory: 2048') + self.assertIsNone(reconcile.propose(record(), current)) + self.assertEqual(transaction.external_changes(record(), current), {'cores': 4, 'memory': 2048}) + + def test_tag_changes_do_not_bypass_identity_or_network_checks(self): + for current in (config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222'), + config('homelab').replace(b'bridge=vmbr0', b'bridge=vmbr2')): + with self.subTest(config=current): + with self.assertRaises(ValueError): + reconcile.propose(record(), current) + with self.assertRaises(ValueError): + transaction.external_changes(record(), current) + + def test_new_volume_still_requires_adoption_when_tags_also_change(self): + saved = record() + current = config('homelab') + b'mp0: local-lvm:vm-200-disk-1,mp=/data,size=8G,backup=1\n' + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + path = root / '200/oci-compose.json' + path.parent.mkdir() + path.write_text(json.dumps(saved)) + with patch.object(reconcile.instances, 'ROOT', root): + proposal = reconcile.propose(saved, current) + self.assertEqual(proposal['candidate']['deployment']['mounts'][0]['container_path'], '/data') + self.assertEqual(len(proposal['details']), 1) + self.assertEqual(json.loads(path.read_text()), saved) + + +class Prepared(Exception): + """Stop a mocked transaction before it can stop or modify any container.""" + + +class TagReplayTests(unittest.TestCase): + def prepare(self, folder, tags, operation, coordinated=None): + current, saved = config(tags), record() + archive = folder / 'image.tar' + archive.write_bytes(b'fixture') + states = [] + + def checkpoint(_path, state, phase): + self.assertEqual(phase, 'prepared') + states.append(copy.deepcopy(state)) + raise Prepared() + + def command(*args): + if args[:2] == ('pct', 'config'): + return current + if args[:3] == ('pvesh', 'get', '/cluster/ha/resources'): + return b'[]' + if args[:2] == ('pct', 'status'): + return b'status: running' + self.fail(f'Unexpected host command: {args}') + + proposal = {'operation': 'recreate', 'candidate': copy.deepcopy(saved)} if operation == 'recreate' else None + image = {'architecture': 'amd64', 'os': 'linux', 'manifest_digest': 'sha256:new', 'defaults': {}} + with (patch.object(transaction.instances, 'read', return_value=saved), + patch.object(transaction, 'run', side_effect=command), + patch.object(transaction, 'check_archive'), + patch.object(transaction, 'image_from_archive', return_value=image), + patch.object(transaction.work_backup, 'locate', side_effect=lambda directory, *_: directory), + patch.object(transaction, 'open_log'), + patch.object(transaction, 'checkpoint', side_effect=checkpoint), + redirect_stdout(io.StringIO()), self.assertRaises(Prepared)): + transaction.apply(folder, 200, archive, operation, proposal=proposal, coordinated=coordinated) + return states[0] + + @unittest.skipUnless(shutil.which('bash') and shutil.which('jq'), 'bash and jq are needed') + def test_update_and_modify_replay_current_tags_including_no_tags(self): + installer = (ROOT / 'remote/install_oci.sh').read_text() + function = re.search(r'^apply_kept_settings\(\) \{\n.*?^\}\n', installer, re.M | re.S) + self.assertIsNotNone(function) + for operation in ('update', 'recreate'): + for tags in ('homelab;oci;proxmenux', 'oci', None, ''): + with self.subTest(operation=operation, tags=tags), tempfile.TemporaryDirectory() as name: + folder = Path(name) + state = self.prepare(folder, tags, operation) + kept = state['runtime_deployment']['kept_proxmox_settings'] + deployment = folder / 'deployment.json' + deployment.write_text(json.dumps({'kept_proxmox_settings': kept})) + calls = folder / 'calls' + script = ''' +pct() { printf '%s\\0' "$@" >> "$CALLS"; printf '\\n' >> "$CALLS"; } +oci_quiet() { "$@"; } +translate() { printf '%s' "$1"; } +die() { printf '%s\\n' "$*" >&2; exit 1; } +''' + done = subprocess.run(['bash', '-euc', script + function.group(0) + '\napply_kept_settings'], + env=dict(os.environ, VMID='200', DEPLOYMENT_FILE=str(deployment), CALLS=str(calls)), + capture_output=True, text=True) + self.assertEqual(done.returncode, 0, done.stderr) + commands = [line.split(b'\0')[:-1] for line in calls.read_bytes().splitlines()] + expected = [b'set', b'200', b'--tags', tags.encode()] if tags else [b'set', b'200', b'--delete', b'tags'] + self.assertIn(expected, commands) + self.assertIn([b'set', b'200', b'--net1', b'name=eth1,bridge=vmbr1,ip=dhcp'], commands) + self.assertIn([b'set', b'200', b'--startup', b'order=20'], commands) + + def test_stack_operations_also_preserve_tags_and_their_absence(self): + for operation in ('update', 'recreate'): + for tags in ('homelab;oci', None, ''): + with self.subTest(operation=operation, tags=tags), tempfile.TemporaryDirectory() as name: + state = self.prepare(Path(name), tags, operation, coordinated={'stack': 'test'}) + self.assertEqual(state['preserved_stack_config']['tags'], tags or '') + + @unittest.skipUnless(shutil.which('bash') and shutil.which('jq'), 'bash and jq are needed') + def test_installer_still_refuses_unsupported_kept_settings(self): + installer = (ROOT / 'remote/install_oci.sh').read_text() + function = re.search(r'^apply_kept_settings\(\) \{\n.*?^\}\n', installer, re.M | re.S).group(0) + with tempfile.TemporaryDirectory() as name: + deployment = Path(name) / 'deployment.json' + deployment.write_text(json.dumps({'kept_proxmox_settings': {'unprivileged': '0'}})) + script = ''' +oci_quiet() { echo 'must not run' >&2; exit 99; } +translate() { printf '%s' "$1"; } +die() { printf '%s\\n' "$*" >&2; exit 1; } +''' + done = subprocess.run(['bash', '-euc', script + function + '\napply_kept_settings'], + env=dict(os.environ, VMID='200', DEPLOYMENT_FILE=str(deployment)), + capture_output=True, text=True) + self.assertEqual(done.returncode, 1) + self.assertIn('Unsupported kept setting: unprivileged', done.stderr) + self.assertNotIn('must not run', done.stderr) + + +if __name__ == '__main__': + unittest.main() From b7b412b45de3ad2032ab88692ba38c0c151303ef Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:23:38 +0000 Subject: [PATCH 02/43] fix(oci): harden runtime HOME resolution --- oci/remote/install_oci.sh | 2 +- oci/remote/oci_runtime.py | 37 +++- oci/tests/test_runtime_home_contract.py | 4 +- oci/tests/test_runtime_home_hardening.py | 260 +++++++++++++++++++++++ 4 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 oci/tests/test_runtime_home_hardening.py diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 7d1d8e85..88d5f0fe 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -1091,7 +1091,7 @@ ensure_runtime_home() { passwd_file="${rootfs}/etc/passwd" mount_ct_rootfs if ! home=$(python3 "$OCI_RUNTIME_RESOLVER" --home "$passwd_file" "$uid" 2>>"${OCI_LOG:-/dev/stderr}"); then - oci_quiet pct unmount "$VMID" || true + oci_quiet pct unmount "$VMID" || die "$(translate "Could not unmount the container filesystem")" return 0 fi oci_quiet pct unmount "$VMID" || die "$(translate "Could not unmount the container filesystem")" diff --git a/oci/remote/oci_runtime.py b/oci/remote/oci_runtime.py index ee3b9d56..3d842fd3 100644 --- a/oci/remote/oci_runtime.py +++ b/oci/remote/oci_runtime.py @@ -4,7 +4,9 @@ from __future__ import annotations import json +import os import shlex +import stat import sys import tarfile from pathlib import Path @@ -90,14 +92,39 @@ def effective_entrypoint( def home_directory(passwd_path: Path, uid: int) -> str | None: - """Return the safe home directory recorded for a container UID.""" - for line in passwd_path.read_text(encoding="utf-8").splitlines(): + """Read a regular image passwd file; only an absent UID defaults to /.""" + if uid < 0: + return None + # The caller supplies /etc/passwd. Pin etc so an image + # symlink or a rename during lookup cannot redirect the host-side read. + directory = os.open(passwd_path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + if not stat.S_ISREG(os.stat(passwd_path.name, dir_fd=directory, follow_symlinks=False).st_mode): + return None + descriptor = os.open( + passwd_path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory, + ) + try: + # Recheck the opened object, not its name; a FIFO replacement + # must neither block open() nor reach a read operation. + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + return None + with os.fdopen(descriptor, encoding="utf-8", closefd=False) as stream: + content = stream.read() + finally: + os.close(descriptor) + finally: + os.close(directory) + for line in content.splitlines(): fields = line.split(":") - if len(fields) < 7 or fields[2] != str(uid): + if len(fields) < 3 or fields[2] != str(uid): continue + if len(fields) != 7: + return None home = fields[5] - return home if home.startswith("/") else None - return None + safe = home.startswith("/") and not any(ord(char) < 32 or ord(char) == 127 for char in home) + return home if safe else None + return "/" def main() -> int: diff --git a/oci/tests/test_runtime_home_contract.py b/oci/tests/test_runtime_home_contract.py index c14e8e26..25e9ca9e 100644 --- a/oci/tests/test_runtime_home_contract.py +++ b/oci/tests/test_runtime_home_contract.py @@ -31,14 +31,14 @@ class RuntimeHomeResolutionTests(unittest.TestCase): ) self.assertEqual(oci_runtime.home_directory(passwd, 1001), "/srv/app") - def test_unknown_or_unsafe_home_is_not_invented(self): + def test_unsafe_record_is_rejected_but_unknown_uid_defaults_to_root(self): passwd = self.passwd( "root:x:0:0:root:relative-home:/bin/sh\n" "app:x:1001:1001:App user::/sbin/nologin\n" ) self.assertIsNone(oci_runtime.home_directory(passwd, 0)) self.assertIsNone(oci_runtime.home_directory(passwd, 1001)) - self.assertIsNone(oci_runtime.home_directory(passwd, 1234)) + self.assertEqual(oci_runtime.home_directory(passwd, 1234), "/") class RuntimeHomeInstallerContractTests(unittest.TestCase): diff --git a/oci/tests/test_runtime_home_hardening.py b/oci/tests/test_runtime_home_hardening.py new file mode 100644 index 00000000..6418709d --- /dev/null +++ b/oci/tests/test_runtime_home_hardening.py @@ -0,0 +1,260 @@ +"""Exercise HOME resolution without reading image symlinks or changing guests.""" + +from contextlib import redirect_stderr, redirect_stdout +import io +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "remote")) +import oci_runtime + + +class HomeHardeningTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + (self.root / "etc").mkdir() + self.passwd = self.root / "etc/passwd" + self.passwd.write_text("root:x:0:0:root:/root:/bin/sh\n", encoding="utf-8") + + def cli(self, uid="1234"): + output, error = io.StringIO(), io.StringIO() + with patch.object(sys, "argv", ["oci_runtime.py", "--home", str(self.passwd), uid]), \ + redirect_stdout(output), redirect_stderr(error): + status = oci_runtime.main() + return status, output.getvalue(), error.getvalue() + + def assert_rejected_without_read(self): + with patch.object(os, "fdopen", side_effect=AssertionError("unsafe file read")): + status, output, _ = self.cli() + self.assertEqual(status, 1) + self.assertEqual(output, "") + + def test_unknown_uid_uses_root_only_after_reading_a_regular_database(self): + self.assertEqual(self.cli(), (0, "/\n", "")) + + def test_empty_regular_database_uses_root(self): + self.passwd.write_text("") + self.assertEqual(self.cli(), (0, "/\n", "")) + + def test_final_symlink_is_rejected_without_reading_its_target(self): + target = self.root / "outside" + target.write_text("app:x:1234:1234::/host-secret:/bin/sh\n") + self.passwd.unlink() + self.passwd.symlink_to(target) + self.assert_rejected_without_read() + self.assertEqual(target.read_text(), "app:x:1234:1234::/host-secret:/bin/sh\n") + + def test_symlinked_etc_is_rejected_without_reading_its_target(self): + directory = self.root / "etc" + directory.rename(self.root / "outside-etc") + directory.symlink_to(self.root / "outside-etc", target_is_directory=True) + self.assert_rejected_without_read() + self.assertEqual(self.cli("0")[:2], (1, "")) + + def test_pinned_etc_does_not_follow_a_later_directory_replacement(self): + original_open = os.open + outside = self.root / "outside" + outside.mkdir() + (outside / "passwd").write_text("root:x:0:0::/outside:/bin/sh\n") + replaced = [] + def replace(path, flags, *args, **kwargs): + if str(path) == "passwd": + (self.root / "etc").rename(self.root / "original-etc") + (self.root / "etc").symlink_to(outside, target_is_directory=True) + replaced.append(True) + return original_open(path, flags, *args, **kwargs) + with patch.object(os, "open", side_effect=replace): + self.assertEqual(self.cli("0"), (0, "/root\n", "")) + self.assertEqual(replaced, [True]) + + def test_directory_is_rejected_without_reading(self): + self.passwd.unlink() + self.passwd.mkdir() + self.assert_rejected_without_read() + + def test_fifo_is_rejected_without_reading_or_waiting_for_a_writer(self): + self.passwd.unlink() + os.mkfifo(self.passwd) + done = subprocess.run( + [sys.executable, "-B", str(ROOT / "remote/oci_runtime.py"), "--home", str(self.passwd), "0"], + capture_output=True, text=True, timeout=3, + ) + self.assertEqual(done.returncode, 1) + self.assertEqual(done.stdout, "") + self.assert_rejected_without_read() + + def test_missing_database_does_not_masquerade_as_unknown_uid(self): + self.passwd.unlink() + self.assert_rejected_without_read() + + def test_permission_failure_does_not_masquerade_as_unknown_uid(self): + with patch.object(os, "open", side_effect=PermissionError("denied")): + self.assert_rejected_without_read() + + def test_invalid_encoding_does_not_produce_a_fallback(self): + self.passwd.write_bytes(b"\xff\xfe") + status, output, _ = self.cli() + self.assertEqual((status, output), (1, "")) + + def test_partial_matching_record_does_not_produce_a_fallback(self): + self.passwd.write_text("app:x:1234:1234\n") + self.assertEqual(self.cli()[:2], (1, "")) + + def test_read_error_closes_both_descriptors_without_a_fallback(self): + descriptors = [] + original_open = os.open + def opened(*args, **kwargs): + fd = original_open(*args, **kwargs) + descriptors.append(fd) + return fd + with patch.object(os, "open", side_effect=opened), \ + patch.object(os, "fdopen", side_effect=OSError("read failed")): + self.assertEqual(self.cli()[:2], (1, "")) + self.assertEqual(len(descriptors), 2) + for fd in descriptors: + with self.assertRaises(OSError): + os.fstat(fd) + + def test_matching_unsafe_home_is_not_replaced_with_root(self): + for value in ("", "relative", "/bad\tvalue", "/bad\x00value", "/bad\x7fvalue"): + with self.subTest(value=repr(value)): + self.passwd.write_text(f"app:x:1234:1234::{value}:/bin/sh\n") + self.assertEqual(self.cli()[:2], (1, "")) + + def test_invalid_uid_does_not_open_the_database(self): + for uid in ("-1", "nobody"): + with self.subTest(uid=uid), patch.object(os, "open") as opened: + self.assertEqual(self.cli(uid)[:2], (1, "")) + opened.assert_not_called() + + def test_symlink_replacement_between_check_and_open_is_not_read(self): + original_open = os.open + outside = self.root / "outside" + outside.write_text("app:x:1234:1234::/outside:/bin/sh\n") + def replace(path, flags, *args, **kwargs): + if str(path) == "passwd": + self.passwd.unlink() + self.passwd.symlink_to(outside) + return original_open(path, flags, *args, **kwargs) + with patch.object(os, "open", side_effect=replace): + self.assert_rejected_without_read() + + def test_fifo_replacement_between_check_and_open_is_not_read(self): + original_open = os.open + def replace(path, flags, *args, **kwargs): + if str(path) == "passwd": + self.passwd.unlink() + os.mkfifo(self.passwd) + return original_open(path, flags, *args, **kwargs) + with patch.object(os, "open", side_effect=replace): + self.assert_rejected_without_read() + + +class HomeInstallerExecutionTests(unittest.TestCase): + """Run the real shell HOME function and Python CLI with fake mount boundaries.""" + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + (self.root / "etc").mkdir() + self.passwd = self.root / "etc/passwd" + self.passwd.write_text("root:x:0:0:root:/root:/bin/sh\napp:x:1001:1001::/srv/app:/bin/sh\n") + self.conf = self.root / "guest.conf" + self.trace = self.root / "trace" + self.source = (ROOT / "remote/install_oci.sh").read_text() + + def run_home(self, config="unrelated: keep\n", unmount_status=0, mount_status=0): + self.conf.write_text(config) + self.trace.write_text("") + functions = [] + for name in ("ensure_runtime_home", "set_runtime_environment"): + found = re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", self.source, re.M | re.S) + self.assertIsNotNone(found, name) + functions.append(found.group(0)) + script = r''' +set -euo pipefail +CONF=$1 +FIXTURE_ROOT=$2 +TRACE=$3 +OCI_RUNTIME_RESOLVER=$4 +PYTHON=$5 +UNMOUNT_STATUS=$6 +MOUNT_STATUS=$7 +OCI_LOG="$FIXTURE_ROOT/resolver.log" +VMID=123456789 +translate() { printf '%s' "$1"; } +die() { printf '%s\n' "$*" >&2; exit 1; } +mount_ct_rootfs() { + printf 'mount\n' >> "$TRACE" + [ "$MOUNT_STATUS" = 0 ] || die 'mount failed' + rootfs=$FIXTURE_ROOT + passwd_file="$rootfs/etc/passwd" +} +oci_quiet() { printf '%s\n' "$*" >> "$TRACE"; return "$UNMOUNT_STATUS"; } +python3() { printf 'resolve\n' >> "$TRACE"; command "$PYTHON" -B "$@"; } +''' + done = subprocess.run( + ["bash", "-c", script + "\n".join(functions) + "\nensure_runtime_home\nprintf 'continued\\n'\n", + "bash", str(self.conf), str(self.root), str(self.trace), str(ROOT / "remote/oci_runtime.py"), + sys.executable, str(unmount_status), str(mount_status)], + capture_output=True, text=True, timeout=5, + ) + return done, self.conf.read_text(), self.trace.read_text().splitlines() + + def test_explicit_home_including_empty_never_mounts_or_resolves(self): + for home in ("/custom", ""): + config = f"unrelated: keep\nlxc.environment.runtime: HOME={home}\n" + done, after, trace = self.run_home(config) + self.assertEqual((done.returncode, after, trace), (0, config, [])) + + def test_default_and_effective_uid_write_the_resolved_home_after_unmount(self): + for uid, expected in ((None, "/root"), (1001, "/srv/app"), (9999, "/")): + config = "unrelated: keep\n" + (f"lxc.init.uid: {uid}\n" if uid is not None else "") + done, after, trace = self.run_home(config) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertEqual(after, config + f"lxc.environment.runtime: HOME={expected}\n") + self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"]) + + def test_invalid_uid_leaves_config_unchanged_without_mount(self): + config = "lxc.init.uid: not-a-uid\n" + done, after, trace = self.run_home(config) + self.assertEqual((done.returncode, after, trace), (0, config, [])) + + def test_rejected_input_unmounts_and_preserves_config(self): + self.passwd.unlink() + self.passwd.symlink_to(self.root / "missing") + config = "unrelated: keep\n" + done, after, trace = self.run_home(config) + self.assertEqual((done.returncode, after), (0, config)) + self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"]) + + def test_failed_unmount_stops_before_config_write_for_success_and_failure(self): + for valid in (True, False): + if not valid: + self.passwd.unlink() + config = "unrelated: keep\n" + done, after, trace = self.run_home(config, unmount_status=1) + self.assertNotEqual(done.returncode, 0) + self.assertEqual(after, config) + self.assertNotIn("continued", done.stdout) + self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"]) + + def test_failed_mount_never_reads_or_writes(self): + config = "unrelated: keep\n" + done, after, trace = self.run_home(config, mount_status=1) + self.assertNotEqual(done.returncode, 0) + self.assertEqual(after, config) + self.assertEqual(trace, ["mount"]) + + +if __name__ == "__main__": + unittest.main() From e89f2596b2f0270a49178c1c3f0c73846d53fdb7 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:09:07 +0000 Subject: [PATCH 03/43] feat: discover native LXC applications on demand with evidence --- .github/scripts/run_offline_qualified.py | 2 + .github/workflows/test-offline-qualified.yml | 8 +- AppImage/components/lxc-app-panel.tsx | 119 ++++- AppImage/messages/en/common.json | 10 + AppImage/messages/sk/common.json | 10 + AppImage/scripts/build_appimage.sh | 1 + AppImage/scripts/flask_server.py | 16 + AppImage/scripts/lxc_apps.py | 7 + AppImage/scripts/native_app_discovery.py | 487 ++++++++++++++++++ .../tests/test_native_app_discovery.py | 441 ++++++++++++++++ CONTRIBUTING.md | 6 +- .../test_native_app_discovery_ui.cjs | 164 ++++++ 12 files changed, 1248 insertions(+), 23 deletions(-) create mode 100644 AppImage/scripts/native_app_discovery.py create mode 100644 AppImage/scripts/tests/test_native_app_discovery.py create mode 100644 tests/lxc_updates/test_native_app_discovery_ui.cjs diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 9bd13abc..fe6c26d4 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -13,6 +13,7 @@ import sys import tempfile PYTHON_FILES = ( + "AppImage/scripts/tests/test_native_app_discovery.py", "tests/test_audit_presentation.py", "tests/test_audit_safety_wording.py", "tests/test_audit_policy.py", @@ -24,6 +25,7 @@ PYTHON_FILES = ( "AppImage/scripts/tests/test_health_persistence_recurrence.py", ) NODE_FILES = ( + "tests/lxc_updates/test_native_app_discovery_ui.cjs", "tests/lxc_updates/test_docker_delegated_ui.cjs", "tests/test_audit_diagnostic_document.cjs", "tests/test_audit_policy.cjs", diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml index 29af7416..a562c6b3 100644 --- a/.github/workflows/test-offline-qualified.yml +++ b/.github/workflows/test-offline-qualified.yml @@ -12,6 +12,7 @@ on: - 'AppImage/package-lock.json' - 'AppImage/scripts/*.py' - 'AppImage/scripts/tests/test_health_persistence_recurrence.py' + - 'AppImage/scripts/tests/test_native_app_discovery.py' - 'AppImage/app/**' - 'AppImage/components/**' - 'AppImage/hooks/**' @@ -32,6 +33,7 @@ on: - 'AppImage/package-lock.json' - 'AppImage/scripts/*.py' - 'AppImage/scripts/tests/test_health_persistence_recurrence.py' + - 'AppImage/scripts/tests/test_native_app_discovery.py' - 'AppImage/app/**' - 'AppImage/components/**' - 'AppImage/hooks/**' @@ -56,7 +58,9 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Run nine qualified Python files + - name: Install native discovery route fixture dependency + run: python3 -m pip install 'Flask>=3,<4' + - name: Run qualified Python files env: PYTHONDONTWRITEBYTECODE: '1' run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python @@ -82,7 +86,7 @@ jobs: run: | npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules - - name: Run eleven qualified Node files + - name: Run qualified Node files env: NODE_PATH: ${{ github.workspace }}/AppImage/node_modules PYTHONDONTWRITEBYTECODE: '1' diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 59939f0b..666009cb 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -19,7 +19,7 @@ * Secure Gateway. */ -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Loader2, Save, RefreshCw, Trash2, Package, ExternalLink, AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff, @@ -93,6 +93,7 @@ interface AppConfig { health_path?: string logo_url?: string helper_slug?: string + discovery_id?: string // Set when the application runs inside Docker: registered for its identity // and installed version, updated by the image it comes from. update_via?: string @@ -121,7 +122,18 @@ interface PortDetail { logo_url: string } +interface NativeDiscovery { + vmid: number + status: "not_scanned" | "complete" | "partial" | "error" | "busy" + stale: boolean + candidates: DetectedApp[] +} + interface DetectedApp { + discovery_id?: string + source?: "native_metadata" + installed_version?: string | null + evidence?: Array<{ kind: string; value: string }> slug: string name: string logo_url?: string | null @@ -462,12 +474,34 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const [loading, setLoading] = useState(!seed) const [sidecar, setSidecar] = useState(seed?.sidecar ?? null) const [suggestions, setSuggestions] = useState(seed?.suggestions ?? null) + const [nativeDiscovery, setNativeDiscovery] = useState(null) + const targetRef = useRef(vmid) + targetRef.current = vmid + const targetGeneration = useRef(0) + const previousTarget = useRef(vmid) + if (previousTarget.current !== vmid) { + previousTarget.current = vmid + targetGeneration.current += 1 + } + useEffect(() => { + const generation = targetGeneration.current + setNativeDiscovery(null) + setEditing(null) + setDetectionNotice(null) + setSearchingApplications(false) + setSaving(false) + if (managed) return + fetchApi(`/api/vms/${vmid}/apps/native-discovery`).then(result => { + if (targetRef.current === vmid && targetGeneration.current === generation) setNativeDiscovery(result) + }).catch(() => { /* cached metadata is optional; never initiate a scan */ }) + return () => { targetGeneration.current += 1 } + }, [vmid, managed]) const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false) const [error, setError] = useState(null) const [searchingApplications, setSearchingApplications] = useState(false) const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null) // Editor state - const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null) + const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null) const [binaryArgsInput, setBinaryArgsInput] = useState("") const [commandArgvInput, setCommandArgvInput] = useState("") const [saving, setSaving] = useState(false) @@ -522,6 +556,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: // `initialData` or the shared cache, the sidecar is already // populated and the user shouldn't see a flash of "Loading…" // while we revalidate. + const generation = targetGeneration.current + const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation if (!sidecar) setLoading(true) setError(null) try { @@ -533,14 +569,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: // the "Loading applications…" flash that used to show while a // second, racing fetch caught up. const bundle = await fetchLxcApps(vmid) - if (bundle) { + if (bundle && isCurrent()) { setSidecar(bundle.sidecar) setSuggestions(bundle.suggestions) } } catch (e: any) { - setError(e?.message || t("vmLxc.appEditor.loadFailed")) + if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.loadFailed")) } finally { - setLoading(false) + if (isCurrent()) setLoading(false) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [vmid, managed]) @@ -617,7 +653,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: return msg } - const editorOpen = !!editing + const editorOpen = !!editing && editing.targetVmid === vmid // The picker catalog is only needed after the user opens the editor. useEffect(() => { @@ -653,8 +689,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: // (`extras[]` from the backend). Both use the same DetectedApp // shape so the empty state renders them uniformly. const detectedList: DetectedApp[] = useMemo(() => { - if (!suggestions) return [] - const out: DetectedApp[] = [] + const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : [])] + if (!suggestions) return out // A ProxMenux OCI install has no helper slug — nothing was installed by a // community script — so its template id identifies it instead. const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id @@ -679,12 +715,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } } return out - }, [suggestions]) + }, [suggestions, nativeDiscovery, vmid]) // Registered slugs — used to filter the detection list down to // what the user hasn't already registered on this CT. const registeredSlugs = useMemo( - () => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]), + () => new Set(apps.flatMap((a) => [a.helper_slug, a.discovery_id]).filter(Boolean) as string[]), [apps], ) // Dismissed slugs — persisted in the sidecar. Chips the user @@ -750,14 +786,30 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } const searchInstalledApplications = async () => { + const generation = targetGeneration.current + const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug)) setSearchingApplications(true) setDetectionNotice(null) setError(null) try { + let native = await fetchApi(`/api/vms/${vmid}/apps/native-discovery`, { + method: "POST", + }) + if (!isCurrent()) return + setNativeDiscovery(native) + if (native.status === "error" || native.status === "busy") { + setDetectionNotice({ found: false, text: t("vmLxc.appEditor.nativeScanFailed") }) + return + } const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, { method: "POST", }) + if (!isCurrent()) return + // Recompose against the refreshed catalog without another native scan. + native = await fetchApi(`/api/vms/${vmid}/apps/native-discovery`) + if (!isCurrent()) return + setNativeDiscovery(native) setSuggestions(result) if (sidecar) setLxcAppsCached(vmid, sidecar, result) @@ -765,11 +817,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: if (result.helper_slug) detected.add(result.helper_slug) for (const item of result.extras || []) detected.add(item.slug) for (const item of result.docker_workloads || []) detected.add(item.slug) + for (const item of native.candidates) detected.add(item.slug) const visible = [...detected].filter( (slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug), ) const newCount = visible.filter((slug) => !before.has(slug)).length - if (newCount === 1) { + if (native.status === "partial") { + setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") }) + } else if (newCount === 1) { setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") }) } else if (newCount > 1) { setDetectionNotice({ @@ -785,9 +840,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: }) } } catch (e: any) { - setError(e?.message || t("vmLxc.appEditor.detectionFailed")) + if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.detectionFailed")) } finally { - setSearchingApplications(false) + if (isCurrent()) setSearchingApplications(false) } } @@ -804,6 +859,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: existing?: AppEntry, opts?: { withTracking?: boolean, preset?: DetectedApp }, ) => { + const generation = targetGeneration.current let seed: AppConfig if (existing) { seed = { @@ -832,6 +888,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: health_path: existing.health_path || "", logo_url: existing.logo_url || "", helper_slug: existing.helper_slug || "", + ...(existing.discovery_id ? { discovery_id: existing.discovery_id } : {}), // Preserved explicitly: saving replaces the whole record, so dropping // it here would silently un-delegate the app the first time someone // edits one of its ports. @@ -862,7 +919,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const p = opts.preset seed.name = p.name seed.logo_url = p.logo_url || "" - seed.helper_slug = p.slug + if (p.discovery_id) { + seed.discovery_id = p.discovery_id + seed.update_method = "none" + seed.update_command = "" + } else seed.helper_slug = p.slug // Docker endpoints come from the real published host-port mappings // listed under Web links. Do not pre-save a catalog default such as // 9000; the user explicitly chooses which workload links to add. @@ -943,9 +1004,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: setShowAdvanced(false) } } + if (targetRef.current !== vmid || targetGeneration.current !== generation) return setBinaryArgsInput((seed.binary_args || []).join(", ")) setCommandArgvInput((seed.command_argv || []).join(", ")) - setEditing({ appId: existing?.id || null, draft: seed }) + setEditing({ appId: existing?.id || null, draft: seed, targetVmid: vmid }) setDetectorTest(null) setError(null) }, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable]) @@ -959,7 +1021,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } const save = async () => { - if (!editing) return + if (!editing || editing.targetVmid !== vmid || targetRef.current !== vmid) return + const generation = targetGeneration.current + const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation setSaving(true) setError(null) try { @@ -973,6 +1037,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: body: JSON.stringify(editing.draft), }) if ((r as any).error) throw new Error((r as any).error) + if (!isCurrent()) return setSidecar(r) setLxcAppsCached(vmid, r, suggestions) setEditing(null) @@ -980,9 +1045,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: setCommandArgvInput("") onChange?.() } catch (e: any) { - setError(e?.message || t("vmLxc.appEditor.saveFailed")) + if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.saveFailed")) } finally { - setSaving(false) + if (isCurrent()) setSaving(false) } } @@ -2452,6 +2517,24 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: ? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" }) : t("vmLxc.appEditor.detectedInContainer")} + {d.source === "native_metadata" && ( +
+
{d.installed_version + ? t("vmLxc.appEditor.versionDetected", { version: d.installed_version }) + : t("vmLxc.appEditor.nativeVersionUnknown")}
+ {(nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && ( +
{t("vmLxc.appEditor.nativeEvidenceStale")}
+ )} + {d.evidence?.map((e, index) =>
+ {t(e.kind === "tcp_listener" ? "vmLxc.appEditor.nativeEvidencePort" + : e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile" + : e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess" + : e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory" + : "vmLxc.appEditor.nativeEvidenceService")}: {e.value} +
)} +
{t("vmLxc.appEditor.nativeRegisterOnly")}
+
+ )} {d.tracking_suggestion?.detector_source === "legacy_fallback" && (
{t("vmLxc.appEditor.legacyDetectorUsed")} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index fa596302..64129339 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1730,6 +1730,16 @@ "searchApplications": "Find applications", "searchingApplications": "Searching applications…", "noApplicationsDetected": "No applications were detected.", + "nativeEvidencePort": "Listening TCP port", + "nativeEvidenceFile": "Installed file", + "nativeEvidenceProcess": "Running executable", + "nativeEvidenceDirectory": "Working directory", + "nativeEvidenceService": "Service definition", + "nativeScanFailed": "Application search could not finish. Previous evidence may be outdated; try again.", + "nativeScanPartial": "Application search is incomplete. Review the available evidence and try again.", + "nativeVersionUnknown": "Installed version unknown", + "nativeEvidenceStale": "Evidence may be outdated or incomplete.", + "nativeRegisterOnly": "Confirm registration in the editor. A known version is a manual value; no updater is configured.", "noNewApplicationsDetected": "No new applications were detected.", "dockerDetectedWithWorkloads": "Docker detected with {count} containerised application(s)", "dockerWorkloadsHeading": "Running inside Docker", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 692fcce6..5d792990 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1682,6 +1682,16 @@ "searchApplications": "Hľadať aplikácie", "searchingApplications": "Hľadajú sa aplikácie…", "noApplicationsDetected": "Neboli zistené žiadne aplikácie.", + "nativeEvidencePort": "Otvorený TCP port", + "nativeEvidenceFile": "Nainštalovaný súbor", + "nativeEvidenceProcess": "Spustený program", + "nativeEvidenceDirectory": "Pracovný priečinok", + "nativeEvidenceService": "Definícia služby", + "nativeScanFailed": "Vyhľadávanie aplikácií sa nepodarilo dokončiť. Predchádzajúce dôkazy môžu byť zastarané; skús to znova.", + "nativeScanPartial": "Vyhľadávanie aplikácií je neúplné. Skontroluj dostupné dôkazy a skús to znova.", + "nativeVersionUnknown": "Nainštalovaná verzia nie je známa", + "nativeEvidenceStale": "Dôkazy môžu byť zastarané alebo neúplné.", + "nativeRegisterOnly": "Registráciu potvrď v editore. Známa verzia je ručný údaj; aktualizátor nie je nastavený.", "noNewApplicationsDetected": "Neboli zistené žiadne nové aplikácie.", "oneNewApplicationDetected": "Bola zistená jedna nová aplikácia.", "newApplicationsDetected": "Boli zistené {count} nové aplikácie.", diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 40560c21..0b02bbb6 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -149,6 +149,7 @@ cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo " cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found" cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found" cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found" diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 3ffa0c4c..f1d6c302 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -2276,6 +2276,8 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None: # or stops; drop the cached IP so the next /api/vms poll re-reads # it via `lxc-info`. QEMU guests do not touch this cache. if guest_type == 'lxc': + import native_app_discovery + native_app_discovery.mark_stale(guest_id) _invalidate_lxc_ip(guest_id) if action in ('start', 'reboot'): if guest_type == 'lxc': @@ -14090,6 +14092,20 @@ def api_vm_apps_suggestions(vmid): return jsonify({'error': str(e)}), 500 +@app.route('/api/vms//apps/native-discovery', methods=['GET', 'POST']) +@require_auth +def api_vm_apps_native_discovery(vmid): + """Only the Find applications button requests POST. GET never probes.""" + import native_app_discovery + import lxc_apps + result = (native_app_discovery.discover(vmid) if request.method == 'POST' + else native_app_discovery.cached(vmid)) + suggestions = _vm_cache_get(_vm_app_suggestions_cache, vmid, _VM_APP_SUGGESTIONS_TTL) or {} + sidecar = lxc_apps.load_sidecar(vmid) or {} + return jsonify(native_app_discovery.filter_candidates( + result, suggestions, sidecar.get('apps') or [])) + + @app.route('/api/vms//apps/adguard-setup', methods=['GET']) @require_auth def api_vm_apps_adguard_setup(vmid): diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index 494c9db5..4b6a5a2e 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -1179,6 +1179,13 @@ def validate_config(payload: dict) -> tuple[bool, Any]: return _err("logo_url must be an http(s) URL (max 512 chars)") conf["logo_url"] = logo + # Native discovery identity is separate from a curated helper/updater. + discovery_id = payload.get("discovery_id") + if discovery_id is not None: + if not isinstance(discovery_id, str) or not re.fullmatch(r"native-[0-9a-f]{24}", discovery_id): + return _err("invalid discovery_id") + conf["discovery_id"] = discovery_id + # Optional helper_slug — set by the frontend when the user picks # an auto-detected app (primary or extra). Persisted so we can # filter the "also detected" chip list against apps already diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py new file mode 100644 index 00000000..a87dfc6e --- /dev/null +++ b/AppImage/scripts/native_app_discovery.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +# ========================================================== +# ProxMenux - A menu-driven script for Proxmox VE management +# Author : VAIO73 +# GitHub : github.com/Vaso73 +# Maintainer : MacRimi +# Copyright : (c) 2026 MacRimi & contributors +# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) +# Version : 1.0 +# Last Updated: 14/10/2026 +# ========================================================== +# Description: +# On-demand native LXC discovery from service and installation metadata. +# Reads bounded data only; never executes discovered programs, starts guests, +# registers applications or infers their update mechanism. +# ========================================================== +"""Separate from catalog detection: callers must explicitly request a scan.""" + +import copy +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import selectors +import signal +import subprocess +import threading +import time + +TOTAL_SECONDS = 20 +PROBE_SECONDS = 3 +OUTPUT_BYTES = 64 * 1024 +MAX_RECORDS = 256 +MAX_CANDIDATES = 50 +CACHE_SECONDS = 300 +LOCAL_CONFIG = Path('/etc/pve/local/lxc') +OCI_ROOT = Path('/usr/local/share/proxmenux/oci/instances') +UPDATE_LOCK_ROOT = Path('/run/lock') +_cache = {} +_locks = {} +_mutex = threading.Lock() +_PATH = re.compile(r'/[A-Za-z0-9_.+/@-]{1,510}\Z') +_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.+@-]{0,63}\Z') +_VERSION = re.compile(r'v?\d+(?:\.\d+){1,4}(?:[-+._][A-Za-z0-9.-]+)?\Z') +_SYSTEM = { + 'ssh', 'sshd', 'cron', 'crond', 'postfix', 'rsyslog', 'syslog', 'syslog-ng', + 'dbus', 'dbus-broker', 'systemd', 'udev', 'networking', 'network', 'networkmanager', + 'dhcpcd', 'dhclient', 'chrony', 'chronyd', 'ntp', 'ntpd', 'openntpd', 'agetty', + 'getty', 'login', 'polkit', 'polkitd', 'accounts-daemon', 'acpid', 'anacron', + 'docker', 'containerd', 'containerd-shim', 'podman', 'qemu-guest-agent', 'rngd', + 'auditd', 'logrotate', 'local', 'bootmisc', 'hostname', 'hwclock', 'modules', 'master', + 'proxmox-boot-sync', 'pve-container', 'pve-firewall', 'pvefw-logger', + 'pvedaemon', 'pvestatd', 'pveproxy', 'pvescheduler', 'spiceproxy', 'lxc', + 'mount-ro', 'mdev', 'sysctl', 'sysfs', 'devfs', 'cgroups', 'rc', 'rcs', 'swap', + 'sleep', 'true', 'false', 'echo', 'cat', 'head', 'tail', 'tee', 'logger', + 'apt', 'apt-get', 'dpkg', 'dpkg-query', 'apk', 'unattended-upgrades', + 'fstrim', 'fsck', 'fsckd', 'e2scrub', 'e2scrub-all', 'e2scrub-reap', + 'irqbalance', 'smartd', 'smartmontools', 'rpcbind', 'nfs-common', 'nfs-server', + 'rpc-statd', 'rpc-statd-notify', 'rpc-gssd', 'rpc-svcgssd', 'nftables', 'iptables', + 'ip6tables', 'firewalld', 'ufw', 'resolvconf', 'wpa-supplicant', 'modemmanager', + 'console-setup', 'keyboard-setup', 'plymouth', 'halt', 'reboot', 'shutdown', + 'rc-local', 'ntpsec', 'networkd-dispatcher', 'busybox', 'atd', 'uuidd', +} +_RUNTIMES = re.compile(r'(?:python[0-9.]*|node(?:js)?|sh|bash|dash|env|java|ruby|perl)\Z') + + +class ProbeError(Exception): + """Safe error code only; guest output must never become an API error.""" + + +def bounded_run(argv, deadline): + """Cap combined stdout/stderr while reading, and kill/reap the process group.""" + remaining = min(PROBE_SECONDS, deadline - time.monotonic()) + if remaining <= 0: + raise ProbeError('deadline') + end = time.monotonic() + remaining + try: + proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + except OSError: + raise ProbeError('unavailable') from None + outputs = [bytearray(), bytearray()] + try: + with selectors.DefaultSelector() as poll: + for index, stream in enumerate((proc.stdout, proc.stderr)): + os.set_blocking(stream.fileno(), False) + poll.register(stream, selectors.EVENT_READ, index) + while poll.get_map(): + wait = end - time.monotonic() + if wait <= 0: + raise ProbeError('timeout') + for key, _ in poll.select(wait): + block = os.read(key.fileobj.fileno(), 4096) + if not block: + poll.unregister(key.fileobj) + continue + if sum(map(len, outputs)) + len(block) > OUTPUT_BYTES: + raise ProbeError('output_limit') + outputs[key.data].extend(block) + try: + proc.wait(timeout=max(0.001, end - time.monotonic())) + except subprocess.TimeoutExpired: + raise ProbeError('timeout') from None + if proc.returncode: + raise ProbeError('read_failed') + return outputs[0].decode('utf-8', errors='strict') + except UnicodeError: + raise ProbeError('invalid_data') from None + finally: + # Also clean up descendants that outlive a successfully exited wrapper. + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() + proc.stdout.close() + proc.stderr.close() + + +# These are fixed read-only scripts, not guest-supplied shell or init scripts. +# awk emits only executable/script paths and WorkingDirectory, never arguments +# or environment values. OpenRC files are parsed as data, never sourced. +SERVICE_SCRIPT = r''' +command -v awk >/dev/null && command -v head >/dev/null || exit 1 +n=0 +for dir in /etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d; do + [ ! -d "$dir" ] || [ -r "$dir" ] || exit 1 + for f in "$dir"/*; do + [ -f "$f" ] || continue + case "$dir:$f" in /etc/init.d:*) kind=openrc;; *:*.service) kind=systemd;; *) continue;; esac + n=$((n+1)); [ "$n" -le 256 ] || { printf 'LIMIT\n'; exit 0; } + [ -r "$f" ] || exit 1 + content=$(timeout -s KILL 1 head -c 16385 "$f") || exit 1 + [ "${#content}" -le 16384 ] || { printf 'LIMIT\n'; exit 0; } + printf '%s\n' "$content" | awk -v kind="$kind" -v file="$f" ' + function clean(v) {gsub(/^[ \t]+|[ \t]+$/, "", v); gsub(/^["\047]|["\047]$/, "", v); return v} + /^ExecStart=/ {v=substr($0,11); sub(/^[-+!:@]+/, "", v); split(v,a,/[ \t]+/); exe=clean(a[1]); script=clean(a[2]); if(script !~ /^\//) script=""} + /^WorkingDirectory=/ {dir=clean(substr($0,18))} + /^command=/ {exe=clean(substr($0,9))} + /^directory=/ {dir=clean(substr($0,11))} + /^command_args=/ {v=clean(substr($0,14)); split(v,a,/[ \t]+/); script=clean(a[1]); if(script !~ /^\//) script=""} + END {printf "S\t%s\t%s\t%s\t%s\t%s\n", file, kind, exe, script, dir}' + done +done +printf 'END\n' +''' +PROCESS_SCRIPT = r''' +command -v readlink >/dev/null && command -v head >/dev/null || exit 1 +n=0 +for dir in /proc/[0-9]*; do + [ -d "$dir" ] || continue + n=$((n+1)); [ "$n" -le "$1" ] || { printf 'LIMIT\n'; exit 0; } + # Never inspect Docker/OCI child workloads or read cmdline/environ. + [ -d "$dir" ] || continue + [ -r "$dir/cgroup" ] || exit 1 + group=$(timeout -s KILL 1 head -c 8193 "$dir/cgroup") || exit 1 + [ "${#group}" -le 8192 ] || { printf 'LIMIT\n'; exit 0; } + case "$group" in *docker*|*libpod*|*kubepods*|*containerd*) continue;; esac + # Kernel threads and vanished processes have no application executable. + [ -L "$dir/exe" ] || continue + exe=$(timeout -s KILL 1 readlink "$dir/exe") || continue + cwd=$(timeout -s KILL 1 readlink "$dir/cwd") || continue + printf 'P\t%s\t%s\t%s\n' "${dir##*/}" "$exe" "$cwd" +done +printf 'END\n' +''' +PORT_SCRIPT = r''' +command -v awk >/dev/null || exit 1 +if command -v ss >/dev/null; then + (timeout -s KILL 1 ss -H -lntp || printf 'FAILED\n') | awk -v cap="$1" ' + /^FAILED$/ {failed=1; exit} + NR > cap {limited=1; exit} + {port=$4; sub(/^.*:/,"",port); rest=$0 + # More than one owner is ambiguous; never choose the first arbitrarily. + if (gsub(/pid=/,"pid=",rest)!=1) next + if (match(rest,/pid=[0-9]+/)) {pid=substr(rest,RSTART+4,RLENGTH-4); printf "L\t%s\t%s\n",pid,port}} + END {print failed ? "FAILED" : (limited ? "LIMIT" : "END")}' +elif command -v netstat >/dev/null; then + (timeout -s KILL 1 netstat -lntp || printf 'FAILED\n') | awk -v cap="$1" ' + /^FAILED$/ {failed=1; exit} + /^tcp/ {n++; if(n>cap) {limited=1; exit}; port=$4; sub(/^.*:/,"",port) + pid=$7; sub(/\/.*$/,"",pid); if(pid ~ /^[0-9]+$/) printf "L\t%s\t%s\n",pid,port} + END {print failed ? "FAILED" : (limited ? "LIMIT" : "END")}' +else + exit 1 +fi +''' +FOOTPRINT_SCRIPT = r''' +[ -f "$1" ] && [ -r "$1" ] || exit 1 +printf 'FILE\n' +if [ -f "$2/package.json" ]; then + [ -r "$2/package.json" ] || exit 1 + timeout -s KILL 1 head -c 8192 "$2/package.json" +fi +''' + + +def _path(value): + return bool(isinstance(value, str) and _PATH.fullmatch(value) + and '..' not in value.split('/')) + + +def _system(name): + raw = name.lower().removesuffix('.service') + stem = raw.split('@')[0] + return raw.startswith('user@') or stem in _SYSTEM or stem.startswith(( + 'systemd-', 'dbus-', 'getty', 'containerd-', 'apt-', 'dpkg-', 'e2scrub', + 'unattended-', 'ifup', 'ifdown', 'user@', 'btrfs-', 'lvm2-', 'lxc-')) + + +def _custom(path): + return _path(path) and path.startswith(('/opt/', '/srv/', '/usr/local/')) + + +def parse_records(text, kind): + lines = text.splitlines() + if not lines or lines[-1] not in ('END', 'LIMIT'): + raise ProbeError('incomplete') + if len(lines) - 1 > MAX_RECORDS: + raise ProbeError('metadata_limit') + records = [] + seen_services = set() + for line in lines[:-1]: + parts = line.split('\t') + if kind == 'S' and len(parts) == 6 and parts[0] == 'S': + _, source, manager, exe, script, directory = parts + if not _path(source) or manager not in ('systemd', 'openrc'): + raise ProbeError('invalid_data') + service = Path(source).name.removesuffix('.service') + if service in seen_services: + continue # /etc overrides vendor units; no duplicate installations. + seen_services.add(service) + if not _NAME.fullmatch(service) or _system(service) or not _path(exe): + continue + runtime = bool(_RUNTIMES.fullmatch(Path(exe).name)) + install = script if runtime else exe + if (not _path(install) or _system(Path(install).name) + or install.startswith(('/usr/lib/systemd/', '/lib/systemd/', + '/usr/lib/udev/', '/lib/udev/', '/usr/lib/apt/'))): + continue + root = directory if _custom(directory) else str(Path(install).parent) + # Generic runtime without an absolute application script is ambiguous. + if runtime and not _custom(install): + continue + records.append({'name': service, 'path': install, 'root': root, + 'service': source, 'manager': manager, 'executable': exe}) + elif kind == 'P' and len(parts) == 4 and parts[0] == 'P': + _, pid, exe, directory = parts + if not pid.isdigit() or not _path(exe) or not _path(directory): + continue + records.append({'pid': pid, 'executable': exe, 'root': directory}) + elif kind == 'L' and len(parts) == 3 and parts[0] == 'L': + _, pid, port = parts + if pid.isdigit() and port.isdigit() and 0 < int(port) < 65536: + records.append({'pid': pid, 'port': int(port)}) + else: + raise ProbeError('invalid_data') + else: + raise ProbeError('invalid_data') + return records, lines[-1] == 'LIMIT', len(lines) - 1 + + +def candidate(vmid, record, footprint): + if not footprint.startswith('FILE\n'): + raise ProbeError('invalid_data') + version, metadata_issue = None, None + metadata = footprint[5:].strip() + if metadata: + try: + manifest = json.loads(metadata) + except (ValueError, TypeError): + manifest = None + # Only the whitelisted version field leaves this function. Never retain + # manifest scripts, repository guesses, config or arbitrary extra fields. + if not isinstance(manifest, dict): + metadata_issue = 'invalid_metadata' + manifest = {} + value = manifest.get('version') + if (isinstance(value, str) and len(value) <= 64 and _VERSION.fullmatch(value) + and record['path'].startswith(record['root'].rstrip('/') + '/')): + version = value + identity = 'native-' + hashlib.sha256( + f'{int(vmid)}:{record["path"]}:{record["root"]}'.encode()).hexdigest()[:24] + evidence = [{'kind': 'installation_file', 'value': record['path']}] + if record.get('service'): + evidence.append({'kind': record['manager'], 'value': record['service']}) + else: + evidence.extend([{'kind': 'process_executable', 'value': record['process']}, + {'kind': 'working_directory', 'value': record['root']}]) + ports = sorted(set(record.get('listening_ports') or [])) + evidence.extend({'kind': 'tcp_listener', 'value': str(port)} for port in ports) + return {'slug': identity, 'discovery_id': identity, 'source': 'native_metadata', + 'name': record['name'], 'installed_version': version, 'evidence': evidence, + 'installation_path': record['path'], 'default_ports': [], + 'listening_ports': ports, + 'metadata_issue': metadata_issue, + 'tracking_suggestion': ({'installed_via': 'manual', 'installed_version': version, + 'detected_version': version, 'detector_verified': True} + if version else None)} + + +def _guard(vmid, deadline, run): + # The local node path rejects QEMU, missing and remote CTs before pct exec. + path = LOCAL_CONFIG / f'{vmid}.conf' + if not path.is_file(): + raise ProbeError('not_local_lxc') + # Presence, including a damaged/in-progress record, excludes generic probes. + try: + os.lstat(OCI_ROOT / str(vmid)) + except FileNotFoundError: + pass + except OSError: + raise ProbeError('oci_unknown') from None + else: + raise ProbeError('managed_oci') + try: + with path.open('rb') as handle: + raw = handle.read(OUTPUT_BYTES + 1) + if len(raw) > OUTPUT_BYTES: + raise ProbeError('config_limit') + config = raw.decode('utf-8') + except (OSError, UnicodeError): + raise ProbeError('config_unavailable') from None + if re.search(r'^lock:', config, re.M): + raise ProbeError('guest_busy') + update_lock = UPDATE_LOCK_ROOT / f'proxmenux-lxc-update-{vmid}.lock' + try: + with update_lock.open('rb') as handle: + try: + fcntl.flock(handle, fcntl.LOCK_SH | fcntl.LOCK_NB) + except BlockingIOError: + raise ProbeError('guest_busy') from None + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + except FileNotFoundError: + pass + except OSError: + raise ProbeError('lock_unavailable') from None + if re.search(r'^ostype:\s*unmanaged\s*$', config, re.M): + raise ProbeError('unsupported_guest') + if not re.search(r'^ostype:\s*(?:debian|ubuntu|alpine|archlinux|centos|fedora|opensuse|gentoo)\s*$', config, re.M): + raise ProbeError('unsupported_guest') + status = run(['pct', 'status', str(vmid)], deadline).strip() + if status != 'status: running': + raise ProbeError('guest_stopped' if status == 'status: stopped' else 'status_unknown') + + +def scan(vmid, run=bounded_run): + deadline = time.monotonic() + TOTAL_SECONDS + _guard(vmid, deadline, run) + records, issues, consumed, processes, listeners = [], [], 0, [], [] + for kind, script in (('S', SERVICE_SCRIPT), ('P', PROCESS_SCRIPT), ('L', PORT_SCRIPT)): + try: + if consumed >= MAX_RECORDS: + issues.append('metadata_limit') + break + parsed, limited, count = parse_records(run( + ['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2', + 'sh', '-c', script, 'discovery', str(MAX_RECORDS - consumed)], deadline), kind) + if consumed + count > MAX_RECORDS: + issues.append('metadata_limit') + parsed = parsed[:MAX_RECORDS - consumed] + consumed = min(MAX_RECORDS, consumed + count) + # The entire collector, not each metadata source, has 256 records. + capacity = MAX_RECORDS - len(records) + if kind == 'S': + records.extend(parsed[:capacity]) + elif kind == 'P': + processes = parsed[:capacity] + else: + listeners = parsed[:capacity] + if limited or len(parsed) > capacity: + issues.append('metadata_limit') + except ProbeError as exc: + issues.append(str(exc)) + for process in processes: + exe, directory = process['executable'], process['root'] + related = [record for record in records if record['executable'] == exe and ( + not _RUNTIMES.fullmatch(Path(exe).name) or record['root'] == directory)] + ports = [entry['port'] for entry in listeners if entry['pid'] == process['pid']] + if len(related) == 1: + related[0].setdefault('listening_ports', []).extend(ports) + elif not related and (_custom(exe) and _custom(directory) + and not _system(Path(exe).name) + and not _RUNTIMES.fullmatch(Path(exe).name) + and exe.startswith(directory.rstrip('/') + '/') + and _NAME.fullmatch(Path(exe).name)): + records.append({'name': Path(exe).name, 'path': exe, 'root': directory, + 'process': exe, 'executable': exe, 'listening_ports': ports}) + items, seen = [], set() + for record in records: + key = (record['path'], record['root']) + if key in seen: + continue + seen.add(key) + if len(items) >= MAX_CANDIDATES: + issues.append('candidate_limit') + break + try: + footprint = run(['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2', + 'sh', '-c', FOOTPRINT_SCRIPT, + 'discovery', record['path'], record['root']], deadline) + item = candidate(vmid, record, footprint) + items.append(item) + if item['metadata_issue']: + issues.append(item['metadata_issue']) + except ProbeError as exc: + issues.append(str(exc)) + if str(exc) == 'deadline': + break + return {'vmid': vmid, 'status': 'partial' if issues else 'complete', + 'candidates': items, 'issues': sorted(set(issues)), 'stale': False, + 'scanned_at': time.time()} + + +def cached(vmid): + with _mutex: + value = copy.deepcopy(_cache.get(int(vmid))) + if value is None: + return {'vmid': int(vmid), 'status': 'not_scanned', 'candidates': [], 'stale': False} + if time.time() - value['scanned_at'] >= CACHE_SECONDS: + value['stale'] = True + return value + + +def mark_stale(vmid): + """Lifecycle events retire freshness, never collect new guest metadata.""" + with _mutex: + if int(vmid) in _cache: + _cache[int(vmid)]['stale'] = True + + +def discover(vmid): + vmid = int(vmid) + with _mutex: + lock = _locks.setdefault(vmid, threading.Lock()) + if not lock.acquire(blocking=False): + return {**cached(vmid), 'status': 'busy', 'stale': True} + try: + try: + result = scan(vmid) + except ProbeError as exc: + result = {**cached(vmid), 'status': 'error', 'issues': [str(exc)], 'stale': True} + result.setdefault('scanned_at', time.time()) + with _mutex: + _cache[vmid] = copy.deepcopy(result) + return result + previous = cached(vmid) + if result['status'] != 'complete' and previous['candidates']: + # Partial output cannot prove that previously seen apps disappeared. + ids = {item['discovery_id'] for item in result['candidates']} + result['candidates'].extend(item for item in previous['candidates'] + if item['discovery_id'] not in ids) + result['candidates'] = result['candidates'][:MAX_CANDIDATES] + result['stale'] = True + with _mutex: + _cache[vmid] = copy.deepcopy(result) + return result + finally: + lock.release() + + +def filter_candidates(result, suggestions, apps): + """Pure composition: never run catalog probes, load or write registries.""" + known = [*apps, *(suggestions.get('extras') or []), + *(suggestions.get('docker_workloads') or [])] + if suggestions.get('helper_slug'): + known.append({'name': suggestions.get('name_suggestion'), + 'helper_slug': suggestions['helper_slug'], + 'tracking_suggestion': suggestions.get('tracking_suggestion')}) + identities = {a.get('discovery_id') for a in known if a.get('discovery_id')} + names, paths = set(), set() + for app in known: + for field in ('name', 'slug', 'helper_slug', 'package'): + if app.get(field): + names.add(str(app[field]).casefold()) + detector = app.get('tracking_suggestion') or app + for field in ('binary_path', 'file_path'): + if detector.get(field): + paths.add(detector[field]) + items = [item for item in result['candidates'] + if item['discovery_id'] not in identities + and item['name'].casefold() not in names + and item['installation_path'] not in paths] + return {**result, 'candidates': items} diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py new file mode 100644 index 00000000..cc1539b1 --- /dev/null +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -0,0 +1,441 @@ +"""Host-independent discovery fixtures; no real pct, guest, auth or registry.""" +import ast +import copy +import fcntl +from functools import wraps +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import Mock, patch + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) +import native_app_discovery as native +import lxc_apps + + +def service(name='handmade', exe='/opt/handmade/server', directory='/opt/handmade', manager='systemd', script=''): + path = ('/etc/systemd/system/' + name + '.service' if manager == 'systemd' + else '/etc/init.d/' + name) + return '\t'.join(['S', path, manager, exe, script, directory]) + '\nEND\n' + + +class DiscoveryFixtures(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.config = self.root / 'lxc' + self.config.mkdir() + (self.config / '101.conf').write_text('ostype: debian\n') + self.oci = self.root / 'oci' + self.oci.mkdir() + for context in (patch.object(native, 'LOCAL_CONFIG', self.config), + patch.object(native, 'OCI_ROOT', self.oci), + patch.object(native, 'UPDATE_LOCK_ROOT', self.root)): + context.start() + self.addCleanup(context.stop) + native._cache.clear() + native._locks.clear() + self.calls = [] + + def runner(self, services=None, processes='END\n', footprint='FILE\n', status='status: running', ports='END\n'): + def run(argv, deadline): + self.calls.append(argv) + self.assertLessEqual(deadline - time.monotonic(), native.TOTAL_SECONDS) + if argv[1] == 'status': + return status + self.assertEqual(argv[:6], ['pct', 'exec', '101', '--', 'timeout', '-s']) + self.assertEqual(argv[6:9], ['KILL', '2', 'sh']) + script = argv[10] + if script == native.SERVICE_SCRIPT: + return services if services is not None else service() + if script == native.PROCESS_SCRIPT: + return processes + if script == native.PORT_SCRIPT: + return ports + if script == native.FOOTPRINT_SCRIPT: + return footprint + self.fail('Unexpected probe') + return run + + def test_unknown_version_is_present_with_evidence_and_no_capabilities(self): + result = native.scan(101, self.runner()) + self.assertEqual(result['status'], 'complete') + item, = result['candidates'] + self.assertIsNone(item['installed_version']) + self.assertIsNone(item['tracking_suggestion']) + self.assertEqual(item['default_ports'], []) + self.assertEqual({e['kind'] for e in item['evidence']}, {'installation_file', 'systemd'}) + for field in ('helper_slug', 'repo', 'update_command', 'binary_path', 'upstream_type'): + self.assertNotIn(field, item) + + def test_openrc_node_script_known_version_is_manual_and_secret_free(self): + result = native.scan(101, self.runner( + service(exe='/usr/bin/node', script='/opt/handmade/server.js', manager='openrc'), + footprint='FILE\n' + json.dumps({'version': '1.2.3', 'scripts': {'start': 'SECRET'}, 'env': 'PASSWORD'}))) + item, = result['candidates'] + self.assertEqual(item['installed_version'], '1.2.3') + self.assertEqual(item['tracking_suggestion']['installed_via'], 'manual') + self.assertNotIn('SECRET', json.dumps(result)) + self.assertNotIn('PASSWORD', json.dumps(result)) + self.assertFalse(any('systemctl' in arg for call in self.calls for arg in call)) + + def test_inactive_service_and_packaged_application_are_not_hidden(self): + result = native.scan(101, self.runner(service(name='legit-app', exe='/usr/bin/legit-app', directory=''))) + self.assertEqual(result['candidates'][0]['name'], 'legit-app') + + def test_system_services_and_ambiguous_runtimes_have_zero_footprint_probes(self): + for name, exe in [('ssh', '/usr/sbin/sshd'), ('cron', '/usr/sbin/cron'), + ('postfix', '/usr/lib/postfix/master'), ('rsyslog', '/usr/sbin/rsyslogd'), + ('systemd-networkd', '/usr/lib/systemd/systemd-networkd'), + ('ambiguous', '/usr/bin/python3')]: + self.calls.clear() + result = native.scan(101, self.runner(service(name=name, exe=exe))) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + + def test_missing_remote_qemu_stopped_busy_oci_guards_precede_exec(self): + scenarios = ['missing', 'stopped', 'busy', 'oci', 'invalid-oci', 'unmanaged'] + for scenario in scenarios: + with self.subTest(scenario=scenario): + (self.config / '101.conf').write_text('ostype: debian\n') + if scenario == 'missing': + (self.config / '101.conf').unlink() + if scenario == 'busy': + (self.config / '101.conf').write_text('lock: backup\n') + if scenario == 'unmanaged': + (self.config / '101.conf').write_text('ostype: unmanaged\n') + if 'oci' in scenario: + (self.oci / '101').mkdir(exist_ok=True) + (self.oci / '101' / 'oci-compose.json').write_text('broken') + self.calls.clear() + with self.assertRaises(native.ProbeError): + native.scan(101, self.runner(status='status: stopped' if scenario == 'stopped' else 'status: running')) + self.assertFalse(any(call[1] == 'exec' for call in self.calls)) + if (self.oci / '101').exists(): + (self.oci / '101' / 'oci-compose.json').unlink() + (self.oci / '101').rmdir() + + def test_failure_and_partial_output_are_not_successful_empty_results(self): + for text in ('', 'malformed\nEND\n', 'S\tpartial', 'LIMIT\n'): + result = native.scan(101, self.runner(services=text)) + self.assertEqual(result['status'], 'partial') + run = self.runner() + def failed(argv, deadline): + if argv[1] == 'exec': + raise native.ProbeError('read_failed') + return run(argv, deadline) + self.assertEqual(native.scan(101, failed)['status'], 'partial') + + def test_optional_invalid_version_metadata_keeps_proven_candidate(self): + result = native.scan(101, self.runner(footprint='FILE\n{broken')) + self.assertEqual(result['status'], 'partial') + item, = result['candidates'] + self.assertIsNone(item['installed_version']) + self.assertIsNone(item['tracking_suggestion']) + + def test_working_directory_manifest_does_not_version_an_unrelated_executable(self): + result = native.scan(101, self.runner(service(exe='/opt/other/server'), + footprint='FILE\n{"version":"1.2.3"}')) + self.assertIsNone(result['candidates'][0]['installed_version']) + + def test_path_injection_and_port_only_data_do_not_become_candidates(self): + for text in (service(exe='/opt/app/$(id)'), service(exe='/opt/../server'), + service(name='app;id'), service(exe='/bin/sh')): + self.assertEqual(native.scan(101, self.runner(text))['candidates'], []) + with self.assertRaises(native.ProbeError): + native.parse_records('PORT\t8080\nEND\n', 'P') + + def test_same_process_deduplicates_but_two_apps_using_node_do_not_merge(self): + services = service(exe='/usr/bin/node', script='/opt/handmade/server.js').removesuffix('END\n') + services += service(name='second', exe='/usr/bin/node', script='/opt/second/server.js', directory='/opt/second') + result = native.scan(101, self.runner(services)) + self.assertEqual(len(result['candidates']), 2) + result = native.scan(101, self.runner(processes='P\t123\t/opt/handmade/server\t/opt/handmade\nEND\n')) + self.assertEqual(len(result['candidates']), 1) + other = native.candidate(102, native.parse_records(service(), 'S')[0][0], 'FILE\n') + self.assertNotEqual(other['discovery_id'], result['candidates'][0]['discovery_id']) + + def test_record_and_candidate_caps(self): + too_many = 'P\t123\t/opt/a/app\t/opt/a\n' * 257 + 'END\n' + with self.assertRaises(native.ProbeError): + native.parse_records(too_many, 'P') + services = ''.join(service(name=f'app{i}', exe=f'/opt/app{i}/server', directory=f'/opt/app{i}').removesuffix('END\n') for i in range(51)) + 'END\n' + result = native.scan(101, self.runner(services)) + self.assertEqual(len(result['candidates']), 50) + self.assertIn('candidate_limit', result['issues']) + + def test_cached_ttl_never_runs_scan_and_error_preserves_last_known(self): + complete = native.scan(101, self.runner()) + with patch.object(native, 'scan', return_value=complete) as scan: + native.discover(101) + for age, stale in ((299.9, False), (300, True), (300.1, True)): + with patch.object(native.time, 'time', return_value=complete['scanned_at'] + age): + self.assertEqual(native.cached(101)['stale'], stale) + self.assertEqual(scan.call_count, 1) + with patch.object(native, 'scan', side_effect=native.ProbeError('timeout')): + result = native.discover(101) + self.assertEqual(result['candidates'], complete['candidates']) + self.assertEqual(native.cached(101)['status'], 'error') + self.assertTrue(native.cached(101)['stale']) + partial = {**complete, 'status': 'partial', 'candidates': [], 'issues': ['read_failed']} + with patch.object(native, 'scan', return_value=partial): + self.assertEqual(native.discover(101)['candidates'], complete['candidates']) + with patch.object(native, 'scan', return_value={**complete, 'candidates': []}): + self.assertEqual(native.discover(101)['candidates'], []) + + def test_same_ct_busy_and_different_ct_cache_isolation(self): + lock = threading.Lock() + lock.acquire() + native._locks[101] = lock + with patch.object(native, 'scan') as scanner: + self.assertEqual(native.discover(101)['status'], 'busy') + scanner.assert_not_called() + self.assertEqual(native.cached(102)['status'], 'not_scanned') + + def test_filter_registered_curated_and_docker_children_without_mutation(self): + result = native.scan(101, self.runner()) + original = copy.deepcopy(result) + for suggestions, apps in (({}, [{'discovery_id': result['candidates'][0]['discovery_id']}]), + ({'extras': [{'name': 'Handmade'}]}, []), + ({'docker_workloads': [{'name': 'Handmade'}]}, []), + ({'helper_slug': 'handmade', 'name_suggestion': 'Handmade'}, [])): + self.assertEqual(native.filter_candidates(result, suggestions, apps)['candidates'], []) + self.assertEqual(result, original) + + def test_actual_save_first_check_and_recheck_never_probe_or_configure_updater(self): + with patch.object(lxc_apps, '_APPS_DIR', str(self.root / 'apps')), \ + patch.object(lxc_apps, '_pct_exec', side_effect=AssertionError('forbidden guest execution')) as execute, \ + patch.object(lxc_apps, 'fetch_latest_upstream_details', side_effect=AssertionError('forbidden upstream')) as upstream: + for version in (None, '1.2.3'): + item = native.candidate(101, native.parse_records(service(), 'S')[0][0], + 'FILE\n' + (json.dumps({'version': version}) if version else '')) + payload = {'name': item['name'], 'discovery_id': item['discovery_id'], + 'update_method': 'none', **(item['tracking_suggestion'] or {})} + ok, sidecar = lxc_apps.add_app(101, payload) + self.assertTrue(ok, sidecar) + app = sidecar['apps'][-1] + self.assertEqual(app['state']['installed_version'], version) + self.assertIsNone(app['state']['update_available']) + self.assertEqual(app['update_method'], 'none') + for field in ('helper_slug', 'update_command', 'repo', 'upstream_type'): + self.assertNotIn(field, app) + lxc_apps.check_app(101, app['id'], force=True) + self.assertIsNone(lxc_apps.get_schedule(101)) + self.assertIsNone(lxc_apps.get_bulk_update(101)) + execute.assert_not_called() + upstream.assert_not_called() + + def test_failed_save_has_no_first_check(self): + with patch.object(lxc_apps, '_write_sidecar', return_value=False), \ + patch.object(lxc_apps, '_read_sidecar', return_value=None), \ + patch.object(lxc_apps, 'check_app') as check: + self.assertFalse(lxc_apps.add_app(101, {'name': 'handmade'})[0]) + check.assert_not_called() + + def test_active_update_lock_blocks_all_guest_probes_without_writing(self): + path = self.root / 'proxmenux-lxc-update-101.lock' + path.write_bytes(b'unchanged') + with path.open('rb') as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + with self.assertRaisesRegex(native.ProbeError, 'guest_busy'): + native.scan(101, self.runner()) + self.assertEqual(path.read_bytes(), b'unchanged') + self.assertEqual(self.calls, []) + + def test_actual_service_parser_reads_data_never_executes_or_sources_it(self): + for manager in ('systemd', 'openrc'): + directory = self.root / manager + directory.mkdir() + marker = self.root / ('forbidden-' + manager) + unit = directory / ('handmade.service' if manager == 'systemd' else 'handmade') + content = ('[Service]\nExecStart=/usr/bin/node /opt/handmade/server.js --password SECRET\nWorkingDirectory=/opt/handmade\n' + if manager == 'systemd' else 'command="/usr/bin/node"\ncommand_args="/opt/handmade/server.js --password SECRET"\ndirectory="/opt/handmade"\n') + unit.write_text(content + '\ntouch ' + str(marker) + '\n') + script = native.SERVICE_SCRIPT.replace('/etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d', str(directory)) + # Substitute only fixture paths, not the collector/parser behavior. + if manager == 'openrc': + script = script.replace('/etc/init.d:*)', str(directory) + ':*)') + output = native.bounded_run(['sh', '-c', script], time.monotonic() + 3) + record, = native.parse_records(output, 'S')[0] + self.assertEqual(record['path'], '/opt/handmade/server.js') + self.assertNotIn('SECRET', output) + self.assertFalse(marker.exists()) + + def test_actual_process_parser_skips_docker_and_never_reads_environ_cmdline(self): + proc = self.root / 'proc' + proc.mkdir() + for pid, group in ((1, '0::/\n'), (2, '0::/docker/child\n')): + directory = proc / str(pid) + directory.mkdir() + (directory / 'cgroup').write_text(group) + (directory / 'exe').symlink_to('/opt/handmade/server') + (directory / 'cwd').symlink_to('/opt/handmade') + (directory / 'cmdline').write_text('SECRET') + (directory / 'environ').write_text('PASSWORD') + script = native.PROCESS_SCRIPT.replace('/proc/[0-9]*', str(proc) + '/[0-9]*') + output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3) + self.assertEqual(output, 'P\t1\t/opt/handmade/server\t/opt/handmade\nEND\n') + self.assertNotIn('"$dir/cmdline"', native.PROCESS_SCRIPT) + self.assertNotIn('"$dir/environ"', native.PROCESS_SCRIPT) + self.assertNotIn('SECRET', output) + + def test_ports_require_exact_process_ownership_and_do_not_infer_http(self): + process = 'P\t123\t/opt/handmade/server\t/opt/handmade\nEND\n' + result = native.scan(101, self.runner(processes=process, ports='L\t123\t8080\nL\t999\t9000\nEND\n')) + item, = result['candidates'] + self.assertEqual(item['listening_ports'], [8080]) + self.assertEqual(item['default_ports'], [], 'TCP listener does not prove HTTP') + result = native.scan(101, self.runner(ports='L\t123\t8080\nEND\n')) + self.assertEqual(result['candidates'][0]['listening_ports'], []) + + def test_shared_runtime_ports_are_assigned_only_by_working_directory(self): + services = service(exe='/usr/bin/node', script='/opt/handmade/server.js').removesuffix('END\n') + services += service(name='second', exe='/usr/bin/node', script='/opt/second/server.js', directory='/opt/second') + process = 'P\t123\t/usr/bin/node\t/opt/second\nEND\n' + result = native.scan(101, self.runner(services, processes=process, ports='L\t123\t8080\nEND\n')) + self.assertEqual(result['candidates'][0]['listening_ports'], []) + self.assertEqual(result['candidates'][1]['listening_ports'], [8080]) + + def test_ss_and_alpine_netstat_parser_are_bounded_and_secret_free(self): + for tool, line in (('ss', 'LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("SECRET",pid=123,fd=7))'), + ('netstat', 'tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 123/SECRET')): + bin_dir = self.root / tool + bin_dir.mkdir() + executable = bin_dir / tool + executable.write_text('#!/bin/sh\nprintf \'%s\\n\' \'' + line + '\'\n') + executable.chmod(0o755) + (bin_dir / 'awk').symlink_to('/usr/bin/awk') + (bin_dir / 'timeout').symlink_to('/usr/bin/timeout') + script = 'PATH=' + str(bin_dir) + '\n' + native.PORT_SCRIPT + output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3) + self.assertEqual(output, 'L\t123\t8080\nEND\n') + self.assertNotIn('SECRET', output) + executable.write_text('#!/bin/sh\nexit 1\n') + output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3) + with self.assertRaises(native.ProbeError): + native.parse_records(output, 'L') + + +class BoundedRunner(unittest.TestCase): + def test_output_limit_is_enforced_during_read(self): + with self.assertRaisesRegex(native.ProbeError, 'output_limit'): + native.bounded_run([sys.executable, '-c', 'import os; os.write(2,b"x"*1000000)'], time.monotonic() + 3) + + def test_pipe_holding_child_does_not_bypass_timeout(self): + start = time.monotonic() + with self.assertRaisesRegex(native.ProbeError, 'timeout'): + native.bounded_run([sys.executable, '-c', 'import os,time; os.fork(); time.sleep(10)'], start + .15) + self.assertLess(time.monotonic() - start, .8) + + def test_failures_never_reveal_stderr_and_deadline_blocks_spawn(self): + with self.assertRaisesRegex(native.ProbeError, '^read_failed$'): + native.bounded_run([sys.executable, '-c', 'import sys; sys.stderr.write("SECRET"); sys.exit(1)'], time.monotonic() + 3) + with patch.object(native.subprocess, 'Popen') as launch: + with self.assertRaisesRegex(native.ProbeError, 'deadline'): + native.bounded_run(['forbidden'], time.monotonic() - 1) + launch.assert_not_called() + + +class Routes(unittest.TestCase): + """Execute actual Flask route bodies; fixture auth, no server startup jobs.""" + def setUp(self): + from flask import Flask, request, jsonify, g + self.app = Flask('native-fixture') + self.authorized = True + def require_auth(fn): + @wraps(fn) + def wrapped(*args, **kwargs): + if not self.authorized: + return jsonify({'error': 'auth_required'}), 401 + return fn(*args, **kwargs) + return wrapped + self.globals = {'app': self.app, 'request': request, 'jsonify': jsonify, + 'require_auth': require_auth, '_vm_app_suggestions_cache': {}, + '_VM_APP_SUGGESTIONS_TTL': 300, + '_vm_cache_get': lambda cache, vmid, ttl: cache.get(vmid), + '_vm_cache_put': lambda cache, vmid, value: cache.update({vmid: value}), + '_pvesh_cache': {}, '_vm_disk_cache': {}, '_vm_cache_invalidate': Mock(), + '_invalidate_lxc_ip': Mock(), '_schedule_started_guest_refresh': Mock(), + '_publish_guest_modal_cache_revision': Mock()} + tree = ast.parse((SCRIPTS / 'flask_server.py').read_text()) + routes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in ( + 'api_vm_apps_suggestions', 'api_vm_apps_native_discovery', 'api_vm_apps_add', + '_handle_guest_lifecycle')] + self.assertEqual(len(routes), 4) + exec(compile(ast.Module(body=routes, type_ignores=[]), str(SCRIPTS / 'flask_server.py'), 'exec'), self.globals) + self.client = self.app.test_client() + + def test_explicit_post_only_get_and_existing_post_never_native_scan(self): + result = {'vmid': 101, 'status': 'complete', 'candidates': [], 'stale': False} + with patch.object(native, 'discover', return_value=result) as scan, \ + patch.object(native, 'cached', return_value=result), \ + patch.object(lxc_apps, 'get_suggestions', return_value={'extras': []}) as suggestions, \ + patch.object(lxc_apps, 'load_sidecar', return_value={'apps': []}), \ + patch.object(lxc_apps, '_write_sidecar') as write: + self.assertEqual(self.client.get('/api/vms/101/apps/native-discovery').status_code, 200) + self.client.get('/api/vms/101/apps/suggestions') + # OCI refresh and Find's existing catalog path retain their purpose. + self.client.post('/api/vms/101/apps/suggestions') + with self.app.test_request_context('/api/vms/101/apps/suggestions'): + from flask import g + g._internal_call = True + self.globals['_vm_app_suggestions_cache'].clear() + self.globals['api_vm_apps_suggestions'](101) + scan.assert_not_called() + self.client.post('/api/vms/101/apps/native-discovery') + scan.assert_called_once_with(101) + self.assertEqual(suggestions.call_count, 2) + write.assert_not_called() + + def test_unauthorized_and_wrong_method_have_zero_scans(self): + with patch.object(native, 'discover') as scan, patch.object(lxc_apps, 'load_sidecar') as read: + self.authorized = False + self.assertEqual(self.client.post('/api/vms/101/apps/native-discovery').status_code, 401) + self.assertEqual(self.client.put('/api/vms/101/apps/native-discovery').status_code, 405) + scan.assert_not_called() + read.assert_not_called() + + def test_actual_lifecycle_marks_last_known_stale_without_scanning(self): + native._cache[101] = {'vmid': 101, 'status': 'complete', 'stale': False, + 'candidates': [], 'scanned_at': time.time()} + with patch.object(native, 'discover') as scan, \ + patch.object(lxc_apps, 'clear_schedule_reboot_required') as clear: + for action in ('start', 'reboot', 'stop'): + self.globals['_handle_guest_lifecycle']('101', 'lxc', action) + self.assertTrue(native.cached(101)['stale']) + scan.assert_not_called() + self.assertEqual(clear.call_count, 2) + + def test_actual_repeated_prewarmer_has_zero_native_scans(self): + tree = ast.parse((SCRIPTS / 'flask_server.py').read_text()) + warmer = next(node for node in tree.body if isinstance(node, ast.FunctionDef) + and node.name == '_vm_modal_prewarmer_pass') + environment = dict(self.globals) + for name in ('_vm_details_cache', '_vm_backups_cache', '_vm_schedule_cache', '_vm_mounts_cache'): + environment[name] = {} + for name in ('_VM_DETAILS_TTL', '_VM_BACKUPS_TTL', '_VM_SCHEDULE_TTL', '_VM_MOUNTS_TTL'): + environment[name] = 300 + for name in ('get_vm_config', 'api_vm_backups', 'api_vm_apps_schedule', 'api_lxc_mount_points'): + environment[name] = lambda vmid: None + environment.update({'get_cached_pvesh_cluster_resources_vm': lambda: [ + {'vmid': 101, 'type': 'lxc', 'status': 'running'}], 'time': Mock(), 'sys': sys}) + exec(compile(ast.Module(body=[warmer], type_ignores=[]), 'flask_server.py', 'exec'), environment) + with patch.object(native, 'discover') as scan, \ + patch.object(lxc_apps, 'ensure_oci_registration'), \ + patch.object(lxc_apps, 'load_sidecar', return_value={'apps': []}), \ + patch.object(lxc_apps, 'get_suggestions', return_value={'extras': []}): + for _ in range(2): + environment['_vm_app_suggestions_cache'].clear() + self.assertEqual(environment['_vm_modal_prewarmer_pass'](), 1) + scan.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e76a028..6e645e4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -876,8 +876,8 @@ and traceback before changing catalogs or translation-provider settings. #### Qualified offline `tests/` CI lanes `.github/workflows/test-offline-qualified.yml` runs **only** the explicitly listed -nine Python files (including the inert active-notification payload fixture and -the focused health recurrence tests) and eleven +ten Python files (including the native discovery fixtures, the inert active-notification payload fixture and +the focused health recurrence tests) and twelve Node files in `.github/scripts/run_offline_qualified.py`. The Python runner gives each test file a private temporary directory through `TMPDIR`. It removes that directory after the test file finishes. @@ -900,7 +900,7 @@ dangling symlink) rather than overwrite or reuse it. In Bash: ) ``` -Requires Python 3.11, Node 22.14, npm and Bash/coreutils for the bounded shell +Requires Python 3.11, Flask 3.x for the native route fixtures, Node 22.14, npm and Bash/coreutils for the bounded shell fixtures. The separate lockfile installs only TypeScript, React, React DOM, Lucide icons and the locked scheduler dependency; it does not build the Monitor or run npm lifecycle scripts. `--legacy-peer-deps` admits the existing Lucide diff --git a/tests/lxc_updates/test_native_app_discovery_ui.cjs b/tests/lxc_updates/test_native_app_discovery_ui.cjs new file mode 100644 index 00000000..51f759b7 --- /dev/null +++ b/tests/lxc_updates/test_native_app_discovery_ui.cjs @@ -0,0 +1,164 @@ +// Execute the real panel, handlers and editor with inert hook/UI/API fixtures. +// This is component interaction evidence, not browser or Proxmox acceptance. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const root = path.resolve(__dirname, '../../AppImage') +const ts = require(path.join(root, 'node_modules/typescript')) +const source = fs.readFileSync(path.join(root, 'components/lxc-app-panel.tsx'), 'utf8') +const compiled = ts.transpileModule(source, {compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX, +}, reportDiagnostics: true}) +assert.equal(compiled.diagnostics.length, 0) + +const flatten = node => !node || typeof node !== 'object' ? [] : [node, ...( + [node.props?.children].flat(Infinity).flatMap(flatten) +)] +const text = node => node == null || node === false ? '' : typeof node !== 'object' + ? String(node) : [node.props?.children].flat(Infinity).map(text).join(' ') +const candidate = { + slug: 'native-' + 'a'.repeat(24), discovery_id: 'native-' + 'a'.repeat(24), + name: 'handmade', source: 'native_metadata', installed_version: null, + evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [], +} + +function fixture() { + let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [] + const calls = [], cachedWrites = [] + let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false} + let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete' + const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}} + const react = { + useState(initial) { + const index = cursor++ + if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial + return [states[index], value => {states[index] = typeof value === 'function' ? value(states[index]) : value}] + }, + useRef(value) {const index = cursor++; return refs[index] ||= {current: value}}, + useMemo(fn) {cursor++; return fn()}, useCallback(fn) {cursor++; return fn}, + useEffect(fn, deps) { + const index = cursor++, prior = effectStates[index] + if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) { + effects.push(() => {prior?.cleanup?.(); effectStates[index] = {deps, cleanup: fn()}}) + } + }, + } + const api = async (url, options = {}) => { + calls.push({url, ...options}) + if (url.endsWith('/native-discovery')) { + if (options.method === 'POST') { + if (scanGate) await scanGate + native = {...native, status: scanStatus, candidates: [candidate]} + } + return structuredClone(native) + } + if (url.endsWith('/apps/suggestions')) return seed.suggestions + if (url.endsWith('/apps') && options.method === 'POST') { + if (saveGate) await saveGate + return {vmid: Number(url.split('/')[3]), apps: [{id: 'saved', ...JSON.parse(options.body), state: {}}]} + } + if (url.endsWith('/adguard-setup')) return {available: false} + return [] + } + const cache = { + getLxcAppsCached: () => null, fetchLxcApps: async () => seed, + subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args), + } + const exports = {} + vm.runInNewContext(compiled.outputText, {exports, console, setTimeout, clearTimeout, + require: name => { + if (name === 'react') return react + if (name === 'react/jsx-runtime') return {jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})} + if (name.includes('api-config')) return {fetchApi: api} + if (name.includes('lxc-apps-cache')) return cache + if (name.includes('i18n/provider')) return {useT: () => (key, values) => key + (values ? JSON.stringify(values) : '')} + if (name.includes('category-color')) return {useIsLightTheme: () => false, categoryChipStyle: () => ({})} + if (name.includes('category-label')) return {getCategoryLabel: value => value} + return new Proxy({}, {get: (_, key) => key}) + }, + }) + function render(next = props || {vmid: 101, initialData: seed}) { + props = next; cursor = 0; effects = [] + tree = exports.LxcAppPanel(props) + for (const effect of effects) effect() + return tree + } + const settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()} + const button = key => { + const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key)) + assert.ok(match, 'Missing button ' + key) + return match + } + return {render, settle, calls, cachedWrites, button, tree: () => tree, + setNative: value => {native = value}, + setScanStatus: value => {scanStatus = value}, + holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(), + holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(), + } +} + +async function main() { + const f = fixture() + f.render(); await f.settle() + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0, 'mount and cache reads never scan') + await f.button('searchApplications').props.onClick(); f.render() + assert.equal(f.calls.filter(c => c.url.endsWith('/native-discovery') && c.method === 'POST').length, 1) + assert.ok(text(f.tree()).includes('nativeVersionUnknown'), 'unknown version remains visible') + assert.ok(text(f.tree()).includes('/opt/handmade/server')) + await f.button('registerButton').props.onClick(); f.render() + assert.ok(text(f.tree()).includes('saveButton'), 'candidate opens actual editor') + f.button('cancelButton').props.onClick(); f.render() + assert.equal(f.calls.filter(c => /\/apps$/.test(c.url) && c.method === 'POST').length, 0, 'cancel never registers') + await f.button('registerButton').props.onClick(); f.render() + await f.button('saveButton').props.onClick(); f.render() + const saved = f.calls.find(c => /\/apps$/.test(c.url) && c.method === 'POST') + const payload = JSON.parse(saved.body) + assert.equal(saved.url, '/api/vms/101/apps') + assert.equal(payload.discovery_id, candidate.discovery_id) + assert.ok(!payload.helper_slug, 'native identity never becomes a helper') + assert.equal(payload.installed_via, '') + assert.equal(payload.update_method, 'none') + assert.equal(payload.update_command, '') + assert.equal(payload.repo, '') + assert.equal(payload.upstream_type, '') + + const partial = fixture() + partial.render(); await partial.settle(); partial.setScanStatus('partial') + await partial.button('searchApplications').props.onClick(); partial.render() + assert.ok(text(partial.tree()).includes('nativeScanPartial')) + assert.ok(text(partial.tree()).includes('nativeEvidenceStale')) + const failed = fixture() + failed.render(); await failed.settle(); failed.setScanStatus('error') + await failed.button('searchApplications').props.onClick(); failed.render() + assert.ok(text(failed.tree()).includes('nativeScanFailed')) + assert.equal(failed.calls.filter(c => c.url.endsWith('/apps/suggestions') && c.method === 'POST').length, 0) + + const late = fixture() + late.render(); await late.settle(); late.holdScan() + const scan = late.button('searchApplications').props.onClick() + late.render({vmid: 102}); await late.settle() + late.resolveScan(); await scan; late.render() + assert.ok(!text(late.tree()).includes('/opt/handmade/server'), 'late A scan cannot appear in B') + assert.equal(late.cachedWrites.length, 0) + assert.equal(late.calls.filter(c => c.url.endsWith('/apps/suggestions') && c.method === 'POST').length, 0) + + const saveLate = fixture() + saveLate.render(); await saveLate.settle() + await saveLate.button('searchApplications').props.onClick(); saveLate.render() + await saveLate.button('registerButton').props.onClick(); saveLate.render() + saveLate.holdSave(); const saving = saveLate.button('saveButton').props.onClick() + const priorWrites = saveLate.cachedWrites.length + saveLate.render({vmid: 102}); await saveLate.settle() + saveLate.resolveSave(); await saving; saveLate.render() + assert.equal(saveLate.cachedWrites.length, priorWrites, 'late save response does not update new target') + assert.equal(saveLate.calls.filter(c => /\/vms\/102\/apps$/.test(c.url) && c.method === 'POST').length, 0) + + const oci = fixture() + oci.render({vmid: 101, oci: {instance: true}, initialData: {sidecar: {apps: []}, suggestions: {oci_instance: {template_id: 'image-app'}, extras: []}}}) + await oci.settle() + await oci.button('refreshData').props.onClick() + assert.equal(oci.calls.filter(c => c.url.endsWith('/native-discovery') && c.method === 'POST').length, 0, 'OCI refresh never native-scans') + console.log('PASS native discovery: real panel Find → editor → Cancel/Save, no automatic scan, safe payload, CT switching and OCI refresh (fixtures)') +} +main().catch(error => {console.error(error); process.exitCode = 1}) From bbe811e8736c1578754ed18b12d52bbc7b1eb8da Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:18:17 +0000 Subject: [PATCH 04/43] fix: hide native discovery editor immediately on target change --- AppImage/components/lxc-app-panel.tsx | 2 +- tests/lxc_updates/test_native_app_discovery_ui.cjs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 666009cb..24003d66 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -1348,7 +1348,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } // ── Editor ───────────────────────────────────────────────────── - if (editing) { + if (editorOpen && editing) { const draft = editing.draft const method = draft.installed_via || "" const isPackaged = method === "dpkg" || method === "apk" diff --git a/tests/lxc_updates/test_native_app_discovery_ui.cjs b/tests/lxc_updates/test_native_app_discovery_ui.cjs index 51f759b7..3bb55dc4 100644 --- a/tests/lxc_updates/test_native_app_discovery_ui.cjs +++ b/tests/lxc_updates/test_native_app_discovery_ui.cjs @@ -134,6 +134,13 @@ async function main() { assert.ok(text(failed.tree()).includes('nativeScanFailed')) assert.equal(failed.calls.filter(c => c.url.endsWith('/apps/suggestions') && c.method === 'POST').length, 0) + const editorSwitch = fixture() + editorSwitch.render(); await editorSwitch.settle() + await editorSwitch.button('searchApplications').props.onClick(); editorSwitch.render() + await editorSwitch.button('registerButton').props.onClick(); editorSwitch.render() + editorSwitch.render({vmid: 102}) + assert.ok(!text(editorSwitch.tree()).includes('saveButton'), 'old editor must disappear in the very first render of another CT') + const late = fixture() late.render(); await late.settle(); late.holdScan() const scan = late.button('searchApplications').props.onClick() From 6d82cf4f15c5880876b6f5660a0d7806aab34652 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:53:01 +0000 Subject: [PATCH 05/43] fix: exclude native system service candidates --- AppImage/scripts/native_app_discovery.py | 19 ++++++++++++-- .../tests/test_native_app_discovery.py | 25 ++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index a87dfc6e..169fd4a2 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -62,6 +62,12 @@ _SYSTEM = { 'ip6tables', 'firewalld', 'ufw', 'resolvconf', 'wpa-supplicant', 'modemmanager', 'console-setup', 'keyboard-setup', 'plymouth', 'halt', 'reboot', 'shutdown', 'rc-local', 'ntpsec', 'networkd-dispatcher', 'busybox', 'atd', 'uuidd', + # Common packaged daemons are infrastructure, not a user-managed app. + 'nginx', 'apache', 'apache2', 'httpd', 'lighttpd', 'haproxy', 'traefik', + 'redis', 'redis-server', 'memcached', 'mysql', 'mysqld', 'mariadb', 'mariadbd', + 'postgres', 'postgresql', 'mongod', 'mongodb', 'rabbitmq-server', + 'elasticsearch', 'opensearch', 'fail2ban', 'named', 'bind9', 'dnsmasq', + 'avahi-daemon', 'smbd', 'nmbd', 'samba', 'vsftpd', 'proftpd', 'cups', 'tor', } _RUNTIMES = re.compile(r'(?:python[0-9.]*|node(?:js)?|sh|bash|dash|env|java|ruby|perl)\Z') @@ -205,9 +211,11 @@ def _path(value): def _system(name): raw = name.lower().removesuffix('.service') stem = raw.split('@')[0] - return raw.startswith('user@') or stem in _SYSTEM or stem.startswith(( + return (raw.startswith('user@') or stem in _SYSTEM + or (raw.startswith('php') and raw.endswith('-fpm')) + or stem.startswith(( 'systemd-', 'dbus-', 'getty', 'containerd-', 'apt-', 'dpkg-', 'e2scrub', - 'unattended-', 'ifup', 'ifdown', 'user@', 'btrfs-', 'lvm2-', 'lxc-')) + 'unattended-', 'ifup', 'ifdown', 'user@', 'btrfs-', 'lvm2-', 'lxc-'))) def _custom(path): @@ -240,6 +248,13 @@ def parse_records(text, kind): or install.startswith(('/usr/lib/systemd/', '/lib/systemd/', '/usr/lib/udev/', '/lib/udev/', '/usr/lib/apt/'))): continue + # /etc units are operator-owned. Do not infer an application from an + # arbitrary packaged systemd unit: without a package-db probe it is + # impossible to distinguish vendor infrastructure from a user app. + if (manager == 'systemd' + and source.startswith(('/usr/lib/systemd/system/', '/lib/systemd/system/')) + and not _custom(install)): + continue root = directory if _custom(directory) else str(Path(install).parent) # Generic runtime without an absolute application script is ambiguous. if runtime and not _custom(install): diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index cc1539b1..21962223 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -19,9 +19,9 @@ import native_app_discovery as native import lxc_apps -def service(name='handmade', exe='/opt/handmade/server', directory='/opt/handmade', manager='systemd', script=''): - path = ('/etc/systemd/system/' + name + '.service' if manager == 'systemd' - else '/etc/init.d/' + name) +def service(name='handmade', exe='/opt/handmade/server', directory='/opt/handmade', manager='systemd', script='', source=None): + path = source or ('/etc/systemd/system/' + name + '.service' if manager == 'systemd' + else '/etc/init.d/' + name) return '\t'.join(['S', path, manager, exe, script, directory]) + '\nEND\n' @@ -100,6 +100,25 @@ class DiscoveryFixtures(unittest.TestCase): self.assertEqual(result['candidates'], []) self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_common_system_daemons_and_vendor_units_have_zero_footprint_probes(self): + cases = [ + ('nginx', '/usr/sbin/nginx', 'openrc', None), + ('apache2', '/usr/sbin/apache2', 'systemd', None), + ('redis-server', '/usr/bin/redis-server', 'systemd', None), + ('mariadb', '/usr/sbin/mariadbd', 'systemd', None), + ('postgresql', '/usr/lib/postgresql/16/bin/postgres', 'systemd', None), + ('php8.3-fpm', '/usr/sbin/php-fpm8.3', 'systemd', None), + ('vendor-daemon', '/usr/bin/vendor-daemon', 'systemd', + '/usr/lib/systemd/system/vendor-daemon.service'), + ] + for name, exe, manager, source in cases: + with self.subTest(name=name): + self.calls.clear() + result = native.scan(101, self.runner( + service(name=name, exe=exe, manager=manager, source=source))) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_missing_remote_qemu_stopped_busy_oci_guards_precede_exec(self): scenarios = ['missing', 'stopped', 'busy', 'oci', 'invalid-oci', 'unmanaged'] for scenario in scenarios: From 61fae8f6f75b30840c65f28f660728653e61c701 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:20:40 +0000 Subject: [PATCH 06/43] fix: filter native discovery helper services --- AppImage/scripts/native_app_discovery.py | 43 ++++++++++++++-- .../tests/test_native_app_discovery.py | 49 +++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index 169fd4a2..35bb4f07 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -69,7 +69,13 @@ _SYSTEM = { 'elasticsearch', 'opensearch', 'fail2ban', 'named', 'bind9', 'dnsmasq', 'avahi-daemon', 'smbd', 'nmbd', 'samba', 'vsftpd', 'proftpd', 'cups', 'tor', } -_RUNTIMES = re.compile(r'(?:python[0-9.]*|node(?:js)?|sh|bash|dash|env|java|ruby|perl)\Z') +_RUNTIMES = re.compile(r'(?:python[0-9.]*|node(?:js)?|npm|pnpm|yarn|bun|sh|bash|dash|env|java|ruby|perl)\Z') +_PACKAGE_ROOT_RUNTIMES = {'node', 'nodejs', 'npm', 'pnpm', 'yarn', 'bun'} +_HELPER_ROLES = { + 'backup', 'bootstrap', 'bridge', 'cleanup', 'housekeeping', 'maintenance', + 'migrate', 'migration', 'restore', 'rotate', 'update', 'updater', 'upgrade', + 'watchdog', 'worker', +} class ProbeError(Exception): @@ -218,8 +224,23 @@ def _system(name): 'unattended-', 'ifup', 'ifdown', 'user@', 'btrfs-', 'lvm2-', 'lxc-'))) +def _helper(name): + """Reject service roles that support an application but are not one themselves.""" + raw = name.lower().removesuffix('.service') + if raw.endswith('@'): + return True # A systemd template is not a concrete running application. + return any(part in _HELPER_ROLES for part in re.split(r'[._-]+', raw) if part) + + def _custom(path): - return _path(path) and path.startswith(('/opt/', '/srv/', '/usr/local/')) + if not _path(path): + return False + if path.startswith(('/opt/', '/srv/', '/usr/local/')): + return True + # Native apps commonly keep their runtime below /home//. + # Do not accept a home directory itself: a second path component is required. + parts = path.split('/') + return len(parts) >= 4 and parts[1] == 'home' and bool(parts[2]) and bool(parts[3]) def parse_records(text, kind): @@ -240,10 +261,21 @@ def parse_records(text, kind): if service in seen_services: continue # /etc overrides vendor units; no duplicate installations. seen_services.add(service) - if not _NAME.fullmatch(service) or _system(service) or not _path(exe): + if (not _NAME.fullmatch(service) or _system(service) or _helper(service) + or not _path(exe)): continue - runtime = bool(_RUNTIMES.fullmatch(Path(exe).name)) - install = script if runtime else exe + runtime_name = Path(exe).name + runtime = bool(_RUNTIMES.fullmatch(runtime_name)) + if runtime and not _path(script): + # A Node/npm wrapper may keep its entry point relative to an + # operator-owned working directory. `package.json` is a bounded, + # declarative footprint that proves the application root without + # reading command arguments, environment or service secrets. + if runtime_name not in _PACKAGE_ROOT_RUNTIMES or not _custom(directory): + continue + install = str(Path(directory) / 'package.json') + else: + install = script if runtime else exe if (not _path(install) or _system(Path(install).name) or install.startswith(('/usr/lib/systemd/', '/lib/systemd/', '/usr/lib/udev/', '/lib/udev/', '/usr/lib/apt/'))): @@ -399,6 +431,7 @@ def scan(vmid, run=bounded_run): related[0].setdefault('listening_ports', []).extend(ports) elif not related and (_custom(exe) and _custom(directory) and not _system(Path(exe).name) + and not _helper(Path(exe).name) and not _RUNTIMES.fullmatch(Path(exe).name) and exe.startswith(directory.rstrip('/') + '/') and _NAME.fullmatch(Path(exe).name)): diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index 21962223..661abd95 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -119,6 +119,55 @@ class DiscoveryFixtures(unittest.TestCase): self.assertEqual(result['candidates'], []) self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_helper_services_and_templates_have_zero_footprint_probes(self): + cases = [ + ('netpulse-stable-update', '/bin/bash', '/opt/netpulse', '/opt/netpulse/stable-apply.sh'), + ('rent-tool-backup', '/bin/bash', '/home/app/rent-tool', '/home/app/rent-tool/scripts/backup-db.sh'), + ('keynest-update', '/bin/bash', '/opt/keynest', '/opt/keynest/keynest-update.sh'), + ('cortendesk-bootstrap-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/bootstrap-worker.sh'), + ('cortendesk-source-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/source-worker.sh'), + ('cortendesk-managed-password@', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/password-bridge.sh'), + ] + for name, exe, directory, script in cases: + with self.subTest(name=name): + self.calls.clear() + result = native.scan(101, self.runner(service( + name=name, exe=exe, directory=directory, script=script))) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + + def test_node_and_npm_wrappers_use_only_custom_package_roots(self): + cases = [ + ('keynest', '/usr/bin/node', '/opt/keynest'), + ('rent-tool', '/usr/local/bin/npm', '/home/app/rent-tool'), + ] + for name, exe, directory in cases: + with self.subTest(name=name): + self.calls.clear() + result = native.scan(101, self.runner( + service(name=name, exe=exe, directory=directory), + footprint='FILE\n{"version":"1.2.3"}')) + item, = result['candidates'] + self.assertEqual(item['name'], name) + self.assertEqual(item['installation_path'], directory + '/package.json') + self.assertEqual(item['installed_version'], '1.2.3') + footprint_calls = [call for call in self.calls if native.FOOTPRINT_SCRIPT in call] + self.assertEqual(len(footprint_calls), 1) + self.assertEqual(footprint_calls[0][-2:], [directory + '/package.json', directory]) + + self.calls.clear() + result = native.scan(101, self.runner(service( + name='ambiguous-home-runtime', exe='/usr/bin/node', directory='/home/app'))) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + + def test_helper_process_fallback_has_zero_footprint_probes(self): + self.calls.clear() + result = native.scan(101, self.runner( + services='END\n', processes='P\t123\t/opt/cortendesk/run-worker\t/opt/cortendesk\nEND\n')) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_missing_remote_qemu_stopped_busy_oci_guards_precede_exec(self): scenarios = ['missing', 'stopped', 'busy', 'oci', 'invalid-oci', 'unmanaged'] for scenario in scenarios: From e978bac91a8b1b6db4b18b6ffdbf3720c9a4c320 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:15:07 +0000 Subject: [PATCH 07/43] fix: detect native node release layouts --- AppImage/scripts/native_app_discovery.py | 97 +++++++++++++++++-- .../tests/test_native_app_discovery.py | 74 +++++++++++++- 2 files changed, 158 insertions(+), 13 deletions(-) diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index 35bb4f07..11f9acf1 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -207,6 +207,27 @@ if [ -f "$2/package.json" ]; then timeout -s KILL 1 head -c 8192 "$2/package.json" fi ''' +MANIFEST_FOOTPRINT_SCRIPT = r''' +command -v head >/dev/null && command -v readlink >/dev/null || exit 1 +# Resolve only the operator-owned working directory and its declared parent. +# A release symlink may change the concrete version directory, but it must not +# escape the bounded custom application root supplied by the host. +workdir=$(timeout -s KILL 1 readlink -f "$1") || exit 1 +anchor=$(timeout -s KILL 1 readlink -f "$2") || exit 1 +parent=$(timeout -s KILL 1 readlink -f "$3") || exit 1 +case "$anchor" in "$parent"/*) ;; *) printf 'OUTSIDE\n'; exit 0;; esac +case "$workdir" in "$anchor"|"$anchor"/*) ;; *) printf 'OUTSIDE\n'; exit 0;; esac +for relative in package.json server/package.json; do + candidate="$workdir/$relative" + [ -f "$candidate" ] && [ -r "$candidate" ] || continue + resolved=$(timeout -s KILL 1 readlink -f "$candidate") || exit 1 + case "$resolved" in "$workdir"/*) ;; *) printf 'OUTSIDE\n'; exit 0;; esac + printf 'MANIFEST\t%s\n' "$relative" + timeout -s KILL 1 head -c 8192 "$resolved" + exit 0 +done +printf 'MISSING\n' +''' def _path(value): @@ -243,6 +264,50 @@ def _custom(path): return len(parts) >= 4 and parts[1] == 'home' and bool(parts[2]) and bool(parts[3]) +def _manifest_bounds(executable, directory): + """Return a narrow, operator-owned boundary for a runtime manifest probe.""" + try: + shared = os.path.commonpath((executable, directory)) + except ValueError: + shared = '' + anchor = shared if _custom(shared) else directory + parts = anchor.split('/') + if anchor.startswith(('/opt/', '/srv/')): + parent = '/' + parts[1] + elif anchor.startswith('/usr/local/'): + parent = '/usr/local' + elif len(parts) >= 4 and parts[1] == 'home' and parts[2]: + parent = f'/home/{parts[2]}' + else: + return None + return anchor, parent + + +def _manifest_footprint(record, run, deadline): + """Read one of two fixed Node manifest locations without following an escape.""" + output = run(['pct', 'exec', str(record['vmid']), '--', 'timeout', '-s', 'KILL', '2', + 'sh', '-c', MANIFEST_FOOTPRINT_SCRIPT, 'discovery', + record['root'], record['manifest_anchor'], record['manifest_parent']], deadline) + header, separator, metadata = output.partition('\n') + if header == 'MISSING': + if metadata: + raise ProbeError('invalid_data') + return None, None + if header == 'OUTSIDE': + if metadata: + raise ProbeError('invalid_data') + raise ProbeError('unsafe_manifest_root') + prefix = 'MANIFEST\t' + relative = header.removeprefix(prefix) + if not header.startswith(prefix) or relative not in ('package.json', 'server/package.json'): + raise ProbeError('invalid_data') + if not separator: + raise ProbeError('invalid_data') + candidate_record = dict(record) + candidate_record['path'] = str(Path(record['root']) / relative) + return candidate_record, 'FILE\n' + metadata + + def parse_records(text, kind): lines = text.splitlines() if not lines or lines[-1] not in ('END', 'LIMIT'): @@ -266,14 +331,18 @@ def parse_records(text, kind): continue runtime_name = Path(exe).name runtime = bool(_RUNTIMES.fullmatch(runtime_name)) + manifest_bounds = None if runtime and not _path(script): # A Node/npm wrapper may keep its entry point relative to an - # operator-owned working directory. `package.json` is a bounded, - # declarative footprint that proves the application root without - # reading command arguments, environment or service secrets. + # operator-owned working directory. Only two fixed manifest + # locations can prove that root; no argv, environment, recursive + # search or guest-provided path is ever used. if runtime_name not in _PACKAGE_ROOT_RUNTIMES or not _custom(directory): continue install = str(Path(directory) / 'package.json') + manifest_bounds = _manifest_bounds(exe, directory) + if manifest_bounds is None: + continue else: install = script if runtime else exe if (not _path(install) or _system(Path(install).name) @@ -291,8 +360,11 @@ def parse_records(text, kind): # Generic runtime without an absolute application script is ambiguous. if runtime and not _custom(install): continue - records.append({'name': service, 'path': install, 'root': root, - 'service': source, 'manager': manager, 'executable': exe}) + record = {'name': service, 'path': install, 'root': root, + 'service': source, 'manager': manager, 'executable': exe} + if manifest_bounds: + record['manifest_anchor'], record['manifest_parent'] = manifest_bounds + records.append(record) elif kind == 'P' and len(parts) == 4 and parts[0] == 'P': _, pid, exe, directory = parts if not pid.isdigit() or not _path(exe) or not _path(directory): @@ -447,10 +519,17 @@ def scan(vmid, run=bounded_run): issues.append('candidate_limit') break try: - footprint = run(['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2', - 'sh', '-c', FOOTPRINT_SCRIPT, - 'discovery', record['path'], record['root']], deadline) - item = candidate(vmid, record, footprint) + probe_record = dict(record) + probe_record['vmid'] = vmid + if 'manifest_anchor' in record: + probe_record, footprint = _manifest_footprint(probe_record, run, deadline) + if probe_record is None: + continue + else: + footprint = run(['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2', + 'sh', '-c', FOOTPRINT_SCRIPT, + 'discovery', record['path'], record['root']], deadline) + item = candidate(vmid, probe_record, footprint) items.append(item) if item['metadata_issue']: issues.append(item['metadata_issue']) diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index 661abd95..4ff2f875 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -44,7 +44,8 @@ class DiscoveryFixtures(unittest.TestCase): native._locks.clear() self.calls = [] - def runner(self, services=None, processes='END\n', footprint='FILE\n', status='status: running', ports='END\n'): + def runner(self, services=None, processes='END\n', footprint='FILE\n', manifest=None, + status='status: running', ports='END\n'): def run(argv, deadline): self.calls.append(argv) self.assertLessEqual(deadline - time.monotonic(), native.TOTAL_SECONDS) @@ -59,6 +60,10 @@ class DiscoveryFixtures(unittest.TestCase): return processes if script == native.PORT_SCRIPT: return ports + if script == native.MANIFEST_FOOTPRINT_SCRIPT: + if manifest is not None: + return manifest + return 'MANIFEST\tpackage.json\n' + footprint.removeprefix('FILE\n') if script == native.FOOTPRINT_SCRIPT: return footprint self.fail('Unexpected probe') @@ -136,7 +141,7 @@ class DiscoveryFixtures(unittest.TestCase): self.assertEqual(result['candidates'], []) self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) - def test_node_and_npm_wrappers_use_only_custom_package_roots(self): + def test_node_and_npm_wrappers_use_only_bounded_manifest_roots(self): cases = [ ('keynest', '/usr/bin/node', '/opt/keynest'), ('rent-tool', '/usr/local/bin/npm', '/home/app/rent-tool'), @@ -151,14 +156,75 @@ class DiscoveryFixtures(unittest.TestCase): self.assertEqual(item['name'], name) self.assertEqual(item['installation_path'], directory + '/package.json') self.assertEqual(item['installed_version'], '1.2.3') - footprint_calls = [call for call in self.calls if native.FOOTPRINT_SCRIPT in call] + footprint_calls = [call for call in self.calls if native.MANIFEST_FOOTPRINT_SCRIPT in call] self.assertEqual(len(footprint_calls), 1) - self.assertEqual(footprint_calls[0][-2:], [directory + '/package.json', directory]) + if directory.startswith('/home/'): + parent = '/home/app' + else: + parent = '/opt' + self.assertEqual(footprint_calls[0][-3:], [directory, directory, parent]) self.calls.clear() result = native.scan(101, self.runner(service( name='ambiguous-home-runtime', exe='/usr/bin/node', directory='/home/app'))) self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.MANIFEST_FOOTPRINT_SCRIPT in call for call in self.calls)) + + def test_node_release_layout_uses_only_server_manifest_under_shared_root(self): + self.calls.clear() + result = native.scan(101, self.runner( + service(name='keynest', exe='/opt/keynest/node/bin/node', directory='/opt/keynest/current'), + manifest='MANIFEST\tserver/package.json\n{"version":"1.5.63"}')) + item, = result['candidates'] + self.assertEqual(item['name'], 'keynest') + self.assertEqual(item['installation_path'], '/opt/keynest/current/server/package.json') + self.assertEqual(item['installed_version'], '1.5.63') + manifest_calls = [call for call in self.calls if native.MANIFEST_FOOTPRINT_SCRIPT in call] + self.assertEqual(len(manifest_calls), 1) + self.assertEqual(manifest_calls[0][-3:], + ['/opt/keynest/current', '/opt/keynest', '/opt']) + + def test_node_release_layout_rejects_manifest_outside_operator_root(self): + self.calls.clear() + result = native.scan(101, self.runner( + service(name='outside-root', exe='/opt/outside/node/bin/node', directory='/opt/outside/current'), + manifest='OUTSIDE\n')) + self.assertEqual(result['candidates'], []) + self.assertIn('unsafe_manifest_root', result['issues']) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + + def test_actual_manifest_probe_accepts_release_and_rejects_symlink_escape(self): + parent = self.root / 'opt' + anchor = parent / 'keynest' + release = anchor / 'releases' / 'v1.5.63' + manifest = release / 'server' / 'package.json' + manifest.parent.mkdir(parents=True) + manifest.write_text('{"version":"1.5.63"}') + current = anchor / 'current' + current.symlink_to(release) + output = native.bounded_run( + ['sh', '-c', native.MANIFEST_FOOTPRINT_SCRIPT, 'fixture', + str(current), str(anchor), str(parent)], time.monotonic() + 3) + self.assertEqual(output, 'MANIFEST\tserver/package.json\n{"version":"1.5.63"}') + + outside = self.root / 'outside' / 'package.json' + outside.parent.mkdir() + outside.write_text('{"version":"not-readable"}') + manifest.unlink() + manifest.symlink_to(outside) + output = native.bounded_run( + ['sh', '-c', native.MANIFEST_FOOTPRINT_SCRIPT, 'fixture', + str(current), str(anchor), str(parent)], time.monotonic() + 3) + self.assertEqual(output, 'OUTSIDE\n') + self.assertNotIn('not-readable', output) + + def test_node_wrapper_with_no_fixed_manifest_is_not_an_error_or_candidate(self): + self.calls.clear() + result = native.scan(101, self.runner( + service(name='missing-manifest', exe='/opt/missing/node/bin/node', directory='/opt/missing/current'), + manifest='MISSING\n')) + self.assertEqual(result['status'], 'complete') + self.assertEqual(result['candidates'], []) self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) def test_helper_process_fallback_has_zero_footprint_probes(self): From 1f75147d5157eb524e297d25fa75130ba32be30f Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:47:53 +0000 Subject: [PATCH 08/43] fix: recognize Hestia panel and filter watch helpers --- AppImage/scripts/native_app_discovery.py | 28 +++++++++++++++++-- .../tests/test_native_app_discovery.py | 18 ++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index 11f9acf1..534c9125 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -74,8 +74,11 @@ _PACKAGE_ROOT_RUNTIMES = {'node', 'nodejs', 'npm', 'pnpm', 'yarn', 'bun'} _HELPER_ROLES = { 'backup', 'bootstrap', 'bridge', 'cleanup', 'housekeeping', 'maintenance', 'migrate', 'migration', 'restore', 'rotate', 'update', 'updater', 'upgrade', - 'watchdog', 'worker', + 'watch', 'watchdog', 'worker', } +_HESTIA_NGINX = '/usr/local/hestia/nginx/sbin/hestia-nginx' +_HESTIA_ROOT = '/usr/local/hestia' +_HESTIA_PANEL_PORT = 8083 class ProbeError(Exception): @@ -253,6 +256,17 @@ def _helper(name): return any(part in _HELPER_ROLES for part in re.split(r'[._-]+', raw) if part) +def _hestia_panel_process(executable, ports): + """Recognize only Hestia's own bound control-panel process. + + Hestia's generated init unit does not expose an ExecStart that the generic + service parser may treat as an application. Its panel process and port are + stable, local runtime metadata, so this stays narrower than accepting + arbitrary vendor services or a generic web server. + """ + return executable == _HESTIA_NGINX and _HESTIA_PANEL_PORT in ports + + def _custom(path): if not _path(path): return False @@ -367,7 +381,10 @@ def parse_records(text, kind): records.append(record) elif kind == 'P' and len(parts) == 4 and parts[0] == 'P': _, pid, exe, directory = parts - if not pid.isdigit() or not _path(exe) or not _path(directory): + # A process rooted at / is normally too ambiguous for generic + # discovery, but it is valid process metadata for the exact Hestia + # panel signature below. It never becomes a generic candidate. + if not pid.isdigit() or not _path(exe) or (directory != '/' and not _path(directory)): continue records.append({'pid': pid, 'executable': exe, 'root': directory}) elif kind == 'L' and len(parts) == 3 and parts[0] == 'L': @@ -407,7 +424,8 @@ def candidate(vmid, record, footprint): evidence.append({'kind': record['manager'], 'value': record['service']}) else: evidence.extend([{'kind': 'process_executable', 'value': record['process']}, - {'kind': 'working_directory', 'value': record['root']}]) + {'kind': 'working_directory', + 'value': record.get('process_root', record['root'])}]) ports = sorted(set(record.get('listening_ports') or [])) evidence.extend({'kind': 'tcp_listener', 'value': str(port)} for port in ports) return {'slug': identity, 'discovery_id': identity, 'source': 'native_metadata', @@ -501,6 +519,10 @@ def scan(vmid, run=bounded_run): ports = [entry['port'] for entry in listeners if entry['pid'] == process['pid']] if len(related) == 1: related[0].setdefault('listening_ports', []).extend(ports) + elif not related and _hestia_panel_process(exe, ports): + records.append({'name': 'Hestia', 'path': exe, 'root': _HESTIA_ROOT, + 'process': exe, 'process_root': directory, 'executable': exe, + 'listening_ports': ports}) elif not related and (_custom(exe) and _custom(directory) and not _system(Path(exe).name) and not _helper(Path(exe).name) diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index 4ff2f875..e9073f6e 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -132,6 +132,7 @@ class DiscoveryFixtures(unittest.TestCase): ('cortendesk-bootstrap-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/bootstrap-worker.sh'), ('cortendesk-source-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/source-worker.sh'), ('cortendesk-managed-password@', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/password-bridge.sh'), + ('miminails-watch', '/usr/local/bin/miminails-watch', '/usr/local/bin', ''), ] for name, exe, directory, script in cases: with self.subTest(name=name): @@ -141,6 +142,23 @@ class DiscoveryFixtures(unittest.TestCase): self.assertEqual(result['candidates'], []) self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_hestia_panel_is_recognized_only_from_its_exact_runtime_and_port(self): + hestia = '/usr/local/hestia/nginx/sbin/hestia-nginx' + process = f'P\t391\t{hestia}\t/\nEND\n' + result = native.scan(101, self.runner( + services='END\n', processes=process, ports='L\t391\t8083\nEND\n')) + item, = result['candidates'] + self.assertEqual(item['name'], 'Hestia') + self.assertEqual(item['installation_path'], hestia) + self.assertEqual(item['listening_ports'], [8083]) + self.assertIn({'kind': 'working_directory', 'value': '/'}, item['evidence']) + + self.calls.clear() + result = native.scan(101, self.runner( + services='END\n', processes=process, ports='L\t391\t8443\nEND\n')) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_node_and_npm_wrappers_use_only_bounded_manifest_roots(self): cases = [ ('keynest', '/usr/bin/node', '/opt/keynest'), From 20acf0852a4567ac937e2bdf95291091fd904656 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:15:43 +0000 Subject: [PATCH 09/43] fix: find Hestia behind bounded process metadata --- AppImage/scripts/native_app_discovery.py | 50 ++++++++++++++++++- .../tests/test_native_app_discovery.py | 14 ++++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index 534c9125..a807ead1 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -202,6 +202,30 @@ else exit 1 fi ''' +HESTIA_PANEL_SCRIPT = r''' +# The generic process collector intentionally stops after a bounded number of +# records and ignores shared listener ownership. Hestia's master/worker pair +# can therefore be outside that generic result. This narrow fallback never +# accepts a name alone: it requires Hestia's exact executable and its panel +# listener on the fixed local port. +[ -x /usr/local/hestia/nginx/sbin/hestia-nginx ] || { printf 'END\n'; exit 0; } +command -v pgrep >/dev/null && command -v readlink >/dev/null && command -v ss >/dev/null && command -v head >/dev/null || exit 1 +n=0 +for pid in $(pgrep -x hestia-nginx 2>/dev/null | head -n 9); do + case "$pid" in *[!0-9]*|'') continue;; esac + n=$((n+1)); [ "$n" -le 8 ] || { printf 'LIMIT\n'; exit 0; } + exe=$(timeout -s KILL 1 readlink "/proc/$pid/exe") || continue + [ "$exe" = /usr/local/hestia/nginx/sbin/hestia-nginx ] || continue + cwd=$(timeout -s KILL 1 readlink "/proc/$pid/cwd") || continue + if timeout -s KILL 1 ss -H -lntp | awk -v pid="$pid" ' + {port=$4; sub(/^.*:/,"",port) + if (port == 8083 && index($0, "pid=" pid ",")) found=1} + END {exit(found ? 0 : 1)}'; then + printf 'H\t%s\t%s\t%s\t8083\n' "$pid" "$exe" "$cwd" + fi +done +printf 'END\n' +''' FOOTPRINT_SCRIPT = r''' [ -f "$1" ] && [ -r "$1" ] || exit 1 printf 'FILE\n' @@ -393,6 +417,15 @@ def parse_records(text, kind): records.append({'pid': pid, 'port': int(port)}) else: raise ProbeError('invalid_data') + elif kind == 'H' and len(parts) == 5 and parts[0] == 'H': + _, pid, exe, directory, port = parts + if (pid.isdigit() and exe == _HESTIA_NGINX + and (directory == '/' or _path(directory)) + and port == str(_HESTIA_PANEL_PORT)): + records.append({'pid': pid, 'executable': exe, 'root': directory, + 'port': _HESTIA_PANEL_PORT}) + else: + raise ProbeError('invalid_data') else: raise ProbeError('invalid_data') return records, lines[-1] == 'LIMIT', len(lines) - 1 @@ -487,7 +520,7 @@ def _guard(vmid, deadline, run): def scan(vmid, run=bounded_run): deadline = time.monotonic() + TOTAL_SECONDS _guard(vmid, deadline, run) - records, issues, consumed, processes, listeners = [], [], 0, [], [] + records, issues, consumed, processes, listeners, hestia_panels = [], [], 0, [], [], [] for kind, script in (('S', SERVICE_SCRIPT), ('P', PROCESS_SCRIPT), ('L', PORT_SCRIPT)): try: if consumed >= MAX_RECORDS: @@ -512,6 +545,16 @@ def scan(vmid, run=bounded_run): issues.append('metadata_limit') except ProbeError as exc: issues.append(str(exc)) + try: + # This is outside the generic record budget: it returns only an exact, + # fixed Hestia signature, not additional arbitrary process metadata. + hestia_panels, limited, _ = parse_records(run( + ['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2', + 'sh', '-c', HESTIA_PANEL_SCRIPT, 'discovery'], deadline), 'H') + if limited: + issues.append('metadata_limit') + except ProbeError as exc: + issues.append(str(exc)) for process in processes: exe, directory = process['executable'], process['root'] related = [record for record in records if record['executable'] == exe and ( @@ -531,6 +574,11 @@ def scan(vmid, run=bounded_run): and _NAME.fullmatch(Path(exe).name)): records.append({'name': Path(exe).name, 'path': exe, 'root': directory, 'process': exe, 'executable': exe, 'listening_ports': ports}) + for panel in hestia_panels: + records.append({'name': 'Hestia', 'path': panel['executable'], 'root': _HESTIA_ROOT, + 'process': panel['executable'], 'process_root': panel['root'], + 'executable': panel['executable'], + 'listening_ports': [panel['port']]}) items, seen = [], set() for record in records: key = (record['path'], record['root']) diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index e9073f6e..f47c33c9 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -45,7 +45,7 @@ class DiscoveryFixtures(unittest.TestCase): self.calls = [] def runner(self, services=None, processes='END\n', footprint='FILE\n', manifest=None, - status='status: running', ports='END\n'): + status='status: running', ports='END\n', hestia_panels='END\n'): def run(argv, deadline): self.calls.append(argv) self.assertLessEqual(deadline - time.monotonic(), native.TOTAL_SECONDS) @@ -60,6 +60,8 @@ class DiscoveryFixtures(unittest.TestCase): return processes if script == native.PORT_SCRIPT: return ports + if script == native.HESTIA_PANEL_SCRIPT: + return hestia_panels if script == native.MANIFEST_FOOTPRINT_SCRIPT: if manifest is not None: return manifest @@ -144,21 +146,25 @@ class DiscoveryFixtures(unittest.TestCase): def test_hestia_panel_is_recognized_only_from_its_exact_runtime_and_port(self): hestia = '/usr/local/hestia/nginx/sbin/hestia-nginx' - process = f'P\t391\t{hestia}\t/\nEND\n' + panel = f'H\t391\t{hestia}\t/\t8083\nEND\n' result = native.scan(101, self.runner( - services='END\n', processes=process, ports='L\t391\t8083\nEND\n')) + services='END\n', processes='LIMIT\n', ports='END\n', hestia_panels=panel)) item, = result['candidates'] self.assertEqual(item['name'], 'Hestia') self.assertEqual(item['installation_path'], hestia) self.assertEqual(item['listening_ports'], [8083]) self.assertIn({'kind': 'working_directory', 'value': '/'}, item['evidence']) + self.assertIn('metadata_limit', result['issues']) self.calls.clear() result = native.scan(101, self.runner( - services='END\n', processes=process, ports='L\t391\t8443\nEND\n')) + services='END\n', processes='LIMIT\n', ports='END\n')) self.assertEqual(result['candidates'], []) self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + with self.assertRaises(native.ProbeError): + native.parse_records(f'H\t391\t{hestia}\t/\t8443\nEND\n', 'H') + def test_node_and_npm_wrappers_use_only_bounded_manifest_roots(self): cases = [ ('keynest', '/usr/bin/node', '/opt/keynest'), From 503302cea35c7b3b8b24fb546e9e36afffa33b81 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:25:17 +0000 Subject: [PATCH 10/43] fix: prefill verified Hestia panel endpoint --- AppImage/scripts/native_app_discovery.py | 12 +++++++++++- .../scripts/tests/test_native_app_discovery.py | 9 +++++++++ tests/lxc_updates/test_native_app_discovery_ui.cjs | 14 ++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index a807ead1..5ade84bd 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -460,11 +460,21 @@ def candidate(vmid, record, footprint): {'kind': 'working_directory', 'value': record.get('process_root', record['root'])}]) ports = sorted(set(record.get('listening_ports') or [])) + # Hestia's control panel is a narrow, verified exception to generic port + # inference: its own executable serves the panel on fixed HTTPS port 8083. + # Keep this declaration on the exact runtime signature so an unrelated + # application merely listening on 8083 never receives an HTTPS guess. + hestia_panel = (record['name'] == 'Hestia' + and record['path'] == _HESTIA_NGINX + and record['root'] == _HESTIA_ROOT + and _HESTIA_PANEL_PORT in ports) evidence.extend({'kind': 'tcp_listener', 'value': str(port)} for port in ports) return {'slug': identity, 'discovery_id': identity, 'source': 'native_metadata', 'name': record['name'], 'installed_version': version, 'evidence': evidence, - 'installation_path': record['path'], 'default_ports': [], + 'installation_path': record['path'], + 'default_ports': [_HESTIA_PANEL_PORT] if hestia_panel else [], 'listening_ports': ports, + **({'scheme': 'https'} if hestia_panel else {}), 'metadata_issue': metadata_issue, 'tracking_suggestion': ({'installed_via': 'manual', 'installed_version': version, 'detected_version': version, 'detector_verified': True} diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index f47c33c9..d1432b65 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -153,9 +153,18 @@ class DiscoveryFixtures(unittest.TestCase): self.assertEqual(item['name'], 'Hestia') self.assertEqual(item['installation_path'], hestia) self.assertEqual(item['listening_ports'], [8083]) + self.assertEqual(item['default_ports'], [8083]) + self.assertEqual(item['scheme'], 'https') self.assertIn({'kind': 'working_directory', 'value': '/'}, item['evidence']) self.assertIn('metadata_limit', result['issues']) + unrelated = native.candidate(101, { + 'name': 'other-panel', 'path': '/opt/other-panel/server', 'root': '/opt/other-panel', + 'process': '/opt/other-panel/server', 'listening_ports': [8083], + }, 'FILE\n') + self.assertEqual(unrelated['default_ports'], []) + self.assertNotIn('scheme', unrelated) + self.calls.clear() result = native.scan(101, self.runner( services='END\n', processes='LIMIT\n', ports='END\n')) diff --git a/tests/lxc_updates/test_native_app_discovery_ui.cjs b/tests/lxc_updates/test_native_app_discovery_ui.cjs index 3bb55dc4..06a77f2e 100644 --- a/tests/lxc_updates/test_native_app_discovery_ui.cjs +++ b/tests/lxc_updates/test_native_app_discovery_ui.cjs @@ -23,7 +23,7 @@ const candidate = { evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [], } -function fixture() { +function fixture({nativeCandidate = candidate} = {}) { let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [] const calls = [], cachedWrites = [] let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false} @@ -49,7 +49,7 @@ function fixture() { if (url.endsWith('/native-discovery')) { if (options.method === 'POST') { if (scanGate) await scanGate - native = {...native, status: scanStatus, candidates: [candidate]} + native = {...native, status: scanStatus, candidates: [nativeCandidate]} } return structuredClone(native) } @@ -123,6 +123,16 @@ async function main() { assert.equal(payload.repo, '') assert.equal(payload.upstream_type, '') + const hestia = fixture({nativeCandidate: { + ...candidate, name: 'Hestia', default_ports: [8083], scheme: 'https', + }}) + hestia.render(); await hestia.settle() + await hestia.button('searchApplications').props.onClick(); hestia.render() + await hestia.button('registerButton').props.onClick(); hestia.render() + await hestia.button('saveButton').props.onClick(); hestia.render() + const hestiaPayload = JSON.parse(hestia.calls.find(c => /\/apps$/.test(c.url) && c.method === 'POST').body) + assert.deepEqual(hestiaPayload.ports, [{port: 8083, scheme: 'https', web_path: ''}], + 'a verified Hestia panel pre-fills only its fixed HTTPS endpoint') const partial = fixture() partial.render(); await partial.settle(); partial.setScanStatus('partial') await partial.button('searchApplications').props.onClick(); partial.render() From c393a56b92d236080d8d9323db0076b2bb3f25e1 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:31:51 +0000 Subject: [PATCH 11/43] fix: preserve native discovery results after delayed cache reads --- AppImage/components/lxc-app-panel.tsx | 7 ++++++- .../test_native_app_discovery_ui.cjs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 24003d66..22154ef6 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -475,6 +475,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const [sidecar, setSidecar] = useState(seed?.sidecar ?? null) const [suggestions, setSuggestions] = useState(seed?.suggestions ?? null) const [nativeDiscovery, setNativeDiscovery] = useState(null) + const nativeRequest = useRef(0) const targetRef = useRef(vmid) targetRef.current = vmid const targetGeneration = useRef(0) @@ -485,6 +486,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } useEffect(() => { const generation = targetGeneration.current + const request = ++nativeRequest.current setNativeDiscovery(null) setEditing(null) setDetectionNotice(null) @@ -492,7 +494,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: setSaving(false) if (managed) return fetchApi(`/api/vms/${vmid}/apps/native-discovery`).then(result => { - if (targetRef.current === vmid && targetGeneration.current === generation) setNativeDiscovery(result) + if (targetRef.current === vmid && targetGeneration.current === generation + && nativeRequest.current === request) setNativeDiscovery(result) }).catch(() => { /* cached metadata is optional; never initiate a scan */ }) return () => { targetGeneration.current += 1 } }, [vmid, managed]) @@ -787,7 +790,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const searchInstalledApplications = async () => { const generation = targetGeneration.current + const request = ++nativeRequest.current const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation + && nativeRequest.current === request const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug)) setSearchingApplications(true) setDetectionNotice(null) diff --git a/tests/lxc_updates/test_native_app_discovery_ui.cjs b/tests/lxc_updates/test_native_app_discovery_ui.cjs index 06a77f2e..2ffa802e 100644 --- a/tests/lxc_updates/test_native_app_discovery_ui.cjs +++ b/tests/lxc_updates/test_native_app_discovery_ui.cjs @@ -28,6 +28,7 @@ function fixture({nativeCandidate = candidate} = {}) { const calls = [], cachedWrites = [] let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false} let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete' + let cacheGate, resolveCache, cachePending = false const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}} const react = { useState(initial) { @@ -47,6 +48,12 @@ function fixture({nativeCandidate = candidate} = {}) { const api = async (url, options = {}) => { calls.push({url, ...options}) if (url.endsWith('/native-discovery')) { + if (options.method !== 'POST' && cachePending) { + cachePending = false + const oldSnapshot = structuredClone(native) + await cacheGate + return oldSnapshot + } if (options.method === 'POST') { if (scanGate) await scanGate native = {...native, status: scanStatus, candidates: [nativeCandidate]} @@ -92,6 +99,8 @@ function fixture({nativeCandidate = candidate} = {}) { } return {render, settle, calls, cachedWrites, button, tree: () => tree, setNative: value => {native = value}, + holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})}, + resolveInitialCache: () => resolveCache(), setScanStatus: value => {scanStatus = value}, holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(), holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(), @@ -133,6 +142,15 @@ async function main() { const hestiaPayload = JSON.parse(hestia.calls.find(c => /\/apps$/.test(c.url) && c.method === 'POST').body) assert.deepEqual(hestiaPayload.ports, [{port: 8083, scheme: 'https', web_path: ''}], 'a verified Hestia panel pre-fills only its fixed HTTPS endpoint') + const delayedCache = fixture() + delayedCache.holdInitialCache() + delayedCache.render(); await delayedCache.settle() + await delayedCache.button('searchApplications').props.onClick(); delayedCache.render() + assert.ok(text(delayedCache.tree()).includes('/opt/handmade/server')) + delayedCache.resolveInitialCache(); await delayedCache.settle() + assert.ok(text(delayedCache.tree()).includes('/opt/handmade/server'), + 'a delayed initial cache read must not erase a completed explicit search') + const partial = fixture() partial.render(); await partial.settle(); partial.setScanStatus('partial') await partial.button('searchApplications').props.onClick(); partial.render() From 22098042ba637e283f50344292510d4780f3b724 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:53:30 +0000 Subject: [PATCH 12/43] fix: exclude Alpine service supervision from native discovery --- AppImage/scripts/native_app_discovery.py | 2 ++ .../scripts/tests/test_native_app_discovery.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index 5ade84bd..29a56b7b 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -46,6 +46,8 @@ _NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.+@-]{0,63}\Z') _VERSION = re.compile(r'v?\d+(?:\.\d+){1,4}(?:[-+._][A-Za-z0-9.-]+)?\Z') _SYSTEM = { 'ssh', 'sshd', 'cron', 'crond', 'postfix', 'rsyslog', 'syslog', 'syslog-ng', + # Alpine/OpenRC system logging and service supervision are not apps. + 'klogd', 'syslogd', 'openrc-user', 'runit', 'runsvdir', 'runsv', 'supervise-daemon', 'dbus', 'dbus-broker', 'systemd', 'udev', 'networking', 'network', 'networkmanager', 'dhcpcd', 'dhclient', 'chrony', 'chronyd', 'ntp', 'ntpd', 'openntpd', 'agetty', 'getty', 'login', 'polkit', 'polkitd', 'accounts-daemon', 'acpid', 'anacron', diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index d1432b65..5e90b4cf 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -126,6 +126,24 @@ class DiscoveryFixtures(unittest.TestCase): self.assertEqual(result['candidates'], []) self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_alpine_openrc_infrastructure_has_zero_footprint_probes(self): + # Actual Alpine 3.24.1 CT217 init metadata: klogd, user and runsvdir. + # The runsvdir definition can exist without /usr/bin/runsvdir installed; + # rejecting it must precede probing, not turn the scan into partial. + for name, executable in ( + ('klogd', '/sbin/klogd'), + ('user', '/usr/libexec/rc/bin/openrc-user'), + ('runsvdir', '/usr/bin/runsvdir'), + ('renamed-system-service', '/usr/libexec/rc/bin/openrc-user'), + ): + with self.subTest(name=name): + self.calls.clear() + result = native.scan(101, self.runner( + service(name=name, exe=executable, directory='', manager='openrc'))) + self.assertEqual(result['status'], 'complete') + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + def test_helper_services_and_templates_have_zero_footprint_probes(self): cases = [ ('netpulse-stable-update', '/bin/bash', '/opt/netpulse', '/opt/netpulse/stable-apply.sh'), From 7aec721eb5b8c2bc6616a3a136efa763478f6e21 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:23:12 +0000 Subject: [PATCH 13/43] fix: invalidate native scans across lifecycle changes and serialize updates --- AppImage/scripts/native_app_discovery.py | 75 ++++++++-- .../tests/test_native_app_discovery.py | 139 +++++++++++++++++- 2 files changed, 201 insertions(+), 13 deletions(-) diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index 29a56b7b..ac9daa9e 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -17,6 +17,7 @@ """Separate from catalog detection: callers must explicitly request a scan.""" import copy +from contextlib import contextmanager import fcntl import hashlib import json @@ -25,6 +26,7 @@ from pathlib import Path import re import selectors import signal +import stat import subprocess import threading import time @@ -40,6 +42,7 @@ OCI_ROOT = Path('/usr/local/share/proxmenux/oci/instances') UPDATE_LOCK_ROOT = Path('/run/lock') _cache = {} _locks = {} +_generations = {} _mutex = threading.Lock() _PATH = re.compile(r'/[A-Za-z0-9_.+/@-]{1,510}\Z') _NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.+@-]{0,63}\Z') @@ -509,7 +512,13 @@ def _guard(vmid, deadline, run): raise ProbeError('guest_busy') update_lock = UPDATE_LOCK_ROOT / f'proxmenux-lxc-update-{vmid}.lock' try: - with update_lock.open('rb') as handle: + fd = os.open(update_lock, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, 'rb') as handle: + info = os.fstat(handle.fileno()) + if not stat.S_ISREG(info.st_mode): + raise ProbeError('lock_unavailable') + if info.st_size: + raise ProbeError('guest_busy') try: fcntl.flock(handle, fcntl.LOCK_SH | fcntl.LOCK_NB) except BlockingIOError: @@ -529,9 +538,45 @@ def _guard(vmid, deadline, run): raise ProbeError('guest_stopped' if status == 'status: stopped' else 'status_unknown') +@contextmanager +def _scan_reservation(vmid): + """Exclude cooperating updates/control tasks for this bounded scan only. + + Keep an empty lock inode after first use, as the update runner does. + Never truncate a pending control-task reservation or unlink its inode. + """ + path = UPDATE_LOCK_ROOT / f'proxmenux-lxc-update-{vmid}.lock' + fd = None + try: + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600) + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise ProbeError('lock_unavailable') + try: + fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB) + except BlockingIOError: + raise ProbeError('guest_busy') from None + if os.fstat(fd).st_size: + raise ProbeError('guest_busy') + yield + except OSError: + raise ProbeError('lock_unavailable') from None + finally: + if fd is not None: + os.close(fd) + + def scan(vmid, run=bounded_run): deadline = time.monotonic() + TOTAL_SECONDS _guard(vmid, deadline, run) + with _scan_reservation(vmid): + result = _scan_metadata(vmid, deadline, run) + # External PVE lifecycle operations do not share our flock. Fail closed + # if the guest stopped, changed ownership or became locked meanwhile. + _guard(vmid, deadline, run) + return result + + +def _scan_metadata(vmid, deadline, run): records, issues, consumed, processes, listeners, hestia_panels = [], [], 0, [], [], [] for kind, script in (('S', SERVICE_SCRIPT), ('P', PROCESS_SCRIPT), ('L', PORT_SCRIPT)): try: @@ -637,6 +682,7 @@ def cached(vmid): def mark_stale(vmid): """Lifecycle events retire freshness, never collect new guest metadata.""" with _mutex: + _generations[int(vmid)] = _generations.get(int(vmid), 0) + 1 if int(vmid) in _cache: _cache[int(vmid)]['stale'] = True @@ -648,23 +694,28 @@ def discover(vmid): if not lock.acquire(blocking=False): return {**cached(vmid), 'status': 'busy', 'stale': True} try: + with _mutex: + generation = _generations.get(vmid, 0) try: result = scan(vmid) except ProbeError as exc: result = {**cached(vmid), 'status': 'error', 'issues': [str(exc)], 'stale': True} result.setdefault('scanned_at', time.time()) - with _mutex: - _cache[vmid] = copy.deepcopy(result) - return result - previous = cached(vmid) - if result['status'] != 'complete' and previous['candidates']: - # Partial output cannot prove that previously seen apps disappeared. - ids = {item['discovery_id'] for item in result['candidates']} - result['candidates'].extend(item for item in previous['candidates'] - if item['discovery_id'] not in ids) - result['candidates'] = result['candidates'][:MAX_CANDIDATES] - result['stale'] = True + # Comparing the generation and publishing must be atomic with lifecycle + # invalidation, including the first scan when no cache exists yet. with _mutex: + previous = copy.deepcopy(_cache.get(vmid)) + if _generations.get(vmid, 0) != generation: + result = {**(previous or {'vmid': vmid, 'candidates': [], + 'scanned_at': time.time()}), + 'status': 'error', 'issues': ['guest_changed'], 'stale': True} + elif result['status'] != 'complete' and previous and previous['candidates']: + # Partial output cannot prove that previously seen apps disappeared. + ids = {item['discovery_id'] for item in result['candidates']} + result['candidates'].extend(item for item in previous['candidates'] + if item['discovery_id'] not in ids) + result['candidates'] = result['candidates'][:MAX_CANDIDATES] + result['stale'] = True _cache[vmid] = copy.deepcopy(result) return result finally: diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index 5e90b4cf..397bb3fc 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -42,6 +42,7 @@ class DiscoveryFixtures(unittest.TestCase): self.addCleanup(context.stop) native._cache.clear() native._locks.clear() + native._generations.clear() self.calls = [] def runner(self, services=None, processes='END\n', footprint='FILE\n', manifest=None, @@ -375,6 +376,142 @@ class DiscoveryFixtures(unittest.TestCase): with patch.object(native, 'scan', return_value={**complete, 'candidates': []}): self.assertEqual(native.discover(101)['candidates'], []) + def test_lifecycle_during_first_or_cached_scan_cannot_publish_fresh_result(self): + complete = native.scan(101, self.runner()) + for prior in (None, complete): + with self.subTest(cached=prior is not None): + native._cache.clear() + if prior: + native._cache[101] = copy.deepcopy(prior) + entered, release = threading.Event(), threading.Event() + results = [] + def delayed(vmid): + entered.set() + if not release.wait(3): + raise AssertionError('scan release timed out') + return copy.deepcopy(complete) + with patch.object(native, 'scan', side_effect=delayed): + worker = threading.Thread(target=lambda: results.append(native.discover(101))) + worker.start() + try: + self.assertTrue(entered.wait(3)) + native.mark_stale(101) + finally: + release.set() + worker.join(3) + self.assertFalse(worker.is_alive()) + self.assertEqual(len(results), 1) + self.assertTrue(results[0]['stale']) + self.assertIn('guest_changed', results[0]['issues']) + self.assertEqual(results[0]['candidates'], prior['candidates'] if prior else []) + self.assertTrue(native.cached(101)['stale']) + with patch.object(native, 'scan', return_value=copy.deepcopy(complete)): + self.assertFalse(native.discover(101)['stale']) + with patch.object(native, 'scan', side_effect=lambda vmid: ( + native.mark_stale(102), copy.deepcopy(complete))[1]): + self.assertFalse(native.discover(101)['stale']) + + def test_scan_excludes_later_updater_and_releases_lock_after_success_or_failure(self): + path = self.root / 'proxmenux-lxc-update-101.lock' + for fail in (False, True): + with self.subTest(fail=fail): + runner = self.runner() + probes = [] + def checked(argv, deadline): + if argv[1] == 'exec': + attempt = subprocess.run(['flock', '-n', str(path), 'true']) + self.assertEqual(attempt.returncode, 1) + probes.append(argv) + if fail: + raise native.ProbeError('timeout') + return runner(argv, deadline) + native.scan(101, checked) + self.assertTrue(probes) + self.assertEqual(subprocess.run(['flock', '-n', str(path), 'true']).returncode, 0) + self.assertEqual(path.read_bytes(), b'') + + def test_pending_control_reservation_blocks_scan_without_touching_it(self): + path = self.root / 'proxmenux-lxc-update-101.lock' + original = b'{"task":"pending-control"}' + path.write_bytes(original) + with self.assertRaisesRegex(native.ProbeError, 'guest_busy'): + native.scan(101, self.runner()) + self.assertFalse(any(call[1] == 'exec' for call in self.calls)) + self.assertEqual(path.read_bytes(), original) + + def test_guest_stop_during_scan_never_returns_complete(self): + runner = self.runner() + statuses = iter(['status: running', 'status: stopped']) + def stopped(argv, deadline): + return next(statuses) if argv[1] == 'status' else runner(argv, deadline) + with self.assertRaisesRegex(native.ProbeError, 'guest_stopped'): + native.scan(101, stopped) + self.assertTrue(all(call[1] == 'exec' for call in self.calls)) + + def test_scan_reservation_closes_race_after_guard_and_releases_after_exception(self): + import lxc_check_job + path = self.root / 'proxmenux-lxc-update-101.lock' + runner = self.runner() + handles = [] + def starts_update(argv, deadline): + if argv[1] == 'status': + handles.append(lxc_check_job.acquire(101)) + return runner(argv, deadline) + with patch.object(lxc_check_job, 'LOCK_DIR', self.root): + try: + with self.assertRaisesRegex(native.ProbeError, 'guest_busy'): + native.scan(101, starts_update) + self.assertFalse(any(call[1] == 'exec' for call in self.calls)) + finally: + for handle in handles: + handle.close() + def crash(argv, deadline): + if argv[1] == 'exec': + with self.assertRaises(BlockingIOError): + lxc_check_job.acquire(101) + # Another CT must remain available. + lxc_check_job.acquire(102).close() + raise RuntimeError('unexpected probe failure') + return runner(argv, deadline) + with self.assertRaisesRegex(RuntimeError, 'unexpected'): + native.scan(101, crash) + lxc_check_job.acquire(101).close() + self.assertEqual(path.read_bytes(), b'') + + def test_failed_scan_releases_discovery_lock_and_keeps_prior_evidence(self): + complete = native.scan(101, self.runner()) + native._cache[101] = copy.deepcopy(complete) + with patch.object(native, 'scan', side_effect=RuntimeError('unexpected')): + with self.assertRaises(RuntimeError): + native.discover(101) + self.assertFalse(native._locks[101].locked()) + self.assertEqual(native.cached(101)['candidates'], complete['candidates']) + + def test_invalid_lock_inode_and_unreadable_config_fail_before_any_probe(self): + import os + path = self.root / 'proxmenux-lxc-update-101.lock' + target = self.root / 'unrelated' + target.write_bytes(b'keep') + for kind in ('symlink', 'fifo', 'directory'): + with self.subTest(kind=kind): + if kind == 'symlink': + path.symlink_to(target) + elif kind == 'fifo': + os.mkfifo(path) + else: + path.mkdir() + try: + with self.assertRaisesRegex(native.ProbeError, 'lock_unavailable'): + native.scan(101, self.runner()) + self.assertEqual(self.calls, []) + self.assertEqual(target.read_bytes(), b'keep') + finally: + path.rmdir() if kind == 'directory' else path.unlink() + with patch.object(Path, 'open', side_effect=PermissionError): + with self.assertRaisesRegex(native.ProbeError, 'config_unavailable'): + native.scan(101, self.runner()) + self.assertEqual(self.calls, []) + def test_same_ct_busy_and_different_ct_cache_isolation(self): lock = threading.Lock() lock.acquire() @@ -546,7 +683,7 @@ class Routes(unittest.TestCase): '_VM_APP_SUGGESTIONS_TTL': 300, '_vm_cache_get': lambda cache, vmid, ttl: cache.get(vmid), '_vm_cache_put': lambda cache, vmid, value: cache.update({vmid: value}), - '_pvesh_cache': {}, '_vm_disk_cache': {}, '_vm_cache_invalidate': Mock(), + '_pvesh_cache': {}, '_vm_disk_cache': {}, '_vm_schedule_cache': {}, '_vm_cache_invalidate': Mock(), '_invalidate_lxc_ip': Mock(), '_schedule_started_guest_refresh': Mock(), '_publish_guest_modal_cache_revision': Mock()} tree = ast.parse((SCRIPTS / 'flask_server.py').read_text()) From 3e1ae3c4ee009533a23c7f4ec59bd0126ca81c08 Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:49:55 +0200 Subject: [PATCH 14/43] Cache unchanged OCI config reads when opening Manage --- .github/workflows/test-oci-registries.yml | 6 + oci/remote/oci_carried_record.py | 51 +++++++- oci/tests/test_carried_config_cache.py | 134 ++++++++++++++++++++++ 3 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 oci/tests/test_carried_config_cache.py diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 9612c0d3..a5272e32 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -28,6 +28,12 @@ jobs: python-version: '3.13' - name: Install OCI test dependencies run: python3 -m pip install -r oci/requirements.txt + - name: Run carried record, recovery and configuration cache regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v + python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v - name: Run offline registry account tests env: PYTHONDONTWRITEBYTECODE: '1' diff --git a/oci/remote/oci_carried_record.py b/oci/remote/oci_carried_record.py index 725640dd..14375ef5 100644 --- a/oci/remote/oci_carried_record.py +++ b/oci/remote/oci_carried_record.py @@ -38,6 +38,8 @@ DIRECTORY = '.proxmenux' NAME = 'oci-record.json' KIND = 'proxmenux.oci-carried-record' STAMP = 'carried.sha256' +CONFIG_CACHE = 'carried-config.json' +LOCAL_CONFIGS = Path('/etc/pve/lxc') LIMIT = 16 * 1024 * 1024 STACK_CONTRACT = '/etc/pve/priv/proxmenux-stack-{}.json' NVIDIA_HOOK = re.compile(r'/usr/local/lib/proxmenux/oci/nvidia-mount-([a-f0-9]{64})\.sh') @@ -279,6 +281,47 @@ def read_stamp(path): return value if isinstance(value, dict) else {} +def configuration(root, vmid, verify=False): + """Reuse pct's formatted output only while its local source is unchanged. + + Opening Manage checks every carried copy. Starting a fresh pct process + for each unchanged guest dominates that check. The private host cache is + keyed by the complete native configuration, including notes, pending + changes and snapshot sections. Lifecycle verification always calls pct. + An unavailable source, invalid cache or concurrent edit falls back to pct. + """ + source = LOCAL_CONFIGS / f'{int(vmid)}.conf' + cache = instances.location(root, vmid).parent / CONFIG_CACHE + + def fingerprint(): + try: + return sha(source.read_bytes()) + except OSError: + return None + + before = fingerprint() + if before is not None and not verify: + try: + if not cache.is_symlink() and cache.stat().st_size <= LIMIT: + value = json.loads(cache.read_text()) + if (isinstance(value, dict) and value.get('schema_version') == 1 + and value.get('source_sha256') == before + and isinstance(value.get('config'), str) and value['config'] + and fingerprint() == before): + return value['config'] + except (OSError, ValueError): + pass + result = _run('pct', 'config', str(vmid)) + if result.returncode != 0: + return None + # Do not associate output from one revision with another revision's hash. + if before is not None and fingerprint() == before: + with contextlib.suppress(OSError, ValueError): + instances.write(cache, {'schema_version': 1, 'source_sha256': before, + 'config': result.stdout}) + return result.stdout + + def superseded(record, existing, stamp): """Whether the container carries a copy this host did not write, with a record that differs from the one of this host. That is a container that @@ -308,10 +351,10 @@ def carry(root, vmid, mount_stopped=True, verify=False, adopted=False): if record.get('status') != 'installed' or record.get('pending_transaction') \ or record.get('pending_stack_transaction'): return 'skipped' - result = _run('pct', 'config', str(vmid)) - if result.returncode != 0 or instances.identity(result.stdout.encode()) != record['installation_id']: + config = configuration(root, vmid, verify) + if config is None or instances.identity(config.encode()) != record['installation_id']: return 'skipped' - value = bundle(record, result.stdout) + value = bundle(record, config) expected = digest(value) record_path = instances.location(root, vmid) path = record_path.parent / STAMP @@ -340,7 +383,7 @@ def carry(root, vmid, mount_stopped=True, verify=False, adopted=False): else: value['saved_at'] = datetime.datetime.now(datetime.timezone.utc).isoformat() value['generation'] = uuid.uuid4().hex - write_copy(rootfs, value, mapped_root(result.stdout)) + write_copy(rootfs, value, mapped_root(config)) stamp = {'generation': value['generation']} outcome = 'carried' if outcome == 'stale': diff --git a/oci/tests/test_carried_config_cache.py b/oci/tests/test_carried_config_cache.py new file mode 100644 index 00000000..026a3598 --- /dev/null +++ b/oci/tests/test_carried_config_cache.py @@ -0,0 +1,134 @@ +"""A fast Manage entry still detects config edits and verifies lifecycle work.""" +import contextlib +import copy +import json +import os +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) +import oci_carried_record as carried + + +class CarriedConfigCacheTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'instances' + self.source = Path(self.tmp.name) / 'lxc' + self.source.mkdir() + self.config = self.source / '120.conf' + self.config.write_text('# proxmenux-instance=example\nunprivileged: 1\n') + (self.root / '120').mkdir(parents=True) + self.cache = self.root / '120' / carried.CONFIG_CACHE + self.patch = patch.object(carried, 'LOCAL_CONFIGS', self.source) + self.patch.start() + self.addCleanup(self.patch.stop) + + def response(self, text='description: proxmenux-instance=example\nunprivileged: 1\n', status=0): + return SimpleNamespace(returncode=status, stdout=text) + + def test_warm_reads_avoid_pct_and_cache_is_private(self): + with patch.object(carried, '_run', return_value=self.response()) as run: + first = carried.configuration(self.root, 120) + self.assertEqual(carried.configuration(self.root, 120), first) + run.assert_called_once_with('pct', 'config', '120') + self.assertEqual(self.cache.stat().st_mode & 0o777, 0o600) + + def test_native_edits_invalidate_even_when_size_and_mtime_are_restored(self): + with patch.object(carried, '_run', side_effect=[self.response(), self.response('new')]) as run: + carried.configuration(self.root, 120) + info = self.config.stat() + self.config.write_text(self.config.read_text().replace('example', 'changed')) + os.utime(self.config, ns=(info.st_atime_ns, info.st_mtime_ns)) + self.assertEqual(carried.configuration(self.root, 120), 'new') + self.assertEqual(run.call_count, 2) + + def test_verify_forces_a_fresh_pct_read_with_a_warm_cache(self): + with patch.object(carried, '_run', side_effect=[self.response(), self.response('verified')]) as run: + carried.configuration(self.root, 120) + self.assertEqual(carried.configuration(self.root, 120, verify=True), 'verified') + self.assertEqual(run.call_count, 2) + + def test_missing_native_source_never_uses_or_populates_a_cache(self): + self.config.unlink() + with patch.object(carried, '_run', return_value=self.response()) as run: + carried.configuration(self.root, 120) + carried.configuration(self.root, 120) + self.assertEqual(run.call_count, 2) + self.assertFalse(self.cache.exists()) + + def test_concurrent_edit_does_not_cache_output_for_the_wrong_revision(self): + def command(*args): + self.config.write_text('changed while pct was reading\n') + return self.response() + with patch.object(carried, '_run', side_effect=command): + carried.configuration(self.root, 120) + self.assertFalse(self.cache.exists()) + + def test_edit_during_cache_lookup_falls_back_to_pct(self): + with patch.object(carried, '_run', return_value=self.response()): + carried.configuration(self.root, 120) + read_text = Path.read_text + def edit_during_read(path, *args, **kwargs): + text = read_text(path, *args, **kwargs) + if path == self.cache: + self.config.write_text('changed while reading the cache\n') + return text + with patch.object(Path, 'read_text', edit_during_read), \ + patch.object(carried, '_run', return_value=self.response('fresh')) as run: + self.assertEqual(carried.configuration(self.root, 120), 'fresh') + run.assert_called_once_with('pct', 'config', '120') + + def test_corrupt_cache_and_pct_failure_are_not_accepted(self): + for text in ('not json', '[]', '{"schema_version": 999}'): + with self.subTest(cache=text): + self.cache.write_text(text) + with patch.object(carried, '_run', return_value=self.response(status=1)) as run: + self.assertIsNone(carried.configuration(self.root, 120)) + run.assert_called_once() + self.assertEqual(self.cache.read_text(), text) + + def test_symlink_cache_does_not_read_or_replace_its_target(self): + target = Path(self.tmp.name) / 'outside' + target.write_text('private original') + self.cache.symlink_to(target) + with patch.object(carried, '_run', return_value=self.response()) as run: + self.assertEqual(carried.configuration(self.root, 120), self.response().stdout) + run.assert_called_once() + self.assertTrue(self.cache.is_symlink()) + self.assertEqual(target.read_text(), 'private original') + + def test_cached_config_still_detects_a_restored_container_record(self): + installation = '488ed3cb-1145-477a-b90f-c46777d69fb2' + record = {'schema_version': 1, 'vmid': 120, 'installation_id': installation, + 'status': 'installed', 'deployment': {'hostname': 'current'}} + carried.instances.write(carried.instances.location(self.root, 120), record) + native = f'description: proxmenux-instance={installation}\n' + with patch.object(carried, '_run', return_value=self.response(native)): + carried.configuration(self.root, 120) + rootfs = Path(self.tmp.name) / 'rootfs' + rootfs.mkdir() + previous = copy.deepcopy(record) + previous['deployment']['hostname'] = 'restored-snapshot' + carried.write_copy(rootfs, {'kind': carried.KIND, 'schema_version': 1, + 'record': previous, 'generation': 'restored-generation'}, + (os.getuid(), os.getgid())) + stamp = self.root / '120' / carried.STAMP + stamp.write_text(json.dumps({'generation': 'host-generation'})) + with patch.object(carried, '_run', side_effect=AssertionError('cached config expected')), \ + patch.object(carried, 'read_cluster', return_value=None), \ + patch.object(carried, 'running_pid', return_value=123), \ + patch.object(carried, 'container_root', return_value=contextlib.nullcontext(rootfs)): + self.assertEqual(carried.carry(self.root, 120, mount_stopped=False), 'stale') + self.assertFalse(carried.instances.location(self.root, 120).exists()) + self.assertTrue((self.root / '120' / f'retired-{installation}.json').exists()) + + +if __name__ == '__main__': + unittest.main() From 23902575154b2835d0e12df0285b99c2c5937fbc Mon Sep 17 00:00:00 2001 From: Jonatan Date: Sat, 17 Oct 2026 15:53:33 +0200 Subject: [PATCH 15/43] fix(oci): skip preparation of read-only volumes --- .github/workflows/test-oci-registries.yml | 7 + oci/remote/install_oci.sh | 11 ++ oci/tests/test_readonly_volume_preparation.py | 187 ++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 oci/tests/test_readonly_volume_preparation.py diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 9612c0d3..dd39bded 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -28,6 +28,13 @@ jobs: python-version: '3.13' - name: Install OCI test dependencies run: python3 -m pip install -r oci/requirements.txt + - name: Run read-only volume preparation regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + test -x /bin/bash + test -x /usr/bin/jq + python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v - name: Run offline registry account tests env: PYTHONDONTWRITEBYTECODE: '1' diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 7d1d8e85..5d39523a 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -1230,6 +1230,17 @@ apply_installer_profile() { fi [[ -d ${rootfs}${target} ]] \ || { oci_log "The volume to prepare does not exist: $target"; failed=1; break; } + # A reused volume may intentionally be mounted read-only (for example, + # /run/secrets). Do not remove files or change ownership/mode on it. + # Use the most specific containing mount: an RW child can override RO. + if [[ $(jq -r --arg target "$target" ' + [.mounts[]? | select(.container_path as $path | + $target == $path or ($target | startswith($path + "/")))] + | sort_by(.container_path | length) | last | .read_only // false' \ + "$DEPLOYMENT_FILE") == true ]]; then + oci_log "Skipping preparation of read-only volume: $target" + continue + fi if [[ $remove_lost_found == true ]]; then rm -rf "${rootfs}${target}/lost+found" || { failed=1; break; } fi diff --git a/oci/tests/test_readonly_volume_preparation.py b/oci/tests/test_readonly_volume_preparation.py new file mode 100644 index 00000000..37f67011 --- /dev/null +++ b/oci/tests/test_readonly_volume_preparation.py @@ -0,0 +1,187 @@ +"""Run the installer's volume preparation loop against filesystem fixtures. + +Write doubles reject every mutation of the protected directory, including +lost+found removal. Writable paths still run real rm/chmod; chown is recorded +without requiring root. These tests never invoke Proxmox or touch a container. +""" + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +INSTALLER = ROOT / "remote/install_oci.sh" + + +def preparation_loop(): + source = INSTALLER.read_text() + start = source.index(" if (( failed == 0 )) && [[ $(jq -r '.stack_managed") + end = source.index("\n if (( failed == 0 && tls_count == 1 )); then", start) + return source[start:end] + + +def preparation(target): + return { + "container_path": target, + "remove_lost_found": True, + "owner_strategy": "mapped-application-user", + "mode": "0750", + } + + +class ReadOnlyVolumePreparationTests(unittest.TestCase): + def run_case(self, mounts, *, target="/run/secrets", read_only=True, + preparations=None): + preparations = preparations if preparations is not None else [preparation(target)] + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) + rootfs = fixture / "rootfs" + for item in preparations: + path = rootfs / item["container_path"].lstrip("/") + path.mkdir(parents=True, exist_ok=True) + (path / "lost+found").mkdir(exist_ok=True) + protected = rootfs / target.lstrip("/") + protected.mkdir(parents=True, exist_ok=True) + sentinel = protected / "sentinel" + sentinel.write_bytes(b"existing secret\n") + sentinel.chmod(0o600) + protected.chmod(0o700) + before = sentinel.stat() + template = {"proxmox": {"installer_profile": { + "volume_preparations": preparations, + }}} + (fixture / "template.json").write_text(json.dumps(template)) + (fixture / "deployment.json").write_text(json.dumps({"mounts": mounts})) + script = r'''set -Eeuo pipefail +rootfs="$FIXTURE/rootfs" +TEMPLATE_FILE="$FIXTURE/template.json" +DEPLOYMENT_FILE="$FIXTURE/deployment.json" +HOST_BIND_UID=110001; HOST_BIND_GID=110001 +HOST_ROOT_UID=100000; HOST_ROOT_GID=100000 +failed=0; volumes_prepared=0 +oci_log() { printf 'log %s\n' "$*"; } +write_op() { + local operation=$1 + shift + local path=${!#} + printf 'WRITE %s %s\n' "$operation" "$path" + if [[ -n $PROTECTED && ( $path == "$PROTECTED" || $path == "$PROTECTED/"* ) ]]; then + printf 'Read-only file system\n' >&2 + return 1 + fi + case "$operation" in + rm|chmod) command "$operation" "$@" ;; + chown) return 0 ;; + esac +} +chown() { write_op chown "$@"; } +chmod() { write_op chmod "$@"; } +rm() { write_op rm "$@"; } +''' + preparation_loop() + r''' +printf 'failed=%s prepared=%s\n' "$failed" "$volumes_prepared" +test "$failed" = 0 +''' + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, + env=dict(os.environ, FIXTURE=str(fixture), + PROTECTED=str(protected) if read_only else ""), + timeout=15, + ) + self.assertEqual(sentinel.read_bytes(), b"existing secret\n") + after = sentinel.stat() + self.assertEqual( + (before.st_ino, before.st_uid, before.st_gid, before.st_mode, before.st_mtime_ns), + (after.st_ino, after.st_uid, after.st_gid, after.st_mode, after.st_mtime_ns), + ) + if read_only: + self.assertEqual(protected.stat().st_mode & 0o777, 0o700) + self.assertTrue((protected / "lost+found").is_dir()) + elif "prepared=1" in result.stdout: + self.assertEqual(protected.stat().st_mode & 0o777, 0o750) + self.assertFalse((protected / "lost+found").exists()) + return result + + def assert_skipped(self, result): + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertNotIn("WRITE ", result.stdout) + self.assertIn("Skipping preparation of read-only volume", result.stdout) + self.assertIn("prepared=0", result.stdout) + + def assert_prepared(self, result): + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + for operation in ("rm", "chown", "chmod"): + self.assertIn(f"WRITE {operation} ", result.stdout) + self.assertIn("prepared=1", result.stdout) + + def test_read_only_managed_and_bind_volumes_receive_no_writes(self): + for mount_type in ("managed-volume", "host-bind"): + with self.subTest(mount_type=mount_type): + self.assert_skipped(self.run_case([ + {"container_path": "/run/secrets", "type": mount_type, "read_only": True}, + ])) + + def test_read_only_parent_protects_nested_preparation(self): + self.assert_skipped(self.run_case([ + {"container_path": "/run", "read_only": True}, + ])) + + def test_writable_child_overrides_read_only_parent_regardless_of_order(self): + mounts = [ + {"container_path": "/run", "read_only": True}, + {"container_path": "/run/secrets", "read_only": False}, + ] + for ordered in (mounts, mounts[::-1]): + with self.subTest(mounts=ordered): + self.assert_prepared(self.run_case(ordered, read_only=False)) + + def test_child_without_read_only_flag_overrides_read_only_parent(self): + self.assert_prepared(self.run_case([ + {"container_path": "/run", "read_only": True}, + {"container_path": "/run/secrets"}, + ], read_only=False)) + + def test_read_only_child_overrides_writable_parent_regardless_of_order(self): + mounts = [ + {"container_path": "/run/secrets", "read_only": True}, + {"container_path": "/run", "read_only": False}, + ] + for ordered in (mounts, mounts[::-1]): + with self.subTest(mounts=ordered): + self.assert_skipped(self.run_case(ordered)) + + def test_writable_volume_is_still_prepared(self): + self.assert_prepared(self.run_case([ + {"container_path": "/data", "read_only": False}, + ], target="/data", read_only=False)) + + def test_missing_read_only_flag_defaults_to_writable(self): + self.assert_prepared(self.run_case([ + {"container_path": "/data"}, + ], target="/data", read_only=False)) + + def test_unrelated_mount_with_similar_prefix_does_not_skip_preparation(self): + self.assert_prepared(self.run_case([ + {"container_path": "/run/secret", "read_only": True}, + ], read_only=False)) + + def test_directory_outside_mounts_is_still_prepared(self): + self.assert_prepared(self.run_case([], read_only=False)) + + def test_skipping_read_only_volume_does_not_skip_following_writable_volume(self): + result = self.run_case([ + {"container_path": "/run/secrets", "read_only": True}, + {"container_path": "/data", "read_only": False}, + ], preparations=[preparation("/run/secrets"), preparation("/data")]) + self.assert_prepared(result) + self.assertIn("Skipping preparation of read-only volume: /run/secrets", result.stdout) + for line in result.stdout.splitlines(): + if line.startswith("WRITE "): + self.assertTrue(line.endswith("/data") or line.endswith("/data/lost+found"), line) + + +if __name__ == "__main__": + unittest.main() From 1c4220316641b42729bbfe471542bbcfcfed00ab Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:17:27 +0200 Subject: [PATCH 16/43] Add selective settings menu for OCI modifications --- .github/workflows/test-oci-registries.yml | 8 + lang/de.json | 9 + lang/es.json | 9 + lang/fr.json | 9 + lang/it.json | 9 + lang/pt.json | 9 + lang/sk.json | 9 + lang/sv.json | 9 + oci/src/proxmenux_oci/management.py | 21 +-- oci/src/proxmenux_oci/recreation.py | 166 ++++++++++++++---- oci/tests/test_modify_menu.py | 201 ++++++++++++++++++++++ oci/tests/test_shared_directories.py | 14 +- 12 files changed, 415 insertions(+), 58 deletions(-) create mode 100644 oci/tests/test_modify_menu.py diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 9612c0d3..2a200d9c 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -32,3 +32,11 @@ jobs: env: PYTHONDONTWRITEBYTECODE: '1' run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v + - name: Run OCI modification menu regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v + python3 -m unittest discover -s oci/tests -p test_recreation.py -v + python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v + python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v diff --git a/lang/de.json b/lang/de.json index 8d643f1a..becced3a 100644 --- a/lang/de.json +++ b/lang/de.json @@ -749,6 +749,7 @@ "Cannot verify the reused disk:": "Kann die wiederverwendete Festplatte nicht überprüfen:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Das Bildarchiv kann nicht in {storage} geschrieben werden. Überprüfen Sie den Speicherzugriff und den Bereitstellungsstatus.", "Capabilities cannot be kept and all dropped at the same time": "Fähigkeiten können nicht gehalten werden und alle gleichzeitig fallen gelassen werden", + "Catalog options": "Katalogoptionen", "Category": "Kategorie", "Caution: Maximum mode generates more heat.": "Achtung: Im Maximalmodus entsteht mehr Wärme.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Ceph-Prüfung durch Benutzerflag übersprungen (--ignore-ceph-check)", @@ -883,6 +884,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Wählen Sie einen Zeitplan (systemd-Kalender-Ereignis-Syntax):", "Choose a script to execute:": "Wählen Sie ein auszuführendes Skript aus:", "Choose a server:": "Wählen Sie einen Server:", + "Choose a setting. Changes are applied only after the final review.": "Wähle eine Einstellung. Änderungen werden erst nach der abschließenden Prüfung angewendet.", "Choose a share to mount:": "Wählen Sie eine Freigabe zum Mounten aus:", "Choose a specific supported GPU or USB node": "Wählen Sie einen bestimmten unterstützten GPU- oder USB-Knoten aus", "Choose action for this controller/NVMe:": "Wählen Sie eine Aktion für diesen Controller/NVMe:", @@ -1766,6 +1768,8 @@ "Disabling and stopping rpcbind service and socket...": "Rpcbind-Dienst und Socket deaktivieren und stoppen ...", "Disabling non-free firmware warnings...": "Warnungen zu nicht kostenloser Firmware werden deaktiviert...", "Disabling portmapper/rpcbind for security...": "Portmapper/rpcbind wird aus Sicherheitsgründen deaktiviert...", + "Discard changes": "Änderungen verwerfen", + "Discard unapplied changes?": "Noch nicht angewendete Änderungen verwerfen?", "Discover targets on portal:": "Entdecken Sie Ziele auf dem Portal:", "Discovering iSCSI targets...": "iSCSI-Ziele werden ermittelt...", "Discovery successful": "Entdeckung erfolgreich", @@ -4261,6 +4265,7 @@ "No password provided.": "Kein Passwort angegeben.", "No paths available": "Keine Pfade verfügbar", "No paths selected. Select at least one path.": "Keine Pfade ausgewählt. Wählen Sie mindestens einen Pfad aus.", + "No pending changes": "Keine ausstehenden Änderungen", "No pending paths to schedule for reboot.": "Keine ausstehenden Pfade zum Planen eines Neustarts.", "No permanent NFS mounts configured.": "Keine permanenten NFS-Mounts konfiguriert.", "No permanent Samba mounts configured.": "Keine permanenten Samba-Mounts konfiguriert.", @@ -4402,6 +4407,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM entfernt (Marionettenartefakte müssen möglicherweise manuell bereinigt werden)", "OVH server detected": "OVH-Server erkannt", "OVH server detection and RTM installation process completed": "OVH-Servererkennung und RTM-Installationsprozess abgeschlossen", + "Object detector": "Objekterkennung", "Object detector for Frigate": "Objektdetektor für Frigate", "Observer is not responding on port 4357": "Beobachter reagiert nicht auf Port 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian ist eine Notiz-App, mit der Sie Ihre Notizen auf Ihrem Gerät erstellen, verknüpfen und organisieren können, mit Hunderten von Plugins und Themes, um Ihren Workflow anzupassen. Sie können Ihre Notizen auch online veröffentlichen, offline darauf zugreifen und sie sicher mit einer End-to-End-Verschlüsselung synchronisieren.", @@ -5328,6 +5334,7 @@ "Reverting ZFS ARC tuning...": "ZFS ARC-Tuning wird zurückgesetzt...", "Reverting pigz wrapper...": "Pigz-Wrapper wird zurückgesetzt...", "Reverting vzdump speed tuning...": "vzdump-Geschwindigkeitsoptimierung wird zurückgesetzt...", + "Review and apply": "Prüfen und anwenden", "Review external OCI changes": "Überprüfen Sie externe OCI-Änderungen", "Review package upgrade": "Paketaktualisierung prüfen", "Review passthrough config files": "Überprüfen Sie die Passthrough-Konfigurationsdateien", @@ -5787,6 +5794,7 @@ "ShareCoveX panel": "ShareCoveX-Panel", "Shared Directory Ready:": "Bereit für das freigegebene Verzeichnis:", "Shared Group": "Geteilte Gruppe", + "Shared directories": "Freigegebene Verzeichnisse", "Shared directory created:": "Gemeinsames Verzeichnis erstellt:", "Shared directory for consume and export": "Gemeinsames Verzeichnis für Konsum und Export", "Shared directory for copy/sync operations": "Gemeinsames Verzeichnis für Copy/Sync operations", @@ -6997,6 +7005,7 @@ "Unable to identify the root filesystem. Aborting.": "Das Root-Dateisystem konnte nicht identifiziert werden. Abbruch.", "Unable to resolve system ZFS pool disks. Aborting.": "System-Pool-Festplatten ZFS können nicht aufgelöst werden. Abbruch.", "Unable to resolve system disk topology. Aborting.": "Die Topologie der Systemfestplatte kann nicht aufgelöst werden. Abbruch.", + "Unapplied changes": "Noch nicht angewendete Änderungen", "Understand the security implications of privileged containers": "Verstehen Sie die Sicherheitsauswirkungen privilegierter Container", "Unexpected Proxmox inventory; recovery blocked": "Unerwartetes Proxmox-Inventar; Erholung blockiert", "Unexpected formatting directory in the new Valkey volume": "Unerwartetes Formatierungsverzeichnis im neuen Valkey-Volume", diff --git a/lang/es.json b/lang/es.json index de4d581e..fa741cf8 100644 --- a/lang/es.json +++ b/lang/es.json @@ -748,6 +748,7 @@ "Cannot verify the reused disk:": "No se puede verificar el disco reutilizado:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "No se puede escribir el archivo de la imagen en {storage}. Comprueba el acceso al almacenamiento y que esté montado.", "Capabilities cannot be kept and all dropped at the same time": "No se pueden mantener las capacidades y todas se disminuyen al mismo tiempo", + "Catalog options": "Opciones del catálogo", "Category": "Categoría", "Caution: Maximum mode generates more heat.": "Precaución: el modo máximo genera más calor.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Comprobación de Ceph omitida por el indicador del usuario (--ignore-ceph-check)", @@ -882,6 +883,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Elija una programación (sintaxis de eventos de calendario de systemd):", "Choose a script to execute:": "Elige un script para ejecutar:", "Choose a server:": "Elige un servidor:", + "Choose a setting. Changes are applied only after the final review.": "Elige qué configurar. Los cambios solo se aplican tras la revisión final.", "Choose a share to mount:": "Elige un recurso compartido para montar:", "Choose a specific supported GPU or USB node": "Elija un nodo concreto de GPU o USB compatible", "Choose action for this controller/NVMe:": "Elige la acción para este controlador/NVMe:", @@ -1764,6 +1766,8 @@ "Disabling and stopping rpcbind service and socket...": "Deshabilitar y detener el servicio y el socket rpcbind...", "Disabling non-free firmware warnings...": "Deshabilitando las advertencias de firmware no libre...", "Disabling portmapper/rpcbind for security...": "Deshabilitando portmapper/rpcbind por seguridad...", + "Discard changes": "Descartar cambios", + "Discard unapplied changes?": "¿Descartar los cambios sin aplicar?", "Discover targets on portal:": "Detectar destinos en el portal:", "Discovering iSCSI targets...": "Descubriendo destinos iSCSI...", "Discovery successful": "Detección correcta", @@ -4256,6 +4260,7 @@ "No password provided.": "No se proporcionó contraseña.", "No paths available": "No hay rutas disponibles", "No paths selected. Select at least one path.": "No hay rutas seleccionadas. Seleccione al menos una ruta.", + "No pending changes": "No hay cambios pendientes", "No pending paths to schedule for reboot.": "No hay rutas pendientes para programar el reinicio.", "No permanent NFS mounts configured.": "No se han configurado montajes NFS permanentes.", "No permanent Samba mounts configured.": "No hay montajes Samba permanentes configurados.", @@ -4394,6 +4399,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM eliminado (los artefactos de las marionetas pueden necesitar una limpieza manual)", "OVH server detected": "Servidor OVH detectado", "OVH server detection and RTM installation process completed": "Finalizado el proceso de detección del servidor OVH e instalación de RTM", + "Object detector": "Detector de objetos", "Object detector for Frigate": "Detector de objetos para Frigate", "Observer is not responding on port 4357": "El observador no responde en el puerto 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian es una aplicación de notas que te permite crear, enlazar y organizar tus notas en tu dispositivo, con cientos de plugins y temas para personalizar tu flujo de trabajo. También puedes publicar tus notas en línea, acceder a ellas sin conexión y sincronizarlas de forma segura con cifrado de extremo a extremo.", @@ -5312,6 +5318,7 @@ "Reverting ZFS ARC tuning...": "Revirtiendo el ajuste ZFS ARC...", "Reverting pigz wrapper...": "Revertir la envoltura de pigz...", "Reverting vzdump speed tuning...": "Revertir el ajuste de velocidad de vzdump...", + "Review and apply": "Revisar y aplicar", "Review external OCI changes": "Revisar cambios externos de OCI", "Review package upgrade": "Revisar la actualización de paquetes", "Review passthrough config files": "Revisar los archivos de configuración de passthrough", @@ -5771,6 +5778,7 @@ "ShareCoveX panel": "Panel de ShareCoveX", "Shared Directory Ready:": "Directorio compartido listo:", "Shared Group": "Grupo compartido", + "Shared directories": "Carpetas compartidas", "Shared directory created:": "Directorio compartido creado:", "Shared directory for consume and export": "Directorio compartido para consumo y exportación", "Shared directory for copy/sync operations": "Directorio compartido para operaciones de copia/sincronización", @@ -6972,6 +6980,7 @@ "Unable to identify the root filesystem. Aborting.": "No se pudo identificar el sistema de archivos raíz. Se cancela.", "Unable to resolve system ZFS pool disks. Aborting.": "No se pudieron resolver los discos del pool ZFS del sistema. Se cancela.", "Unable to resolve system disk topology. Aborting.": "No se pudo resolver la topología de discos del sistema. Se cancela.", + "Unapplied changes": "Cambios sin aplicar", "Understand the security implications of privileged containers": "Comprender las implicaciones de seguridad de los contenedores privilegiados", "Unexpected Proxmox inventory; recovery blocked": "Inventario Proxmox inesperado; recuperación bloqueada", "Unexpected formatting directory in the new Valkey volume": "Directorio de formato inesperado en el nuevo volumen Valkey", diff --git a/lang/fr.json b/lang/fr.json index 2d3be751..861f56e4 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -749,6 +749,7 @@ "Cannot verify the reused disk:": "Impossible de vérifier le disque réutilisé :", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Impossible d'écrire l'archive d'images sur {storage}. Vérifiez l'accès au stockage et l'état du montage.", "Capabilities cannot be kept and all dropped at the same time": "Les capacités ne peuvent être conservées et toutes abandonnées en même temps", + "Catalog options": "Options du catalogue", "Category": "Catégorie", "Caution: Maximum mode generates more heat.": "Attention : le mode maximum génère plus de chaleur.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Vérification Ceph ignorée par l'indicateur utilisateur (--ignore-ceph-check)", @@ -883,6 +884,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Choisissez un calendrier (syntaxe d'événement de calendrier systemd) :", "Choose a script to execute:": "Choisissez un script à exécuter :", "Choose a server:": "Choisissez un serveur :", + "Choose a setting. Changes are applied only after the final review.": "Choisissez un réglage. Les modifications ne sont appliquées qu’après la vérification finale.", "Choose a share to mount:": "Choisissez un partage à monter :", "Choose a specific supported GPU or USB node": "Choisissez un GPU ou un nœud USB pris en charge spécifique", "Choose action for this controller/NVMe:": "Choisissez une action pour ce contrôleur/NVMe :", @@ -1766,6 +1768,8 @@ "Disabling and stopping rpcbind service and socket...": "Désactivation et arrêt du service et du socket rpcbind...", "Disabling non-free firmware warnings...": "Désactivation des avertissements du firmware non libre...", "Disabling portmapper/rpcbind for security...": "Désactivation de portmapper/rpcbind pour la sécurité...", + "Discard changes": "Abandonner les modifications", + "Discard unapplied changes?": "Abandonner les modifications non appliquées ?", "Discover targets on portal:": "Découvrez les cibles sur le portail :", "Discovering iSCSI targets...": "Découverte des cibles iSCSI...", "Discovery successful": "Découverte réussie", @@ -4261,6 +4265,7 @@ "No password provided.": "Aucun mot de passe fourni.", "No paths available": "Aucun chemin disponible", "No paths selected. Select at least one path.": "Aucun chemin sélectionné. Sélectionnez au moins un chemin.", + "No pending changes": "Aucune modification en attente", "No pending paths to schedule for reboot.": "Aucun chemin en attente pour planifier le redémarrage.", "No permanent NFS mounts configured.": "Aucun montage NFS permanent configuré.", "No permanent Samba mounts configured.": "Aucun montage Samba permanent configuré.", @@ -4402,6 +4407,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM supprimé (les artefacts de marionnettes peuvent nécessiter un nettoyage manuel)", "OVH server detected": "Serveur OVH détecté", "OVH server detection and RTM installation process completed": "Processus de détection du serveur OVH et d'installation de RTM terminé", + "Object detector": "Détecteur d’objets", "Object detector for Frigate": "Détecteur d'objets pour Frigate", "Observer is not responding on port 4357": "L'observateur ne répond pas au port 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian est une application de prise de notes qui vous permet de créer, de lier et d'organiser vos notes sur votre appareil, avec des centaines de plugins et de thèmes pour personnaliser votre workflow. Vous pouvez également publier vos notes en ligne, les accéder hors ligne et les synchroniser en toute sécurité avec le chiffrement de bout en bout.", @@ -5328,6 +5334,7 @@ "Reverting ZFS ARC tuning...": "Annulation du réglage ZFS ARC...", "Reverting pigz wrapper...": "Rétablissement du wrapper pigz...", "Reverting vzdump speed tuning...": "Annulation du réglage de la vitesse de vzdump...", + "Review and apply": "Vérifier et appliquer", "Review external OCI changes": "Examiner les modifications externes de OCI", "Review package upgrade": "Examiner la mise à niveau des paquets", "Review passthrough config files": "Examiner les fichiers de configuration relais", @@ -5787,6 +5794,7 @@ "ShareCoveX panel": "Panneau ShareCoveX", "Shared Directory Ready:": "Répertoire partagé prêt :", "Shared Group": "Groupe partagé", + "Shared directories": "Dossiers partagés", "Shared directory created:": "Répertoire partagé créé & #160;:", "Shared directory for consume and export": "Répertoire partagé pour la consommation et l'exportation", "Shared directory for copy/sync operations": "Répertoire partagé pour copier/sync operations", @@ -6996,6 +7004,7 @@ "Unable to identify the root filesystem. Aborting.": "Impossible d'identifier le système de fichiers racine. Avorter.", "Unable to resolve system ZFS pool disks. Aborting.": "Impossible de résoudre les disques du pool système ZFS.Avorter.", "Unable to resolve system disk topology. Aborting.": "Impossible de résoudre la topologie du disque système. Avorter.", + "Unapplied changes": "Modifications non appliquées", "Understand the security implications of privileged containers": "Comprendre les implications de sécurité des conteneurs privilégiés", "Unexpected Proxmox inventory; recovery blocked": "stocks imprévus Proxmox; récupération bloquée", "Unexpected formatting directory in the new Valkey volume": "Répertoire de formatage inattendu dans le nouveau volume Valkey", diff --git a/lang/it.json b/lang/it.json index 13e3c788..58abc3a7 100644 --- a/lang/it.json +++ b/lang/it.json @@ -749,6 +749,7 @@ "Cannot verify the reused disk:": "Impossibile verificare il disco riutilizzato:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Impossibile scrivere l'archivio immagini su {storage}. Controlla l'accesso allo spazio di archiviazione e lo stato di montaggio.", "Capabilities cannot be kept and all dropped at the same time": "Non è possibile conservare alcune capability e rimuoverle tutte contemporaneamente", + "Catalog options": "Opzioni del catalogo", "Category": "Categoria", "Caution: Maximum mode generates more heat.": "Attenzione: la modalità massima genera più calore.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Controllo Ceph saltato dal flag utente (--ignore-ceph-check)", @@ -883,6 +884,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Scegli una pianificazione (sintassi calendar-event di systemd):", "Choose a script to execute:": "Scegli uno script da eseguire:", "Choose a server:": "Scegli un server:", + "Choose a setting. Changes are applied only after the final review.": "Scegli cosa configurare. Le modifiche vengono applicate solo dopo la verifica finale.", "Choose a share to mount:": "Scegli una condivisione da montare:", "Choose a specific supported GPU or USB node": "Scegli uno specifico nodo GPU o USB supportato", "Choose action for this controller/NVMe:": "Scegli l'azione per questo controller/NVMe:", @@ -1766,6 +1768,8 @@ "Disabling and stopping rpcbind service and socket...": "Disabilitazione e arresto del servizio e del socket rpcbind...", "Disabling non-free firmware warnings...": "Disabilitazione degli avvisi sul firmware non libero...", "Disabling portmapper/rpcbind for security...": "Disabilitazione di portmapper/rpcbind per sicurezza...", + "Discard changes": "Scarta le modifiche", + "Discard unapplied changes?": "Scartare le modifiche non applicate?", "Discover targets on portal:": "Scopri target sul portale:", "Discovering iSCSI targets...": "Individuazione dei target iSCSI...", "Discovery successful": "Scoperta riuscita", @@ -4261,6 +4265,7 @@ "No password provided.": "Nessuna password fornita.", "No paths available": "Nessun percorso disponibile", "No paths selected. Select at least one path.": "Nessun percorso selezionato. Seleziona almeno un percorso.", + "No pending changes": "Nessuna modifica in sospeso", "No pending paths to schedule for reboot.": "Nessun percorso in sospeso da pianificare per il riavvio.", "No permanent NFS mounts configured.": "Nessun montaggio NFS permanente configurato.", "No permanent Samba mounts configured.": "Nessun montaggio Samba permanente configurato.", @@ -4402,6 +4407,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM rimosso (gli artefatti di Puppet potrebbero richiedere una pulizia manuale)", "OVH server detected": "Rilevato server OVH", "OVH server detection and RTM installation process completed": "Processo di rilevamento dei server OVH e installazione RTM completato", + "Object detector": "Rilevatore di oggetti", "Object detector for Frigate": "Rilevatore di oggetti per Frigate", "Observer is not responding on port 4357": "Observer non risponde sulla porta 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian è un’app per creare, collegare e organizzare le note sul tuo dispositivo, con centinaia di plugin e temi per personalizzare il flusso di lavoro. Puoi pubblicare le note online, consultarle offline e sincronizzarle in modo sicuro con crittografia end-to-end.", @@ -5328,6 +5334,7 @@ "Reverting ZFS ARC tuning...": "Rimozione delle ottimizzazioni di ZFS ARC…", "Reverting pigz wrapper...": "Rimozione del wrapper pigz e ripristino della configurazione precedente...", "Reverting vzdump speed tuning...": "Rimozione ottimizzazioni velocità vzdump…", + "Review and apply": "Verifica e applica", "Review external OCI changes": "Esamina le modifiche OCI esterne", "Review package upgrade": "Esamina l'aggiornamento dei pacchetti", "Review passthrough config files": "Esamina i file di configurazione passthrough", @@ -5787,6 +5794,7 @@ "ShareCoveX panel": "Pannello ShareCoveX", "Shared Directory Ready:": "Directory condivisa pronta:", "Shared Group": "Gruppo condiviso", + "Shared directories": "Cartelle condivise", "Shared directory created:": "Directory condivisa creata:", "Shared directory for consume and export": "Directory condivisa per consume ed export", "Shared directory for copy/sync operations": "Directory condivisa per copia e sincronizzazione", @@ -6997,6 +7005,7 @@ "Unable to identify the root filesystem. Aborting.": "Impossibile identificare il filesystem root. Interruzione.", "Unable to resolve system ZFS pool disks. Aborting.": "Impossibile individuare i dischi del pool ZFS di sistema. Interruzione.", "Unable to resolve system disk topology. Aborting.": "Impossibile determinare la topologia del disco di sistema. Interruzione.", + "Unapplied changes": "Modifiche non applicate", "Understand the security implications of privileged containers": "Comprendere le implicazioni sulla sicurezza dei container privilegiati", "Unexpected Proxmox inventory; recovery blocked": "Inventario Proxmox inatteso; ripristino bloccato", "Unexpected formatting directory in the new Valkey volume": "Directory di formattazione imprevista nel nuovo volume Valkey", diff --git a/lang/pt.json b/lang/pt.json index e95b4413..e9f79ec2 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -749,6 +749,7 @@ "Cannot verify the reused disk:": "Não foi possível verificar o disco reutilizado:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Não é possível gravar o arquivo de imagem em {storage}. Verifique o acesso ao armazenamento e o status de montagem.", "Capabilities cannot be kept and all dropped at the same time": "Capacidades não podem ser mantidas e todas retiradas ao mesmo tempo", + "Catalog options": "Opções do catálogo", "Category": "Categoria", "Caution: Maximum mode generates more heat.": "Cuidado: O modo máximo gera mais calor.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Verificação do Ceph ignorada pelo sinalizador do usuário (--ignore-ceph-check)", @@ -883,6 +884,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Escolha uma programação (sintaxe de evento de calendário do systemd):", "Choose a script to execute:": "Escolha um script para executar:", "Choose a server:": "Escolha um servidor:", + "Choose a setting. Changes are applied only after the final review.": "Escolha o que configurar. As alterações só são aplicadas após a revisão final.", "Choose a share to mount:": "Escolha um compartilhamento para montar:", "Choose a specific supported GPU or USB node": "Escolha um GPU ou nó USB específico compatível", "Choose action for this controller/NVMe:": "Escolha a ação para este controlador/NVMe:", @@ -1766,6 +1768,8 @@ "Disabling and stopping rpcbind service and socket...": "Desativando e parando o serviço e soquete rpcbind...", "Disabling non-free firmware warnings...": "Desativando avisos de firmware não-livre...", "Disabling portmapper/rpcbind for security...": "Desativando portmapper/rpcbind por segurança...", + "Discard changes": "Descartar alterações", + "Discard unapplied changes?": "Descartar as alterações por aplicar?", "Discover targets on portal:": "Descubra alvos no portal:", "Discovering iSCSI targets...": "Descobrindo alvos iSCSI...", "Discovery successful": "Descoberta bem-sucedida", @@ -4261,6 +4265,7 @@ "No password provided.": "Nenhuma senha fornecida.", "No paths available": "Nenhum caminho disponível", "No paths selected. Select at least one path.": "Nenhum caminho selecionado. Selecione pelo menos um caminho.", + "No pending changes": "Sem alterações pendentes", "No pending paths to schedule for reboot.": "Não há caminhos pendentes para agendar a reinicialização.", "No permanent NFS mounts configured.": "Nenhuma montagem NFS permanente configurada.", "No permanent Samba mounts configured.": "Nenhuma montagem permanente do Samba configurada.", @@ -4402,6 +4407,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM removido (artefatos de marionetes podem precisar de limpeza manual)", "OVH server detected": "Servidor OVH detectado", "OVH server detection and RTM installation process completed": "Processo de detecção do servidor OVH e instalação RTM concluído", + "Object detector": "Detetor de objetos", "Object detector for Frigate": "Detector de objetos para Frigate", "Observer is not responding on port 4357": "Observador não está respondendo na porta 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian é um aplicativo de anotações que permite criar, vincular e organizar suas notas em seu dispositivo, com centenas de plugins e temas para personalizar seu fluxo de trabalho. Você também pode publicar suas notas online, acessá-las offline e sincronizá-las com segurança com criptografia de ponta a ponta.", @@ -5328,6 +5334,7 @@ "Reverting ZFS ARC tuning...": "Revertendo ajuste do ZFS ARC...", "Reverting pigz wrapper...": "Revertendo o wrapper pigz...", "Reverting vzdump speed tuning...": "Revertendo o ajuste de velocidade do vzdump...", + "Review and apply": "Rever e aplicar", "Review external OCI changes": "revisar alterações externas do OCI", "Review package upgrade": "Rever a atualização de pacotes", "Review passthrough config files": "Revise os arquivos de configuração de passagem", @@ -5787,6 +5794,7 @@ "ShareCoveX panel": "Painel ShareCoveX", "Shared Directory Ready:": "Diretório compartilhado pronto:", "Shared Group": "Grupo Compartilhado", + "Shared directories": "Diretórios partilhados", "Shared directory created:": "Directório partilhado criado:", "Shared directory for consume and export": "Diretório compartilhado para consumir e exportar", "Shared directory for copy/sync operations": "Diretório compartilhado para copiar/sincronizar operations", @@ -6997,6 +7005,7 @@ "Unable to identify the root filesystem. Aborting.": "Não foi possível identificar o sistema de arquivos raiz. Abortando.", "Unable to resolve system ZFS pool disks. Aborting.": "Não foi possível resolver os discos do pool ZFS do sistema. Abortando.", "Unable to resolve system disk topology. Aborting.": "Não é possível resolver a topologia do disco do sistema. Abortando.", + "Unapplied changes": "Alterações por aplicar", "Understand the security implications of privileged containers": "Entenda as implicações de segurança de contêineres privilegiados", "Unexpected Proxmox inventory; recovery blocked": "Inesperado inventário Proxmox; recuperação bloqueada", "Unexpected formatting directory in the new Valkey volume": "Directório de formatação inesperado no novo volume Valkey", diff --git a/lang/sk.json b/lang/sk.json index c29bb126..c72c1e92 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -749,6 +749,7 @@ "Cannot verify the reused disk:": "Nie je možné overiť znovu použitý disk:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Nie je možné zapísať archív obrázkov do {storage}. Skontrolujte prístup k úložisku a stav pripojenia.", "Capabilities cannot be kept and all dropped at the same time": "Nie je možné udržať si schopnosti a zároveň ich znížiť.", + "Catalog options": "Možnosti katalógu", "Category": "Kategória", "Caution: Maximum mode generates more heat.": "Pozor: maximálny režim vytvára viac tepla.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Kontrola Ceph preskočená používateľskou voľbou (--ignore-ceph-check)", @@ -883,6 +884,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Vyberte plán (syntax systémového kalendára a udalosti):", "Choose a script to execute:": "Vyberte skript, ktorý sa má spustiť:", "Choose a server:": "Vyberte server:", + "Choose a setting. Changes are applied only after the final review.": "Vyberte nastavenie. Zmeny sa použijú až po záverečnej kontrole.", "Choose a share to mount:": "Vyberte zdieľanie na pripojenie:", "Choose a specific supported GPU or USB node": "Vyberte konkrétny podporovaný uzol GPU alebo USB", "Choose action for this controller/NVMe:": "Vyberte akciu pre tento Controller/NVMe:", @@ -1766,6 +1768,8 @@ "Disabling and stopping rpcbind service and socket...": "Zakázanie a zastavenie služby rpcbind a soketu...", "Disabling non-free firmware warnings...": "Vypínam upozornenia na non-free firmware...", "Disabling portmapper/rpcbind for security...": "Z bezpečnostných dôvodov vypínam portmapper/rpcbind...", + "Discard changes": "Zahodiť zmeny", + "Discard unapplied changes?": "Zahodiť nepoužité zmeny?", "Discover targets on portal:": "Vyhľadať targety na portáli:", "Discovering iSCSI targets...": "Vyhľadávam iSCSI targety...", "Discovery successful": "Vyhľadanie bolo úspešné", @@ -4261,6 +4265,7 @@ "No password provided.": "Nebolo zadané heslo.", "No paths available": "Nie sú dostupné žiadne cesty", "No paths selected. Select at least one path.": "Nie sú vybrané žiadne cesty. Vyberte aspoň jednu cestu.", + "No pending changes": "Žiadne čakajúce zmeny", "No pending paths to schedule for reboot.": "Nie sú žiadne čakajúce cesty, ktoré by sa mali naplánovať na reštart.", "No permanent NFS mounts configured.": "Nie sú nastavené žiadne trvalé NFS pripojenia.", "No permanent Samba mounts configured.": "Nie sú nastavené žiadne trvalé Samba pripojenia.", @@ -4404,6 +4409,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM bol odstránený (Puppet súbory môže byť potrebné vyčistiť ručne)", "OVH server detected": "Zistený OVH server", "OVH server detection and RTM installation process completed": "Kontrola OVH servera a inštalácia RTM sú dokončené", + "Object detector": "Detektor objektov", "Object detector for Frigate": "Detektor objektov pre Frigate", "Observer is not responding on port 4357": "Pozorovateľ nereaguje na prístav 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian je note-prijímajúce aplikácie, ktoré vám umožní vytvoriť, odkaz, a usporiadať poznámky na vašom zariadení, so stovkami pluginov a tém prispôsobiť svoje pracovné postupy. Môžete tiež zverejniť svoje poznámky on-line, prístup je offline, a synchronizovať ich bezpečne s end-to-end šifrovanie.", @@ -5330,6 +5336,7 @@ "Reverting ZFS ARC tuning...": "Vraciam späť ladenie ZFS ARC...", "Reverting pigz wrapper...": "Vraciam späť wrapper pigz...", "Reverting vzdump speed tuning...": "Vraciam späť ladenie rýchlosti vzdump...", + "Review and apply": "Skontrolovať a použiť", "Review external OCI changes": "Skontrolujte externé zmeny OCI", "Review package upgrade": "Skontrolovať aktualizáciu balíkov", "Review passthrough config files": "Skontrolovať konfiguračné súbory passthrough", @@ -5789,6 +5796,7 @@ "ShareCoveX panel": "Panel ShareCoveX", "Shared Directory Ready:": "Zdieľaný priečinok je pripravený:", "Shared Group": "Zdieľaná skupina", + "Shared directories": "Zdieľané priečinky", "Shared directory created:": "Vytvorený zdieľaný adresár:", "Shared directory for consume and export": "Zdieľaný adresár pre spotrebu a export", "Shared directory for copy/sync operations": "Zdieľaný adresár pre kopírovanie/sync operations", @@ -6998,6 +7006,7 @@ "Unable to identify the root filesystem. Aborting.": "Nepodarilo sa identifikovať koreňový súborový systém. Prerušuje sa.", "Unable to resolve system ZFS pool disks. Aborting.": "Nepodarilo sa vyriešiť systémové disky ZFS fondu. Prerušuje sa.", "Unable to resolve system disk topology. Aborting.": "Nepodarilo sa vyriešiť topológiu systémového disku. Prerušuje sa.", + "Unapplied changes": "Nepoužité zmeny", "Understand the security implications of privileged containers": "Uistite sa, že rozumiete bezpečnostným dôsledkom privilegovaných kontajnerov", "Unexpected Proxmox inventory; recovery blocked": "Neočakávaný inventár Proxmox; zablokovaná obnova", "Unexpected formatting directory in the new Valkey volume": "Neočakávaný formátovanie adresára v novom hlasitosti Valkey", diff --git a/lang/sv.json b/lang/sv.json index 377b3f16..5ee88288 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -749,6 +749,7 @@ "Cannot verify the reused disk:": "Kan inte verifiera den återanvända disken:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Kan inte skriva bildarkivet till {storage}. Kontrollera lagringsåtkomst och monteringsstatus.", "Capabilities cannot be kept and all dropped at the same time": "Förmågor kan inte hållas och alla tappas samtidigt", + "Catalog options": "Katalogalternativ", "Category": "Kategori", "Caution: Maximum mode generates more heat.": "Varning: Maximalt läge genererar mer värme.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Ceph-kontroll hoppas över av användarflaggan (--ignore-ceph-check)", @@ -883,6 +884,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Välj ett schema (syntax för systemd kalenderhändelser):", "Choose a script to execute:": "Välj ett skript att köra:", "Choose a server:": "Välj en server:", + "Choose a setting. Changes are applied only after the final review.": "Välj en inställning. Ändringarna tillämpas först efter den slutliga granskningen.", "Choose a share to mount:": "Välj en resurs att montera:", "Choose a specific supported GPU or USB node": "Välj en specifik GPU- eller USB-nod som stöds", "Choose action for this controller/NVMe:": "Välj åtgärd för denna kontroller/NVMe:", @@ -1766,6 +1768,8 @@ "Disabling and stopping rpcbind service and socket...": "Inaktivera och stoppa rpcbind-tjänsten och -socket...", "Disabling non-free firmware warnings...": "Inaktiverar icke-fri firmware-varningar...", "Disabling portmapper/rpcbind for security...": "Inaktiverar portmapper/rpcbind för säkerhet...", + "Discard changes": "Kasta ändringar", + "Discard unapplied changes?": "Kasta ändringar som inte har tillämpats?", "Discover targets on portal:": "Upptäck mål på portalen:", "Discovering iSCSI targets...": "Upptäcker iSCSI-mål...", "Discovery successful": "Upptäckten lyckades", @@ -4261,6 +4265,7 @@ "No password provided.": "Inget lösenord angett.", "No paths available": "Inga vägar tillgängliga", "No paths selected. Select at least one path.": "Inga sökvägar har valts. Välj minst en väg.", + "No pending changes": "Inga väntande ändringar", "No pending paths to schedule for reboot.": "Inga väntande sökvägar att schemalägga för omstart.", "No permanent NFS mounts configured.": "Inga permanenta NFS-monteringar har konfigurerats.", "No permanent Samba mounts configured.": "Inga permanenta Samba-fästen har konfigurerats.", @@ -4402,6 +4407,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM har tagits bort (Puppet artefakter kan behöva manuell rengöring)", "OVH server detected": "OVH-server upptäckt", "OVH server detection and RTM installation process completed": "OVH-serverdetektering och RTM-installation har slutförts", + "Object detector": "Objektdetektor", "Object detector for Frigate": "Objektdetektor för Frigate", "Observer is not responding on port 4357": "Observer svarar inte på port 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian är en noterande app som låter dig skapa, länka och organisera dina anteckningar på din enhet, med hundratals plugins och teman för att anpassa ditt arbetsflöde. Du kan också publicera dina anteckningar online, komma åt dem offline och synkronisera dem säkert med end-to-end-kryptering.", @@ -5328,6 +5334,7 @@ "Reverting ZFS ARC tuning...": "Återställer ZFS ARC-inställning...", "Reverting pigz wrapper...": "Återställer pigz-omslag...", "Reverting vzdump speed tuning...": "Återställer vzdump-hastighetsinställning...", + "Review and apply": "Granska och tillämpa", "Review external OCI changes": "Granska externa OCI ändringar", "Review package upgrade": "Granska paketuppgraderingen", "Review passthrough config files": "Granska passthrough-konfigurationsfiler", @@ -5787,6 +5794,7 @@ "ShareCoveX panel": "ShareCoveX panel", "Shared Directory Ready:": "Redo för delad katalog:", "Shared Group": "Delad grupp", + "Shared directories": "Delade kataloger", "Shared directory created:": "Delad katalog skapad:", "Shared directory for consume and export": "Delad katalog för konsumering och export", "Shared directory for copy/sync operations": "Delad katalog för kopia/synkronisera operations", @@ -6997,6 +7005,7 @@ "Unable to identify the root filesystem. Aborting.": "Det går inte att identifiera rotfilsystemet. Avbryter.", "Unable to resolve system ZFS pool disks. Aborting.": "Det går inte att lösa system ZFS pooldiskar. Avbryter.", "Unable to resolve system disk topology. Aborting.": "Det gick inte att lösa systemdisktopologin. Avbryter.", + "Unapplied changes": "Ändringar som inte har tillämpats", "Understand the security implications of privileged containers": "Förstå säkerhetskonsekvenserna av privilegierade containrar", "Unexpected Proxmox inventory; recovery blocked": "Oväntad Proxmox inventering; återhämtning blockerad", "Unexpected formatting directory in the new Valkey volume": "Oväntad formateringskatalog i den nya Valkey-volymen", diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index cbf8e041..699a279a 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -423,25 +423,8 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): proposal = None if action == 'modify': from .recreation import edit_recreation - from .cli import _deployment_summary_text - from .ui import BacktrackUI, RestartWizard - wizard = BacktrackUI(ui) - try: - while True: - try: - proposal = edit_recreation(record, wizard) - approved = wizard.review(_deployment_summary_text(proposal['candidate']['template'], - proposal['candidate']['deployment']), translate('Modify OCI'), - question=translate('Apply these changes?'), default=True) - break - except RestartWizard: - wizard.restart() - except ValueError as error: - if not wizard.retry_last(error): - raise - finally: - wizard.close() - if not approved: + proposal = edit_recreation(record, ui) + if proposal is None: return False elif not ui.review(translate('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If the update fails after the container was changed, the backup is restored. An update that stops halfway is recovered from the OCI management menu.'), translate('Update OCI'), question=translate('Update now?'), default=True): diff --git a/oci/src/proxmenux_oci/recreation.py b/oci/src/proxmenux_oci/recreation.py index d8ddf4b9..f0d6f691 100644 --- a/oci/src/proxmenux_oci/recreation.py +++ b/oci/src/proxmenux_oci/recreation.py @@ -4,7 +4,7 @@ import re from pathlib import Path, PurePosixPath from .i18n import translate -from .ui import UserCancelled +from .ui import BacktrackUI, RestartWizard, UserCancelled def positive_integer(ui, prompt, value, minimum=1): @@ -30,13 +30,13 @@ def edit_network(deployment, ui): network.update(bridge=bridge, ipv4=ipv4, gateway=gateway) -def edit_acceleration(candidate, ui): +def edit_acceleration(candidate, ui, confirm=True): from .installer import configure_acceleration deployment = candidate['deployment'] template = candidate.get('template', {}) installer = template.get('proxmox', {}).get('installer_profile', {}) hardware = installer.get('hardware_acceleration') - if not hardware or not ui.confirm(translate('Change GPU acceleration?'), False): + if not hardware or (confirm and not ui.confirm(translate('Change GPU acceleration?'), False)): return profiles = hardware.get('profiles', []) reference = template.get('container_contract', {}).get('image', {}).get('reference', '') @@ -104,44 +104,142 @@ def edit_environment(deployment, ui): environment.append(item) -def edit_recreation(record, ui): - candidate = copy.deepcopy(record) - refresh_template(candidate, ui) +def modification_options(candidate): + """Only offer supported settings; never put environment values in the menu.""" deployment = candidate['deployment'] resources = deployment['resources'] - resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores']) - resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128) + profile = candidate.get('template', {}).get('proxmox', {}).get('installer_profile', {}) + network = deployment.get('network', {}) + address = network.get('ipv4', 'dhcp') + + def row(label, value): + return f"{label:<26} {value}" + + options = [ + ('cores', row(translate('CPU cores'), resources['cores'])), + ('memory', row(translate('RAM in MiB'), resources['memory_mb'])), + ('network', row(translate('Network'), + f"{network.get('bridge', '-')} · {'DHCP' if address == 'dhcp' else address}")), + ('paths', row(translate('Extra paths'), len(deployment.get('mounts', [])))), + ] + if profile.get('shared_directories'): + options.append(('shares', translate('Shared directories'))) + if profile.get('hardware_acceleration'): + hardware = profile['hardware_acceleration'] + selected = next((p for p in hardware.get('profiles', []) + if p['id'] == deployment.get('hardware_profile')), {}) + options.append(('acceleration', row(translate('Acceleration'), + translate(selected.get('label') or deployment.get('hardware_profile') or 'CPU')))) + if profile.get('object_detector'): + options.append(('detector', translate('Object detector'))) + options += [ + ('devices', row(translate('Devices'), len(deployment.get('devices', [])))), + ('environment', row(translate('Variables'), len(deployment.get('environment', [])))), + ('catalog', translate('Catalog options')), + ('apply', translate('Review and apply')), + ('discard', translate('Discard changes')), + ] + return options + + +def edit_setting(candidate, ui, setting): + deployment = candidate['deployment'] template = candidate.get('template', {}) - if template.get('proxmox', {}).get('installer_profile', {}).get('shared_directories'): + profile = template.get('proxmox', {}).get('installer_profile', {}) + if setting == 'cores': + resources = deployment['resources'] + resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores']) + elif setting == 'memory': + resources = deployment['resources'] + resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128) + elif setting == 'network': + edit_network(deployment, ui) + elif setting == 'shares': from .installer import ask_shared_directories deployment['mounts'] += ask_shared_directories( ui, template, deployment['mounts'], deployment['rootfs']['storage'], True, optional=True, unprivileged=deployment.get('security', {}).get('unprivileged', True)) - from .custom_mounts import ask_custom_mounts - deployment['mounts'] = ask_custom_mounts( - ui, deployment['mounts'], deployment['rootfs']['storage']) - if ui.confirm(translate('Change the access network?'), False): - edit_network(deployment, ui) - edit_acceleration(candidate, ui) - from .installer import configure_detector - installer_profile = candidate.get('template', {}).get('proxmox', {}).get('installer_profile', {}) - deployment['devices'], notes = configure_detector(installer_profile, deployment.get('devices', []), ui) - if notes: - deployment['completion_notes'] = notes - from .extra_devices import ask_extra_devices - reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '') - repository = reference.split('@')[0].rsplit(':', 1)[0] - deployment['devices'] = ask_extra_devices( - ui, deployment.get('devices', []), deployment.get('security', {}).get('unprivileged', True), - allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server')) - from .extra_devices import device_permissions - deployment['device_permissions'] = device_permissions( - reference, deployment['devices'], deployment.get('device_permissions')) - edit_environment(deployment, ui) - proposal = {'operation': 'recreate', 'candidate': candidate} - if record.get('observed', {}).get('config_sha256'): - proposal['base_config_sha256'] = record['observed']['config_sha256'] - return proposal + elif setting == 'paths': + from .custom_mounts import ask_custom_mounts + deployment['mounts'] = ask_custom_mounts(ui, deployment['mounts'], deployment['rootfs']['storage']) + elif setting == 'acceleration': + edit_acceleration(candidate, ui, confirm=False) + elif setting == 'detector': + from .installer import configure_detector + deployment['devices'], notes = configure_detector(profile, deployment.get('devices', []), ui) + if notes: + deployment['completion_notes'] = notes + elif setting == 'devices': + from .extra_devices import ask_extra_devices + reference = template.get('container_contract', {}).get('image', {}).get('reference', '') + repository = reference.split('@')[0].rsplit(':', 1)[0] + deployment['devices'] = ask_extra_devices( + ui, deployment.get('devices', []), deployment.get('security', {}).get('unprivileged', True), + allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server')) + elif setting == 'environment': + edit_environment(deployment, ui) + elif setting == 'catalog': + refresh_template(candidate, ui) + else: + raise ValueError(setting) + if setting in ('acceleration', 'detector', 'devices'): + from .extra_devices import device_permissions + reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '') + permissions = device_permissions(reference, deployment.get('devices', []), deployment.get('device_permissions')) + if permissions is not None or 'device_permissions' in deployment: + deployment['device_permissions'] = permissions + + +def edit_section(candidate, ui, setting): + """A cancelled or invalid section cannot leave a half-edited draft.""" + wizard = BacktrackUI(ui) + try: + while True: + edited = copy.deepcopy(candidate) + try: + edit_setting(edited, wizard, setting) + return edited + except RestartWizard: + wizard.restart() + except UserCancelled: + return candidate + except ValueError as error: + if not wizard.retry_last(error): + ui.message(str(error), translate('Modify OCI')) + return candidate + finally: + wizard.close() + + +def edit_recreation(record, ui): + """Keep a draft across independently editable settings; apply it once.""" + from .cli import _deployment_summary_text + candidate = copy.deepcopy(record) + selected = 'cores' + while True: + pending = candidate != record + status = translate('Unapplied changes') if pending else translate('No pending changes') + text = (f"CT {record.get('vmid', '-')}: {candidate['deployment'].get('hostname', '')}\n" + f"{status}\n\n" + + translate('Choose a setting. Changes are applied only after the final review.')) + selected = ui.choose(text, modification_options(candidate), selected, + title=translate('Modify OCI'), show_tags=False) + if selected is None or selected == 'discard': + if not pending or ui.confirm(translate('Discard unapplied changes?'), False): + return None + selected = 'apply' + elif selected == 'apply': + if not pending: + ui.message(translate('Nothing was changed.'), translate('Modify OCI')) + return None + if ui.review(_deployment_summary_text(candidate['template'], candidate['deployment']), + translate('Modify OCI'), question=translate('Apply these changes?'), default=True): + proposal = {'operation': 'recreate', 'candidate': candidate} + if record.get('observed', {}).get('config_sha256'): + proposal['base_config_sha256'] = record['observed']['config_sha256'] + return proposal + else: + candidate = edit_section(candidate, ui, selected) def refresh_template(candidate, ui): diff --git a/oci/tests/test_modify_menu.py b/oci/tests/test_modify_menu.py new file mode 100644 index 00000000..2709da4f --- /dev/null +++ b/oci/tests/test_modify_menu.py @@ -0,0 +1,201 @@ +"""Selective OCI edits stay in a draft until reviewed, then use one transaction.""" +import copy +import json +from pathlib import Path +import stat +import sys +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) +sys.path.insert(0, str(ROOT / 'remote')) + +from proxmenux_oci import management, recreation +from proxmenux_oci.ui import BackRequested, UserCancelled + + +def record(): + return {'vmid': 115, 'installation_id': 'test-installation', 'status': 'installed', + 'observed': {'config_sha256': 'native-config-fingerprint'}, + 'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'}, + 'container_contract': {'image': {'reference': 'example/app:latest'}}, + 'proxmox': {'installer_profile': {}}}, + 'deployment': {'vmid': 115, 'hostname': 'example', 'resources': {'cores': 2, 'memory_mb': 512}, + 'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp', 'gateway': None}, + 'rootfs': {'storage': 'local-zfs', 'size_gb': 8}, 'mounts': [ + {'container_path': '/data', 'source': 'local-zfs', + 'type': 'managed-volume', 'size_gb': 8, 'backup': True}], + 'devices': [], 'security': {'unprivileged': True}, + 'environment': [{'name': 'PASSWORD', 'value': 'private-test-secret', + 'sensitive': True}]}} + + +class MenuUI: + def __init__(self, choices, answers=(), reviews=(True,), confirms=()): + self.choices, self.answers = iter(choices), iter(answers) + self.reviews, self.confirms = iter(reviews), iter(confirms) + self.events = [] + self.back_enabled = False + + def choose(self, text, options, default=None, **kwargs): + self.events.append(('menu', text, dict(options))) + value = next(self.choices) + if value is not None: + assert value in dict(options), value + return value + + def ask(self, text, default=None, **kwargs): + self.events.append(('ask', text, default)) + value = next(self.answers) + if isinstance(value, Exception): + raise value + return value + + def confirm(self, text, default=False): + self.events.append(('confirm', text)) + return next(self.confirms) + + def review(self, text, *args, **kwargs): + self.events.append(('review', text)) + return next(self.reviews) + + def message(self, text, *args): + self.events.append(('message', text)) + + +@patch('proxmenux_oci.i18n.language', return_value='en') +class ModifyMenuTests(unittest.TestCase): + def test_ram_only_does_not_ask_about_cpu_network_paths_devices_or_catalog(self, _language): + original = record() + saved = copy.deepcopy(original) + ui = MenuUI(['memory', 'apply'], ['768']) + with patch.object(recreation, 'refresh_template') as refresh: + result = recreation.edit_recreation(original, ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 2, 'memory_mb': 768}) + self.assertEqual([e[1] for e in ui.events if e[0] == 'ask'], ['RAM in MiB']) + self.assertEqual(result['base_config_sha256'], 'native-config-fingerprint') + self.assertEqual(original, saved) + refresh.assert_not_called() + self.assertFalse(ui.back_enabled) + + def test_several_sections_and_revisits_keep_the_current_draft(self, _language): + ui = MenuUI(['memory', 'cores', 'memory', 'apply'], ['768', '3', '1024']) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 1024}) + self.assertEqual([e[2] for e in ui.events if e[0] == 'ask'], ['512', '2', '768']) + self.assertEqual(sum(e[0] == 'review' for e in ui.events), 1) + + def test_declining_review_returns_to_menu_without_losing_edits(self, _language): + ui = MenuUI(['cores', 'apply', 'memory', 'apply'], ['3', '768'], reviews=[False, True]) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 768}) + self.assertEqual(sum(e[0] == 'review' for e in ui.events), 2) + + def test_unchanged_apply_does_not_propose_recreating_the_guest(self, _language): + ui = MenuUI(['memory', 'apply'], ['512']) + self.assertIsNone(recreation.edit_recreation(record(), ui)) + self.assertFalse(any(e[0] == 'review' for e in ui.events)) + self.assertIn(('message', 'Nothing was changed.'), ui.events) + + def test_discard_leaves_the_saved_record_untouched(self, _language): + original = record() + saved = copy.deepcopy(original) + ui = MenuUI(['memory', 'discard'], ['768'], confirms=[True]) + self.assertIsNone(recreation.edit_recreation(original, ui)) + self.assertEqual(original, saved) + self.assertFalse(any(e[0] == 'review' for e in ui.events)) + + def test_declining_discard_preserves_the_draft(self, _language): + ui = MenuUI(['memory', None, 'apply'], ['768'], confirms=[False]) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources']['memory_mb'], 768) + + def test_cancel_without_changes_asks_nothing_else(self, _language): + ui = MenuUI([None]) + self.assertIsNone(recreation.edit_recreation(record(), ui)) + self.assertEqual(len(ui.events), 1) + + def test_invalid_ram_is_retried_without_restarting_other_sections(self, _language): + ui = MenuUI(['cores', 'memory', 'apply'], ['3', 'invalid', '64', '768']) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 768}) + self.assertEqual(sum(e[0] == 'message' for e in ui.events), 2) + self.assertEqual(sum(e[:2] == ('ask', 'Cores') for e in ui.events), 1) + + def test_back_from_first_field_returns_to_menu_and_keeps_other_edits(self, _language): + ui = MenuUI(['cores', 'memory', 'apply'], ['3', BackRequested()]) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 512}) + self.assertFalse(ui.back_enabled) + + def test_cancelling_a_partly_edited_section_rolls_back_only_that_section(self, _language): + real_edit = recreation.edit_setting + def edit(candidate, ui, setting): + if setting == 'environment': + candidate['deployment']['environment'][0]['value'] = 'partial-value' + raise UserCancelled() + real_edit(candidate, ui, setting) + ui = MenuUI(['cores', 'environment', 'apply'], ['3']) + with patch.object(recreation, 'edit_setting', side_effect=edit): + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources']['cores'], 3) + self.assertEqual(result['candidate']['deployment']['environment'], record()['deployment']['environment']) + + def test_menu_and_review_do_not_expose_a_saved_secret(self, _language): + ui = MenuUI(['cores', 'apply'], ['3']) + recreation.edit_recreation(record(), ui) + self.assertNotIn('private-test-secret', json.dumps(ui.events)) + + def test_optional_categories_follow_the_application_profile(self, _language): + candidate = record() + options = dict(recreation.modification_options(candidate)) + self.assertTrue({'cores', 'memory', 'network', 'paths', 'devices', 'environment', 'catalog', 'apply'} <= options.keys()) + self.assertFalse({'shares', 'acceleration', 'detector'} & options.keys()) + candidate['template']['proxmox']['installer_profile'] = { + 'shared_directories': {'base_path': '/shares'}, 'object_detector': {'devices': []}, + 'hardware_acceleration': {'profiles': [{'id': 'intel', 'label': 'Intel GPU'}]}} + candidate['deployment']['hardware_profile'] = 'intel' + options = dict(recreation.modification_options(candidate)) + self.assertTrue({'shares', 'acceleration', 'detector'} <= options.keys()) + self.assertIn('Intel GPU', options['acceleration']) + + +class ModifyDispatchTests(unittest.TestCase): + def call_manager(self, proposal, lifecycle): + import oci_instances + import oci_instance_reconcile + saved = record() + row = {'vmid': 115, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'} + with patch.object(management, 'carry_records', return_value={}), \ + patch.object(management, 'check_selected', return_value=row), \ + patch.object(management, '_work_backup_ready', return_value=True), \ + patch.object(oci_instances, 'read', return_value=saved), \ + patch.object(oci_instances, 'command', return_value='hostname: example'), \ + patch.object(oci_instance_reconcile, 'propose', return_value=None), \ + patch.object(recreation, 'edit_recreation', return_value=proposal), \ + patch.object(management, '_run_lifecycle', side_effect=lifecycle): + return management.manage_instance(ROOT, Mock(), row, action='modify') + + def test_cancel_does_not_start_a_lifecycle_transaction(self): + lifecycle = Mock() + self.assertFalse(self.call_manager(None, lifecycle)) + lifecycle.assert_not_called() + + def test_apply_sends_one_private_proposal_to_the_existing_backend(self): + proposal = {'operation': 'recreate', 'candidate': record(), 'base_config_sha256': 'fingerprint'} + seen = [] + def lifecycle(command, title): + path = Path(command[command.index('--proposal') + 1]) + seen.append(json.loads(path.read_text())) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertNotIn('private-test-secret', ' '.join(command)) + self.assertIn('oci_update_current.py', command[1]) + self.assertIn('--acknowledge-external-data', command) + return True + self.assertTrue(self.call_manager(proposal, lifecycle)) + self.assertEqual(seen, [proposal]) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_shared_directories.py b/oci/tests/test_shared_directories.py index d8204835..55545374 100644 --- a/oci/tests/test_shared_directories.py +++ b/oci/tests/test_shared_directories.py @@ -43,15 +43,18 @@ class ScriptedUI: def password(self, text, required=True): return self._answer(text, "") - def choose(self, text, options, default=None): + def choose(self, text, options, default=None, **_kwargs): return self._answer(text, default) def confirm(self, text, default=False): return self._answer(text, default) - def message(self, text): + def message(self, text, *_args): pass + def review(self, *_args, **_kwargs): + return True + @patch("proxmenux_oci.i18n.language", return_value="en") @patch("proxmenux_oci.installer.host.storages", side_effect=storages) @@ -223,14 +226,15 @@ class SharedDirectoriesTests(unittest.TestCase): patch("proxmenux_oci.recreation.edit_acceleration"), \ patch("proxmenux_oci.recreation.edit_environment"): ui = ScriptedUI({"Add a directory to share": [True], NAME: ["photos"], + "Choose a setting. Changes are applied only after the final review.": ["shares", "apply"], "Where to store /shares/photos": ["host-bind"], "Host path for /shares/photos": ["/tank/photos"]}) proposal = edit_recreation(record, ui) mounts = [m["container_path"] for m in proposal["candidate"]["deployment"]["mounts"]] self.assertEqual(mounts, ["/config", "/shares/media", "/shares/photos"]) - untouched = edit_recreation(record, ScriptedUI()) - self.assertEqual([m["container_path"] for m in untouched["candidate"]["deployment"]["mounts"]], - ["/config", "/shares/media"]) + untouched = edit_recreation(record, ScriptedUI({ + "Choose a setting. Changes are applied only after the final review.": ["discard"]})) + self.assertIsNone(untouched) self.assertEqual([m["container_path"] for m in plan["mounts"]], ["/config", "/shares/media"]) From 784ce13c1494820299b39c77ce43cbd608bbe02e Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:43:52 +0200 Subject: [PATCH 17/43] Reuse OCI images and apply resource edits without rebuilding --- .github/workflows/test-oci-registries.yml | 4 + lang/de.json | 2 + lang/es.json | 2 + lang/fr.json | 2 + lang/it.json | 2 + lang/pt.json | 2 + lang/sk.json | 2 + lang/sv.json | 2 + oci/remote/oci_resource_modify.py | 112 ++++++++ oci/remote/oci_update_current.py | 53 +++- oci/tests/test_modify_image_reuse.py | 312 ++++++++++++++++++++++ 11 files changed, 481 insertions(+), 14 deletions(-) create mode 100644 oci/remote/oci_resource_modify.py create mode 100644 oci/tests/test_modify_image_reuse.py diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 9612c0d3..feb68709 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -28,6 +28,10 @@ jobs: python-version: '3.13' - name: Install OCI test dependencies run: python3 -m pip install -r oci/requirements.txt + - name: Run OCI image reuse and resource edit regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v - name: Run offline registry account tests env: PYTHONDONTWRITEBYTECODE: '1' diff --git a/lang/de.json b/lang/de.json index 8d643f1a..6b0524e7 100644 --- a/lang/de.json +++ b/lang/de.json @@ -443,6 +443,7 @@ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Die geprüfte Paketaktualisierung jetzt anwenden? Dienste können neu starten und zur Entfernung aufgeführte Pakete werden entfernt.", "Apply these changes?": "Diese Änderungen anwenden?", "Applying AMD-specific fixes...": "Anwenden von AMD-spezifischen Korrekturen...", + "Applying CPU and RAM changes without rebuilding the container...": "CPU- und RAM-Änderungen werden ohne Neuerstellung des Containers angewendet...", "Applying Changes": "Anwenden von Änderungen", "Applying Controller/NVMe passthrough to VM": "Anwenden von Controller/NVMe-Passthrough auf die VM", "Applying balanced memory optimization settings...": "Ausgewogene Einstellungen zur Speicheroptimierung werden angewendet...", @@ -6636,6 +6637,7 @@ "The repair must preserve the image dependencies:": "Die Reparatur muss die Bildabhängigkeiten bewahren:", "The requested VMID block is already in use": "Der angeforderte VMID-Block wird bereits verwendet", "The requested machine learning GPU profile is not working; it is not replaced by CPU": "Das angeforderte Machine Learning-GPU-Profil funktioniert nicht; es wird nicht durch CPU ersetzt", + "The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Die Ressourcenänderung konnte nicht vollständig rückgängig gemacht werden. Öffne die Verwaltung erneut, um die aktuellen Proxmox-Einstellungen zu prüfen und zu übernehmen.", "The restored service did not pass its health check": "Der wiederhergestellte Service bestand seinen Gesundheitscheck nicht", "The restored service stopped; the recovery is not confirmed": "Der wiederhergestellte Dienst wurde gestoppt; die Wiederherstellung wird nicht bestätigt", "The reviewed Tandoor stack does not require a privileged LXC.": "Der überprüfte Tandoor-Stack requi ist kein privilegierter LXC.", diff --git a/lang/es.json b/lang/es.json index de4d581e..4f8d1903 100644 --- a/lang/es.json +++ b/lang/es.json @@ -440,6 +440,7 @@ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "¿Aplicar ahora la actualización de paquetes revisada? Los servicios podrían reiniciarse y se eliminarán los paquetes indicados para su eliminación.", "Apply these changes?": "¿Aplicar estos cambios?", "Applying AMD-specific fixes...": "Aplicando correcciones específicas de AMD...", + "Applying CPU and RAM changes without rebuilding the container...": "Aplicando los cambios de CPU y RAM sin recrear el contenedor...", "Applying Changes": "Aplicando cambios", "Applying Controller/NVMe passthrough to VM": "Aplicando el passthrough de controlador/NVMe a la VM", "Applying balanced memory optimization settings...": "Aplicando configuraciones equilibradas de optimización de memoria...", @@ -6612,6 +6613,7 @@ "The repair must preserve the image dependencies:": "La reparación debe preservar las dependencias de la imagen:", "The requested VMID block is already in use": "El bloque VMID solicitado ya está en uso", "The requested machine learning GPU profile is not working; it is not replaced by CPU": "El perfil de GPU solicitado para Machine learning no funciona; no se sustituye por CPU", + "The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "El cambio de recursos no se ha podido revertir por completo. Vuelve al menú de gestión para revisar e incorporar la configuración actual de Proxmox.", "The restored service did not pass its health check": "El servicio restaurado no superó su comprobación de estado", "The restored service stopped; the recovery is not confirmed": "El servicio restaurado se detuvo; la recuperación no se confirma", "The reviewed Tandoor stack does not require a privileged LXC.": "La pila de Tandoor revisada no necesita un LXC privilegiado.", diff --git a/lang/fr.json b/lang/fr.json index 2d3be751..c552a567 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -443,6 +443,7 @@ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Appliquer maintenant la mise à niveau des paquets examinée ? Des services peuvent redémarrer et les paquets indiqués pour suppression seront supprimés.", "Apply these changes?": "Appliquer ces modifications ?", "Applying AMD-specific fixes...": "Application de correctifs spécifiques à AMD...", + "Applying CPU and RAM changes without rebuilding the container...": "Application des modifications de CPU et de RAM sans recréer le conteneur...", "Applying Changes": "Application des modifications", "Applying Controller/NVMe passthrough to VM": "Application du relais Controller/NVMe à la VM", "Applying balanced memory optimization settings...": "Application des paramètres d'optimisation de la mémoire équilibrée...", @@ -6635,6 +6636,7 @@ "The repair must preserve the image dependencies:": "La réparation doit préserver les dépendances de l'image:", "The requested VMID block is already in use": "Le bloc VMID demandé est déjà utilisé", "The requested machine learning GPU profile is not working; it is not replaced by CPU": "Le profil GPU d'apprentissage automatique demandé ne fonctionne pas; il n'est pas remplacé par CPU", + "The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Impossible d’annuler entièrement la modification des ressources. Rouvrez le menu de gestion pour vérifier et importer les réglages actuels de Proxmox.", "The restored service did not pass its health check": "Le service restauré n'a pas réussi son bilan de santé", "The restored service stopped; the recovery is not confirmed": "Le service restauré s'est arrêté; la récupération n'est pas confirmée", "The reviewed Tandoor stack does not require a privileged LXC.": "La pile Tandoor revue ne require un LXC privilégié.", diff --git a/lang/it.json b/lang/it.json index 13e3c788..2be35ba8 100644 --- a/lang/it.json +++ b/lang/it.json @@ -443,6 +443,7 @@ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Applicare ora l'aggiornamento dei pacchetti esaminato? I servizi potrebbero riavviarsi e i pacchetti indicati per la rimozione verranno rimossi.", "Apply these changes?": "Applicare queste modifiche?", "Applying AMD-specific fixes...": "Applicazione delle correzioni specifiche AMD...", + "Applying CPU and RAM changes without rebuilding the container...": "Applicazione delle modifiche a CPU e RAM senza ricreare il container...", "Applying Changes": "Applicazione delle modifiche", "Applying Controller/NVMe passthrough to VM": "Applicazione del passthrough Controller/NVMe alla VM", "Applying balanced memory optimization settings...": "Applicazione delle impostazioni bilanciate di ottimizzazione della memoria...", @@ -6636,6 +6637,7 @@ "The repair must preserve the image dependencies:": "La riparazione deve conservare le dipendenze dell'immagine:", "The requested VMID block is already in use": "Il blocco di VMID richiesto è già in uso", "The requested machine learning GPU profile is not working; it is not replaced by CPU": "Il profilo GPU richiesto per il machine learning non funziona; non viene sostituito dalla CPU", + "The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Non è stato possibile annullare completamente la modifica delle risorse. Riapri il menu di gestione per verificare e importare le impostazioni attuali di Proxmox.", "The restored service did not pass its health check": "Il servizio ripristinato non ha superato il controllo dello stato", "The restored service stopped; the recovery is not confirmed": "Il servizio ripristinato si è arrestato; il recupero non è confermato", "The reviewed Tandoor stack does not require a privileged LXC.": "Lo stack Tandoor esaminato non richiede un container LXC privilegiato.", diff --git a/lang/pt.json b/lang/pt.json index e95b4413..1c0a3622 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -443,6 +443,7 @@ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Aplicar agora a atualização de pacotes revista? Os serviços podem reiniciar e os pacotes indicados para remoção serão removidos.", "Apply these changes?": "Aplicar essas alterações?", "Applying AMD-specific fixes...": "Aplicando correções específicas da AMD...", + "Applying CPU and RAM changes without rebuilding the container...": "A aplicar as alterações de CPU e RAM sem recriar o contentor...", "Applying Changes": "Aplicando alterações", "Applying Controller/NVMe passthrough to VM": "Aplicando passagem de controlador/NVMe à VM", "Applying balanced memory optimization settings...": "Aplicando configurações de otimização de memória balanceadas...", @@ -6636,6 +6637,7 @@ "The repair must preserve the image dependencies:": "O reparo deve preservar as dependências da imagem:", "The requested VMID block is already in use": "O bloco VMID solicitado já está em uso", "The requested machine learning GPU profile is not working; it is not replaced by CPU": "O perfil de aprendizado de máquina solicitado GPU não está funcionando; não é substituído por CPU", + "The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Não foi possível reverter totalmente a alteração dos recursos. Volte a abrir o menu de gestão para rever e importar as definições atuais do Proxmox.", "The restored service did not pass its health check": "O serviço restaurado não passou no seu exame de saúde", "The restored service stopped; the recovery is not confirmed": "O serviço restaurado parou; a recuperação não é confirmada", "The reviewed Tandoor stack does not require a privileged LXC.": "A pilha Tandoor revisada não require um LXC privilegiado.", diff --git a/lang/sk.json b/lang/sk.json index c29bb126..5117ff23 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -443,6 +443,7 @@ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Použiť teraz skontrolovanú aktualizáciu balíkov? Služby sa môžu reštartovať a balíky označené na odstránenie budú odstránené.", "Apply these changes?": "Použiť tieto zmeny?", "Applying AMD-specific fixes...": "Používam opravy špecifické pre AMD...", + "Applying CPU and RAM changes without rebuilding the container...": "Používajú sa zmeny CPU a RAM bez opätovného vytvorenia kontajnera...", "Applying Changes": "Používam zmeny", "Applying Controller/NVMe passthrough to VM": "Používam priame priradenie Controller/NVMe do VM", "Applying balanced memory optimization settings...": "Používam vyvážené nastavenia optimalizácie pamäte...", @@ -6637,6 +6638,7 @@ "The repair must preserve the image dependencies:": "Oprava musí zachovať závislosť obrazu:", "The requested VMID block is already in use": "Požadovaný blok VMID sa už používa", "The requested machine learning GPU profile is not working; it is not replaced by CPU": "Požadovaný profil strojového učenia sa GPU nefunguje; nie je nahradený CPU", + "The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Zmenu prostriedkov sa nepodarilo úplne vrátiť späť. Znova otvorte správu aplikácií, skontrolujte aktuálne nastavenia systému Proxmox a prevezmite ich.", "The restored service did not pass its health check": "Obnovená služba neprešla kontrolou stavu", "The restored service stopped; the recovery is not confirmed": "Obnovená služba sa zastavila; obnova sa nepotvrdila", "The reviewed Tandoor stack does not require a privileged LXC.": "Preskúmaný Tandoor stack require privilegovaný LXC.", diff --git a/lang/sv.json b/lang/sv.json index 377b3f16..45d309ff 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -443,6 +443,7 @@ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Tillämpa den granskade paketuppgraderingen nu? Tjänster kan startas om och paket som anges för borttagning kommer att tas bort.", "Apply these changes?": "Tillämpa dessa ändringar?", "Applying AMD-specific fixes...": "Tillämpar AMD-specifika korrigeringar...", + "Applying CPU and RAM changes without rebuilding the container...": "Tillämpar ändringarna av CPU och RAM utan att återskapa containern...", "Applying Changes": "Tillämpa ändringar", "Applying Controller/NVMe passthrough to VM": "Tillämpar styrenhets-/NVMe-passthrough på VM", "Applying balanced memory optimization settings...": "Tillämpar inställningar för balanserad minnesoptimering...", @@ -6636,6 +6637,7 @@ "The repair must preserve the image dependencies:": "Reparationen måste bevara bildberoenden:", "The requested VMID block is already in use": "Det begärda VMID-blocket används redan", "The requested machine learning GPU profile is not working; it is not replaced by CPU": "Den begärda maskininlärningen GPU-profilen fungerar inte; den ersätts inte av CPU.", + "The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Det gick inte att helt återställa resursändringen. Öppna hanteringsmenyn igen för att granska och importera de aktuella Proxmox-inställningarna.", "The restored service did not pass its health check": "Den återställda tjänsten passerade inte hälsokontrollen", "The restored service stopped; the recovery is not confirmed": "Den återställda tjänsten stannade; återhämtningen bekräftas inte", "The reviewed Tandoor stack does not require a privileged LXC.": "Den granskade Tandoor stack inte require en privilegierad LXC.", diff --git a/oci/remote/oci_resource_modify.py b/oci/remote/oci_resource_modify.py new file mode 100644 index 00000000..6ae818bc --- /dev/null +++ b/oci/remote/oci_resource_modify.py @@ -0,0 +1,112 @@ +"""Apply CPU/RAM-only proposals to the existing guest, without an image archive.""" +import copy +import hashlib +from pathlib import Path +import uuid + +import oci_instances as instances +import oci_instance_transaction as transaction +from oci_installation_state import parse_config, sha +from oci_ui import translate, msg_info, msg_ok, msg_warn + +CONFIG_DIR = Path('/etc/pve/lxc') + + +def changes(record, candidate, config): + """Return a narrow native edit, or None when the normal installer is needed.""" + previous = transaction.with_default_healthcheck(copy.deepcopy(record)) + candidate = transaction.with_default_healthcheck(copy.deepcopy(candidate)) + old, new = previous['deployment'], candidate['deployment'] + if previous['template'] != candidate['template'] or old.get('host_monitor'): + return None + if {k: v for k, v in old.items() if k != 'resources'} != { + k: v for k, v in new.items() if k != 'resources'}: + return None + original, desired = old.get('resources', {}), new.get('resources', {}) + changed = {key for key in original.keys() | desired.keys() if original.get(key) != desired.get(key)} + if not changed or changed - {'cores', 'memory_mb'}: + return None + cfg = parse_config(config) + cpu = 'cpulimit' if 'cpulimit' in cfg and 'cores' not in cfg else 'cores' + parameters = {} + for key in sorted(changed): + value = desired.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value < (128 if key == 'memory_mb' else 1): + return None + native = 'memory' if key == 'memory_mb' else cpu + if native not in cfg: + return None + parameters[native] = str(value) + return parameters + + +def guarded_set(vmid, installation_id, expected, parameters): + # pct uses this SHA1 under its own configuration lock. Sample the native + # file before checking pct's view, so a concurrent edit cannot be accepted + # accidentally by taking a fresh digest after validation. + native = (CONFIG_DIR / f'{vmid}.conf').read_bytes() + current = instances.command('pct', 'config', str(vmid)) + if current != expected or instances.identity(current) != installation_id: + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + command = ['pct', 'set', str(vmid), '--digest', hashlib.sha1(native).hexdigest()] + for key, value in sorted(parameters.items()): + command += ['--' + key, value] + transaction.run(*command) + + +def expected_config(before, after, parameters): + def unrelated(config): + return b''.join(line for line in config.splitlines(keepends=True) + if line.split(b':', 1)[0].decode() not in parameters) + new = parse_config(after) + return unrelated(before) == unrelated(after) and all(new.get(key) == value for key, value in parameters.items()) + + +def apply(root, record, candidate, config, parameters): + """The caller holds the instance lock and has checked image identity/preflight.""" + if changes(record, candidate, config) != parameters: + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + vmid = record['vmid'] + directory = instances.location(root, vmid).parent / 'modifications' / uuid.uuid4().hex + instances.write(directory / 'before.json', {'record': record, 'config': config.decode()}) + transaction.open_log(directory) + msg_info(translate('Applying CPU and RAM changes without rebuilding the container...')) + applied = False + after = None + try: + guarded_set(vmid, record['installation_id'], config, parameters) + applied = True + after = instances.command('pct', 'config', str(vmid)) + if not expected_config(config, after, parameters): + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + observed = record['observed'] + fresh = instances.observe(vmid, record['installation_id'], observed['archive_path'], + observed['resolved_registry_digest'], observed['image']) + if fresh['config_sha256'] != sha(after): + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + updated = copy.deepcopy(record) + updated.update(deployment=copy.deepcopy(candidate['deployment']), observed=fresh) + instances.write(instances.location(root, vmid), updated) + except Exception: + if applied: + try: + current = instances.command('pct', 'config', str(vmid)) + if not expected_config(config, current, parameters): + raise ValueError('Concurrent configuration change; resource rollback refused') + previous = parse_config(config) + guarded_set(vmid, record['installation_id'], current, + {key: previous[key] for key in parameters}) + instances.write(instances.location(root, vmid), record) + except Exception as error: + transaction.log(f'resource rollback: {type(error).__name__}') + msg_warn(translate('The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.')) + raise + # Updating the carried record is best effort here, as it is after other + # management operations. The normal menu also refreshes it on return. + try: + import oci_carried_record + oci_carried_record.carry(root, vmid, mount_stopped=False) + except (OSError, ValueError, RuntimeError): + transaction.log('carried record will be refreshed on the next management entry') + msg_ok(translate('Changes applied. Data kept.')) + return directory diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py index bba91c04..4ade01c1 100644 --- a/oci/remote/oci_update_current.py +++ b/oci/remote/oci_update_current.py @@ -8,6 +8,7 @@ import re import shlex import subprocess import sys +import tarfile import tempfile import oci_instances as instances @@ -56,7 +57,7 @@ def run_quiet(args, error, capture=False): process.wait() -def resolve_archive(desired, config, current=None, check=None, required_digest=None): +def resolve_archive(desired, config, current=None, check=None, required_digest=None, local_archives=()): # Shared by individual and coordinated operations; no guest mutation here. # When the registry still serves the current digest nothing is downloaded. reference = desired['template']['container_contract']['image']['reference'] @@ -83,6 +84,27 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N return None, digest if check: check() + verifier = Path(__file__).with_name('verify_oci_archive.py') + + def intact(path): + try: + run_quiet([sys.executable, str(verifier), str(path)], + translate('The image did not pass the integrity check')) + return same_image(candidate, image_from_archive(str(path))) + except (RuntimeError, OSError, ValueError, KeyError, tarfile.TarError): + return False + + # Initial installations and updates use different archive names. The + # instance record knows the actual archive; validate it before looking + # only for the deterministic update-cache name. Never delete a rejected + # recorded path: another instance may still refer to it. + for saved in local_archives: + path = Path(saved) if saved else None + if path and not path.is_symlink() and path.is_file(): + msg_info(translate('Verifying the image integrity...')) + if intact(path): + msg_ok(translate('Using the verified image from the cache')) + return path, digest storage = desired['deployment']['template_storage'] if not re.fullmatch(r'[A-Za-z0-9_-]+', storage): raise ValueError(translate('Invalid template storage')) @@ -91,16 +113,6 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N archive.parent.mkdir(parents=True, exist_ok=True) if archive.is_symlink(): raise ValueError(translate('Unsafe OCI archive path')) - verifier = Path(__file__).with_name('verify_oci_archive.py') - - def intact(path): - try: - run_quiet([sys.executable, str(verifier), str(path)], - translate('The image did not pass the integrity check')) - except RuntimeError: - return False - return same_image(candidate, image_from_archive(str(path))) - if archive.exists(): msg_info(translate('Verifying the image integrity...')) if intact(archive): @@ -181,10 +193,23 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non changes = transaction.external_changes(record, config) transaction.preflight(record, desired, config) msg_ok(translate('Container checked')) - current = record['observed']['image'] if operation == 'update' else None - archive, digest = resolve_archive(desired, config, current, lambda: work_backup.locate( - instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid)) + resource_changes = None + if operation == 'recreate' and not keep_backup: + import oci_resource_modify + resource_changes = oci_resource_modify.changes(record, desired, config) + current = record['observed']['image'] if operation == 'update' or resource_changes else None + check = lambda: work_backup.locate( + instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid) + if operation == 'recreate': + archive, digest = resolve_archive(desired, config, current, check, + local_archives=[record['observed'].get('archive_path')]) + else: + archive, digest = resolve_archive(desired, config, current, check) if archive is None: + if resource_changes: + import oci_operation_notice + with oci_operation_notice.operation([vmid], 'modify', application_name(record, vmid)): + return oci_resource_modify.apply(instances.ROOT, record, desired, config, resource_changes) msg_ok(translate('The image is already up to date; nothing was changed.')) return file_storage = None diff --git a/oci/tests/test_modify_image_reuse.py b/oci/tests/test_modify_image_reuse.py new file mode 100644 index 00000000..03020ced --- /dev/null +++ b/oci/tests/test_modify_image_reuse.py @@ -0,0 +1,312 @@ +"""Unchanged OCI images permit native resource edits or verified local reuse.""" +import copy +import hashlib +import json +from pathlib import Path +import re +import stat +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT/'remote')) +import oci_carried_record +import oci_instances as instances +import oci_resource_modify as resources +import oci_update_current as update + +DIGEST = 'sha256:'+'a'*64 +NEW = 'sha256:'+'b'*64 +IDENTITY = '11111111-1111-4111-8111-111111111111' +CONFIG = (f'arch: amd64\ncores: 2\ndescription: proxmenux-instance={IDENTITY}\n' + 'memory: 512\nrootfs: local-zfs:subvol-115-disk-0,size=8G\n' + 'lxc.environment.runtime: A=one\nlxc.environment.runtime: B=two\n').encode() + + +def record(): + return {'schema_version': 1, 'vmid': 115, 'installation_id': IDENTITY, 'status': 'installed', + 'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'}, + 'container_contract': {'image': {'reference': 'example/app:latest'}}, + 'proxmox': {'installer_profile': {'startup_healthcheck': {'type': 'running'}}}}, + 'deployment': {'vmid': 115, 'hostname': 'example', 'template_storage': 'local', + 'resources': {'cores': 2, 'memory_mb': 512, 'swap_mb': 512}, + 'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp'}, 'mounts': [], 'devices': [], + 'environment': [{'name': 'PASSWORD', 'value': 'private-test-value', 'sensitive': True}]}, + 'observed': {'config': CONFIG.decode(), 'config_sha256': resources.sha(CONFIG), + 'image': {'manifest_digest': DIGEST, 'architecture': 'amd64', 'os': 'linux'}, + 'resolved_registry_digest': DIGEST, 'archive_path': '/missing/deleted-image.tar'}} + + +class ResourceModifyTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name)/'instances' + self.configs = Path(self.temp.name)/'configs' + self.configs.mkdir() + self.file = self.configs/'115.conf' + self.file.write_bytes(CONFIG) + self.saved = record() + self.candidate = copy.deepcopy(self.saved) + self.candidate['deployment']['resources'].update(cores=3, memory_mb=768) + self.params = {'cores': '3', 'memory': '768'} + instances.write(instances.location(self.root, 115), self.saved) + self.calls = [] + self.native_failure = None + def run(*args): + self.calls.append(args) + self.assertEqual(args[:3], ('pct', 'set', '115')) + expected = args[args.index('--digest')+1] + self.assertEqual(expected, hashlib.sha1(self.file.read_bytes()).hexdigest()) + if self.native_failure: + raise self.native_failure + text = self.file.read_text() + for index in range(5, len(args), 2): + text = re.sub(r'^'+args[index][2:]+r': .*$', args[index][2:]+': '+args[index+1], text, flags=re.M) + self.file.write_text(text) + return b'' + def observe(*args): + result = copy.deepcopy(self.saved['observed']) + result.update(config=self.file.read_text(), config_sha256=resources.sha(self.file.read_bytes())) + return result + patches = [patch.object(resources, 'CONFIG_DIR', self.configs), + patch.object(instances, 'command', side_effect=lambda *args: self.file.read_bytes()), + patch.object(resources.transaction, 'run', side_effect=run), + patch.object(resources.transaction, 'open_log'), patch.object(resources.transaction, 'log'), + patch.object(instances, 'observe', side_effect=observe), + patch.object(oci_carried_record, 'carry', return_value='carried')] + for p in patches: + p.start() + self.addCleanup(p.stop) + + def apply(self): + return resources.apply(self.root, self.saved, self.candidate, CONFIG, self.params) + + def test_resource_only_change_needs_no_archive_and_preserves_every_other_field(self): + directory = self.apply() + after = instances.read(self.root, 115) + self.assertEqual(after['deployment']['resources'], {'cores': 3, 'memory_mb': 768, 'swap_mb': 512}) + for key in ('network', 'mounts', 'devices', 'environment'): + self.assertEqual(after['deployment'][key], self.saved['deployment'][key]) + self.assertEqual(after['observed']['image'], self.saved['observed']['image']) + self.assertEqual(after['observed']['archive_path'], '/missing/deleted-image.tar') + self.assertEqual(after['observed']['config_sha256'], resources.sha(self.file.read_bytes())) + self.assertNotIn('pending_transaction', after) + self.assertEqual(len(self.calls), 1) + self.assertEqual(stat.S_IMODE((directory/'before.json').stat().st_mode), 0o600) + self.assertEqual(json.loads((directory/'before.json').read_text())['record'], self.saved) + self.assertNotIn('private-test-value', str(self.calls)) + + def test_network_environment_template_and_extra_device_edits_use_the_normal_installer(self): + for mutate in ( + lambda c: c['deployment']['network'].update(ipv4='192.0.2.2/24'), + lambda c: c['deployment']['environment'][0].update(value='changed'), + lambda c: c['template']['container_contract']['image'].update(reference='example/app:other'), + lambda c: c['deployment']['devices'].append({'host_path': '/dev/ttyACM0'}), + lambda c: c['deployment']['resources'].update(swap_mb=1024), + ): + candidate = copy.deepcopy(self.candidate) + mutate(candidate) + self.assertIsNone(resources.changes(self.saved, candidate, CONFIG)) + + def test_invalid_values_and_unchanged_proposal_do_not_use_the_native_path(self): + for value in (True, 0, -1, 64, 128.5, '768'): + candidate = copy.deepcopy(self.saved) + candidate['deployment']['resources']['memory_mb'] = value + self.assertIsNone(resources.changes(self.saved, candidate, CONFIG)) + self.assertIsNone(resources.changes(self.saved, copy.deepcopy(self.saved), CONFIG)) + + def test_cpu_limit_profile_stays_a_cpu_limit(self): + config = CONFIG.replace(b'cores: 2', b'cpulimit: 2') + self.assertEqual(resources.changes(self.saved, self.candidate, config), {'cpulimit': '3', 'memory': '768'}) + + def test_native_failure_leaves_saved_record_and_config_unchanged(self): + self.native_failure = RuntimeError('native API rejected edit') + with self.assertRaisesRegex(RuntimeError, 'rejected'): + self.apply() + self.assertEqual(self.file.read_bytes(), CONFIG) + self.assertEqual(instances.read(self.root, 115), self.saved) + + def test_observation_failure_rolls_back_resource_values(self): + with patch.object(instances, 'observe', side_effect=RuntimeError('observation failed')): + with self.assertRaisesRegex(RuntimeError, 'observation failed'): + self.apply() + self.assertEqual(self.file.read_bytes(), CONFIG) + self.assertEqual(instances.read(self.root, 115), self.saved) + self.assertEqual(len(self.calls), 2) + + def test_record_write_failure_rolls_back_resources(self): + real_write = instances.write + failed = False + def write(path, value): + nonlocal failed + if Path(path) == instances.location(self.root, 115) and not failed: + failed = True + raise OSError('record could not be saved') + real_write(path, value) + with patch.object(instances, 'write', side_effect=write): + with self.assertRaisesRegex(OSError, 'could not be saved'): + self.apply() + self.assertEqual(self.file.read_bytes(), CONFIG) + self.assertEqual(instances.read(self.root, 115), self.saved) + + def test_configuration_drift_is_rejected_before_native_set(self): + self.file.write_bytes(CONFIG.replace(b'memory: 512', b'memory: 1024')) + with self.assertRaisesRegex(ValueError, 'changed'): + self.apply() + self.assertEqual(self.calls, []) + + def test_repeated_environment_entries_are_all_preserved_by_the_guard(self): + changed = CONFIG.replace(b'cores: 2', b'cores: 3').replace(b'memory: 512', b'memory: 768') + self.assertTrue(resources.expected_config(CONFIG, changed, self.params)) + self.assertFalse(resources.expected_config(CONFIG, changed.replace(b'A=one', b'A=foreign'), self.params)) + + def test_a_concurrent_unrelated_change_is_not_overwritten_during_rollback(self): + def observe(*args): + self.file.write_bytes(self.file.read_bytes().replace(b'A=one', b'A=foreign')) + raise RuntimeError('concurrent edit') + with patch.object(instances, 'observe', side_effect=observe), patch.object(resources, 'msg_warn') as warn: + with self.assertRaisesRegex(RuntimeError, 'concurrent edit'): + self.apply() + self.assertIn(b'A=foreign', self.file.read_bytes()) + self.assertEqual(len(self.calls), 1) + warn.assert_called_once() + self.assertEqual(instances.read(self.root, 115), self.saved) + + def test_a_carried_record_error_does_not_undo_the_successful_edit(self): + with patch.object(oci_carried_record, 'carry', side_effect=RuntimeError('copy unavailable')): + self.apply() + self.assertEqual(instances.read(self.root, 115)['deployment']['resources']['memory_mb'], 768) + self.assertEqual(len(self.calls), 1) + + +class CachedArchiveTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name)/'original-installation.tar' + self.path.write_bytes(b'fixture verified by stub') + self.desired = record() + self.image = {'manifest_digest': DIGEST} + self.candidate = dict(self.image, registry_digest=DIGEST) + + def resolve(self, paths, image=None): + def run(args, error, capture=False): + self.assertNotEqual(args[0], 'skopeo', 'No layers should be downloaded') + return json.dumps(self.candidate) if '-c' in args else b'' + with patch.object(update, 'run_quiet', side_effect=run), \ + patch.object(update, 'image_from_archive', return_value=image or self.image), \ + patch.object(instances, 'command') as command: + result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=paths) + command.assert_not_called() + return result + + def test_original_installation_archive_is_reused_without_update_cache_lookup(self): + self.assertEqual(self.resolve([self.path]), (self.path, DIGEST)) + + def test_invalid_original_cache_is_kept_and_a_second_matching_archive_is_reused(self): + other = self.path.with_name('other.tar') + other.write_bytes(b'second fixture') + def image(path): + return {'manifest_digest': NEW if str(path) == str(self.path) else DIGEST} + with patch.object(update, 'run_quiet', side_effect=lambda args, *a, **kw: json.dumps(self.candidate) if '-c' in args else b''), \ + patch.object(update, 'image_from_archive', side_effect=image), \ + patch.object(instances, 'command') as command: + result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=[self.path, other]) + self.assertEqual(result, (other, DIGEST)) + self.assertTrue(self.path.exists()) + command.assert_not_called() + + def test_a_new_digest_downloads_once_instead_of_using_an_old_archive(self): + candidate = {'manifest_digest': NEW, 'registry_digest': NEW} + cached = self.path.with_name('update.tar') + copies = [] + def run(args, *a, **kwargs): + if '-c' in args: + return json.dumps(candidate) + if args[0] == 'skopeo': + copies.append(args) + Path(args[-1].removeprefix('oci-archive:')).write_bytes(b'new verified fixture') + return b'' + def image(path): + return {'manifest_digest': DIGEST if str(path) == str(self.path) else NEW} + with patch.object(update, 'run_quiet', side_effect=run), \ + patch.object(update, 'image_from_archive', side_effect=image), \ + patch.object(instances, 'command', return_value=str(cached).encode()), \ + patch.object(update.work_backup, 'available', return_value=1024**4), \ + patch.object(update.transaction, 'log'): + result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=[self.path]) + self.assertEqual(result, (cached, NEW)) + self.assertEqual(len(copies), 1) + self.assertIn('docker://example/app@'+NEW, copies[0]) + self.assertEqual(self.path.read_bytes(), b'fixture verified by stub') + + def test_a_broken_or_symlink_original_cache_does_not_hide_a_valid_update_cache(self): + cached = self.path.with_name('update.tar') + cached.write_bytes(b'valid update fixture') + link = self.path.with_name('link.tar') + link.symlink_to(self.path) + verified = [] + def run(args, *a, **kwargs): + if '-c' in args: + return json.dumps(self.candidate) + self.assertNotEqual(args[0], 'skopeo') + verified.append(Path(args[-1])) + if Path(args[-1]) == self.path: + raise RuntimeError('corrupt archive') + return b'' + with patch.object(update, 'run_quiet', side_effect=run), \ + patch.object(update, 'image_from_archive', return_value=self.image), \ + patch.object(instances, 'command', return_value=str(cached).encode()): + result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=[link, self.path]) + self.assertEqual(result, (cached, DIGEST)) + self.assertNotIn(link, verified) + self.assertTrue(link.is_symlink()) + self.assertTrue(self.path.exists()) + + +class UpdateRoutingTests(unittest.TestCase): + def run_update(self, new_image=False, resource_edit=True): + saved = record() + candidate = copy.deepcopy(saved) + candidate['deployment']['resources']['memory_mb'] = 768 + if not resource_edit: + candidate['deployment']['environment'][0]['value'] = 'changed' + proposal = {'operation': 'recreate', 'candidate': candidate} + import contextlib, oci_operation_notice + with patch.object(instances, 'locked', return_value=contextlib.nullcontext()), \ + patch.object(instances, 'read', return_value=saved), \ + patch.object(instances, 'command', return_value=CONFIG), \ + patch.object(update.transaction, 'candidate_contract', return_value=candidate), \ + patch.object(update.transaction, 'external_changes', return_value={}), \ + patch.object(update.transaction, 'preflight'), \ + patch.object(update, 'resolve_archive', return_value=(Path('/new.tar') if new_image or not resource_edit else None, NEW if new_image else DIGEST)) as resolve, \ + patch.object(resources, 'apply') as apply_resources, \ + patch.object(update.transaction, 'apply') as apply_image, \ + patch.object(oci_operation_notice, 'operation', return_value=contextlib.nullcontext()): + update.update(115, proposal=proposal) + return resolve, apply_resources, apply_image + + def test_unchanged_image_routes_cpu_ram_edits_to_the_existing_guest(self): + resolve, native, image = self.run_update() + self.assertEqual(resolve.call_args.args[2]['manifest_digest'], DIGEST) + native.assert_called_once() + image.assert_not_called() + + def test_a_new_image_still_uses_the_normal_verified_recreation(self): + _resolve, native, image = self.run_update(new_image=True) + native.assert_not_called() + image.assert_called_once() + + def test_other_modifications_offer_the_recorded_archive_to_the_resolver(self): + resolve, native, image = self.run_update(resource_edit=False) + self.assertIsNone(resolve.call_args.args[2]) + self.assertEqual(resolve.call_args.kwargs['local_archives'], ['/missing/deleted-image.tar']) + native.assert_not_called() + image.assert_called_once() + + +if __name__ == '__main__': + unittest.main() From d286b650d78ca1300222b95ef2d835a358350f2c Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:49:39 +0200 Subject: [PATCH 18/43] Keep native resource edits independent of OCI recreation checks --- oci/remote/oci_resource_modify.py | 6 +++++ oci/remote/oci_update_current.py | 20 +++++++++------- oci/tests/test_modify_image_reuse.py | 35 ++++++++++++++++++++++++---- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/oci/remote/oci_resource_modify.py b/oci/remote/oci_resource_modify.py index 6ae818bc..9d4083cf 100644 --- a/oci/remote/oci_resource_modify.py +++ b/oci/remote/oci_resource_modify.py @@ -14,6 +14,10 @@ CONFIG_DIR = Path('/etc/pve/lxc') def changes(record, candidate, config): """Return a narrow native edit, or None when the normal installer is needed.""" + if (any(key in record for key in ('stack', 'stack_member', 'native_stack_intent')) + or record['deployment'].get('stack_managed') + or instances.identity(config) != record['installation_id']): + return None previous = transaction.with_default_healthcheck(copy.deepcopy(record)) candidate = transaction.with_default_healthcheck(copy.deepcopy(candidate)) old, new = previous['deployment'], candidate['deployment'] @@ -66,6 +70,8 @@ def apply(root, record, candidate, config, parameters): """The caller holds the instance lock and has checked image identity/preflight.""" if changes(record, candidate, config) != parameters: raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + if transaction.external_changes(record, config): + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) vmid = record['vmid'] directory = instances.location(root, vmid).parent / 'modifications' / uuid.uuid4().hex instances.write(directory / 'before.json', {'record': record, 'config': config.decode()}) diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py index 4ade01c1..b5101fc9 100644 --- a/oci/remote/oci_update_current.py +++ b/oci/remote/oci_update_current.py @@ -191,13 +191,21 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non config = instances.command('pct', 'config', str(vmid)) desired = transaction.candidate_contract(record, operation, proposal) changes = transaction.external_changes(record, config) - transaction.preflight(record, desired, config) - msg_ok(translate('Container checked')) resource_changes = None - if operation == 'recreate' and not keep_backup: + if operation == 'recreate' and not keep_backup and not changes: import oci_resource_modify resource_changes = oci_resource_modify.changes(record, desired, config) - current = record['observed']['image'] if operation == 'update' or resource_changes else None + # CPU/RAM edits retain the installed image and all unrelated native + # settings. They do not implicitly update the application or depend + # on the registry and the narrower recreation profile. + if resource_changes: + msg_ok(translate('Container checked')) + import oci_operation_notice + with oci_operation_notice.operation([vmid], 'modify', application_name(record, vmid)): + return oci_resource_modify.apply(instances.ROOT, record, desired, config, resource_changes) + transaction.preflight(record, desired, config) + msg_ok(translate('Container checked')) + current = record['observed']['image'] if operation == 'update' else None check = lambda: work_backup.locate( instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid) if operation == 'recreate': @@ -206,10 +214,6 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non else: archive, digest = resolve_archive(desired, config, current, check) if archive is None: - if resource_changes: - import oci_operation_notice - with oci_operation_notice.operation([vmid], 'modify', application_name(record, vmid)): - return oci_resource_modify.apply(instances.ROOT, record, desired, config, resource_changes) msg_ok(translate('The image is already up to date; nothing was changed.')) return file_storage = None diff --git a/oci/tests/test_modify_image_reuse.py b/oci/tests/test_modify_image_reuse.py index 03020ced..95c7339d 100644 --- a/oci/tests/test_modify_image_reuse.py +++ b/oci/tests/test_modify_image_reuse.py @@ -163,6 +163,19 @@ class ResourceModifyTests(unittest.TestCase): self.assertTrue(resources.expected_config(CONFIG, changed, self.params)) self.assertFalse(resources.expected_config(CONFIG, changed.replace(b'A=one', b'A=foreign'), self.params)) + def test_cpu_change_preserves_homepage_dns_timezone_and_cpu_priority(self): + extra = b'nameserver: 10.0.1.1\ntimezone: Europe/Madrid\ncpuunits: 100\n' + config = CONFIG + extra + self.file.write_bytes(config) + self.saved['observed'].update(config=config.decode(), config_sha256=resources.sha(config)) + self.saved['deployment']['resources']['cpu_units'] = 100 + self.candidate = copy.deepcopy(self.saved) + self.candidate['deployment']['resources']['cores'] = 3 + instances.write(instances.location(self.root,115),self.saved) + resources.apply(self.root,self.saved,self.candidate,config,{'cores':'3'}) + self.assertEqual(self.file.read_bytes(), config.replace(b'cores: 2',b'cores: 3')) + self.assertEqual(instances.read(self.root,115)['deployment']['resources']['cpu_units'],100) + def test_a_concurrent_unrelated_change_is_not_overwritten_during_rollback(self): def observe(*args): self.file.write_bytes(self.file.read_bytes().replace(b'A=one', b'A=foreign')) @@ -268,7 +281,7 @@ class CachedArchiveTests(unittest.TestCase): class UpdateRoutingTests(unittest.TestCase): - def run_update(self, new_image=False, resource_edit=True): + def run_update(self, new_image=False, resource_edit=True, reject_recreation=False): saved = record() candidate = copy.deepcopy(saved) candidate['deployment']['resources']['memory_mb'] = 768 @@ -281,7 +294,7 @@ class UpdateRoutingTests(unittest.TestCase): patch.object(instances, 'command', return_value=CONFIG), \ patch.object(update.transaction, 'candidate_contract', return_value=candidate), \ patch.object(update.transaction, 'external_changes', return_value={}), \ - patch.object(update.transaction, 'preflight'), \ + patch.object(update.transaction, 'preflight', side_effect=ValueError('advanced Proxmox settings') if reject_recreation else None), \ patch.object(update, 'resolve_archive', return_value=(Path('/new.tar') if new_image or not resource_edit else None, NEW if new_image else DIGEST)) as resolve, \ patch.object(resources, 'apply') as apply_resources, \ patch.object(update.transaction, 'apply') as apply_image, \ @@ -291,15 +304,27 @@ class UpdateRoutingTests(unittest.TestCase): def test_unchanged_image_routes_cpu_ram_edits_to_the_existing_guest(self): resolve, native, image = self.run_update() - self.assertEqual(resolve.call_args.args[2]['manifest_digest'], DIGEST) + resolve.assert_not_called() native.assert_called_once() image.assert_not_called() - def test_a_new_image_still_uses_the_normal_verified_recreation(self): - _resolve, native, image = self.run_update(new_image=True) + def test_a_new_image_for_other_configuration_changes_uses_verified_recreation(self): + _resolve, native, image = self.run_update(new_image=True,resource_edit=False) native.assert_not_called() image.assert_called_once() + def test_cpu_edits_do_not_implicitly_upgrade_the_image(self): + resolve,native,image=self.run_update(new_image=True) + resolve.assert_not_called() + native.assert_called_once() + image.assert_not_called() + + def test_homepage_resource_edit_does_not_run_the_recreation_preflight(self): + resolve,native,image=self.run_update(reject_recreation=True) + resolve.assert_not_called() + native.assert_called_once() + image.assert_not_called() + def test_other_modifications_offer_the_recorded_archive_to_the_resolver(self): resolve, native, image = self.run_update(resource_edit=False) self.assertIsNone(resolve.call_args.args[2]) From b70e968f731125be077f29485b78e1c66b5f19ca Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:10:57 +0200 Subject: [PATCH 19/43] Apply OCI runtime settings to existing containers with recoverable edits --- .github/workflows/test-oci-registries.yml | 4 +- lang/de.json | 13 + lang/es.json | 13 + lang/fr.json | 13 + lang/it.json | 13 + lang/pt.json | 13 + lang/sk.json | 13 + lang/sv.json | 13 + oci/README.md | 17 +- oci/remote/oci_instance_transaction.py | 13 +- oci/remote/oci_native_modify.py | 396 ++++++++++++++++++++++ oci/remote/oci_update_current.py | 27 +- oci/src/proxmenux_oci/installer.py | 4 +- oci/tests/test_modify_image_reuse.py | 1 + oci/tests/test_native_modify.py | 258 ++++++++++++++ 15 files changed, 802 insertions(+), 9 deletions(-) create mode 100644 oci/remote/oci_native_modify.py create mode 100644 oci/tests/test_native_modify.py diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index feb68709..ca96aed2 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -31,7 +31,9 @@ jobs: - name: Run OCI image reuse and resource edit regressions env: PYTHONDONTWRITEBYTECODE: '1' - run: python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v + run: | + python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v + python3 -m unittest discover -s oci/tests -p test_native_modify.py -v - name: Run offline registry account tests env: PYTHONDONTWRITEBYTECODE: '1' diff --git a/lang/de.json b/lang/de.json index 6b0524e7..562eca99 100644 --- a/lang/de.json +++ b/lang/de.json @@ -448,6 +448,7 @@ "Applying Controller/NVMe passthrough to VM": "Anwenden von Controller/NVMe-Passthrough auf die VM", "Applying balanced memory optimization settings...": "Ausgewogene Einstellungen zur Speicheroptimierung werden angewendet...", "Applying changes safely...": "Änderungen sicher anwenden...", + "Applying container settings without rebuilding the container...": "Containereinstellungen werden ohne Neuerstellung des Containers angewendet...", "Applying custom restore": "Benutzerdefinierte Wiederherstellung anwenden", "Applying default VM configuration": "Anwenden der Standard-VM-Konfiguration", "Applying host permissions for unprivileged LXC bind-mounts...": "Anwenden von Hostberechtigungen für nicht privilegierte LXC-Bind-Mounts ...", @@ -3214,6 +3215,8 @@ "Invalid ALLOWED_HOSTS value": "Wert für ungültig ALLOWED HOSTs", "Invalid Home Assistant OS check timeout": "Invalid Home Assistant OS Check Timeout", "Invalid ID": "Ungültige ID", + "Invalid IPv4 address": "Ungültige IPv4-Adresse", + "Invalid IPv4 gateway": "Ungültiges IPv4-Gateway", "Invalid Intel render path": "Ungültiger Intel-Renderpfad", "Invalid Jellyfin path:": "Ungültige Jellyfin-Strecke:", "Invalid MAC address:": "Ungültige MAC-Adresse:", @@ -3246,6 +3249,7 @@ "Invalid administrator email": "E-Mail des ungültigen Administrators", "Invalid administrator user name": "Ungültiger Administrator Benutzername", "Invalid base VMID": "Ungültige Basis VMID", + "Invalid bridge": "Ungültige Bridge", "Invalid check package:": "Ungültiges Kontrollpaket:", "Invalid configuration path:": "Ungültiger Konfigurationspfad:", "Invalid consume/export volumes": "Ungültige Verbrauchs-/Ausfuhrmengen", @@ -3309,6 +3313,7 @@ "Invalid remote name": "Name der ungültigen Fernbedienung", "Invalid remote path": "Ungültiger Fernpfad", "Invalid repair version:": "Ungültige Reparaturversion:", + "Invalid resource value": "Ungültiger Ressourcenwert", "Invalid resources": "Ungültige Mittel", "Invalid restored volume path": "Ungültiger wiederhergestellter Volumenpfad", "Invalid running state": "Invaliditätsstatus", @@ -3327,6 +3332,7 @@ "Invalid stack members": "Ungültige Stapelmitglieder", "Invalid stack name": "Name des ungültigen Stacks", "Invalid stack operation": "Ungültiger Stapel operation", + "Invalid storage": "Ungültiger Speicher", "Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "Ungültige Speicher-ID. Verwenden Sie nur Buchstaben, Zahlen, Bindestriche und Unterstriche.", "Invalid storage name": "Ungültiger Speichername", "Invalid suite application": "Anwendung ungültiger Suiten", @@ -4888,6 +4894,7 @@ "Proxmox VE configuration completed.": "Proxmox VE-Konfiguration abgeschlossen.", "Proxmox VE safe update completed": "Sicheres Proxmox VE-Update abgeschlossen", "Proxmox auth logger service created and started": "Proxmox-Auth-Logger-Dienst erstellt und gestartet", + "Proxmox could not apply the container settings": "Proxmox konnte die Containereinstellungen nicht anwenden", "Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Das Proxmox-Enterprise-Repository fehlt möglicherweise oder ist nicht zugänglich. Ich versuche, auf kein Abonnement umzustellen...", "Proxmox filter configured": "Proxmox-Filter konfiguriert", "Proxmox iSCSI Storage Status:": "Proxmox iSCSI-Speicherstatus:", @@ -6327,6 +6334,7 @@ "The build could not be checked beforehand; continuing without that check.": "Der Build konnte vorher nicht überprüft werden;ohne diese Prüfung weitermachen.", "The cached image does not match the current digest": "Das zwischengespeicherte Bild entspricht nicht dem aktuellen Digest", "The cached image is damaged; it will be downloaded again.": "Das zwischengespeicherte Bild ist beschädigt; es wird erneut heruntergeladen.", + "The changes need the application installer": "Die Änderungen erfordern das Installationsprogramm der Anwendung", "The character device does not exist:": "Das Zeichengerät existiert nicht:", "The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Der Befehl fragt nach einem Passwort, das nicht wiederholt wird. Nach dem Erstellen der Benutzer fragt die Weboberfläche nach einem Benutzernamen und einem Passwort.", "The command does not name an image": "Der Befehl benennt kein Bild", @@ -6446,6 +6454,7 @@ "The host bind source does not exist:": "Die Host-Bind-Quelle existiert nicht:", "The host bind source is not a regular file or directory:": "Die Host-Bind-Quelle ist keine reguläre Datei oder ein Verzeichnis:", "The host directory changed before it was mounted": "Das Host-Verzeichnis wurde geändert, bevor es gemountet wurde", + "The host directory does not exist": "Das Verzeichnis auf dem Host ist nicht vorhanden", "The host directory may not be accessible from an unprivileged container.": "Auf das Hostverzeichnis kann von einem unprivilegierten Container aus möglicherweise nicht zugegriffen werden.", "The host has no IPv4 address on the selected bridge": "Der Host hat keine IPv4-Adresse auf der ausgewählten Brücke", "The host monitor does not see the real host memory": "Der Host-Monitor sieht den echten Host-Speicher nicht", @@ -6683,6 +6692,7 @@ "The selected VM": "Die ausgewählte VM", "The selected VM is running.": "Die ausgewählte VM läuft.", "The selected base folder does not exist and could not be created:": "Der ausgewählte Basisordner existiert nicht und konnte nicht erstellt werden:", + "The selected bridge does not exist": "Die ausgewählte Bridge ist nicht vorhanden", "The selected bridge has no IPv4 subnet for the host-monitor firewall": "Die ausgewählte Bridge verfügt über kein IPv4-Subnetz für die Host-Monitor-Firewall", "The selected configuration needs to start the LXC during the installation": "Die ausgewählte Konfiguration muss den LXC während der Installation starten", "The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Der ausgewählte Container ist nicht privilegiert. Für den direkten Geräte-Passthrough ist ein privilegierter Container erforderlich.", @@ -6705,6 +6715,7 @@ "The service builds its own image; only a published image can be installed": "Der Dienst erstellt ein eigenes Image; nur ein veröffentlichtes Image kann installiert werden", "The service declares no image:": "Der Dienst erklärt kein Bild:", "The setting has no final value:": "Die Einstellung hat keinen Endwert:", + "The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Die Einstellungen konnten nicht vollständig zurückgesetzt werden. Prüfe den Container und die gespeicherte Änderung, bevor du fortfährst.", "The share already exists in smb.conf:": "Die Freigabe existiert bereits in smb.conf:", "The shared destination is not a directory": "Das gemeinsame Ziel ist kein Verzeichnis", "The shared directory points to a protected host path": "Das Shared Directory zeigt auf einen geschützten Hostpfad", @@ -6779,6 +6790,7 @@ "These interface configurations will be removed": "Diese Schnittstellenkonfigurationen werden entfernt", "These manual changes cannot be adopted safely:": "Diese manuellen Änderungen können nicht sicher übernommen werden:", "These paths will not be restored live and will be extracted for manual recovery.": "Diese Pfade werden nicht live wiederhergestellt und zur manuellen Wiederherstellung extrahiert.", + "These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Diese Einstellungen erfordern eine anwendungsspezifische Anpassung. Das Image wurde nicht heruntergeladen und der Container nicht neu erstellt.", "These values are asked during the installation:": "Diese Werte werden während der Installation abgefragt:", "They are described in the image documentation:": "Sie sind in der Dokumentation des Images beschrieben:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Sie werden zu oci-compose hinzugefügt, bevor Sie fortfahren. Nicht unterstützte oder geänderte Ressourcen werden nicht importiert.", @@ -7114,6 +7126,7 @@ "Unsupported mount type": "Typ der nicht unterstützten Halterung", "Unsupported mount type:": "Typ der nicht unterstützten Halterung:", "Unsupported native device type:": "Nicht unterstützter nativer Gerätetyp:", + "Unsupported network settings": "Nicht unterstützte Netzwerkeinstellungen", "Unsupported operation": "Nicht unterstützte operation", "Unsupported or unknown Proxmox version; no repository changed.": "Nicht unterstützte oder unbekannte Proxmox-Version; es wurde kein Repository geändert.", "Unsupported output format:": "Nicht unterstütztes Ausgabeformat:", diff --git a/lang/es.json b/lang/es.json index 4f8d1903..57a6014b 100644 --- a/lang/es.json +++ b/lang/es.json @@ -445,6 +445,7 @@ "Applying Controller/NVMe passthrough to VM": "Aplicando el passthrough de controlador/NVMe a la VM", "Applying balanced memory optimization settings...": "Aplicando configuraciones equilibradas de optimización de memoria...", "Applying changes safely...": "Aplicando cambios de forma segura...", + "Applying container settings without rebuilding the container...": "Aplicando los ajustes sin recrear el contenedor...", "Applying custom restore": "Aplicar restauración personalizada", "Applying default VM configuration": "Aplicar la configuración predeterminada de VM", "Applying host permissions for unprivileged LXC bind-mounts...": "Aplicando permisos de host para montajes de enlace LXC sin privilegios...", @@ -3211,6 +3212,8 @@ "Invalid ALLOWED_HOSTS value": "Valor inválido ALLOWED_HOSTS", "Invalid Home Assistant OS check timeout": "Inválido Home Assistant OS check timeout", "Invalid ID": "ID no válida", + "Invalid IPv4 address": "Dirección IPv4 no válida", + "Invalid IPv4 gateway": "Puerta de enlace IPv4 no válida", "Invalid Intel render path": "Ruta de renderización Intel no válida", "Invalid Jellyfin path:": "Camino inválido Jellyfin:", "Invalid MAC address:": "Dirección MAC inválida:", @@ -3243,6 +3246,7 @@ "Invalid administrator email": "Correo electrónico del administrador inválido", "Invalid administrator user name": "Nombre del usuario del administrador inválido", "Invalid base VMID": "Base inválida VMID", + "Invalid bridge": "Puente no válido", "Invalid check package:": "Paquete de comprobación inválido:", "Invalid configuration path:": "Vía de configuración inválida:", "Invalid consume/export volumes": "Volumen de consumo/exportación inválidos", @@ -3306,6 +3310,7 @@ "Invalid remote name": "Nombre remoto inválido", "Invalid remote path": "Camino remoto inválido", "Invalid repair version:": "Versión de reparación inválida:", + "Invalid resource value": "Valor de recursos no válido", "Invalid resources": "Recursos inválidos", "Invalid restored volume path": "Camino de volumen restaurado inválido", "Invalid running state": "Estado de funcionamiento inválido", @@ -3324,6 +3329,7 @@ "Invalid stack members": "Miembros de pila no válidos", "Invalid stack name": "Nombre de pila no válido", "Invalid stack operation": "Operación de pila no válida", + "Invalid storage": "Almacenamiento no válido", "Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "ID de almacenamiento no válido. Utilice únicamente letras, números, guiones y guiones bajos.", "Invalid storage name": "Nombre de almacenamiento no válido", "Invalid suite application": "Aplicación de suite inválida", @@ -4879,6 +4885,7 @@ "Proxmox VE configuration completed.": "Configuración de Proxmox VE completada.", "Proxmox VE safe update completed": "Actualización segura de Proxmox VE completada", "Proxmox auth logger service created and started": "Servicio de registro de autenticación Proxmox creado e iniciado", + "Proxmox could not apply the container settings": "Proxmox no ha podido aplicar los ajustes del contenedor", "Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Es posible que falte el repositorio empresarial de Proxmox o que no se pueda acceder a él. Intentando cambiar a sin suscripción...", "Proxmox filter configured": "Filtro Proxmox configurado", "Proxmox iSCSI Storage Status:": "Estado de almacenamiento Proxmox iSCSI:", @@ -6310,6 +6317,7 @@ "The build could not be checked beforehand; continuing without that check.": "La compilación no se pudo comprobar de antemano; se continúa sin esa comprobación.", "The cached image does not match the current digest": "La imagen en caché no coincide con el digest actual", "The cached image is damaged; it will be downloaded again.": "La imagen en caché está dañada; se descargará de nuevo.", + "The changes need the application installer": "Los cambios requieren el instalador de la aplicación", "The character device does not exist:": "El dispositivo de carácter no existe:", "The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "El comando pide una contraseña que no se muestra al escribirla. Tras crear los usuarios, la interfaz web pide un nombre de usuario y una contraseña.", "The command does not name an image": "El comando no nombra ninguna imagen", @@ -6428,6 +6436,7 @@ "The host bind source does not exist:": "La fuente de host bind no existe:", "The host bind source is not a regular file or directory:": "La fuente host bind no es un archivo o directorio regular:", "The host directory changed before it was mounted": "El directorio host cambió antes de montarlo", + "The host directory does not exist": "El directorio del host no existe", "The host directory may not be accessible from an unprivileged container.": "Es posible que no se pueda acceder al directorio del host desde un contenedor sin privilegios.", "The host has no IPv4 address on the selected bridge": "El host no tiene dirección IPv4 en el puente seleccionado", "The host monitor does not see the real host memory": "El monitor host no ve la memoria real del host", @@ -6659,6 +6668,7 @@ "The selected VM": "La máquina virtual seleccionada", "The selected VM is running.": "La VM seleccionada se está ejecutando.", "The selected base folder does not exist and could not be created:": "La carpeta base seleccionada no existe y no se pudo crear:", + "The selected bridge does not exist": "El puente seleccionado no existe", "The selected bridge has no IPv4 subnet for the host-monitor firewall": "El bridge seleccionado no tiene subred IPv4 para el cortafuegos del monitor del host", "The selected configuration needs to start the LXC during the installation": "La configuración seleccionada debe iniciar el LXC durante la instalación", "The selected container is unprivileged. A privileged container is required for direct device passthrough.": "El contenedor seleccionado no tiene privilegios. Se requiere un contenedor privilegiado para el passthrough directo de dispositivos.", @@ -6681,6 +6691,7 @@ "The service builds its own image; only a published image can be installed": "El servicio construye su propia imagen; solo se puede instalar una imagen publicada", "The service declares no image:": "El servicio no declara imagen:", "The setting has no final value:": "El ajuste no tiene valor final:", + "The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "No se han podido revertir todos los ajustes. Revisa el contenedor y la modificación guardada antes de continuar.", "The share already exists in smb.conf:": "El recurso compartido ya existe en smb.conf:", "The shared destination is not a directory": "El destino compartido no es un directorio", "The shared directory points to a protected host path": "El directorio compartido apunta a una ruta de host protegida", @@ -6754,6 +6765,7 @@ "These interface configurations will be removed": "Estas configuraciones de interfaz serán eliminadas", "These manual changes cannot be adopted safely:": "Estos cambios manuales no se pueden incorporar con seguridad:", "These paths will not be restored live and will be extracted for manual recovery.": "Estas rutas no se restaurarán en vivo y se extraerán para su recuperación manual.", + "These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Estos ajustes requieren una adaptación específica de la aplicación. No se ha descargado la imagen ni se ha recreado el contenedor.", "These values are asked during the installation:": "Estos valores se preguntan durante la instalación:", "They are described in the image documentation:": "Están descritos en la documentación de la imagen:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Se añadirán al oci-compose antes de continuar. No se importarán recursos incompatibles o modificados.", @@ -7089,6 +7101,7 @@ "Unsupported mount type": "Tipo de montaje no compatible", "Unsupported mount type:": "Tipo de montaje no compatible:", "Unsupported native device type:": "Tipo de dispositivo nativo sin soporte:", + "Unsupported network settings": "Ajustes de red no compatibles", "Unsupported operation": "Operación sin soporte", "Unsupported or unknown Proxmox version; no repository changed.": "Versión de Proxmox no compatible o desconocida; no se ha modificado ningún repositorio.", "Unsupported output format:": "Formato de salida no admitido:", diff --git a/lang/fr.json b/lang/fr.json index c552a567..1f37b051 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -448,6 +448,7 @@ "Applying Controller/NVMe passthrough to VM": "Application du relais Controller/NVMe à la VM", "Applying balanced memory optimization settings...": "Application des paramètres d'optimisation de la mémoire équilibrée...", "Applying changes safely...": "Appliquer les modifications en toute sécurité...", + "Applying container settings without rebuilding the container...": "Application des paramètres sans recréer le conteneur...", "Applying custom restore": "Application d'une restauration personnalisée", "Applying default VM configuration": "Application de la configuration de VM par défaut", "Applying host permissions for unprivileged LXC bind-mounts...": "Application des autorisations d'hôte pour les montages de liaison LXC non privilégiés...", @@ -3214,6 +3215,8 @@ "Invalid ALLOWED_HOSTS value": "Valeur HÔTES INDÉSIRABLES", "Invalid Home Assistant OS check timeout": "Non valide Home Assistant OS check timeout", "Invalid ID": "Pièce d'identité invalide", + "Invalid IPv4 address": "Adresse IPv4 non valide", + "Invalid IPv4 gateway": "Passerelle IPv4 non valide", "Invalid Intel render path": "Chemin de rendu Intel incorrect", "Invalid Jellyfin path:": "Chemin Jellyfin non valide:", "Invalid MAC address:": "Adresse MAC non valide:", @@ -3246,6 +3249,7 @@ "Invalid administrator email": "Courriel administrateur non valide", "Invalid administrator user name": "Nom d'utilisateur non valide", "Invalid base VMID": "VMID de base non valide", + "Invalid bridge": "Pont réseau non valide", "Invalid check package:": "Dossier de contrôle non valide:", "Invalid configuration path:": "Chemin de configuration non valide & #160;:", "Invalid consume/export volumes": "Volumes de consommation/d'exportation non valides", @@ -3309,6 +3313,7 @@ "Invalid remote name": "Nom distant non valide", "Invalid remote path": "Voie distante non valide", "Invalid repair version:": "Version de réparation non valide:", + "Invalid resource value": "Valeur de ressource non valide", "Invalid resources": "Ressources non valides", "Invalid restored volume path": "Voie de volume restaurée non valide", "Invalid running state": "État de fonctionnement non valide", @@ -3327,6 +3332,7 @@ "Invalid stack members": "Membres de pile non valides", "Invalid stack name": "Nom de la pile non valide", "Invalid stack operation": "Pile non valide operation", + "Invalid storage": "Stockage non valide", "Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "ID de stockage invalide. Utilisez uniquement des lettres, des chiffres, des traits d'union et des traits de soulignement.", "Invalid storage name": "Nom de stockage invalide", "Invalid suite application": "Demande de suite non valide", @@ -4888,6 +4894,7 @@ "Proxmox VE configuration completed.": "Configuration de Proxmox VE terminée.", "Proxmox VE safe update completed": "Mise à jour sécurisée de Proxmox VE terminée", "Proxmox auth logger service created and started": "Service d'enregistrement d'authentification Proxmox créé et démarré", + "Proxmox could not apply the container settings": "Proxmox n’a pas pu appliquer les paramètres du conteneur", "Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Le référentiel d'entreprise Proxmox peut être manquant ou inaccessible. J'essaie de passer au sans abonnement...", "Proxmox filter configured": "Filtre Proxmox configuré", "Proxmox iSCSI Storage Status:": "État du stockage Proxmox iSCSI :", @@ -6326,6 +6333,7 @@ "The build could not be checked beforehand; continuing without that check.": "La build n'a pas pu être vérifiée au préalable ;continuer sans cette vérification.", "The cached image does not match the current digest": "L'image mise en cache ne correspond pas au digest actuel", "The cached image is damaged; it will be downloaded again.": "L'image mise en cache est endommagée ; elle sera à nouveau téléchargée.", + "The changes need the application installer": "Ces modifications nécessitent le programme d’installation de l’application", "The character device does not exist:": "Le dispositif de caractère n'existe pas :", "The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "La commande demande un mot de passe qui n'est pas repris. Après la création des utilisateurs, l'interface web demande un nom d'utilisateur et un mot de passe.", "The command does not name an image": "La commande ne nomme pas une image", @@ -6445,6 +6453,7 @@ "The host bind source does not exist:": "La source de liaison hôte n'existe pas:", "The host bind source is not a regular file or directory:": "La source de liaison hôte n'est pas un fichier ou un répertoire régulier :", "The host directory changed before it was mounted": "Le répertoire hôte a changé avant son montage", + "The host directory does not exist": "Le répertoire de l’hôte n’existe pas", "The host directory may not be accessible from an unprivileged container.": "Le répertoire hôte peut ne pas être accessible à partir d'un conteneur non privilégié.", "The host has no IPv4 address on the selected bridge": "L'hôte n'a pas d'adresse IPv4 sur le pont sélectionné", "The host monitor does not see the real host memory": "Le moniteur hôte ne voit pas la mémoire de l'hôte réel", @@ -6682,6 +6691,7 @@ "The selected VM": "La VM sélectionnée", "The selected VM is running.": "La VM sélectionnée est en cours d'exécution.", "The selected base folder does not exist and could not be created:": "Le dossier de base sélectionné n'existe pas et n'a pas pu être créé :", + "The selected bridge does not exist": "Le pont réseau sélectionné n’existe pas", "The selected bridge has no IPv4 subnet for the host-monitor firewall": "Le pont sélectionné n'a pas de sous-réseau IPv4 pour le pare-feu du moniteur hôte", "The selected configuration needs to start the LXC during the installation": "La configuration sélectionnée doit démarrer le LXC pendant l'installation", "The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Le conteneur sélectionné n'est pas privilégié. Un conteneur privilégié est requis pour le relais direct du périphérique.", @@ -6704,6 +6714,7 @@ "The service builds its own image; only a published image can be installed": "Le service construit sa propre image; seule une image publiée peut être installée", "The service declares no image:": "Le service ne déclare aucune image:", "The setting has no final value:": "Le réglage n'a pas de valeur finale:", + "The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Les paramètres n’ont pas pu être entièrement rétablis. Vérifiez le conteneur et la modification enregistrée avant de continuer.", "The share already exists in smb.conf:": "Le partage existe déjà dans smb.conf :", "The shared destination is not a directory": "La destination partagée n'est pas un répertoire", "The shared directory points to a protected host path": "Le répertoire partagé indique un chemin d'hôte protégé", @@ -6778,6 +6789,7 @@ "These interface configurations will be removed": "Ces configurations d'interface seront supprimées", "These manual changes cannot be adopted safely:": "Ces modifications manuelles ne peuvent pas être adoptées en toute sécurité :", "These paths will not be restored live and will be extracted for manual recovery.": "Ces chemins ne seront pas restaurés en direct et seront extraits pour une récupération manuelle.", + "These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Ces paramètres nécessitent une adaptation propre à l’application. L’image n’a pas été téléchargée et le conteneur n’a pas été recréé.", "These values are asked during the installation:": "Ces valeurs sont demandées lors de l'installation:", "They are described in the image documentation:": "Ils sont décrits dans la documentation de l'image :", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Ils seront ajoutés à oci-compose avant de continuer. Les ressources non prises en charge ou modifiées ne sont pas importées.", @@ -7113,6 +7125,7 @@ "Unsupported mount type": "Type de montage non supporté", "Unsupported mount type:": "Type de montage non pris en charge:", "Unsupported native device type:": "Type de périphérique natif non pris en charge:", + "Unsupported network settings": "Paramètres réseau non pris en charge", "Unsupported operation": "operation non prise en charge", "Unsupported or unknown Proxmox version; no repository changed.": "Version Proxmox non prise en charge ou inconnue ; aucun référentiel n'a été modifié.", "Unsupported output format:": "Format de sortie non pris en charge :", diff --git a/lang/it.json b/lang/it.json index 2be35ba8..cddb6f85 100644 --- a/lang/it.json +++ b/lang/it.json @@ -448,6 +448,7 @@ "Applying Controller/NVMe passthrough to VM": "Applicazione del passthrough Controller/NVMe alla VM", "Applying balanced memory optimization settings...": "Applicazione delle impostazioni bilanciate di ottimizzazione della memoria...", "Applying changes safely...": "Applicazione sicura delle modifiche...", + "Applying container settings without rebuilding the container...": "Applicazione delle impostazioni senza ricreare il container...", "Applying custom restore": "Applicazione del ripristino personalizzato", "Applying default VM configuration": "Applicazione della configurazione predefinita della VM", "Applying host permissions for unprivileged LXC bind-mounts...": "Applicazione delle autorizzazioni sull’host per i bind mount dei container LXC non privilegiati...", @@ -3214,6 +3215,8 @@ "Invalid ALLOWED_HOSTS value": "Valore ALLOWED_HOSTS non valido", "Invalid Home Assistant OS check timeout": "Timeout controllo Home Assistant OS non valido", "Invalid ID": "ID non valido", + "Invalid IPv4 address": "Indirizzo IPv4 non valido", + "Invalid IPv4 gateway": "Gateway IPv4 non valido", "Invalid Intel render path": "Percorso di rendering Intel non valido", "Invalid Jellyfin path:": "Percorso Jellyfin non valido:", "Invalid MAC address:": "Indirizzo MAC non valido:", @@ -3246,6 +3249,7 @@ "Invalid administrator email": "Email amministratore non valida", "Invalid administrator user name": "Nome utente amministratore non valido", "Invalid base VMID": "VMID di base non valido", + "Invalid bridge": "Bridge non valido", "Invalid check package:": "Pacchetto controllo non valido:", "Invalid configuration path:": "Percorso configurazione non valido:", "Invalid consume/export volumes": "Volumi di acquisizione/esportazione non validi", @@ -3309,6 +3313,7 @@ "Invalid remote name": "Nome del remote non valido", "Invalid remote path": "Percorso remoto non valido", "Invalid repair version:": "Versione di riparazione non valida:", + "Invalid resource value": "Valore della risorsa non valido", "Invalid resources": "Risorse non valide", "Invalid restored volume path": "Percorso volume ripristinato non valido", "Invalid running state": "Stato di esecuzione non valido", @@ -3327,6 +3332,7 @@ "Invalid stack members": "Membri stack non validi", "Invalid stack name": "Nome stack non valido", "Invalid stack operation": "Operazione stack non valida", + "Invalid storage": "Archiviazione non valida", "Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "ID storage non valido. Usa solo lettere, numeri, trattini e trattini bassi.", "Invalid storage name": "Nome storage non valido", "Invalid suite application": "Applicazione suite non valida", @@ -4888,6 +4894,7 @@ "Proxmox VE configuration completed.": "Configurazione Proxmox VE completata.", "Proxmox VE safe update completed": "Aggiornamento sicuro di Proxmox VE completato", "Proxmox auth logger service created and started": "Servizio log autenticazione Proxmox creato e avviato", + "Proxmox could not apply the container settings": "Proxmox non è riuscito ad applicare le impostazioni del container", "Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Il repository enterprise Proxmox potrebbe essere mancante o inaccessibile. Tentativo di passare al repository no-subscription...", "Proxmox filter configured": "Filtro Proxmox configurato", "Proxmox iSCSI Storage Status:": "Stato storage iSCSI Proxmox:", @@ -6327,6 +6334,7 @@ "The build could not be checked beforehand; continuing without that check.": "Non è stato possibile verificare la build in anticipo; si prosegue senza tale verifica.", "The cached image does not match the current digest": "L'immagine nella cache non corrisponde al digest corrente", "The cached image is damaged; it will be downloaded again.": "L'immagine nella cache è danneggiata; verrà scaricata nuovamente.", + "The changes need the application installer": "Le modifiche richiedono il programma di installazione dell’applicazione", "The character device does not exist:": "Il dispositivo a caratteri non esiste:", "The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Il comando richiede una password non visualizzata durante la digitazione. Dopo la creazione degli utenti, l’interfaccia web richiede nome utente e password.", "The command does not name an image": "Il comando non specifica un’immagine", @@ -6446,6 +6454,7 @@ "The host bind source does not exist:": "La sorgente del bind mount sull’host non esiste:", "The host bind source is not a regular file or directory:": "La sorgente del bind mount sull'host non è un file regolare né una directory:", "The host directory changed before it was mounted": "La directory dell'host è cambiata prima del montaggio", + "The host directory does not exist": "La directory dell’host non esiste", "The host directory may not be accessible from an unprivileged container.": "La directory dell’host potrebbe non essere accessibile da un container non privilegiato.", "The host has no IPv4 address on the selected bridge": "L'host non ha un indirizzo IPv4 sul bridge selezionato", "The host monitor does not see the real host memory": "Il monitoraggio dell'host non rileva la memoria reale dell'host", @@ -6683,6 +6692,7 @@ "The selected VM": "La VM selezionata", "The selected VM is running.": "La VM selezionata è in esecuzione.", "The selected base folder does not exist and could not be created:": "La cartella di base selezionata non esiste e non è stato possibile crearla:", + "The selected bridge does not exist": "Il bridge selezionato non esiste", "The selected bridge has no IPv4 subnet for the host-monitor firewall": "Il bridge selezionato non ha una sottorete IPv4 per il firewall del monitoraggio dell’host", "The selected configuration needs to start the LXC during the installation": "La configurazione selezionata richiede l'avvio del container LXC durante l'installazione", "The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Il container selezionato non è privilegiato. È richiesto un container privilegiato per il passthrough diretto del dispositivo.", @@ -6705,6 +6715,7 @@ "The service builds its own image; only a published image can be installed": "Il servizio crea la propria immagine; si possono installare solo immagini pubblicate", "The service declares no image:": "Il servizio non dichiara alcuna immagine:", "The setting has no final value:": "L'impostazione non ha un valore finale:", + "The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Non è stato possibile ripristinare tutte le impostazioni. Controlla il container e la modifica salvata prima di continuare.", "The share already exists in smb.conf:": "La condivisione esiste già in smb.conf:", "The shared destination is not a directory": "La destinazione condivisa non è una directory", "The shared directory points to a protected host path": "La directory condivisa punta a un percorso protetto dell'host", @@ -6779,6 +6790,7 @@ "These interface configurations will be removed": "Queste configurazioni dell'interfaccia verranno rimosse", "These manual changes cannot be adopted safely:": "Queste modifiche manuali non possono essere acquisite in modo sicuro:", "These paths will not be restored live and will be extracted for manual recovery.": "Questi percorsi non verranno ripristinati sul sistema in esecuzione e verranno estratti per il ripristino manuale.", + "These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Queste impostazioni richiedono un adattamento specifico per l’applicazione. L’immagine non è stata scaricata e il container non è stato ricreato.", "These values are asked during the installation:": "Questi valori verranno richiesti durante l’installazione:", "They are described in the image documentation:": "Sono descritti nella documentazione dell'immagine:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Se confermi e la verifica finale riesce, queste risorse verranno registrate in oci-compose prima di proseguire. Le risorse non supportate o modificate non vengono acquisite.", @@ -7114,6 +7126,7 @@ "Unsupported mount type": "Tipo mount non supportato", "Unsupported mount type:": "Tipo mount non supportato:", "Unsupported native device type:": "Tipo dispositivo nativo non supportato:", + "Unsupported network settings": "Impostazioni di rete non supportate", "Unsupported operation": "Operazione non supportata", "Unsupported or unknown Proxmox version; no repository changed.": "Versione Proxmox non supportata o sconosciuta; nessun repository è stato modificato.", "Unsupported output format:": "Formato output non supportato:", diff --git a/lang/pt.json b/lang/pt.json index 1c0a3622..8e0abe42 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -448,6 +448,7 @@ "Applying Controller/NVMe passthrough to VM": "Aplicando passagem de controlador/NVMe à VM", "Applying balanced memory optimization settings...": "Aplicando configurações de otimização de memória balanceadas...", "Applying changes safely...": "Aplicando alterações com segurança...", + "Applying container settings without rebuilding the container...": "A aplicar as definições sem recriar o contentor...", "Applying custom restore": "Aplicando restauração personalizada", "Applying default VM configuration": "Aplicando configuração de VM padrão", "Applying host permissions for unprivileged LXC bind-mounts...": "Aplicando permissões de host para montagens de ligação LXC sem privilégios...", @@ -3214,6 +3215,8 @@ "Invalid ALLOWED_HOSTS value": "Valor Allowed hosts inválido", "Invalid Home Assistant OS check timeout": "Tempo de verificação do sistema operacional Home Assistant inválido", "Invalid ID": "ID inválido", + "Invalid IPv4 address": "Endereço IPv4 inválido", + "Invalid IPv4 gateway": "Gateway IPv4 inválido", "Invalid Intel render path": "Caminho de renderização Intel inválido", "Invalid Jellyfin path:": "Caminho Jellyfin inválido:", "Invalid MAC address:": "Endereço MAC inválido:", @@ -3246,6 +3249,7 @@ "Invalid administrator email": "E- mail de administrador inválido", "Invalid administrator user name": "Nome de usuário do administrador inválido", "Invalid base VMID": "VMID base inválida", + "Invalid bridge": "Bridge inválida", "Invalid check package:": "Pacote de verificação inválido:", "Invalid configuration path:": "Caminho de configuração inválido:", "Invalid consume/export volumes": "Volumes de consumo/exportação inválidos", @@ -3309,6 +3313,7 @@ "Invalid remote name": "Nome remoto inválido", "Invalid remote path": "Caminho remoto inválido", "Invalid repair version:": "Versão de reparação inválida:", + "Invalid resource value": "Valor de recurso inválido", "Invalid resources": "Recursos inválidos", "Invalid restored volume path": "Caminho de volume restaurado inválido", "Invalid running state": "Estado de execução inválido", @@ -3327,6 +3332,7 @@ "Invalid stack members": "Membros inválidos da pilha", "Invalid stack name": "Nome da pilha inválido", "Invalid stack operation": "Pilha inválida operation", + "Invalid storage": "Armazenamento inválido", "Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "ID de armazenamento inválido. Use apenas letras, números, hífens e sublinhados.", "Invalid storage name": "Nome de armazenamento inválido", "Invalid suite application": "Aplicativo de suite inválido", @@ -4888,6 +4894,7 @@ "Proxmox VE configuration completed.": "Configuração do Proxmox VE concluída.", "Proxmox VE safe update completed": "Atualização segura do Proxmox VE concluída", "Proxmox auth logger service created and started": "Serviço de registrador de autenticação Proxmox criado e iniciado", + "Proxmox could not apply the container settings": "O Proxmox não conseguiu aplicar as definições do contentor", "Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "O repositório corporativo Proxmox pode estar ausente ou inacessível. Tentando mudar para sem assinatura...", "Proxmox filter configured": "Filtro Proxmox configurado", "Proxmox iSCSI Storage Status:": "Status de armazenamento iSCSI do Proxmox:", @@ -6327,6 +6334,7 @@ "The build could not be checked beforehand; continuing without that check.": "A compilação não pôde ser verificada antecipadamente;continuando sem essa verificação.", "The cached image does not match the current digest": "A imagem em cache não corresponde à digest atual", "The cached image is damaged; it will be downloaded again.": "A imagem em cache está danificada; será transferida novamente.", + "The changes need the application installer": "As alterações requerem o instalador da aplicação", "The character device does not exist:": "O dispositivo de caracteres não existe:", "The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "O comando pede uma senha que não seja ecoada. Depois de criar os usuários, a interface web pede um nome de usuário e uma senha.", "The command does not name an image": "O comando não nomeia uma imagem", @@ -6446,6 +6454,7 @@ "The host bind source does not exist:": "O código de ligação da máquina não existe:", "The host bind source is not a regular file or directory:": "O código de ligação da máquina não é um ficheiro ou directório regular:", "The host directory changed before it was mounted": "A pasta da máquina mudou antes de ser montada", + "The host directory does not exist": "O diretório do anfitrião não existe", "The host directory may not be accessible from an unprivileged container.": "O diretório host pode não estar acessível a partir de um contêiner sem privilégios.", "The host has no IPv4 address on the selected bridge": "A máquina não tem endereço IPv4 na ponte seleccionada", "The host monitor does not see the real host memory": "O monitor da máquina não vê a memória verdadeira da máquina", @@ -6683,6 +6692,7 @@ "The selected VM": "A VM selecionada", "The selected VM is running.": "A VM selecionada está em execução.", "The selected base folder does not exist and could not be created:": "A pasta base selecionada não existe e não pôde ser criada:", + "The selected bridge does not exist": "A bridge selecionada não existe", "The selected bridge has no IPv4 subnet for the host-monitor firewall": "A ponte selecionada não possui sub-rede IPv4 para o firewall do monitor host", "The selected configuration needs to start the LXC during the installation": "A configuração selecionada precisa iniciar o LXC durante a instalação", "The selected container is unprivileged. A privileged container is required for direct device passthrough.": "O contêiner selecionado não tem privilégios. Um contêiner privilegiado é necessário para passagem direta do dispositivo.", @@ -6705,6 +6715,7 @@ "The service builds its own image; only a published image can be installed": "O serviço constrói sua própria imagem; somente uma imagem publicada pode ser instalada", "The service declares no image:": "O serviço não declara imagem:", "The setting has no final value:": "A configuração não tem valor final:", + "The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Não foi possível repor todas as definições. Reveja o contentor e a alteração guardada antes de continuar.", "The share already exists in smb.conf:": "O compartilhamento já existe em smb.conf:", "The shared destination is not a directory": "O destino compartilhado não é um diretório", "The shared directory points to a protected host path": "A pasta partilhada aponta para uma localização protegida da máquina", @@ -6779,6 +6790,7 @@ "These interface configurations will be removed": "Essas configurações de interface serão removidas", "These manual changes cannot be adopted safely:": "Estas alterações manuais não podem ser adotadas com segurança:", "These paths will not be restored live and will be extracted for manual recovery.": "Esses caminhos não serão restaurados ao vivo e serão extraídos para recuperação manual.", + "These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Estas definições requerem uma adaptação específica da aplicação. A imagem não foi transferida e o contentor não foi recriado.", "These values are asked during the installation:": "Estes valores são questionados durante a instalação:", "They are described in the image documentation:": "Estão descritos na documentação da imagem:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Eles serão adicionados ao oci-compose antes de continuar. Recursos não suportados ou alterados não são importados.", @@ -7114,6 +7126,7 @@ "Unsupported mount type": "Tipo de montagem não suportado", "Unsupported mount type:": "Tipo de montagem não suportado:", "Unsupported native device type:": "Tipo de dispositivo nativo não suportado:", + "Unsupported network settings": "Definições de rede não suportadas", "Unsupported operation": "operation não suportado", "Unsupported or unknown Proxmox version; no repository changed.": "Versão Proxmox não suportada ou desconhecida; nenhum repositório foi alterado.", "Unsupported output format:": "Formato de saída não suportado:", diff --git a/lang/sk.json b/lang/sk.json index 5117ff23..3d6cd693 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -448,6 +448,7 @@ "Applying Controller/NVMe passthrough to VM": "Používam priame priradenie Controller/NVMe do VM", "Applying balanced memory optimization settings...": "Používam vyvážené nastavenia optimalizácie pamäte...", "Applying changes safely...": "Bezpečne používam zmeny...", + "Applying container settings without rebuilding the container...": "Aplikujú sa nastavenia bez opätovného vytvorenia kontajnera...", "Applying custom restore": "Používam vlastnú obnovu", "Applying default VM configuration": "Používam predvolené nastavenie VM", "Applying host permissions for unprivileged LXC bind-mounts...": "Nastavujem oprávnenia hosta pre LXC bind mounty v neprivilegovaných kontajneroch...", @@ -3214,6 +3215,8 @@ "Invalid ALLOWED_HOSTS value": "Neplatná hodnota UDELENÁ HOSTS", "Invalid Home Assistant OS check timeout": "Neplatný čas odhlásenia Home Assistant OS", "Invalid ID": "Neplatné ID", + "Invalid IPv4 address": "Neplatná adresa IPv4", + "Invalid IPv4 gateway": "Neplatná brána IPv4", "Invalid Intel render path": "Neplatná cesta k zobrazeniu informácií", "Invalid Jellyfin path:": "Neplatná cesta Jellyfin:", "Invalid MAC address:": "Neplatná adresa MAC:", @@ -3246,6 +3249,7 @@ "Invalid administrator email": "Neplatný administrátorský mail", "Invalid administrator user name": "Neplatné meno správcu", "Invalid base VMID": "Neplatná základňa VMID", + "Invalid bridge": "Neplatný sieťový most", "Invalid check package:": "Neplatný kontrolný balík:", "Invalid configuration path:": "Neplatná konfiguračná dráha:", "Invalid consume/export volumes": "Neplatné objemy spotreby/vývozu", @@ -3309,6 +3313,7 @@ "Invalid remote name": "Neplatné vzdialené meno", "Invalid remote path": "Neplatná vzdialená cesta", "Invalid repair version:": "Neplatná verzia opravy:", + "Invalid resource value": "Neplatná hodnota prostriedkov", "Invalid resources": "Neplatné zdroje", "Invalid restored volume path": "Neplatná cesta obnoveného objemu", "Invalid running state": "Neplatný jazdný stav", @@ -3327,6 +3332,7 @@ "Invalid stack members": "Neplatní členovia stohu", "Invalid stack name": "Neplatný názov stohu", "Invalid stack operation": "Neplatný zásobník operation", + "Invalid storage": "Neplatné úložisko", "Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "Neplatné ID úložiska. Použite iba písmená, čísla, pomlčky a podčiarkovníky.", "Invalid storage name": "Neplatný názov úložiska", "Invalid suite application": "Neplatná aplikácia", @@ -4890,6 +4896,7 @@ "Proxmox VE configuration completed.": "Nastavenie Proxmox VE je dokončené.", "Proxmox VE safe update completed": "Bezpečná aktualizácia Proxmox VE je dokončená", "Proxmox auth logger service created and started": "Služba na logovanie Proxmox prihlásení bola vytvorená a spustená", + "Proxmox could not apply the container settings": "Proxmox nedokázal použiť nastavenia kontajnera", "Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Proxmox enterprise repozitár môže chýbať alebo byť nedostupný. Skúšam prepnúť na no-subscription...", "Proxmox filter configured": "Proxmox filter nastavený", "Proxmox iSCSI Storage Status:": "Stav iSCSI úložiska v Proxmoxe:", @@ -6328,6 +6335,7 @@ "The build could not be checked beforehand; continuing without that check.": "Zostavenie nebolo možné vopred skontrolovať;pokračovanie bez tejto kontroly.", "The cached image does not match the current digest": "Comment", "The cached image is damaged; it will be downloaded again.": "Comment", + "The changes need the application installer": "Zmeny vyžadujú inštalačný program aplikácie", "The character device does not exist:": "Znakové zariadenie neexistuje:", "The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Príkaz vyžaduje heslo, ktoré nie je ozvene. Po vytvorení užívateľov, webové rozhranie žiada o užívateľské meno a heslo.", "The command does not name an image": "Príkaz nepomenuje obrázok", @@ -6447,6 +6455,7 @@ "The host bind source does not exist:": "Zdroj hostiteľskej väzby neexistuje:", "The host bind source is not a regular file or directory:": "Zdroj odkazu hostiteľa nie je bežný súbor alebo adresár:", "The host directory changed before it was mounted": "Adresár hostiteľa sa zmenil predtým, ako bol pripojený", + "The host directory does not exist": "Adresár na hostiteľovi neexistuje", "The host directory may not be accessible from an unprivileged container.": "Priečinok na hostovi nemusí byť dostupný z neprivilegovaného kontajnera.", "The host has no IPv4 address on the selected bridge": "Hostiteľ nemá IPv4 adresu na vybranom moste", "The host monitor does not see the real host memory": "Monitor hostiteľa nevidí skutočnú pamäť hostiteľa", @@ -6684,6 +6693,7 @@ "The selected VM": "Vybraná VM", "The selected VM is running.": "Vybraná VM beží.", "The selected base folder does not exist and could not be created:": "Vybraný základný priečinok neexistuje a nepodarilo sa ho vytvoriť:", + "The selected bridge does not exist": "Vybraný sieťový most neexistuje", "The selected bridge has no IPv4 subnet for the host-monitor firewall": "Vybraný bridge nemá IPv4 podsieť pre firewall monitora hostiteľa", "The selected configuration needs to start the LXC during the installation": "Vybraná konfigurácia musí spustiť LXC počas inštalácie", "The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Vybraný kontajner je neprivilegovaný. Na priame priradenie zariadenia je potrebný privilegovaný kontajner.", @@ -6706,6 +6716,7 @@ "The service builds its own image; only a published image can be installed": "Služba buduje svoj vlastný obraz; môže byť nainštalovaný iba zverejnený obrázok", "The service declares no image:": "Služba neprehlasuje žiadny obrázok:", "The setting has no final value:": "Nastavenie nemá žiadnu konečnú hodnotu:", + "The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Nepodarilo sa vrátiť všetky nastavenia. Pred pokračovaním skontrolujte kontajner a uloženú zmenu.", "The share already exists in smb.conf:": "Zdieľanie už existuje v smb.conf:", "The shared destination is not a directory": "Zdieľaná destinácia nie je adresár", "The shared directory points to a protected host path": "Zdieľaný adresár ukazuje na chránenú cestu hostiteľa", @@ -6780,6 +6791,7 @@ "These interface configurations will be removed": "Tieto nastavenia rozhraní sa odstránia", "These manual changes cannot be adopted safely:": "Tieto manuálne zmeny nie je možné bezpečne prijať:", "These paths will not be restored live and will be extracted for manual recovery.": "Tieto cesty sa neobnovia za behu a rozbalia sa na ručnú obnovu.", + "These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Tieto nastavenia vyžadujú úpravu špecifickú pre aplikáciu. Obraz sa nestiahol a kontajner sa nevytvoril znova.", "These values are asked during the installation:": "Tieto hodnoty sa požadujú počas inštalácie:", "They are described in the image documentation:": "Ich popis nájdete v dokumentácii image:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Pred pokračovaním budú pridané do oci-compose. Nepodporované alebo zmenené zdroje sa neimportujú.", @@ -7115,6 +7127,7 @@ "Unsupported mount type": "Nepodporovaný typ pripojenia", "Unsupported mount type:": "Nepodporovaný typ pripojenia:", "Unsupported native device type:": "Nepodporovaný natívne typ zariadenia:", + "Unsupported network settings": "Nepodporované sieťové nastavenia", "Unsupported operation": "Nepodporovaná operation", "Unsupported or unknown Proxmox version; no repository changed.": "Nepodporovaná alebo neznáma verzia Proxmox; žiadne úložisko sa nezmenilo.", "Unsupported output format:": "Nepodporovaný výstupný formát:", diff --git a/lang/sv.json b/lang/sv.json index 45d309ff..51c60d2c 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -448,6 +448,7 @@ "Applying Controller/NVMe passthrough to VM": "Tillämpar styrenhets-/NVMe-passthrough på VM", "Applying balanced memory optimization settings...": "Tillämpar inställningar för balanserad minnesoptimering...", "Applying changes safely...": "Tillämpar ändringar på ett säkert sätt...", + "Applying container settings without rebuilding the container...": "Tillämpar inställningarna utan att återskapa containern...", "Applying custom restore": "Använder anpassad återställning", "Applying default VM configuration": "Tillämpar standard VM-konfiguration", "Applying host permissions for unprivileged LXC bind-mounts...": "Tillämpar värdbehörigheter för oprivilegierade LXC bind-mounts...", @@ -3214,6 +3215,8 @@ "Invalid ALLOWED_HOSTS value": "Invalid ALLOWED_HOSTS värde", "Invalid Home Assistant OS check timeout": "Ogiltig Home Assistant OS check timeout", "Invalid ID": "Ogiltigt ID", + "Invalid IPv4 address": "Ogiltig IPv4-adress", + "Invalid IPv4 gateway": "Ogiltig IPv4-gateway", "Invalid Intel render path": "Invalid Intel render bana", "Invalid Jellyfin path:": "Invalid Jellyfin väg:", "Invalid MAC address:": "Ogiltig MAC-adress:", @@ -3246,6 +3249,7 @@ "Invalid administrator email": "Invalid administratör e-post", "Invalid administrator user name": "Invalid administratör användarnamn", "Invalid base VMID": "Ogiltig bas VMID", + "Invalid bridge": "Ogiltig brygga", "Invalid check package:": "Ogiltigt checkpaket:", "Invalid configuration path:": "Ogiltig konfigurationsväg:", "Invalid consume/export volumes": "Ogiltiga konsum/exportvolymer", @@ -3309,6 +3313,7 @@ "Invalid remote name": "Ogiltigt fjärrnamn", "Invalid remote path": "Invalid fjärrväg", "Invalid repair version:": "Ogiltig reparationsversion:", + "Invalid resource value": "Ogiltigt resursvärde", "Invalid resources": "Ogiltiga resurser", "Invalid restored volume path": "Invalid återställd volymväg", "Invalid running state": "Ogiltig körstatus", @@ -3327,6 +3332,7 @@ "Invalid stack members": "Ogiltiga stackmedlemmar", "Invalid stack name": "Invalid stack namn", "Invalid stack operation": "Ogiltig stack operation", + "Invalid storage": "Ogiltig lagring", "Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "Ogiltigt lagrings-ID. Använd endast bokstäver, siffror, bindestreck och understreck.", "Invalid storage name": "Ogiltigt lagringsnamn", "Invalid suite application": "Invalid suite applikation", @@ -4888,6 +4894,7 @@ "Proxmox VE configuration completed.": "Proxmox VE-konfigurationen slutförd.", "Proxmox VE safe update completed": "Proxmox VE säker uppdatering slutförd", "Proxmox auth logger service created and started": "Proxmox authlogger-tjänst skapad och startade", + "Proxmox could not apply the container settings": "Proxmox kunde inte tillämpa containerns inställningar", "Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Proxmox företagsrepo kan saknas eller är otillgänglig. Försöker byta till ingen prenumeration...", "Proxmox filter configured": "Proxmox-filter konfigurerat", "Proxmox iSCSI Storage Status:": "Proxmox iSCSI-lagringsstatus:", @@ -6327,6 +6334,7 @@ "The build could not be checked beforehand; continuing without that check.": "Bygget kunde inte kontrolleras i förväg;fortsätter utan den kontrollen.", "The cached image does not match the current digest": "Den cachade bilden matchar inte den nuvarande smältningen", "The cached image is damaged; it will be downloaded again.": "Den cachade bilden är skadad; den kommer att laddas ner igen.", + "The changes need the application installer": "Ändringarna kräver programmets installationsverktyg", "The character device does not exist:": "Karaktärenheten existerar inte:", "The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Kommandot begär ett lösenord som inte echoed. Efter att ha skapat användarna ber webbgränssnittet om ett användarnamn och ett lösenord.", "The command does not name an image": "Kommandot heter inte en bild", @@ -6446,6 +6454,7 @@ "The host bind source does not exist:": "Värdbindningskällan finns inte:", "The host bind source is not a regular file or directory:": "Värdbindningskällan är inte en vanlig fil eller katalog:", "The host directory changed before it was mounted": "Värdkatalogen ändrades innan den monterades", + "The host directory does not exist": "Katalogen på värden finns inte", "The host directory may not be accessible from an unprivileged container.": "Värdkatalogen kanske inte är tillgänglig från en oprivilegierad behållare.", "The host has no IPv4 address on the selected bridge": "Värden har ingen IPv4-adress på den valda bron", "The host monitor does not see the real host memory": "Värdskärmen ser inte det verkliga värdminnet", @@ -6683,6 +6692,7 @@ "The selected VM": "Den valda virtuella datorn", "The selected VM is running.": "Den valda virtuella datorn körs.", "The selected base folder does not exist and could not be created:": "Den valda basmappen finns inte och kunde inte skapas:", + "The selected bridge does not exist": "Den valda bryggan finns inte", "The selected bridge has no IPv4 subnet for the host-monitor firewall": "Den valda bryggan har inget IPv4-undernät för värd-monitorns brandvägg", "The selected configuration needs to start the LXC during the installation": "Den valda konfigurationen måste starta LXC under installationen", "The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Den valda behållaren är oprivilegierad. En privilegierad behållare krävs för direkt enhetsgenomföring.", @@ -6705,6 +6715,7 @@ "The service builds its own image; only a published image can be installed": "Tjänsten bygger sin egen bild; endast en publicerad bild kan installeras.", "The service declares no image:": "Tjänsten förklarar ingen bild:", "The setting has no final value:": "Inställningen har inget slutvärde:", + "The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Det gick inte att återställa alla inställningar. Kontrollera containern och den sparade ändringen innan du fortsätter.", "The share already exists in smb.conf:": "Delningen finns redan i smb.conf:", "The shared destination is not a directory": "Den delade destinationen är inte en katalog", "The shared directory points to a protected host path": "Den delade katalogen pekar på en skyddad värdväg", @@ -6779,6 +6790,7 @@ "These interface configurations will be removed": "Dessa gränssnittskonfigurationer kommer att tas bort", "These manual changes cannot be adopted safely:": "Dessa manuella ändringar kan inte användas på ett säkert sätt:", "These paths will not be restored live and will be extracted for manual recovery.": "Dessa sökvägar kommer inte att återställas live och kommer att extraheras för manuell återställning.", + "These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Dessa inställningar kräver en anpassning för just det här programmet. Avbildningen hämtades inte och containern återskapades inte.", "These values are asked during the installation:": "Dessa värden tillfrågas under installationen:", "They are described in the image documentation:": "De beskrivs i bilddokumentationen:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "De kommer att läggas till oci-compose innan du fortsätter. Resurser som inte stöds eller ändras importeras inte.", @@ -7114,6 +7126,7 @@ "Unsupported mount type": "Unsupported Mount Typ", "Unsupported mount type:": "Unsupported Mount Typ:", "Unsupported native device type:": "Inbyggd enhetstyp som inte stöds:", + "Unsupported network settings": "Nätverksinställningarna stöds inte", "Unsupported operation": "Unsupported operation", "Unsupported or unknown Proxmox version; no repository changed.": "Ostödd eller okänd version av Proxmox; inget arkiv har ändrats.", "Unsupported output format:": "Utdataformat som inte stöds:", diff --git a/oci/README.md b/oci/README.md index bde5cfb3..27ea49b3 100644 --- a/oci/README.md +++ b/oci/README.md @@ -142,7 +142,7 @@ An application made of several images is one entry of the catalog and one instal | Operation | What changes | What is kept | |---|---|---| | **Update** | The rootfs is replaced by the image the saved tag publishes today | Record, container disks, host directories, network and devices | -| **Modify** | An editor opens with the current resources, network, paths and GPU; the container is rebuilt with the changes | The data of container disks and host directories | +| **Modify** | Choose a setting, review it, then apply native LXC changes to the existing container; runtime settings may require a restart | Installed image, rootfs, persistent data and unrelated Proxmox settings | | **Recreate** (multi-container) | Every container is rebuilt from the image it was installed with | Data, network and devices | | **Watchdog** | Whether the application is started again when it stops on its own | Everything; only the choice is saved | | **Remove** | The LXC, or every container of the application, its disks and its record | Host directories, with their content | @@ -159,6 +159,21 @@ Settings changed in Proxmox VE after the installation, such as memory, cores or A rolling tag is not an unattended update: the digest changes only through an explicit update. +For standalone applications, CPU and RAM changes apply live. Network settings, +environment overrides, directory mounts, additional managed volumes and supported +native devices use a controlled stop/start when needed. Modify does not query the +registry or download an image for these edits, even if the cached archive has +already been cleaned. Removing a managed mount detaches its disk without deleting +its data. An optional retained backup does not force recreation. + +The native path checks the saved configuration and installation identity, keeps a +private before/after journal, and restores the original settings if applying them +or checking the restarted service fails. It refuses to overwrite concurrent edits. +Application-specific installer adaptations (such as changing an NVIDIA runtime or +post-start configuration) are not silently converted into a full recreation: +unsupported same-image changes stop with an explanation before modifying the CT. +Image changes and coordinated stack operations retain their separate update flow. + ## From a Compose file or a docker run command to an oci-compose The converter reads a Compose file with one service. A `docker run` command and an image reference alone are first written as a Compose file and follow the same path, so the three produce the same kind of oci-compose. diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index 59579967..25f9bf6c 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -453,7 +453,7 @@ def external_changes(record, config, adopt=True): return values -def preflight(record, candidate, config, coordinated=None): +def preflight(record, candidate, config, coordinated=None, in_place=False, baseline=True): deployment = record['deployment'] desired = candidate['deployment'] if coordinated and (record.get('native_stack_intent') or deployment.get('rootfs_adaptation_replay_required')): @@ -462,7 +462,7 @@ def preflight(record, candidate, config, coordinated=None): raise ValueError(translate('Stack members are updated together with their stack')) if instances.identity(config) != record['installation_id']: raise ValueError(translate('The container identity changed; the container is not replaced')) - for key, value in external_changes(record, config, adopt=not coordinated).items(): + for key, value in (external_changes(record, config, adopt=not coordinated) if baseline else {}).items(): section, name = ADOPTABLE[key] recorded = deployment.get(section, {}) if section else deployment target = desired.setdefault(section, {}) if section else desired @@ -491,7 +491,8 @@ def preflight(record, candidate, config, coordinated=None): runtime_keys.add('lxc.mount.entry') runtime_settings.check(config, deployment, record['vmid']) coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS) - if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k)}: + preserved_keys = {'nameserver', 'searchdomain', 'timezone', 'protection'} if in_place else set() + if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - preserved_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k) or (in_place and re.fullmatch(r'unused[0-9]+', k))}: raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile')) unprivileged = deployment.get('security', {}).get('unprivileged', True) is not False if (desired.get('security', {}).get('unprivileged', True) is not False) != unprivileged \ @@ -532,7 +533,8 @@ def preflight(record, candidate, config, coordinated=None): raise ValueError(translate('Changing the rootfs or its storage requires a separate migration')) actual = mounts(config) gpu_devices.check(config, deployment) - gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment) + if baseline: + gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment) recorded_files = {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)} if files != recorded_files: raise ValueError(translate('A container mount has a source, backup or permission different from the saved record')) @@ -1273,6 +1275,9 @@ def complete_recovery(root, journal, state): def recover(root, journal): state = json.loads(journal.read_text()) + if state.get('kind') == 'native-modify': + import oci_native_modify + return oci_native_modify.recover(root, journal) vmid = state['vmid'] show = not state.get('coordinated') if show: diff --git a/oci/remote/oci_native_modify.py b/oci/remote/oci_native_modify.py new file mode 100644 index 00000000..8538995c --- /dev/null +++ b/oci/remote/oci_native_modify.py @@ -0,0 +1,396 @@ +"""Edit an installed application's LXC configuration, retaining its rootfs/data. + +Runtime changes use a controlled stop/start, not pct destroy/create. Values, +including secrets and NUL-separated environment, reach PVE over stdin only. +""" +import copy +import hashlib +import ipaddress +import json +from pathlib import Path +import re +import subprocess +import uuid + +import oci_instances as instances +import oci_instance_transaction as transaction +import oci_resource_modify as resources +import oci_gpu_devices as devices +import oci_host_mounts as host_mounts +from oci_installation_state import parse_config, sha +from oci_ui import translate, msg_info, msg_ok, msg_warn + +PERL_UPDATE = r''' +use strict; use warnings; use JSON::PP; +use PVE::RPCEnvironment; use PVE::INotify; use PVE::API2::LXC::Config; +PVE::RPCEnvironment->setup_default_cli_env(); +my $p = decode_json(do { local $/; }); +$p->{node} = PVE::INotify::nodename(); +PVE::API2::LXC::Config->update_vm($p); +''' + + +def environment(config): + result = {} + entries = [] + for line in config.decode().splitlines(): + if line.startswith('lxc.environment.runtime: '): + entries.append(line.split(': ', 1)[1]) + elif line.startswith('env: '): + # pct config exposes PVE's public env property, whereas older + # observations and native files contain repeated runtime lines. + entries.extend(line.split(': ', 1)[1].split('\0')) + for entry in entries: + name, sep, value = entry.partition('=') + if not sep or name in result: + raise ValueError(translate('Duplicate variable in the contract; review it before editing')) + result[name] = value + return result + + +def environment_values(items): + result = {} + for item in items: + name, value = item['name'], str(item['value']) + if (not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) or name in result + or any(ord(c) < 32 or ord(c) == 127 for c in value)): + raise ValueError(translate('The value contains an unsupported character')) + result[name] = value + return result + + +def changes(record, candidate, config): + """Only unchanged recipes and native settings; adapters keep their installer.""" + old = transaction.with_default_healthcheck(copy.deepcopy(record)) + candidate = transaction.with_default_healthcheck(copy.deepcopy(candidate)) + if (old['template'] != candidate['template'] or old['deployment'].get('host_monitor') + or any(k in record for k in ('stack', 'stack_member', 'native_stack_intent')) + or old['deployment'].get('stack_managed')): + return None + before, after = old['deployment'], candidate['deployment'] + changed = {k for k in before.keys() | after.keys() if before.get(k) != after.get(k)} + allowed = {'resources', 'network', 'environment', 'devices', 'mounts', 'hardware_profile'} + if not changed or changed - allowed: + return None + resource_keys = {k for k in before.get('resources', {}).keys() | after.get('resources', {}).keys() + if before.get('resources', {}).get(k) != after.get('resources', {}).get(k)} + if resource_keys - {'cores', 'memory_mb', 'swap_mb', 'cpu_units'}: + return None + if 'devices' in changed: + if any(d.get('kind', 'character-device') not in ('character-device', 'block-device') + or d.get('append_host_device_gid_to_environment') + for plan in (before, after) for d in plan.get('devices', [])): + return None + if before.get('device_permissions') or after.get('device_permissions'): + return None + if 'mounts' in changed: + if any(transaction.is_file_bind(m) for plan in (before, after) for m in plan.get('mounts', [])): + return None + return changed + + +def plan(record, candidate, config): + changed = changes(record, candidate, config) + if not changed: + return None + transaction.preflight(record, candidate, config, in_place=True) + old, new = record['deployment'], candidate['deployment'] + cfg = parse_config(config) + values, delete = {}, [] + if 'resources' in changed: + for key, native, minimum in (('cores', 'cpulimit' if 'cpulimit' in cfg and 'cores' not in cfg else 'cores', 1), + ('memory_mb', 'memory', 128), ('swap_mb', 'swap', 0), ('cpu_units', 'cpuunits', 1)): + value = new['resources'].get(key) + if old['resources'].get(key) == value: + continue + if value is None and key == 'cpu_units': + if native in cfg: delete.append(native) + elif isinstance(value, bool) or not isinstance(value, int) or value < minimum: + raise ValueError(translate('Invalid resource value')) + else: + values[native] = str(value) + if 'network' in changed: + network = new['network'] + if set(network) - {'bridge', 'ipv4', 'gateway', 'firewall', 'host_managed', 'mac_address'}: + raise ValueError(translate('Unsupported network settings')) + if not re.fullmatch(r'[A-Za-z0-9_.:-]+', network['bridge']): + raise ValueError(translate('Invalid bridge')) + if not Path('/sys/class/net', network['bridge'], 'bridge').is_dir(): + raise ValueError(translate('The selected bridge does not exist')) + address = network['ipv4'] + if address not in ('dhcp', 'manual'): + if ipaddress.ip_interface(address).version != 4: + raise ValueError(translate('Invalid IPv4 address')) + gateway = network.get('gateway') + if gateway and ipaddress.ip_address(gateway).version != 4: + raise ValueError(translate('Invalid IPv4 gateway')) + fields = dict(p.split('=', 1) for p in cfg['net0'].split(',') if '=' in p) + fields.update(bridge=network['bridge'], ip=address) + if gateway: fields['gw'] = gateway + else: fields.pop('gw', None) + for key, native in (('firewall', 'firewall'), ('host_managed', 'host-managed')): + if old['network'].get(key) != network.get(key): + fields[native] = '1' if network.get(key) else '0' + if old['network'].get('mac_address') != network.get('mac_address'): + raise ValueError(translate('The MAC address of the container cannot be kept')) + values['net0'] = ','.join(k+'='+v for k, v in fields.items()) + if 'environment' in changed: + env = environment(config) + previous = environment_values(old.get('environment', [])) + wanted = environment_values(new.get('environment', [])) + defaults = dict(item.split('=', 1) for item in record['observed']['image'].get('defaults', {}).get('Env', []) if '=' in item) + for name in previous.keys() - wanted.keys(): + if name in defaults: env[name] = defaults[name] + else: env.pop(name, None) + env.update(wanted) + if env: values['env'] = '\0'.join(k+'='+v for k, v in env.items()) + else: delete.append('env') + if 'devices' in changed: + inventory = devices.planned(new) + existing = {v['path']: k for k, v in ((k, dict(p.split('=', 1) for p in v.split(','))) + for k, v in cfg.items() if re.fullmatch(r'dev[0-9]+', k))} + used = set(existing.values()) + for path, key in existing.items(): + if path not in inventory: delete.append(key) + for device in new.get('devices', []): + path = device['host_path'] + key = existing.get(path) + if not key: + key = next('dev'+str(n) for n in range(256) if 'dev'+str(n) not in used) + used.add(key) + info = inventory[path] + mode = info['mode'] if device.get('mode') == 'preserve-host' else int(device.get('mode', '0660'), 8) + values[key] = (f"path={path},mode={mode:04o},uid={int(device.get('uid', 0))}," + f"gid={info['gid'] if device['gid_strategy'] == 'host-device-gid' else 0}," + f"deny-write={1 if device.get('deny_write') else 0}") + if 'mounts' in changed: + actual = transaction.mounts(config) + used = {m['key'] for m in actual.values()} + wanted = {m['container_path']: m for m in new.get('mounts', [])} + for target, mount in actual.items(): + if target not in wanted: delete.append(mount['key']) + for target, mount in wanted.items(): + if target in actual: + original = next(m for m in old['mounts'] if m['container_path'] == target) + if mount == original: continue + fields = dict(p.split('=', 1) for p in actual[target]['value'].split(',')[1:] if '=' in p) + fields['ro'] = '1' if mount.get('read_only') else '0' + values[actual[target]['key']] = actual[target]['volume']+','+','.join(k+'='+v for k, v in fields.items()) + continue + key = next('mp'+str(n) for n in range(256) if 'mp'+str(n) not in used) + used.add(key) + source = mount['source'] + if mount['type'] == 'managed-volume': + if not re.fullmatch(r'[A-Za-z0-9_-]+', source): + raise ValueError(translate('Invalid storage')) + source += ':'+str(mount['size_gb']) + else: + source = host_mounts.valid_path(source) + if not Path(source).is_dir(): + if not mount.get('create_if_missing'): + raise ValueError(translate('The host directory does not exist')) + values[key] = (f"{source},mp={target},backup={1 if mount['backup'] else 0}," + f"ro={1 if mount.get('read_only') else 0}") + return {'values': values, 'delete': delete, 'restart': bool(changed - {'resources'}), + 'changed': sorted(changed)} + + +def verify_values(config, operation): + """PVE may canonicalize property strings; compare their actual fields.""" + cfg = parse_config(config) + for key, value in operation['values'].items(): + if key == 'env': + if environment(config) != dict(item.split('=', 1) for item in value.split('\0')): + raise ValueError(translate('Proxmox could not apply the container settings')) + elif re.fullmatch(r'(mp|dev|net)[0-9]+', key): + desired = dict(p.split('=', 1) for p in value.split(',') if '=' in p) + actual = dict(p.split('=', 1) for p in cfg.get(key, '').split(',') if '=' in p) + defaults = {'uid': '0', 'gid': '0', 'deny-write': '0', 'ro': '0', 'backup': '0', 'mode': '0660', 'firewall': '0'} + def matches(k, v): + observed = actual.get(k, defaults.get(k)) + if k == 'mode': return observed is not None and int(observed, 8) == int(v, 8) + return observed == v + if any(not matches(k, v) for k, v in desired.items()): + raise ValueError(translate('Proxmox could not apply the container settings')) + elif cfg.get(key) != value: + raise ValueError(translate('Proxmox could not apply the container settings')) + if any(key in cfg or (key == 'env' and environment(config)) for key in operation['delete']): + raise ValueError(translate('Proxmox could not apply the container settings')) + + +def set_config(vmid, installation_id, expected, values, delete=()): + raw = (resources.CONFIG_DIR / f'{vmid}.conf').read_bytes() + if instances.command('pct', 'config', str(vmid)) != expected or instances.identity(expected) != installation_id: + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + payload = dict(values, vmid=vmid, digest=hashlib.sha1(raw).hexdigest()) + if delete: payload['delete'] = ','.join(delete) + transaction.log('Applying native settings: '+', '.join(sorted(set(values) | set(delete)))) + process = subprocess.run(['perl', '-e', PERL_UPDATE], input=json.dumps(payload).encode(), + capture_output=True, timeout=120) + # API errors may include secret values. Only keep the exit status. + if process.returncode: + raise RuntimeError(translate('Proxmox could not apply the container settings')) + + +def untouched(before, after, keys): + keys = set(keys) + if 'env' in keys: keys.add('lxc.environment.runtime') + def keep(config): + return b''.join(line for line in config.splitlines(keepends=True) + if line.split(b':', 1)[0].decode() not in keys + and not re.match(rb'unused[0-9]+:', line)) + return keep(before) == keep(after) + + +def restore_settings(root, record, before, now, operation): + vmid = record['vmid'] + keys = set(operation['values']) | set(operation['delete']) + if operation['restart']: transaction.stop(vmid) + original = parse_config(before) + restore = {k: original[k] for k in keys if k in original and k != 'env'} + remove = [k for k in keys if k not in original and k != 'env' and k in parse_config(now)] + if 'env' in keys: + env = environment(before) + if env: restore['env'] = '\0'.join(k+'='+v for k, v in env.items()) + elif environment(now): remove.append('env') + if restore or remove: + set_config(vmid, record['installation_id'], now, restore, remove) + restored_config = instances.command('pct', 'config', str(vmid)) + verify_values(restored_config, {'values': restore, 'delete': remove}) + if not untouched(before, restored_config, keys): + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + restored = copy.deepcopy(record) + obs = record['observed'] + restored['observed'] = instances.observe(vmid, record['installation_id'], + obs['archive_path'], obs['resolved_registry_digest'], obs['image']) + if restored['observed']['config_sha256'] != sha(restored_config): + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + return restored + + +def recover(root, journal): + """Recover through the normal Manage interrupted-operation menu.""" + state = json.loads(journal.read_text()) + record, operation = state['record'], state['operation_plan'] + vmid = record['vmid'] + current = instances.read(root, vmid) + if (state['kind'] != 'native-modify' or state['phase'] in ('committed', 'rolled-back') + or current.get('pending_transaction') != str(journal) + or current.get('transaction_id') != state['id'] + or current['installation_id'] != record['installation_id']): + raise ValueError(translate('The record does not belong to this operation')) + before = state['config'].encode() + now = instances.command('pct', 'config', str(vmid)) + keys = set(operation['values']) | set(operation['delete']) + if instances.identity(now) != record['installation_id'] or not untouched(before, now, keys): + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + if state.get('after_config'): + if now not in (before, state['after_config'].encode()): + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + elif now != before: + # A crash during the PVE API call is recoverable only when the full + # planned edit is present. Refuse unknown partial/concurrent changes. + verify_values(now, operation) + host_mounts.verify_sources(state.get('original_host_sources', {})) + tx_record = restore_settings(root, record, before, now, operation) if now != before else copy.deepcopy(record) + if state['was_running']: + if transaction.run('pct', 'status', str(vmid)).strip() != b'status: running': + transaction.run('pct', 'start', str(vmid)) + transaction.healthcheck(vmid, record['template']) + instances.write(instances.location(root, vmid), tx_record) + state['phase'] = 'rolled-back' + instances.write(journal, state) + msg_ok(translate('Previous installation restored')) + + +def apply(root, record, candidate, config, acknowledge_external_data=False): + operation = plan(record, candidate, config) + if not operation: + raise ValueError(translate('The changes need the application installer')) + original_sources, desired_sources = transaction.freeze_host_sources( + record, candidate, acknowledge_external_data) + vmid = record['vmid'] + folder = instances.location(root, vmid).parent / 'modifications' / uuid.uuid4().hex + running = transaction.run('pct', 'status', str(vmid)).strip() == b'status: running' + journal = {'kind': 'native-modify', 'id': folder.name, 'vmid': vmid, 'operation': 'recreate', + 'record': record, 'config': config.decode(), 'operation_plan': operation, + 'phase': 'prepared', 'was_running': running, 'original_host_sources': original_sources} + instances.write(folder / 'before.json', journal) + transaction.open_log(folder) + transaction._run['journal'] = folder / 'before.json' + pending = copy.deepcopy(record) + pending.update(status='updating', transaction_id=folder.name, pending_transaction=str(folder / 'before.json')) + instances.write(instances.location(root, vmid), pending) + updated_config = None + changed = False + try: + if operation['restart'] and running: + transaction.stop(vmid) + # Do not accept a concurrent edit merely because the service stopped. + if instances.command('pct', 'config', str(vmid)) != config: + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + host_mounts.verify_sources(desired_sources) + for mount in candidate['deployment'].get('mounts', []): + if mount['type'] == 'host-bind' and mount.get('create_if_missing'): + Path(mount['source']).mkdir(parents=True, exist_ok=True) + host_mounts.verify_sources(original_sources) + # Capture newly-created directories before installing their mounts. + pinned = {p: host_mounts.validate_source(p) for p in desired_sources} + for path, previous in desired_sources.items(): + if previous['exists'] and not host_mounts.same_source(previous, pinned[path]): + raise ValueError(translate('A shared source does not match its recorded identity')) + host_mounts.verify_sources(pinned) + msg_info(translate('Applying container settings without rebuilding the container...')) + if operation['values'] or operation['delete']: + changed = True # API updates may partially succeed before an error. + set_config(vmid, record['installation_id'], config, operation['values'], operation['delete']) + updated_config = instances.command('pct', 'config', str(vmid)) + verify_values(updated_config, operation) + keys = set(operation['values']) | set(operation['delete']) + if not untouched(config, updated_config, keys): + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + transaction.preflight(candidate, candidate, updated_config, in_place=True, baseline=False) + journal.update(phase='configured', after_config=updated_config.decode()) + instances.write(folder / 'before.json', journal) + if operation['restart'] and running: + transaction.run('pct', 'start', str(vmid)) + transaction.healthcheck(vmid, candidate['template']) + observed = record['observed'] + fresh = instances.observe(vmid, record['installation_id'], observed['archive_path'], + observed['resolved_registry_digest'], observed['image']) + host_mounts.verify_observation(pinned, fresh) + if fresh['config_sha256'] != sha(updated_config): + raise ValueError(translate('The instance changed while it was being edited; open Modify again')) + result = copy.deepcopy(record) + result.update(deployment=copy.deepcopy(candidate['deployment']), observed=fresh) + instances.write(instances.location(root, vmid), result) + journal['phase'] = 'committed' + instances.write(folder / 'before.json', journal) + except BaseException: + try: + now = instances.command('pct', 'config', str(vmid)) + keys = set(operation['values']) | set(operation['delete']) + if changed: + if not untouched(config, now, keys) or (updated_config is not None and now != updated_config): + raise ValueError('Concurrent edit; rollback refused') + restored = restore_settings(root, record, config, now, operation) + else: + restored = copy.deepcopy(record) + if operation['restart'] and running: + if transaction.run('pct', 'status', str(vmid)).strip() != b'status: running': + transaction.run('pct', 'start', str(vmid)) + transaction.healthcheck(vmid, record['template']) + instances.write(instances.location(root, vmid), restored) + journal['phase'] = 'rolled-back' + instances.write(folder / 'before.json', journal) + except Exception as error: + transaction.log('native rollback failed: '+type(error).__name__) + msg_warn(translate('The settings could not be fully rolled back. Review the container and its saved modification before continuing.')) + raise + try: + import oci_carried_record + oci_carried_record.carry(root, vmid, mount_stopped=False) + except (OSError, ValueError, RuntimeError): + transaction.log('carried record will be refreshed on the next management entry') + msg_ok(translate('Changes applied. Data kept.')) + return folder diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py index b5101fc9..ca2bb142 100644 --- a/oci/remote/oci_update_current.py +++ b/oci/remote/oci_update_current.py @@ -180,6 +180,16 @@ def application_name(record, vmid): return f'CT {vmid}' +def backup_native_settings(vmid, storage): + """An optional retained backup does not require rebuilding the guest.""" + if storage: + import oci_keep_backup + oci_keep_backup.validate(storage) + transaction.run('vzdump', str(vmid), '--storage', storage, '--mode', 'snapshot', + '--compress', 'zstd', '--notes-template', + 'ProxMenux OCI: before modifying container settings') + + def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=None): operation = 'recreate' if proposal is not None else 'update' msg_info(translate('Checking the container before the update...') if operation == 'update' @@ -192,7 +202,7 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non desired = transaction.candidate_contract(record, operation, proposal) changes = transaction.external_changes(record, config) resource_changes = None - if operation == 'recreate' and not keep_backup and not changes: + if operation == 'recreate' and not changes: import oci_resource_modify resource_changes = oci_resource_modify.changes(record, desired, config) # CPU/RAM edits retain the installed image and all unrelated native @@ -202,7 +212,22 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non msg_ok(translate('Container checked')) import oci_operation_notice with oci_operation_notice.operation([vmid], 'modify', application_name(record, vmid)): + backup_native_settings(vmid, keep_backup) return oci_resource_modify.apply(instances.ROOT, record, desired, config, resource_changes) + if operation == 'recreate' and not changes: + import oci_native_modify + if oci_native_modify.changes(record, desired, config): + import oci_operation_notice + with oci_operation_notice.operation([vmid], 'modify', application_name(record, vmid)): + # Validate the native plan before an optional full backup. + oci_native_modify.plan(record, desired, config) + backup_native_settings(vmid, keep_backup) + return oci_native_modify.apply(instances.ROOT, record, desired, config, + acknowledge_external_data=acknowledge_external_data) + if (operation == 'recreate' + and record['template']['container_contract']['image']['reference'] + == desired['template']['container_contract']['image']['reference']): + raise ValueError(translate('These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.')) transaction.preflight(record, desired, config) msg_ok(translate('Container checked')) current = record['observed']['image'] if operation == 'update' else None diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index a10f92c5..f8e88d43 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -1588,7 +1588,7 @@ def _run_remote_install( shutil.copy2(project_root / 'remote' / 'haos_healthcheck.py', temporary / 'haos_healthcheck.py') shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py') shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py') - for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'): + for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'): shutil.copy2(project_root / 'remote' / helper, temporary / helper) if deployment_kind == 'generic-multi-lxc-stack': shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py') @@ -1625,7 +1625,7 @@ def _run_remote_install( archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py') archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py') archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py') - for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'): + for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'): archive.add(project_root / 'remote' / helper, arcname=helper) if deployment_kind == 'generic-multi-lxc-stack': archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py') diff --git a/oci/tests/test_modify_image_reuse.py b/oci/tests/test_modify_image_reuse.py index 95c7339d..01d475c0 100644 --- a/oci/tests/test_modify_image_reuse.py +++ b/oci/tests/test_modify_image_reuse.py @@ -287,6 +287,7 @@ class UpdateRoutingTests(unittest.TestCase): candidate['deployment']['resources']['memory_mb'] = 768 if not resource_edit: candidate['deployment']['environment'][0]['value'] = 'changed' + candidate['template']['container_contract']['image']['reference'] = 'example/app:other' proposal = {'operation': 'recreate', 'candidate': candidate} import contextlib, oci_operation_notice with patch.object(instances, 'locked', return_value=contextlib.nullcontext()), \ diff --git a/oci/tests/test_native_modify.py b/oci/tests/test_native_modify.py new file mode 100644 index 00000000..62e37885 --- /dev/null +++ b/oci/tests/test_native_modify.py @@ -0,0 +1,258 @@ +"""Native runtime edits keep rootfs, defaults and secrets; failures roll back.""" +import contextlib +import copy +import json +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch, Mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'remote')) +import oci_native_modify as native +import oci_instances as instances +import oci_update_current as update +import oci_operation_notice +from test_modify_image_reuse import record, CONFIG + + +class NativePlanTests(unittest.TestCase): + def setUp(self): + self.record = record() + self.record['observed']['image']['defaults'] = {'Env': ['A=image-default']} + self.record['deployment']['environment'] = [{'name': 'A', 'value': 'one'}] + self.config = CONFIG+b'net0: name=eth0,bridge=vmbr0,ip=dhcp,hwaddr=AA:BB:CC:DD:EE:FF,tag=20,ip6=auto\n' + self.candidate = copy.deepcopy(self.record) + p = patch.object(native.transaction, 'preflight') + self.preflight = p.start(); self.addCleanup(p.stop) + + def test_environment_preserves_image_defaults_and_other_runtime_values(self): + self.candidate['deployment']['environment'] = [{'name': 'SECRET', 'value': "a' $x \" ; = value", 'sensitive': True}] + result = native.plan(self.record, self.candidate, self.config) + env = dict(x.split('=', 1) for x in result['values']['env'].split('\0')) + self.assertEqual(env, {'A': 'image-default', 'B': 'two', 'SECRET': "a' $x \" ; = value"}) + self.assertTrue(result['restart']) + + def test_remove_environment_without_default_restores_image_environment(self): + self.record['observed']['image']['defaults'] = {} + self.candidate['deployment']['environment'] = [] + self.assertEqual(native.plan(self.record, self.candidate, self.config)['values']['env'], 'B=two') + + def test_public_pct_environment_and_native_lines_have_the_same_values(self): + raw=b'lxc.environment.runtime: A=one\nlxc.environment.runtime: B=two\n' + public=b'env: A=one\0B=two\n' + self.assertEqual(native.environment(raw),native.environment(public)) + self.assertEqual(native.environment(public),{'A':'one','B':'two'}) + + def test_duplicate_invalid_and_control_character_values_are_refused(self): + for items in ([{'name':'X','value':'a'},{'name':'X','value':'b'}], + [{'name':'X','value':'a\nb'}],[{'name':'BAD NAME','value':'a'}]): + self.candidate['deployment']['environment'] = items + with self.assertRaises(ValueError): native.plan(self.record, self.candidate, self.config) + + def test_network_preserves_mac_vlan_ipv6_and_interface_identity(self): + self.candidate['deployment']['network'].update(ipv4='192.0.2.2/24', gateway='192.0.2.1') + with patch.object(Path, 'is_dir', return_value=True): + result = native.plan(self.record, self.candidate, self.config) + fields = dict(x.split('=',1) for x in result['values']['net0'].split(',')) + self.assertEqual(fields['hwaddr'], 'AA:BB:CC:DD:EE:FF') + self.assertEqual(fields['tag'], '20'); self.assertEqual(fields['ip6'], 'auto') + self.assertEqual(fields['ip'], '192.0.2.2/24'); self.assertEqual(fields['gw'], '192.0.2.1') + + def test_invalid_network_is_rejected_before_stopping(self): + for value in ('nonsense', '2001:db8::1/64'): + self.candidate['deployment']['network']['ipv4'] = value + with patch.object(Path, 'is_dir', return_value=True), self.assertRaises(ValueError): + native.plan(self.record,self.candidate,self.config) + + def test_new_managed_disk_uses_native_allocation_and_existing_disk_is_untouched(self): + self.candidate['deployment']['mounts'] = [{'type':'managed-volume','container_path':'/extra', + 'source':'local-zfs','size_gb':1,'backup':True,'read_only':False}] + result = native.plan(self.record,self.candidate,self.config) + self.assertEqual(result['values'], {'mp0':'local-zfs:1,mp=/extra,backup=1,ro=0'}) + self.assertNotIn('rootfs', result['values']) + + def test_removing_mount_detaches_it_without_freeing_its_volume(self): + mount={'type':'managed-volume','container_path':'/extra','source':'local-zfs','size_gb':1,'backup':True} + self.record['deployment']['mounts']=[mount] + result=native.plan(self.record,self.candidate,self.config+b'mp4: local-zfs:subvol-115-disk-1,mp=/extra,backup=1,size=1G\n') + self.assertEqual(result['delete'],['mp4']) + + def test_image_or_installer_adapter_changes_do_not_take_native_path(self): + for key in ('image', 'adapter'): + candidate=copy.deepcopy(self.record) + if key=='image':candidate['template']['container_contract']['image']['reference']='new/image' + else:candidate['deployment']['post_start_configurations']=[{'id':'rootfs-adapter'}] + self.assertIsNone(native.changes(self.record,candidate,self.config)) + + def test_secrets_use_stdin_never_arguments_or_logs(self): + secret="super-secret with 'quotes' and $shell" + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);(root/'115.conf').write_bytes(self.config) + with patch.object(native.resources,'CONFIG_DIR',root), \ + patch.object(instances,'command',return_value=self.config), \ + patch.object(native.transaction,'log') as log, \ + patch.object(native.subprocess,'run',return_value=Mock(returncode=0)) as run: + native.set_config(115,self.record['installation_id'],self.config,{'env':'SECRET='+secret+'\0B=two'}) + self.assertNotIn(secret,str(run.call_args.args)) + self.assertNotIn(secret,str(log.call_args_list)) + self.assertIn(secret,json.loads(run.call_args.kwargs['input'])['env']) + + def test_native_api_failure_does_not_echo_secret_stderr(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);(root/'115.conf').write_bytes(self.config) + with patch.object(native.resources,'CONFIG_DIR',root),patch.object(instances,'command',return_value=self.config), \ + patch.object(native.subprocess,'run',return_value=Mock(returncode=1,stderr=b'secret')): + with self.assertRaisesRegex(RuntimeError,'Proxmox could not apply') as error: + native.set_config(115,self.record['installation_id'],self.config,{'env':'SECRET=secret'}) + self.assertNotIn('secret',str(error.exception)) + + def test_verifies_all_environment_entries_and_native_values(self): + native.verify_values(CONFIG,{'values':{'env':'A=one\0B=two','cores':'2'},'delete':[]}) + for values in ({'env':'A=wrong\0B=two'},{'cores':'3'}): + with self.assertRaises(ValueError):native.verify_values(CONFIG,{'values':values,'delete':[]}) + with self.assertRaises(ValueError):native.verify_values(CONFIG,{'values':{},'delete':['env']}) + + def test_native_property_canonicalization_keeps_modes_and_default_flags(self): + cfg=CONFIG+b'dev0: path=/dev/fuse,mode=660\n' + native.verify_values(cfg,{'values':{'dev0':'path=/dev/fuse,mode=0660,uid=0,gid=0,deny-write=0'},'delete':[]}) + + +class NativeApplyTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + self.root=Path(self.temp.name) + self.record=record();self.candidate=copy.deepcopy(self.record) + self.candidate['deployment']['environment']=[{'name':'A','value':'changed'}] + self.config=CONFIG + self.state={'config':CONFIG,'running':True} + self.commands=[] + def run(*args): + self.commands.append(args) + if args[:2]==('pct','status'):return b'status: running' if self.state['running'] else b'status: stopped' + if args[:2]==('pct','start'):self.state['running']=True + return b'' + def stop(vmid):self.state['running']=False + def setter(vmid,identity,expected,values,delete=()): + self.assertEqual(expected,self.state['config']) + text=self.state['config'].decode() + if 'env' in values or 'env' in delete: + public='env: ' in text + text=''.join(x for x in text.splitlines(keepends=True) if not x.startswith(('lxc.environment.runtime: ','env: '))) + if public and values.get('env'):text+='env: '+values['env']+'\n' + else: + for item in values.get('env','').split('\0'): + if item:text+='lxc.environment.runtime: '+item+'\n' + self.state['config']=text.encode() + def observe(*args): + observed=copy.deepcopy(self.record['observed']) + observed.update(config=self.state['config'].decode(),config_sha256=native.sha(self.state['config'])) + return observed + patches=[patch.object(native,'plan',return_value={'values':{'env':'A=changed\0B=two'},'delete':[],'restart':True,'changed':['environment']}), + patch.object(native,'set_config',side_effect=setter),patch.object(instances,'command',side_effect=lambda *a:self.state['config']), + patch.object(native.transaction,'run',side_effect=run),patch.object(native.transaction,'stop',side_effect=stop), + patch.object(native.transaction,'healthcheck'),patch.object(native.transaction,'preflight'), + patch.object(native.transaction,'freeze_host_sources',return_value=({},{})), + patch.object(native.transaction,'open_log'),patch.object(native.transaction,'log'), + patch.object(instances,'observe',side_effect=observe),patch('oci_carried_record.carry')] + for p in patches:p.start();self.addCleanup(p.stop) + instances.write(instances.location(self.root,115),self.record) + + def apply(self):return native.apply(self.root,self.record,self.candidate,self.config) + + def test_runtime_edit_restarts_existing_guest_and_keeps_rootfs_image_and_data(self): + folder=self.apply() + saved=instances.read(self.root,115) + self.assertEqual(saved['observed']['image'],self.record['observed']['image']) + self.assertIn(b'rootfs: local-zfs:subvol-115-disk-0',self.state['config']) + self.assertEqual(saved['deployment'],self.candidate['deployment']) + self.assertTrue(self.state['running']) + self.assertEqual(json.loads((folder/'before.json').read_text())['phase'],'committed') + self.assertFalse(any(c[:2] in (('pct','create'),('pct','destroy')) for c in self.commands)) + + def test_failed_healthcheck_rolls_back_and_starts_original_service(self): + with patch.object(native.transaction,'healthcheck',side_effect=[RuntimeError('not healthy'),None]): + with self.assertRaisesRegex(RuntimeError,'not healthy'):self.apply() + self.assertEqual(self.state['config'],CONFIG) + self.assertTrue(self.state['running']) + self.assertEqual(instances.read(self.root,115)['deployment'],self.record['deployment']) + + def test_health_failure_restores_public_nul_separated_environment(self): + self.config=CONFIG.replace(b'lxc.environment.runtime: A=one\nlxc.environment.runtime: B=two\n',b'env: A=one\0B=two\n') + self.state['config']=self.config + self.record['observed'].update(config=self.config.decode(),config_sha256=native.sha(self.config)) + instances.write(instances.location(self.root,115),self.record) + with patch.object(native.transaction,'healthcheck',side_effect=[RuntimeError('not healthy'),None]): + with self.assertRaises(RuntimeError):self.apply() + self.assertEqual(native.environment(self.state['config']),{'A':'one','B':'two'}) + self.assertTrue(self.state['running']) + + def test_stopped_guest_stays_stopped(self): + self.state['running']=False + self.apply() + self.assertFalse(self.state['running']) + self.assertNotIn(('pct','start','115'),self.commands) + + def test_interrupted_edit_is_recovered_through_transaction_dispatch(self): + folder=self.apply() + path=folder/'before.json';state=json.loads(path.read_text());state['phase']='configured' + instances.write(path,state) + pending=copy.deepcopy(self.record) + pending.update(status='updating',transaction_id=folder.name,pending_transaction=str(path)) + instances.write(instances.location(self.root,115),pending) + native.transaction.recover(self.root,path) + self.assertEqual(self.state['config'],CONFIG) + saved=instances.read(self.root,115) + self.assertEqual(saved['status'],'installed');self.assertNotIn('pending_transaction',saved) + self.assertEqual(json.loads(path.read_text())['phase'],'rolled-back') + + def test_recovery_refuses_a_concurrent_environment_edit(self): + folder=self.apply();path=folder/'before.json';state=json.loads(path.read_text());state['phase']='configured' + instances.write(path,state) + pending=copy.deepcopy(self.record) + pending.update(status='updating',transaction_id=folder.name,pending_transaction=str(path)) + instances.write(instances.location(self.root,115),pending) + self.state['config']=self.state['config'].replace(b'A=changed',b'A=concurrent') + with self.assertRaisesRegex(ValueError,'changed'):native.transaction.recover(self.root,path) + self.assertIn(b'A=concurrent',self.state['config']) + + def test_concurrent_change_before_write_is_not_overwritten(self): + def stop(vmid): + self.state['running']=False + self.state['config']+=b'timezone: host\n' + with patch.object(native.transaction,'stop',side_effect=stop): + with self.assertRaisesRegex(ValueError,'changed'):self.apply() + self.assertIn(b'timezone: host',self.state['config']) + self.assertTrue(self.state['running']) + + def test_router_does_not_resolve_download_or_recreate_for_environment_edit(self): + with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ + patch.object(native.transaction,'external_changes',return_value={}), \ + patch.object(update,'resolve_archive',side_effect=AssertionError('registry path')), \ + patch.object(native.transaction,'apply',side_effect=AssertionError('recreation')), \ + patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()): + update.update(115,proposal={'operation':'recreate','candidate':self.candidate}) + self.assertEqual(instances.read(self.root,115)['deployment']['environment'],self.candidate['deployment']['environment']) + + def test_unsupported_same_image_adaptation_never_silently_recreates(self): + self.candidate['deployment']['post_start_configurations']=[{'id':'unsupported-adapter'}] + with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ + patch.object(native.transaction,'external_changes',return_value={}), \ + patch.object(update,'resolve_archive',side_effect=AssertionError('registry path')), \ + patch.object(native.transaction,'apply',side_effect=AssertionError('recreation')): + with self.assertRaisesRegex(ValueError,'application-specific adaptation'): + update.update(115,proposal={'operation':'recreate','candidate':self.candidate}) + + def test_optional_backup_does_not_force_recreation(self): + with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ + patch.object(native.transaction,'external_changes',return_value={}), \ + patch.object(update,'resolve_archive',side_effect=AssertionError('registry path')), \ + patch.object(update,'backup_native_settings') as backup, \ + patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()): + update.update(115,proposal={'operation':'recreate','candidate':self.candidate},keep_backup='test-storage') + backup.assert_called_once_with(115,'test-storage') + + +if __name__=='__main__':unittest.main() From 14513cc898a84e797d9d562b8f6ce6602fba2210 Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:19:24 +0200 Subject: [PATCH 20/43] Speed up OCI Modify entry by deferring backup probes and reusing verified config --- .../test_oci_update_preservation_wording.py | 1 + .github/workflows/test-oci-registries.yml | 4 + oci/src/proxmenux_oci/management.py | 29 ++++- oci/tests/test_modify_latency.py | 100 ++++++++++++++++++ 4 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 oci/tests/test_modify_latency.py diff --git a/.github/scripts/tests/test_oci_update_preservation_wording.py b/.github/scripts/tests/test_oci_update_preservation_wording.py index 44146970..477196e9 100644 --- a/.github/scripts/tests/test_oci_update_preservation_wording.py +++ b/.github/scripts/tests/test_oci_update_preservation_wording.py @@ -130,6 +130,7 @@ class UpdateWording(unittest.TestCase): reconcile.propose = lambda *args: None scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'translate': lambda text: text, 'check_selected': lambda project, row: row, + '_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)), 'images': SimpleNamespace(offer_removal=lambda *args: None), 'carry_records': lambda project, **options: {}, '_work_backup_ready': lambda project, ui, vmid: True, diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 9612c0d3..7625fe1c 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -32,3 +32,7 @@ jobs: env: PYTHONDONTWRITEBYTECODE: '1' run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v + - name: Run OCI Modify latency regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index cbf8e041..9b71d1b1 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -106,13 +106,32 @@ def _config_hostname(vmid): return '' +def _selected_configuration(vmid): + """Reuse pct's output only while its complete native source is unchanged. + + The forced carried-record verification has just fetched a fresh config. + Older installations without that source-keyed cache still query pct. + The lifecycle backend independently validates again before any mutation. + """ + import oci_instances as instances + try: + import oci_carried_record + reader = getattr(oci_carried_record, 'configuration', None) + config = reader(instances.ROOT, vmid) if reader else None + if isinstance(config, str) and config: + return config.encode() + except (ImportError, OSError, ValueError, RuntimeError, subprocess.SubprocessError): + pass + return instances.command('pct', 'config', str(vmid)) + + def check_selected(project, row): """Validate only the chosen container; lifecycle backends validate its stack.""" sys.path.insert(0, str(project / 'remote')) import oci_instances as instances with instances.locked(instances.ROOT): record = instances.read(instances.ROOT, row['vmid']) - config = instances.command('pct', 'config', str(row['vmid'])) + config = _selected_configuration(row['vmid']) marker = instances.identity(config) reason = 'matched' if marker == record['installation_id'] else 'identity-unconfirmed' return public_row(record, {'reason': reason}) @@ -397,12 +416,12 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): return _toggle_watchdog(project, ui, row['vmid'], watched) if action == 'remove': return _remove(project, ui, row['vmid']) - if not _work_backup_ready(project, ui, row['vmid']): + if action != 'modify' and not _work_backup_ready(project, ui, row['vmid']): return False record = instances.read(instances.ROOT, row['vmid']) import oci_instance_reconcile as reconcile try: - current_config = instances.command('pct', 'config', str(row['vmid'])) + current_config = _selected_configuration(row['vmid']) adoption = reconcile.propose(record, current_config) except (OSError, ValueError, RuntimeError) as error: ui.message(str(error), translate('Review external OCI changes')) @@ -443,6 +462,10 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): wizard.close() if not approved: return False + # Opening or cancelling the editor needs no disk/storage probes. + # Keep the existing backup checks after the user confirms edits. + if not _work_backup_ready(project, ui, row['vmid']): + return False elif not ui.review(translate('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If the update fails after the container was changed, the backup is restored. An update that stops halfway is recovered from the OCI management menu.'), translate('Update OCI'), question=translate('Update now?'), default=True): return False diff --git a/oci/tests/test_modify_latency.py b/oci/tests/test_modify_latency.py new file mode 100644 index 00000000..788215ad --- /dev/null +++ b/oci/tests/test_modify_latency.py @@ -0,0 +1,100 @@ +"""Opening Modify avoids backup probes while preserving operation safeguards.""" +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock,patch +ROOT=Path(__file__).resolve().parents[1] +sys.path[:0]=[str(ROOT/'src'),str(ROOT/'remote')] +from proxmenux_oci import management as menu,recreation +import oci_carried_record as carried +import oci_instances as instances +import oci_instance_reconcile as reconcile +from proxmenux_oci.ui import UserCancelled + + +def record(): + return {'vmid': 115, 'installation_id': 'test-installation', 'status': 'installed', + 'observed': {'config_sha256': 'native-config-fingerprint'}, + 'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'}, + 'container_contract': {'image': {'reference': 'example/app:latest'}}, + 'proxmox': {'installer_profile': {}}}, + 'deployment': {'vmid': 115, 'hostname': 'example', 'resources': {'cores': 2, 'memory_mb': 512}, + 'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp', 'gateway': None}, + 'rootfs': {'storage': 'local-zfs', 'size_gb': 8}, 'mounts': [ + {'container_path': '/data', 'source': 'local-zfs', + 'type': 'managed-volume', 'size_gb': 8, 'backup': True}], + 'devices': [], 'security': {'unprivileged': True}, + 'environment': [{'name': 'PASSWORD', 'value': 'private-test-secret', + 'sensitive': True}]}} + + +class SelectedConfigTests(unittest.TestCase): + def test_valid_source_keyed_cache_avoids_another_pct_process(self): + with patch.object(carried,'configuration',create=True,return_value='description: current\n') as reader, \ + patch.object(instances,'command') as command: + self.assertEqual(menu._selected_configuration(115),b'description: current\n') + reader.assert_called_once_with(instances.ROOT,115) + command.assert_not_called() + + def test_old_or_unavailable_cache_falls_back_to_pct(self): + for reader in (None,Mock(return_value=None),Mock(return_value={}),Mock(side_effect=OSError('cache unavailable'))): + with self.subTest(reader=reader),patch.object(carried,'configuration',reader,create=True), \ + patch.object(instances,'command',return_value=b'live config') as command: + self.assertEqual(menu._selected_configuration(115),b'live config') + command.assert_called_once_with('pct','config','115') + + def test_identity_check_is_still_performed_on_the_selected_config(self): + r=record();r['installation_id']='11111111-1111-4111-8111-111111111111' + import contextlib + with patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ + patch.object(instances,'read',return_value=r), \ + patch.object(menu,'_selected_configuration',return_value=b'description: another container\n'): + self.assertEqual(menu.check_selected(ROOT,{'vmid':115})['reason'],'identity-unconfirmed') + + +class EditorLatencyTests(unittest.TestCase): + def run_editor(self,proposal=None,backup_ready=True,stale=False,adoption_error=False,approved=True): + events=[];saved=record() + row={'vmid':115,'reason':'matched','stack':False,'pending':False,'status':'installed'} + def edit(*a,**kw): + events.append('editor') + if proposal is None:raise UserCancelled() + return proposal + def backup(*a,**kw):events.append('backup');return backup_ready + ui=Mock();ui.unattended=True;ui.review.return_value=approved + with patch.object(menu,'carry_records',return_value={115:'stale'} if stale else {}) as verify, \ + patch.object(menu,'check_selected',return_value=row), \ + patch.object(menu,'_selected_configuration',return_value=b'current config'), \ + patch.object(instances,'read',return_value=saved), \ + patch.object(reconcile,'propose',side_effect=ValueError('external changes') if adoption_error else None,return_value=None), \ + patch.object(recreation,'edit_recreation',side_effect=edit), \ + patch.object(menu,'_work_backup_ready',side_effect=backup), \ + patch.object(menu,'_run_lifecycle',side_effect=lambda *a:events.append('lifecycle') or True): + try:result=menu.manage_instance(ROOT,ui,row,action='modify') + except UserCancelled:result=False + verify.assert_called_once_with(ROOT,vmids=[115],verify=True) + return result,events + + def test_open_and_cancel_has_no_backup_or_storage_probes(self): + result,events=self.run_editor() + self.assertFalse(result);self.assertEqual(events,['editor']) + + def test_declining_the_review_has_no_backup_or_storage_probes(self): + result,events=self.run_editor({'operation':'recreate','candidate':record()},approved=False) + self.assertFalse(result);self.assertEqual(events,['editor']) + + def test_confirmed_edit_keeps_backup_check_before_the_lifecycle(self): + result,events=self.run_editor({'operation':'recreate','candidate':record()}) + self.assertTrue(result);self.assertEqual(events,['editor','backup','lifecycle']) + + def test_missing_backup_space_still_prevents_the_lifecycle(self): + result,events=self.run_editor({'operation':'recreate','candidate':record()},backup_ready=False) + self.assertFalse(result);self.assertEqual(events,['editor','backup']) + + def test_restored_or_conflicting_instance_cannot_enter_editor(self): + for options in ({'stale':True},{'adoption_error':True}): + result,events=self.run_editor(**options) + self.assertFalse(result);self.assertEqual(events,[]) + + +if __name__=='__main__':unittest.main() From 98ae1f36260c1a541d58656524588f921efeecc8 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 17 Oct 2026 18:13:44 +0200 Subject: [PATCH 21/43] feat(pbs): post-install menu for Proxmox Backup Server - Open the post-install menu from the PBS menu - List only the customizable options that apply to PBS - Offer the PBS no-subscription repository through the native repository API - Upgrade PBS with the package list shown before applying it - Remove and restore the subscription notice on the PBS web interface - Install Figurine on PBS without the Proxmox VE aliases - Offer the Helper-Scripts PBS post-install and microcode scripts - Tune firewall bridges after the systemd udev rules in the automated post-install - Add PBS repository policy tests and syntax checks - Document the PBS differences on the post-install page - Use backups in the Spanish PBS update message --- .github/workflows/test-pbs.yml | 5 +- AppImage/components/health-status-modal.tsx | 19 +- AppImage/components/monitor-switcher.tsx | 283 +++++++++++++++ AppImage/components/notification-settings.tsx | 14 +- AppImage/components/pbs-dashboard.tsx | 44 --- AppImage/components/pbs-datastore-usage.tsx | 112 ++++++ AppImage/components/pbs-panel.tsx | 202 ++++++++--- AppImage/components/proxmox-dashboard.tsx | 79 +++-- AppImage/components/settings.tsx | 19 +- AppImage/components/system-overview.tsx | 80 ++++- AppImage/lib/use-platform.ts | 8 + AppImage/messages/en/common.json | 49 ++- AppImage/messages/es/common.json | 49 ++- AppImage/scripts/build_appimage.sh | 2 + .../scripts/flask_monitor_links_routes.py | 58 ++++ AppImage/scripts/flask_notification_routes.py | 15 + AppImage/scripts/flask_server.py | 38 ++- AppImage/scripts/monitor_links.py | 321 ++++++++++++++++++ .../tests/test_fwbr_udev_rule_order.py | 12 +- AppImage/scripts/tests/test_monitor_links.py | 150 ++++++++ lang/es.json | 19 +- scripts/global/remove-banner-pve-v3.sh | 13 +- scripts/global/repository-functions.sh | 45 +++ scripts/global/repository_policy.py | 83 +++-- scripts/global/update-pbs.sh | 188 ++++++++++ scripts/menus/menu_post_install.sh | 27 +- scripts/menus/pbs_menu.sh | 28 +- scripts/post_install/auto_post_install.sh | 46 ++- .../post_install/customizable_post_install.sh | 50 ++- scripts/post_install/uninstall-tools.sh | 10 +- tests/test_repository_policy_pbs.py | 103 ++++++ web/app/[locale]/docs/post-install/page.tsx | 10 + web/messages/en/docs/post-install/index.json | 11 + web/messages/es/docs/post-install/index.json | 11 + 34 files changed, 1976 insertions(+), 227 deletions(-) create mode 100644 AppImage/components/monitor-switcher.tsx delete mode 100644 AppImage/components/pbs-dashboard.tsx create mode 100644 AppImage/components/pbs-datastore-usage.tsx create mode 100644 AppImage/lib/use-platform.ts create mode 100644 AppImage/scripts/flask_monitor_links_routes.py create mode 100644 AppImage/scripts/monitor_links.py create mode 100644 AppImage/scripts/tests/test_monitor_links.py create mode 100644 scripts/global/update-pbs.sh create mode 100644 tests/test_repository_policy_pbs.py diff --git a/.github/workflows/test-pbs.yml b/.github/workflows/test-pbs.yml index 2d60623c..2dbc71b0 100644 --- a/.github/workflows/test-pbs.yml +++ b/.github/workflows/test-pbs.yml @@ -17,6 +17,9 @@ on: - 'scripts/global/repository-functions.sh' - 'scripts/menus/pbs_menu.sh' - 'scripts/utilities/pbs_update.sh' + - 'scripts/global/update-pbs.sh' + - 'scripts/menus/menu_post_install.sh' + - 'scripts/post_install/*.sh' push: branches: [main, develop] paths: @@ -49,7 +52,7 @@ jobs: run: | python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v - for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh; do + for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do bash -n "$script" done - uses: actions/setup-node@v6 diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index c8be541e..090a51fb 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -42,6 +42,7 @@ import { } from "lucide-react" import { ScriptTerminalModal } from "./script-terminal-modal" import { useT } from "@/lib/i18n/provider" +import { useIsPbs } from "@/lib/use-platform" interface CategoryCheck { status: string @@ -119,6 +120,7 @@ const CATEGORIES = [ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) { const t = useT() + const isPbs = useIsPbs() const [loading, setLoading] = useState(true) const [healthData, setHealthData] = useState(null) const [dismissedItems, setDismissedItems] = useState([]) @@ -460,6 +462,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu return originalStatus?.toUpperCase() || "UNKNOWN" } + // Checks the host reports as not applicable (guests on PBS) are not listed or counted. + const visibleCategories = CATEGORIES.filter(({ key }) => + !(healthData?.details?.[key as keyof typeof healthData.details] as { not_applicable?: boolean } | undefined)?.not_applicable) + const categoryLabel = (key: string) => t(`healthStatus.categories.${isPbs && key === "services" ? "pbsServices" : key}`) + const getHealthStats = () => { if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 } @@ -469,7 +476,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu let critical = 0 let unknown = 0 - CATEGORIES.forEach(({ key }) => { + visibleCategories.forEach(({ key }) => { const categoryData = healthData.details[key as keyof typeof healthData.details] if (categoryData) { const effectiveStatus = getEffectiveStatus(key, categoryData.status) @@ -481,7 +488,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu } }) - return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown } + return { total: visibleCategories.length, healthy, info, warnings, critical, unknown } } const stats = getHealthStats() @@ -759,7 +766,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {/* Category List */}
- {CATEGORIES.map(({ key, Icon }) => { + {visibleCategories.map(({ key, Icon }) => { const categoryData = healthData.details[key as keyof typeof healthData.details] const originalStatus = categoryData?.status || "UNKNOWN" const status = getEffectiveStatus(key, originalStatus) @@ -784,7 +791,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
-

{t(`healthStatus.categories.${key}`)}

+

{categoryLabel(key)}

{hasChecks && ( ({Object.values(checks).filter(c => c.installed !== false).length}) @@ -871,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {filteredDismissed.map((item) => { const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category) const CatIcon = catMeta?.Icon || BellOff - const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category + const catLabel = catMeta ? categoryLabel(catMeta.key) : item.category const isPermanent = item.permanent || item.suppression_remaining_hours === -1 return ( @@ -936,7 +943,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- {catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label} + {catMeta ? categoryLabel(catMeta.key) : cs.label}
{durationLabel} diff --git a/AppImage/components/monitor-switcher.tsx b/AppImage/components/monitor-switcher.tsx new file mode 100644 index 00000000..0cd2d34b --- /dev/null +++ b/AppImage/components/monitor-switcher.tsx @@ -0,0 +1,283 @@ +"use client" + +import { useEffect, useState } from "react" +import useSWR, { mutate as refreshCache } from "swr" +import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Plus, Server, Trash2 } from "lucide-react" +import { Button } from "./ui/button" +import { Input } from "./ui/input" +import { Label } from "./ui/label" +import { Switch } from "./ui/switch" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog" +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, +} from "./ui/dropdown-menu" +import { fetchApi } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" + +interface MonitorLink { + key: string + name: string + product: "pve" | "pbs" + group: "cluster" | "standalone" | "pbs" + url: string | null + status: "online" | "offline" | "unknown" + native_url?: string | null + manual: boolean + hidden: boolean + custom_url: boolean +} +interface MonitorLinks { self: { name: string; product: string }; links: MonitorLink[] } + +const GROUPS = ["cluster", "standalone", "pbs"] as const +const fetcher = (url: string) => fetchApi(url) +const ORIGIN_KEY = "proxmenux-came-from" + +interface Origin { url: string; name: string; product: "pve" | "pbs" } + +// The Monitor a jump came from, as the browser reached it. Only a bare http(s) +// origin is accepted, and the parameters leave the address at once. +function takeOrigin(): (Origin & { fresh?: boolean }) | null { + if (typeof window === "undefined") return null + const params = new URLSearchParams(window.location.search) + const from = params.get("pmx_from") + if (from !== null) { + const name = params.get("pmx_name") || "" + const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve" + for (const key of ["pmx_from", "pmx_name", "pmx_product"]) params.delete(key) + const query = params.toString() + window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash) + try { + const parsed = new URL(from) + if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) { + const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product } + try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ } + return { ...origin, fresh: true } + } + } catch { /* not an address */ } + } + try { + const stored = JSON.parse(sessionStorage.getItem(ORIGIN_KEY) || "null") + return stored && typeof stored.url === "string" && /^https?:\/\//.test(stored.url) ? stored : null + } catch { return null } +} + +// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there. +function destination(link: MonitorLink): { href: string; native: boolean } | null { + if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true } + return link.url ? { href: link.url, native: false } : null +} + +function ProductIcon({ product, className }: { product: string; className?: string }) { + return product === "pbs" ? : +} + +export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: string; compact?: boolean }) { + const t = useT() + const [manageOpen, setManageOpen] = useState(false) + // The backend keeps the list ready, so the menu opens already filled. + const { data, error, isLoading } = useSWR("/api/monitor-links", fetcher, { revalidateOnFocus: false, refreshInterval: 300000 }) + + const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null) + const [offerOrigin, setOfferOrigin] = useState(false) + const [adding, setAdding] = useState(false) + const [addError, setAddError] = useState("") + useEffect(() => { setOrigin(takeOrigin()) }, []) + const originKnown = !!origin && !!data?.links.some(link => link.url === origin.url) + useEffect(() => { + // Offered once, right after arriving, when this Monitor has no way back yet. + if (origin?.fresh && data && !originKnown) setOfferOrigin(true) + }, [origin, data, originKnown]) + + // The address carries where the jump comes from, so the other Monitor can offer the way back. + const withOrigin = (href: string) => { + const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" }) + return `${href}/?${params.toString()}` + } + const open = (href: string, newTab: boolean) => { + if (newTab) window.open(href, "_blank", "noopener,noreferrer") + else window.location.assign(href) + } + const go = (link: MonitorLink, newTab: boolean) => { + const target = destination(link) + if (!target) return + if (target.native) open(target.href, true) + else open(withOrigin(target.href), newTab) + } + const addOrigin = async () => { + if (!origin) return + setAdding(true); setAddError("") + try { + await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: origin.name, url: origin.url, product: origin.product }) }) + await refreshCache("/api/monitor-links") + setOfferOrigin(false) + } catch (err) { + setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) + } finally { setAdding(false) } + } + + return
event.stopPropagation()}> + + + + + + {t("monitorLinks.thisNode")} + + + + {nodeName} + + {origin && !originKnown && open(withOrigin(origin.url), false)}> + + {t("monitorLinks.backTo", { name: origin.name })} + } + {isLoading &&

{t("monitorLinks.checking")}

} + {error &&

{t("monitorLinks.loadFailed")}

} + {data && data.links.length === 0 &&

{t("monitorLinks.empty")}

} + {data && GROUPS.map(group => { + const rows = data.links.filter(link => link.group === group) + if (rows.length === 0) return null + return
+ + {t(`monitorLinks.groups.${group}`)} + {rows.map(link => { + const target = destination(link) + return go(link, false)}> + + + {link.name} + {target?.native + ? {t("monitorLinks.pbsInterface")} + : link.status !== "online" && {t("monitorLinks.noResponse")}} + {target && } + + })} +
+ })} + + setManageOpen(true)}> + {t("monitorLinks.manage")} + +
+
+ {manageOpen && } + {origin && + event.stopPropagation()}> + + {t("monitorLinks.cameFromTitle")} + {t("monitorLinks.cameFromBody", { name: origin.name })} + +

{origin.url}

+ {addError &&

{addError}

} +
+ + +
+
+
} +
+} + +function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const t = useT() + const { data, mutate } = useSWR("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false }) + const [form, setForm] = useState({ name: "", url: "", product: "pve" }) + const [addresses, setAddresses] = useState>({}) + const [busy, setBusy] = useState(false) + const [error, setError] = useState("") + + const run = async (action: () => Promise) => { + setBusy(true); setError("") + try { + await action() + await mutate() + await refreshCache("/api/monitor-links") + } catch (err) { + setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) + } finally { setBusy(false) } + } + const send = (path: string, method: string, body?: unknown) => + fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) + + return + event.stopPropagation()}> + + {t("monitorLinks.dialogTitle")} + {t("monitorLinks.dialogDescription")} + + +
+

{t("monitorLinks.addTitle")}

+
+
+ + setForm({ ...form, name: event.target.value })} /> +
+
+ + setForm({ ...form, url: event.target.value })} /> +
+
+ + +
+
+ +
+ + {error &&

{error}

} + +
+ {data?.links.length === 0 &&

{t("monitorLinks.empty")}

} + {data?.links.map(link => { + const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "") + return
+
+ + {link.name} + {link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")} + {!link.manual && } + {link.manual && } +
+
+ setAddresses({ ...addresses, [link.key]: event.target.value })} /> + +
+
+ })} +
+
+
+} diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index e84590ee..194d5574 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" import { fetchApi } from "../lib/api-config" import { useT } from "../lib/i18n/provider" +import { useIsPbs } from "../lib/use-platform" import { Bell, BellOff, Send, CheckCircle2, XCircle, Loader2, AlertTriangle, Info, Settings2, Zap, Eye, EyeOff, @@ -154,6 +155,8 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } { } const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key })) +// Guests, vzdump backups and the cluster do not exist on Proxmox Backup Server. +const PVE_ONLY_CATEGORIES = new Set(["vm_ct", "backup", "cluster"]) const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const @@ -330,6 +333,7 @@ const DEFAULT_CONFIG: NotificationConfig = { export function NotificationSettings() { const t = useT() + const isPbs = useIsPbs() const { resolvedTheme } = useTheme() const [config, setConfig] = useState(DEFAULT_CONFIG) const [status, setStatus] = useState(null) @@ -703,7 +707,7 @@ export function NotificationSettings() {
- {EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => { + {EVENT_CATEGORIES.filter(cat => cat.key !== "other" && !(isPbs && PVE_ONLY_CATEGORIES.has(cat.key))).map(cat => { const isEnabled = overrides.categories[cat.key] ?? true const isExpanded = expandedCategories.has(`${chName}.${cat.key}`) // Hide the LXC update toggle when the user has disabled the @@ -922,7 +926,7 @@ export function NotificationSettings() { const wasEnabled = originalConfig.enabled const isNowDisabled = !nextConfig.enabled - if (wasEnabled && isNowDisabled) { + if (wasEnabled && isNowDisabled && !isPbs) { try { await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" }) } catch { @@ -1217,8 +1221,10 @@ export function NotificationSettings() { setConfig(newConfig) setOriginalConfig(newConfig) - // 2) Auto-configure PVE webhook - try { + // 2) Auto-configure PVE webhook; PBS has no /etc/pve notifications + if (isPbs) { + setWebhookSetup({ status: "idle", fallback_commands: [], error: "" }) + } else try { const setup = await fetchApi<{ configured: boolean secret?: string diff --git a/AppImage/components/pbs-dashboard.tsx b/AppImage/components/pbs-dashboard.tsx deleted file mode 100644 index fd90b1b6..00000000 --- a/AppImage/components/pbs-dashboard.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client" - -import { useState } from "react" -import useSWR from "swr" -import { PBSPanel } from "./pbs-panel" -import { Security } from "./security" -import { SystemLogs } from "./system-logs" -import { TerminalPanel } from "./terminal-panel" -import { ThemeToggle } from "./theme-toggle" -import { Button } from "./ui/button" -import { Card, CardContent } from "./ui/card" -import { fetchApi } from "../lib/api-config" -import { useT } from "../lib/i18n/provider" - -interface Vitals { hostname: string; cpu_usage: number; memory_usage: number; memory_used: number; memory_total: number; uptime: string } - -export function PBSDashboard() { - const t = useT() - const [tab, setTab] = useState("overview") - const { data } = useSWR("/api/system", url => fetchApi(url), { refreshInterval: 10000 }) - return
-
-

ProxMenux · PBS

{data?.hostname || t("pbs.loading")}

- - -
- - {tab === "overview" && <> -
- {[[t("pbs.cpu"), data ? `${data.cpu_usage.toFixed(1)}%` : "—"], - [t("pbs.memory"), data ? `${data.memory_used.toFixed(1)} / ${data.memory_total.toFixed(1)} GiB (${data.memory_usage.toFixed(1)}%)` : "—"], - [t("pbs.uptime"), data?.uptime || "—"]].map(([label, value]) => -

{label}

{value}

)} -
- - } - {tab === "logs" && } - {tab === "terminal" && } - {tab === "security" && } -
-} diff --git a/AppImage/components/pbs-datastore-usage.tsx b/AppImage/components/pbs-datastore-usage.tsx new file mode 100644 index 00000000..944ba340 --- /dev/null +++ b/AppImage/components/pbs-datastore-usage.tsx @@ -0,0 +1,112 @@ +"use client" + +import { CalendarClock, Database } from "lucide-react" +import { Badge } from "./ui/badge" +import { Progress } from "./ui/progress" +import { formatStorage } from "../lib/utils" +import { useT } from "../lib/i18n/provider" + +export interface PBSDatastore { + name: string + path?: string + total?: number | null + used?: number | null + avail?: number | null + error?: string + "backend-type"?: string + "estimated-full-date"?: number + "gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number } +} + +export function datastorePercent(store: PBSDatastore): number | null { + if (store.total == null || store.used == null || store.total <= 0) return null + return (store.used / store.total) * 100 +} + +// Same thresholds and colours as the PVE storage cards. +export function usageTone(percent: number): { bar: string; text: string; used: string } { + if (percent > 90) return { bar: "[&>div]:bg-red-500", text: "text-red-500", used: "text-red-400" } + if (percent > 75) return { bar: "[&>div]:bg-yellow-500", text: "text-yellow-500", used: "text-yellow-400" } + return { bar: "[&>div]:bg-blue-500", text: "text-muted-foreground", used: "text-blue-400" } +} + +const gib = (bytes: number) => formatStorage(bytes / 1024 ** 3) + +// A datastore without a readable capacity keeps its message: it is never shown as empty. +export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatastore; compact?: boolean }) { + const t = useT() + const percent = datastorePercent(store) + if (percent == null) { + return
+
+ {store.name} + — +
+

{store.error || t("pbs.capacityUnavailable")}

+
+ } + const tone = usageTone(percent) + return
+
+ {store.name} + {percent.toFixed(1)}% +
+ + {!compact &&
+ {gib(store.used!)} / {gib(store.total!)} + {t("pbs.free")}: {gib(store.avail ?? store.total! - store.used!)} +
} +
+} + +// Same layout as the "Proxmox storage" cards of the PVE Monitor. +export function PBSDatastoreCard({ store }: { store: PBSDatastore }) { + const t = useT() + const percent = datastorePercent(store) + const tone = percent == null ? null : usageTone(percent) + const fullDate = store["estimated-full-date"] + const type = store["backend-type"] || "datastore" + return
+
+
+ +

{store.name}

+ {type} +
+
+ + {store.error ? t("storage.status.error") : t("storage.status.active")} + + {percent != null && {percent.toFixed(2)}%} +
+
+ {percent != null && tone ? ( +
+ +
+
+

{t("storage.total")}

+

{gib(store.total!)}

+
+
+

{t("storage.used")}

+

{gib(store.used!)}

+
+
+

{t("storage.available")}

+

{gib(store.avail ?? store.total! - store.used!)}

+
+
+
+ ) : ( +

{store.error || t("storage.capacityNotReported")}

+ )} + {(store.path || fullDate !== undefined) &&
+ {store.path && {store.path}} + {fullDate !== undefined && + {t("pbs.estimatedFull")}: + {fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")} + } +
} +
+} diff --git a/AppImage/components/pbs-panel.tsx b/AppImage/components/pbs-panel.tsx index fb1354eb..d71b30a9 100644 --- a/AppImage/components/pbs-panel.tsx +++ b/AppImage/components/pbs-panel.tsx @@ -2,29 +2,74 @@ import { useEffect, useState } from "react" import useSWR from "swr" +import { + Activity, ArchiveRestore, Database, DatabaseBackup, Download, ListChecks, + RefreshCw, Scissors, Server, Settings2, ShieldCheck, Terminal, Trash2, +} from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" +import { Badge } from "./ui/badge" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" +import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage" import { fetchApi } from "../lib/api-config" -import { formatBytes } from "../lib/utils" import { useT } from "../lib/i18n/provider" interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean } interface Connections { local_available: boolean; servers: Connection[] } -interface Datastore { name: string; path?: string; total?: number | null; used?: number | null; avail?: number | null; error?: string } interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string } interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string } interface PBSStatus { name: string; version: string | null; fetched_at: number; services: Record; - datastores: Datastore[] | null; tasks: Task[] | null; + datastores: PBSDatastore[] | null; tasks: Task[] | null; sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record; } const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" } -const selectClass = "h-10 rounded-md border bg-background px-3 text-sm" +const NEW_CONNECTION = "__new__" +const badgeTone = { + ok: "bg-green-500/10 text-green-500 border-green-500/20", + warn: "bg-amber-500/10 text-amber-500 border-amber-500/20", + error: "bg-red-500/10 text-red-500 border-red-500/20", + running: "bg-blue-500/10 text-blue-500 border-blue-500/20", + muted: "bg-muted text-muted-foreground border-border", +} -export function PBSPanel() { +const TASK_ICONS: Record = { + backup: DatabaseBackup, reader: ArchiveRestore, verify: ShieldCheck, verificationjob: ShieldCheck, + verify_group: ShieldCheck, verify_snapshot: ShieldCheck, prune: Scissors, prunejob: Scissors, + garbage_collection: Trash2, sync: RefreshCw, syncjob: RefreshCw, aptupdate: Download, + termproxy: Terminal, dircreate: Database, "create-datastore": Database, +} + +// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running. +function taskState(task: Task): keyof typeof badgeTone { + if (!task.status) return task.endtime ? "muted" : "running" + if (task.status === "OK") return "ok" + if (task.status.startsWith("WARNINGS")) return "warn" + return "error" +} + +function jobState(state?: string): keyof typeof badgeTone { + if (!state) return "muted" + if (state === "ok") return "ok" + if (state.toLowerCase().startsWith("warn")) return "warn" + return "error" +} + +function formatDuration(seconds: number) { + if (!Number.isFinite(seconds) || seconds < 0) return "—" + if (seconds < 60) return `${Math.round(seconds)}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s` + const hours = Math.floor(minutes / 60) + return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h` +} + +// `localOnly` is the PBS host's own Monitor: no connection picker, the +// header Refresh already reloads the page data. +export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) { const t = useT() const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR( "/api/pbs/servers", url => fetchApi(url), { revalidateOnFocus: false }) @@ -69,36 +114,43 @@ export function PBSPanel() { } catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) } finally { setBusy(false) } } - const capacity = (value?: number | null) => value == null ? "—" : formatBytes(value) + const now = Date.now() / 1000 - return
-
-

{t("pbs.title")}

- - - -
+ return
+ {!localOnly &&
+

{t("pbs.title")}

+ + + +
} {(connectionError || statusError) &&

{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}

} {isLoading &&

{t("pbs.loading")}

} {!selected && !connectionError && connections &&

{t("pbs.noServer")}

} - {manage && + {manage && !localOnly && {t("pbs.manage")}

{t("pbs.permissionsHelp")}

- +
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
@@ -118,32 +170,78 @@ export function PBSPanel() { } {data && <> -

{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}

- {Object.keys(data.services).length > 0 &&
- {Object.entries(data.services).map(([service, state]) => {service}: {state})} -
} +
+ {data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()} + {Object.entries(data.services).map(([service, state]) => + {service}: {state})} +
{Object.entries(data.errors).map(([section, detail]) =>

{t(`pbs.${section}`)}: {detail}

)} - {t("pbs.datastores")} - {data.datastores?.length === 0 &&

{t("pbs.noDatastores")}

} - {data.datastores && data.datastores.length > 0 &&
- - {data.datastores.map(row => )} -
{t("pbs.name")}{t("pbs.total")}{t("pbs.used")}{t("pbs.free")}
{row.name}{row.error &&

{row.error}

}
{capacity(row.total)}{capacity(row.used)}{capacity(row.avail)}
} -
- {t("pbs.tasks")} - {data.tasks?.length === 0 &&

{t("pbs.noTasks")}

} - {data.tasks && data.tasks.length > 0 &&
- - {data.tasks.map(row => )} -
{t("pbs.task")}{t("pbs.started")}{t("pbs.status")}
{row.worker_type} {row.worker_id}{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}{row.status || (row.endtime ? "—" : t("pbs.running"))}
} -
- {t("pbs.jobs")} -
- - {(["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => - ))} -
{t("pbs.job")}{t("pbs.datastores")}{t("pbs.schedule")}{t("pbs.status")}
{t(`pbs.${kind}`)} · {row.id}{row.store || "—"}{row.schedule || "—"}{row["last-run-state"] || "—"}
-
+ + + {t("pbs.datastores")} + + {data.datastores?.length === 0 &&

{t("pbs.noDatastores")}

} + {data.datastores && data.datastores.length > 0 &&
+ {data.datastores.map(store => )} +
} +
+
+ + + {t("pbs.tasks")} + + {data.tasks?.length === 0 &&

{t("pbs.noTasks")}

} + {data.tasks && data.tasks.length > 0 &&
+ + + + + + + {data.tasks.map(row => { + const Icon = TASK_ICONS[row.worker_type || ""] || Activity + const state = taskState(row) + const end = row.endtime ?? (state === "running" ? now : undefined) + return + + + + + + })} +
{t("pbs.task")}{t("pbs.started")}{t("pbs.duration")}{t("pbs.status")}
+ + {row.worker_type} + {row.worker_id && {row.worker_id}} + {row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}{row.starttime && end ? formatDuration(end - row.starttime) : "—"} + {state === "running" ? t("pbs.running") : row.status || "—"} +
} +
+
+ + + {t("pbs.jobs")} + + {(() => { + const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row }))) + if (jobs.length === 0) return

{t("pbs.noJobs")}

+ return
+ + + + + + + {jobs.map(({ kind, row }) => + + + + + )} +
{t("pbs.job")}{t("pbs.datastores")}{t("pbs.schedule")}{t("pbs.status")}
{t(`pbs.${kind}`)} · {row.id}{row.store || "—"}{row.schedule || "—"}{row["last-run-state"] || "—"}
+ })()} +
+
}
} diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index 679c9aaf..4b426082 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -18,7 +18,8 @@ import { Security } from "./security" import { Profile } from "./profile" import { About } from "./about" import { BackupTab } from "./backup-tab" -import { PBSDashboard } from "./pbs-dashboard" +import { PBSPanel } from "./pbs-panel" +import { MonitorSwitcher } from "./monitor-switcher" import { OnboardingCarousel } from "./onboarding-carousel" import { HealthStatusModal } from "./health-status-modal" import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal" @@ -46,7 +47,9 @@ import { ClipboardCheck, Info, DatabaseBackup, + Database, ChevronDown, + Clock, } from "lucide-react" import Image from "next/image" import { ThemeToggle } from "./theme-toggle" @@ -105,10 +108,27 @@ export function ProxmoxDashboard() { } function PVEDashboard() { + return +} + +function PBSDashboard() { + return +} + +type Product = "pve" | "pbs" +type TopTab = TabId | "datastores" + +// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the +// same header, health and administration with its own top-level sections. +const PBS_TABS: TopTab[] = ["overview", "datastores", "terminal", "admin"] + +function MonitorDashboard({ product }: { product: Product }) { const t = useT() - useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) - useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) - const { order: tabOrder } = useTabOrder() + const isPbs = product === "pbs" + useSWR(isPbs ? null : "/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) + useSWR(isPbs ? null : "/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) + const { order: userTabOrder } = useTabOrder() + const tabOrder: TopTab[] = isPbs ? PBS_TABS : userTabOrder const [systemStatus, setSystemStatus] = useState({ status: "healthy", uptime: "Loading...", @@ -126,8 +146,8 @@ function PVEDashboard() { // runs post-hydration; the tiny flash is acceptable and matches the // pattern next-themes uses for the same reason. useEffect(() => { - setActiveTab(firstActualTab()) - }, []) + setActiveTab(isPbs ? "overview" : firstActualTab()) + }, [isPbs]) const [infoCount, setInfoCount] = useState(0) const [updateAvailable, setUpdateAvailable] = useState(false) const [showNavigation, setShowNavigation] = useState(true) @@ -400,6 +420,7 @@ function PVEDashboard() { const getActiveTabLabel = () => { switch (activeTab) { case "overview": return t("navigation.overview") + case "datastores": return t("pbs.datastores") case "apps": return t("navigation.apps") case "vms": return t("navigation.virtualMachines") case "storage": return t("navigation.storage") @@ -419,8 +440,9 @@ function PVEDashboard() { return (
- - setShowReleaseNotes(false)} /> + {/* Both describe the PVE Monitor. */} + {!isPbs && } + {!isPbs && setShowReleaseNotes(false)} />} {!isServerConnected && (
@@ -473,22 +495,17 @@ function PVEDashboard() {

{t("app.title")}

-

{t("app.description")}

+

{t(isPbs ? "app.descriptionPbs" : "app.description")}

- - {t("status.node", { node: displayServerName })} + + {t("status.uptime", { uptime: displayUptime })}
{/* Desktop Actions */}
-
- -
-
{t("status.node", { node: displayServerName })}
-
-
+
@@ -589,9 +606,7 @@ function PVEDashboard() { )}
- - {t("status.uptime", { uptime: displayUptime })} - +
@@ -632,7 +647,7 @@ function PVEDashboard() { { value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false }, { value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false }, { value: "about", label: t("navigation.about"), Icon: Info, default: false }, - ] + ].filter(item => !isPbs || item.value !== "audit") const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab) const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab) const isNodeActive = activeNodeItem !== undefined @@ -698,9 +713,10 @@ function PVEDashboard() { ) - const renderTop = (id: TabId) => { + const renderTop = (id: TopTab) => { switch (id) { case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview")) + case "datastores": return renderDirect("datastores", Database, t("pbs.datastores")) case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps")) case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines")) case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup")) @@ -710,7 +726,7 @@ function PVEDashboard() { } } return ( - + {tabOrder.map(renderTop)} ) @@ -771,6 +787,7 @@ function PVEDashboard() { {tabOrder.map((id): React.ReactNode => { switch (id) { case "overview": return btn("overview", LayoutDashboard, t("navigation.overview")) + case "datastores": return btn("datastores", Database, t("pbs.datastores")) case "apps": return btn("apps", Grid3x3, t("navigation.apps")) case "vms": return btn("vms", Boxes, t("navigation.virtualMachines")) case "backup": return btn("backup", DatabaseBackup, t("navigation.backup")) @@ -786,7 +803,7 @@ function PVEDashboard() { {btn("logs", ScrollText, t("navigation.systemLogs"))} {btn("security", ShieldCheck, t("navigation.security"))} - {btn("audit", ClipboardCheck, t("navigation.audit"))} + {!isPbs && btn("audit", ClipboardCheck, t("navigation.audit"))} {btn("settings", SettingsIcon, t("navigation.settings"))} {btn("about", Info, t("navigation.about"))} @@ -820,9 +837,16 @@ function PVEDashboard() { 5 s / 59 s refresh intervals keep the data fresh in the background — reopening the tab is instant. */} - + + {isPbs && ( + + + + )} + + {!isPbs && <> @@ -848,11 +872,13 @@ function PVEDashboard() { + } + {!isPbs && <> @@ -860,13 +886,14 @@ function PVEDashboard() { + } - + {/* Profile tab — not surfaced in the top tabs nav. The only diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index a49968f6..37e3edc8 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -16,6 +16,7 @@ import { Input } from "./ui/input" import { getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider" +import { useIsPbs } from "../lib/use-platform" import type { LanguageCode } from "../lib/i18n/languages" import { NavTabOrderCard } from "./nav-tab-order-card" @@ -312,6 +313,7 @@ interface NetworkInterface { export function Settings() { const { language, setLanguage, t } = useI18n() + const isPbs = useIsPbs() const tFallback = (key: string, fallback: string) => { const translated = t(key) return translated === key ? fallback : translated @@ -1124,7 +1126,7 @@ export function Settings() {
{/* Navigation Tab Order — user-orderable top-level tabs */} - + {!isPbs && } {/* Network Units Settings */} @@ -1280,7 +1282,7 @@ export function Settings() { {/* Per-category rows */}
- {suppressionCategories.map((cat) => { + {suppressionCategories.filter(cat => !isPbs || healthCategoryKey(cat) !== "vms").map((cat) => { const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse const effectiveHours = getEffectiveHours(cat) const isCustomMode = effectiveHours === -2 || (cat.key in customValues) @@ -1288,7 +1290,8 @@ export function Settings() { const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2 const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key) - const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label) + const categoryKey = isPbs && healthCategoryKey(cat) === "services" ? "pbsServices" : healthCategoryKey(cat) + const catLabel = tFallback(`settings.healthMonitor.categories.${categoryKey}`, cat.label) return (
@@ -1496,6 +1499,7 @@ export function Settings() { + {!isPbs && <> {/* Remote Storage Exclusions */} @@ -1678,6 +1682,7 @@ export function Settings() { )} + } {/* Network Interface Exclusions */} @@ -1875,6 +1880,7 @@ export function Settings() { is re-enabled). */} + {!isPbs && <> {/* GitHub API — optional authentication for app upstream checks. */} @@ -1981,15 +1987,16 @@ export function Settings() { )} + } - {/* Notification Settings */} - + {/* Notification Settings (PVE only for now) */} + {!isPbs && } {/* Issue #195: snippets storage selector. Only renders when more than one storage advertises content=snippets — on a typical standalone host with just `local` there's nothing to choose, so showing an empty selector would be noise. */} - {snippetsCandidates.length > 1 && ( + {!isPbs && snippetsCandidates.length > 1 && (
diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx index ab443d42..ecbeeef7 100644 --- a/AppImage/components/system-overview.tsx +++ b/AppImage/components/system-overview.tsx @@ -1,10 +1,12 @@ "use client" import { useState, useEffect } from "react" +import useSWR from "swr" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Progress } from "./ui/progress" import { Badge } from "./ui/badge" -import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react" +import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react" +import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage" import { NodeMetricsCharts } from "./node-metrics-charts" import { NetworkTrafficChart } from "./network-traffic-chart" import { TemperatureDetailModal } from "./temperature-detail-modal" @@ -185,9 +187,17 @@ const getUnitsSettings = (): "Bytes" | "Bits" => { return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes" } -export function SystemOverview() { +export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) { const t = useT() const { language } = useI18n() + const isPbs = product === "pbs" + // On Proxmox Backup Server the datastores take the place of guests and PVE storage. + const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>( + isPbs ? "/api/pbs/status?server=local" : null, + (url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url), + { refreshInterval: 60000, revalidateOnFocus: false }, + ) + const datastores = pbsStatus?.datastores ?? null const [systemData, setSystemData] = useState(null) const [vmData, setVmData] = useState([]) const [storageData, setStorageData] = useState(null) @@ -212,8 +222,8 @@ export function SystemOverview() { const fetchAllData = async () => { const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([ fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))), - fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))), - Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() => + (isPbs ? Promise.resolve([]) : fetchVMData()).finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))), + Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()]).finally(() => setLoadingStates((prev) => ({ ...prev, storage: false })), ), fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))), @@ -248,12 +258,13 @@ export function SystemOverview() { }, 5000) const vmInterval = setInterval(async () => { + if (isPbs) return const data = await fetchVMData() setVmData(data) }, 59000) const storageInterval = setInterval(async () => { - const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()]) + const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()]) if (storage) setStorageData(storage) if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage) }, 59000) @@ -278,7 +289,7 @@ export function SystemOverview() { clearInterval(networkInterval) window.removeEventListener("networkUnitChanged" as any, handleUnitChange) } - }, [t]) + }, [t, isPbs]) if (!hasAttemptedLoad || loadingStates.system) { return ( @@ -527,7 +538,46 @@ export function SystemOverview() { - {/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */} + {isPbs ? ( + + + {t("pbs.datastores")} + + + + {!datastores && !pbsError ? ( +
+
+
+
+ ) : !datastores ? ( +

{t("pbs.loadFailed")}

+ ) : datastores.length === 0 ? ( +

{t("pbs.noDatastores")}

+ ) : ( +
+
+ {datastores.length} + {datastores.some(store => store.error) ? ( + + {t("pbs.datastoresUnavailable", { count: datastores.filter(store => store.error).length })} + + ) : ( + + {t("pbs.datastoresOnline")} + + )} +
+ {datastores.slice(0, 2).map(store => )} + {datastores.length > 2 && ( +

{t("pbs.moreDatastores", { count: datastores.length - 2 })}

+ )} +
+ )} +
+
+ ) : ( + /* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */ {t("overview.activeVmLxc")} @@ -565,6 +615,7 @@ export function SystemOverview() { )} + )} 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`} @@ -708,7 +759,16 @@ export function SystemOverview() {
- {vmLxcStorages && vmLxcStorages.length > 0 ? ( + {isPbs ? ( +
+
{t("pbs.datastores")}
+ {!datastores ? ( +
{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}
+ ) : datastores.length === 0 ? ( +
{t("pbs.noDatastores")}
+ ) : datastores.map(store => )} +
+ ) : vmLxcStorages && vmLxcStorages.length > 0 ? (
{t("overview.vmLxcStorage")}
@@ -744,7 +804,7 @@ export function SystemOverview() {
)} - {localStorage && ( + {!isPbs && localStorage && (
{t("overview.localStorageSystem")}
@@ -899,7 +959,7 @@ export function SystemOverview() { {formatSystemUptime(systemData.uptime)}
- {t("overview.proxmoxVersion")} + {t(isPbs ? "overview.pbsVersion" : "overview.proxmoxVersion")} {systemData.proxmox_version || t("app.notAvailable")}
diff --git a/AppImage/lib/use-platform.ts b/AppImage/lib/use-platform.ts new file mode 100644 index 00000000..cbb99cc6 --- /dev/null +++ b/AppImage/lib/use-platform.ts @@ -0,0 +1,8 @@ +import useSWR from "swr" +import { fetchApi } from "./api-config" + +// Shares the dashboard's /api/platform request through the SWR cache. +export function useIsPbs(): boolean { + const { data } = useSWR<{ product: string }>("/api/platform", (url: string) => fetchApi<{ product: string }>(url), { revalidateOnFocus: false }) + return data?.product === "pbs" +} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index fa596302..cc8d31f7 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -50,11 +50,20 @@ "navigation": "PBS navigation", "cpu": "CPU", "memory": "Memory", - "uptime": "Uptime" + "uptime": "Uptime", + "capacityUnavailable": "Capacity not available.", + "datastoresUnavailable": "{count} unavailable", + "datastoresOnline": "All available", + "moreDatastores": "+{count} more datastores", + "estimatedFull": "Estimated full", + "estimatedFullUnknown": "Not enough data yet", + "duration": "Duration", + "noJobs": "No sync, verification or prune schedules are configured." }, "app": { "title": "ProxMenux Monitor", "description": "Proxmox System Dashboard", + "descriptionPbs": "Proxmox Backup Server Dashboard", "loading": "Loading...", "connecting": "Connecting to ProxMenux Monitor", "unknown": "Unknown", @@ -195,6 +204,7 @@ "dayMany": "{count} days" }, "proxmoxVersion": "Proxmox Version:", + "pbsVersion": "PBS Version:", "kernel": "Kernel:", "availableUpdates": "Available Updates:", "packages": "packages", @@ -1880,6 +1890,7 @@ "network": "Network Interfaces", "vms": "VMs & Containers", "services": "PVE Services", + "pbsServices": "PBS Services", "logs": "System Logs", "updates": "System Updates", "security": "Security & Certificates", @@ -4874,6 +4885,7 @@ "network": "Network interfaces", "vms": "VMs & containers", "services": "PVE services", + "pbsServices": "PBS services", "logs": "System logs", "updates": "System updates", "security": "Security & certificates" @@ -6529,5 +6541,38 @@ "cancel": "Cancel" } }, - "runtime": {"notifications":{"templates":{"state_change":{"title":"{hostname}: {category} changed to {current}{entity_suffix}","body":"{category} status changed from {previous} to {current}.\n{reason}","label":"Health state changed"},"new_error":{"title":"{hostname}: New {severity} - {category}{entity_suffix}","body":"{reason}","label":"New health issue"},"error_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"The {category} issue has been resolved.\n{reason}\n🚦 Previous severity: {original_severity}\n⏱️ Duration: {duration}","label":"Recovery notification"},"error_escalated":{"title":"{hostname}: Escalated to {severity} - {category}{entity_suffix}","body":"{reason}","label":"Health issue escalated"},"health_degraded":{"title":"{title_or_default}","body":"{reason}","label":"Health check degraded"},"lxc_updates_available":{"title":"{hostname}: {count} LXC(s) with package updates available","body":"📊 {count} LXC(s) with pending package updates (📦 {total_packages} total, 🔒 {security_count} security):\n\n{ct_list}","label":"LXC updates available (experimental)"},"lxc_update_applied":{"title":"{hostname}: LXC {ct_name} ({vmid}) update {result}","body":"{details}","label":"LXC update applied"},"oci_update_completed":{"title":"{hostname}: {app_name} updated","body":"{app_name} was updated to its new image and its data was kept.\nContainers: {containers}","label":"OCI application updated"},"oci_update_failed":{"title":"{hostname}: {app_name} update did not complete","body":"The update of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application update failed"},"oci_recreate_completed":{"title":"{hostname}: {app_name} recreated","body":"{app_name} was recreated with its saved configuration and its data was kept.\nContainers: {containers}","label":"OCI application recreated"},"oci_recreate_failed":{"title":"{hostname}: {app_name} recreation did not complete","body":"The recreation of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application recreation failed"},"oci_modify_completed":{"title":"{hostname}: {app_name} modified","body":"{app_name} was modified with its new options and its data was kept.\nContainers: {containers}","label":"OCI application modified"},"oci_modify_failed":{"title":"{hostname}: {app_name} modification did not complete","body":"The modification of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application modification failed"},"oci_watchdog_restarted":{"title":"{hostname}: {app_name} restarted after it stopped","body":"{app_name} stopped on its own and the watchdog started it again.\nContainers: {containers}","label":"OCI application restarted by the watchdog"},"oci_watchdog_failed":{"title":"{hostname}: {app_name} could not be kept running","body":"The watchdog could not keep {app_name} running: it stopped again or did not start after several attempts, and it is left stopped.\nContainers: {containers}\nStart it by hand once the cause is solved; the watchdog then watches it again.","label":"OCI application left stopped by the watchdog"},"app_update_available":{"title":"{hostname}: {app_name} update available on CT {vmid}","body":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","label":"App update available"},"docker_stack_update_available":{"title":"{hostname}: Docker updates available on CT {vmid}","body":"Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n{details}","label":"Docker updates available"},"vm_start":{"title":"{hostname}: VM {vmname} ({vmid}) started","body":"Virtual machine {vmname} (ID: {vmid}) is now running.","label":"VM started"},"vm_start_warning":{"title":"{hostname}: VM {vmname} ({vmid}) started with warnings","body":"Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"VM started (warnings)"},"vm_stop":{"title":"{hostname}: VM {vmname} ({vmid}) stopped","body":"Virtual machine {vmname} (ID: {vmid}) has been stopped.","label":"VM stopped"},"vm_shutdown":{"title":"{hostname}: VM {vmname} ({vmid}) shut down","body":"Virtual machine {vmname} (ID: {vmid}) has been cleanly shut down.","label":"VM shutdown"},"vm_fail":{"title":"{hostname}: VM {vmname} ({vmid}) FAILED","body":"Virtual machine {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"VM FAILED"},"vm_restart":{"title":"{hostname}: VM {vmname} ({vmid}) restarted","body":"Virtual machine {vmname} (ID: {vmid}) has been restarted.","label":"VM restarted"},"ct_start":{"title":"{hostname}: CT {vmname} ({vmid}) started","body":"Container {vmname} (ID: {vmid}) is now running.","label":"CT started"},"ct_start_warning":{"title":"{hostname}: CT {vmname} ({vmid}) started with warnings","body":"Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"CT started (warnings)"},"ct_stop":{"title":"{hostname}: CT {vmname} ({vmid}) stopped","body":"Container {vmname} (ID: {vmid}) has been stopped.","label":"CT stopped"},"ct_shutdown":{"title":"{hostname}: CT {vmname} ({vmid}) shut down","body":"Container {vmname} (ID: {vmid}) has been cleanly shut down.","label":"CT shutdown"},"ct_restart":{"title":"{hostname}: CT {vmname} ({vmid}) restarted","body":"Container {vmname} (ID: {vmid}) has been restarted.","label":"CT restarted"},"ct_fail":{"title":"{hostname}: CT {vmname} ({vmid}) FAILED","body":"Container {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"CT FAILED"},"migration_start":{"title":"{hostname}: Migration started — {vmname} ({vmid})","body":"Live migration of {vmname} (ID: {vmid}) to node {target_node} has started.","label":"Migration started"},"migration_complete":{"title":"{hostname}: Migration complete — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) successfully migrated to node {target_node}.","label":"Migration complete"},"migration_warning":{"title":"{hostname}: Migration complete with warnings — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}","label":"Migration (warnings)"},"migration_fail":{"title":"{hostname}: Migration FAILED — {vmname} ({vmid})","body":"Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}","label":"Migration FAILED"},"replication_fail":{"title":"{hostname}: Replication FAILED — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Replication FAILED"},"replication_complete":{"title":"{hostname}: Replication complete — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) completed successfully.","label":"Replication complete"},"backup_start":{"title":"{hostname}: Backup started on {storage}","body":"Backup job started on storage {storage}.\n{reason}","label":"Backup started"},"backup_complete":{"title":"{hostname} → {storage}: Backup complete — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}","label":"Backup complete"},"backup_warning":{"title":"{hostname} → {storage}: Backup complete with warnings — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) on {storage} completed but encountered warnings.\nWarnings: {reason}","label":"Backup (warnings)"},"backup_fail":{"title":"{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}","label":"Backup FAILED"},"host_backup_start":{"title":"{hostname}: Host backup started → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}","label":"Host backup started"},"host_backup_complete":{"title":"{hostname}: Host backup complete → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}","label":"Host backup complete"},"host_backup_fail":{"title":"{hostname}: Host backup FAILED → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}","label":"Host backup FAILED"},"snapshot_complete":{"title":"{hostname}: Snapshot created — {vmname} ({vmid})","body":"Snapshot \"{snapshot_name}\" created for {vmname} (ID: {vmid}).","label":"Snapshot created"},"snapshot_fail":{"title":"{hostname}: Snapshot FAILED — {vmname} ({vmid})","body":"Snapshot creation for {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Snapshot FAILED"},"cpu_high":{"title":"{hostname}: High CPU usage — {value}%","body":"CPU usage has reached {value}% on {cores} cores.\n{details}","label":"High CPU usage"},"ram_high":{"title":"{hostname}: High memory usage — {value}%","body":"Memory usage: {used} / {total} ({value}%).\n{details}","label":"High memory usage"},"temp_high":{"title":"{hostname}: High sensor temperature — {value}°C","body":"Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}","label":"High temperature"},"disk_space_low":{"title":"{hostname}: Low disk space on {mount}","body":"Filesystem {mount}: {used}% used ({available} available).\nFree up disk space to avoid service disruption.","label":"Low disk space"},"disk_io_error":{"title":"{hostname}: Disk failure detected — {device}","body":"I/O error or disk failure detected on device {device}.\n{reason}","label":"Disk failure / I/O error"},"storage_unavailable":{"title":"{hostname}: Storage unavailable — {storage_name}","body":"PVE storage \"{storage_name}\" (type: {storage_type}) is not accessible.\nReason: {reason}","label":"Storage unavailable"},"smart_test_complete":{"title":"{hostname}: SMART test completed — {device}","body":"SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}","label":"SMART test completed"},"smart_test_failed":{"title":"{hostname}: SMART test FAILED — {device}","body":"SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}","label":"SMART test FAILED"},"gpu_mode_switch":{"title":"{hostname}: GPU mode changed to {new_mode}","body":"GPU passthrough mode has been switched.\nGPU: {gpu_name} ({gpu_pci})\nPrevious mode: {old_mode}\nNew mode: {new_mode}\n{details}","label":"GPU mode switched"},"gpu_passthrough_blocked":{"title":"{hostname}: {guest_type} {guest_id} blocked at startup","body":"PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\nReason: {reason}\n{details}","label":"GPU passthrough blocked"},"pci_passthrough_conflict":{"title":"{hostname}: PCIe device conflict detected — {device_pci}","body":"A PCIe device is assigned to multiple guests.\nDevice: {device_pci}\nConflicting guests: {guest_list}\nAction required: Stop one of the guests or reassign the device.","label":"PCIe device conflict"},"load_high":{"title":"{hostname}: High system load — {value}","body":"System load average is {value} on {cores} cores.\n{details}","label":"High system load"},"network_down":{"title":"{hostname}: Network connectivity lost{entity_suffix}","body":"The node has lost network connectivity.\nReason: {reason}","label":"Network connectivity lost"},"network_latency":{"title":"{hostname}: High network latency — {value}ms","body":"Latency to gateway: {value}ms (threshold: {threshold}ms).\nThis may indicate network congestion or hardware issues.","label":"High network latency"},"auth_fail":{"title":"{hostname}: Authentication failure","body":"Failed login attempt detected.\nSource IP: {source_ip}\nUser: {username}\nService: {service}","label":"Authentication failure"},"ip_block":{"title":"{hostname}: IP blocked by Fail2Ban","body":"IP address {source_ip} has been banned.\nJail: {jail}\nFailed attempts: {failures}","label":"IP blocked by Fail2Ban"},"firewall_issue":{"title":"{hostname}: Firewall issue detected{entity_suffix}","body":"A firewall configuration issue has been detected.\nReason: {reason}","label":"Firewall issue detected"},"user_permission_change":{"title":"{hostname}: User permission changed","body":"User: {username}\nChange: {change_details}","label":"User permission changed"},"split_brain":{"title":"{hostname}: Cluster event reported","body":"A cluster event was reported:\n{reason}","label":"Cluster event"},"node_disconnect":{"title":"{hostname}: Node {node_name} disconnected","body":"Node {node_name} has disconnected from the cluster.","label":"Node disconnected"},"node_reconnect":{"title":"{hostname}: Node {node_name} reconnected","body":"Node {node_name} has rejoined the cluster successfully.","label":"Node reconnected"},"system_startup":{"title":"{hostname}: {reason}","body":"{summary}","label":"System startup report"},"system_shutdown":{"title":"{hostname}: System shutting down","body":"The node is shutting down.\n{reason}","label":"System shutting down"},"system_reboot":{"title":"{hostname}: System rebooting","body":"The node is rebooting.\n{reason}","label":"System rebooting"},"system_restore_completed":{"title":"{hostname}: Host restore finished","body":"Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}","label":"Host restore completed"},"system_problem":{"title":"{hostname}: System problem detected{entity_suffix}","body":"A system-level problem has been detected.\nReason: {reason}","label":"System problem detected"},"service_fail":{"title":"{hostname}: Service failed — {service_name}","body":"System service \"{service_name}\" has failed.\nReason: {reason}","label":"Service failed"},"oom_kill":{"title":"{hostname}: OOM Kill — {process}","body":"Process \"{process}\" was killed by the Out-of-Memory manager.\n{reason}","label":"Out of memory kill"},"service_fail_batch":{"title":"{hostname}: {service_count} services failed{entity_suffix}","body":"{reason}","label":"Service fail batch"},"cron_output":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Cron job output (per-cron stdout via mail)"},"system_mail":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Smartd / mail bounces (PVE system mail)"},"apt_listchanges":{"title":"{hostname}: {pve_title}","body":"Upstream package information forwarded by Proxmox VE through apt-listchanges. The following text comes from the package maintainer and is not a ProxMenux recommendation.\n\n{reason}","label":"apt-listchanges package notices"},"webhook_test":{"title":"{hostname}: Webhook test received","body":"PVE webhook connectivity test successful.\n{reason}","label":"Webhook test"},"update_available":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity: {security_count}\nProxmox: {pve_count}\nKernel: {kernel_count}\nImportant packages:\n{important_list}","label":"Updates available (legacy)"},"unknown_persistent":{"title":"{hostname}: Check unavailable - {category}{entity_suffix}","body":"Health check for {category} has been unavailable for 3+ cycles.\n{reason}","label":"Check unavailable"},"health_persistent":{"title":"{hostname}: {count} active health issue(s){entity_suffix}","body":"The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.","label":"Active health issues (daily)"},"health_issue_new":{"title":"{hostname}: New health issue — {category}{entity_suffix}","body":"New {severity} issue detected in: {category}\nDetails: {reason}","label":"New health issue"},"health_issue_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"{category} issue has been resolved.\n{reason}\nDuration: {duration}","label":"Health issue resolved"},"update_summary":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity updates: {security_count}\nProxmox-related updates: {pve_count}\nKernel updates: {kernel_count}\nImportant packages:\n{important_list}","label":"Host package updates"},"pve_update":{"title":"{hostname}: Proxmox VE {new_version} available","body":"A new Proxmox VE release is available.\nCurrent: {current_version}\nNew: {new_version}\n{details}","label":"Proxmox VE update available"},"update_complete":{"title":"{hostname}: System update completed","body":"System packages have been successfully updated.\n{details}","label":"Host update completed"},"ai_model_migrated":{"title":"{hostname}: AI model updated","body":"The AI model for notifications has been automatically updated.\nProvider: {provider}\nPrevious model: {old_model}\nNew model: {new_model}\n\n{message}","label":"AI model auto-updated"},"proxmenux_update":{"title":"{hostname}: ProxMenux {new_version} available","body":"A new version of ProxMenux is available.\nCurrent: {current_version}\nNew: {new_version}","label":"ProxMenux update available"},"mount_stale":{"title":"{hostname}: stale remote mount {mount_target}","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is stale{lxc_scope}.\nStat timed out or returned an error: {error}\n\nApps writing to this path will silently land on the underlying filesystem and may fill the disk. Remount or fix connectivity ASAP.","label":"Remote mount stale"},"mount_readonly":{"title":"{hostname}: remote mount {mount_target} is read-only","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is mounted read-only{lxc_scope}. Writes will fail. If this was unintentional, remount with rw.","label":"Remote mount read-only"},"lxc_disk_low":{"title":"{hostname}: CT {vmid} rootfs at {usage_percent}%","body":"CT {vmid} ({name}) rootfs is at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nA full LXC rootfs prevents the container from booting cleanly. Either expand the rootfs (pct resize {vmid} rootfs +1G) or free space inside the container.","label":"LXC rootfs near full"},"vm_disk_low":{"title":"{hostname}: VM {vmid} filesystems at {usage_percent}%","body":"VM {vmid} ({name}) guest filesystems are at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nReported by the QEMU guest agent. Includes every persistent filesystem the guest mounts on a block device — virtual disks and PCI-passthrough drives alike. Free up space inside the guest or expand the affected storage before writes start to fail.","label":"VM filesystems near full"},"lxc_mount_low":{"title":"{hostname}: CT {vmid} mount {mount} at {usage_percent}%","body":"Mount {mount} inside CT {vmid} ({name}) is at {usage_percent}% used.\nFilesystem type: {fstype}\n\nA full mount inside a container often blocks the application silently — writes either fail or, worse, land on the rootfs and trigger the rootfs alert next. Free up space on the mount or expand it.","label":"LXC mount near full"},"pve_storage_full":{"title":"{hostname}: PVE storage {storage_name} at {usage_percent}%","body":"Proxmox storage \"{storage_name}\" (type: {storage_type}) is at {usage_percent}% used.\n\nOnce full, no new VM/CT can be provisioned and existing guests may fail to write. Move/delete unused volumes or expand the underlying pool/LV/RBD image.","label":"PVE storage near full"},"zfs_pool_full":{"title":"{hostname}: ZFS pool {pool_name} at {usage_percent}%","body":"ZFS pool \"{pool_name}\" is at {usage_percent}% capacity.\n\nZFS performance and write reliability degrade sharply above ~80% capacity (CoW needs free space for new blocks). Free up snapshots, prune old datasets, or add more vdevs to the pool.","label":"ZFS pool near full"},"post_install_update":{"title":"{hostname}: {count} ProxMenux optimization update(s) available","body":"{count} ProxMenux optimization update(s) available on this host.\n\n🛠️ Available versions:\n{tool_list}\n\n💡 Apply from:\n • ProxMenux Monitor → Settings → ProxMenux Optimizations\n • Or open the ProxMenux menu → Settings post-install Proxmox → \"Apply available updates\"","label":"ProxMenux optimization updates available"},"secure_gateway_update_available":{"title":"{hostname}: {app_name} update available{update_title_suffix}","body":"{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) pending in its container.\n{version_line}\n\n💡 Open ProxMenux Monitor > Security > Secure Gateway and click \"Update\" to apply.\n\n🗂️ Packages:\n{package_list}","label":"Secure Gateway update available"},"nvidia_driver_update_available":{"title":"{hostname}: NVIDIA driver update available — v{latest_version}","body":"A newer maintenance release is available for the installed NVIDIA driver branch.\n🔹 Currently installed: v{current_version}\n🟢 Latest available: v{latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\nReinstalling rebuilds the DKMS module against the running kernel and requires a reboot to load the new driver.","label":"NVIDIA driver update available"},"coral_driver_update_available":{"title":"{hostname}: Coral TPU driver update available — {latest_version}","body":"A newer {variant_label} is available.\n🔹 Currently installed: {current_version}\n🟢 Latest available: {latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\n{reboot_note}","label":"Coral TPU driver update available"},"burst_auth_fail":{"title":"{hostname}: +{count} more auth failures in {window}","body":"+{count} additional authentication failures detected in {window} ({total_count} total).\nSources: {entity_list}","label":"Auth failures burst"},"burst_ip_block":{"title":"{hostname}: Fail2Ban banned +{count} more IPs in {window}","body":"+{count} additional IPs banned by Fail2Ban in {window} ({total_count} total).\nIPs: {entity_list}","label":"IP block burst"},"burst_disk_io":{"title":"{hostname}: +{count} more disk I/O errors on {entity_list}","body":"+{count} additional I/O errors detected in {window} ({total_count} total).\nDevices: {entity_list}","label":"Disk I/O burst"},"burst_cluster":{"title":"{hostname}: Cluster flapping detected (+{count} more changes)","body":"Cluster state changed +{count} more times in {window} ({total_count} total).\nNodes: {entity_list}","label":"Cluster flapping burst"},"burst_service_fail":{"title":"{hostname}: +{count} more services failed in {window}","body":"+{count} additional service failures detected in {window} ({total_count} total).\nThis typically indicates a node reboot or PVE service restart.\n\nAdditional failures:\n{details}","label":"Service fail burst"},"burst_system":{"title":"{hostname}: +{count} more system problems in {window}","body":"+{count} additional system problems detected in {window} ({total_count} total).\n\nAdditional issues:\n{details}","label":"System problems burst"},"burst_generic":{"title":"{hostname}: +{count} more {event_type} events in {window}","body":"+{count} additional events of type {event_type} in {window} ({total_count} total).\n\nAdditional events:\n{details}","label":"Generic burst"},"kernel_warning":{"title":"{hostname}: Kernel diagnostic event detected","body":"The kernel recorded a diagnostic event: {kernel_details}","label":"Kernel warnings and diagnostic traces"}},"lxcUpdate":{"status":{"success":"succeeded","failure":"failed","partial":"completed partially","deferred":"deferred","skipped":"skipped"},"source":{"scheduled":"Scheduled","manual":"Manual"},"sourceLabel":"Source","targets":"Targets","osPending":"OS packages pending: {before} → {after}","osUnverified":"OS packages: update command executed; result not verified","applications":"Applications: {items}","applicationsUnverified":"Applications: updater executed; no tracked version change was observed","dockerEngineChange":"Docker Engine: {before} → {after}","dockerEngineVerified":"Docker Engine: verified at {version}","dockerEngineUnverified":"Docker Engine: update command executed; version not verified","dockerImagesPending":"Docker images pending: {before} → {after}","dockerImagesChanged":"Docker images changed: {images}","deferredTargets":"Deferred targets: {targets}","reason":"Reason: {reason}","restartRequired":"Restart required: {value}","yes":"yes","no":"no","verificationPending":"Verification pending until the container is running","verificationWarning":"Verification warning: {error}","duration":"Duration: {duration}"},"fallback":{"unknownTitle":"{hostname}: {event_type}","healthCheckDegraded":"Health check degraded","none":"none","temperatureAlertTitle":"{hostname}: Sensor temperature alert","temperatureAlertBody":"Temperature alert recorded without a complete measurement payload.","recordedReason":"Recorded reason: {reason}","recordedDetails":"Recorded details: {details}"},"fields":{"vmid":"VM/CT","name":"Name","device":"Device","sourceIp":"Source IP","node":"Node","category":"Category","service":"Service","jail":"Jail","user":"User","count":"Count","window":"Window","affected":"Affected"},"vzdump":{"size":"Size: {value}","duration":"Duration: {value}","file":"File","backups":"{count} backups","failed":"{count} failed","total":"Total: {value}","time":"Time: {value}"},"startup":{"issuesTitle":"{hostname}: System startup - {count} issue(s) detected","completeTitle":"{hostname}: System startup completed","operational":"All systems operational.","vmCountOne":"{count} VM","vmCountMany":"{count} VMs","ctCountOne":"{count} CT","ctCountMany":"{count} CTs","started":"✅ {counts} started","vmFailed":"❌ VM failed: {name} - {reason}","ctFailed":"❌ CT failed: {name} - {reason}","unknownError":"unknown error","storageUnavailable":"⚠️ Storage: {count} unavailable ({names})","servicesFailed":"⚠️ Services: {count} failed ({names})","duration":"⏱️ Startup completed in {minutes} min"},"appUpdates":{"singleTitle":"{hostname}: {app_name} update available on CT {vmid}","singleBody":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","emptyTitle":"{hostname}: Application updates available","emptyBody":"Application updates are available.","batchTitle":"{hostname}: {count} application updates available","batchLeadOneContainer":"{count} applications in {container_count} LXC container have a newer version:","batchLeadManyContainers":"{count} applications in {container_count} LXC containers have a newer version:","additional":"… {count} additional application(s)","app":"app","unknown":"unknown"},"healthDegraded":{"categories":{"cpu":"CPU usage and temperature","memory":"Memory and swap","storage":"Storage mounts and space","disks":"Disk I/O and errors","network":"Network interfaces","vms":"VMs and containers","services":"PVE services","logs":"System logs","updates":"System updates","security":"Security"},"severity":{"critical":"Critical","warning":"Warning"},"singleTitle":"{hostname}: {severity} health status – {category}","multipleTitle":"{hostname}: {count} health checks degraded","multipleLine":"• {severity}: {category}\n {reason}","reasons":{"cpuSustained":"CPU is above {threshold}% for {duration}s."}},"digest":{"title":"{hostname}: 24h summary ({timestamp})","readFailed":"digest read failed: {error}","empty":"No INFO events buffered for this digest window.","lead":"{count} INFO events grouped by category:\n","more":" • … and {count} more","footer":"(Critical/Warning events arrived at the time they happened, not in this digest.)","quietTitle":"{hostname}: {count} events buffered during Quiet Hours","groups":{"vm_ct":"VM a CT","backup":"Backup","resources":"Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"Services","health":"Health","updates":"Updates","hardware":"Hardware","system":"System","other":"Other"}},"test":{"title":"ProxMenux Test","welcome":"Welcome to ProxMenux Monitor!","verify":"This is a test message to verify your notification channel is working correctly.","configuration":"Channel configuration:","iconsEnabled":"Icons: enabled","iconsDisabled":"Icons: disabled","aiEnabled":"AI: enabled ({info})","aiDisabled":"AI: disabled","alerts":"You will receive alerts about system events, health status changes, and security incidents.","photoCaption":"You can use this image as the profile photo for your notification bot."},"channels":{"discord":{"category":"Category","host":"Host","severity":"Severity"},"email":{"report":"{group} Report","details":"Details","host":"Host","footer":"ProxMenux Notification Service","severity":{"critical":"Critical","warning":"Warning","info":"Information","ok":"Resolved","default":"Notice"},"groups":{"vm_ct":"Virtual Machine / Container","backup":"Backup & Snapshot","resources":"System Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"System Services","health":"Health Monitor","updates":"System Updates","system":"System","hardware":"Hardware","other":"System Notification"},"fields":{"vmCtId":"VM/CT ID","name":"Name","action":"Action","targetNode":"Target Node","reason":"Reason","storage":"Storage","status":"Status","size":"Size","duration":"Duration","snapshot":"Snapshot","metric":"Metric","currentValue":"Current Value","threshold":"Threshold","cpuCores":"CPU Cores","memory":"Memory","temperature":"Temperature","mountPoint":"Mount Point","usage":"Usage","available":"Available","device":"Device","severity":"Severity","storageName":"Storage Name","type":"Type","interface":"Interface","latency":"Latency","event":"Event","sourceIp":"Source IP","username":"Username","service":"Service","jail":"Jail","failures":"Failures","change":"Change","node":"Node","quorum":"Quorum","nodesAffected":"Nodes Affected","process":"Process","category":"Category","previousSeverity":"Previous Severity","activeIssues":"Active Issues","totalUpdates":"Total Updates","securityUpdates":"Security Updates","proxmoxUpdates":"Proxmox Updates","kernelUpdates":"Kernel Updates","importantPackages":"Important Packages","currentVersion":"Current Version","newVersion":"New Version"},"status":{"failed":"Failed","completed":"Completed","started":"Started","unconfirmed":"Unconfirmed","completed_with_warnings":"Completed with warnings"}}},"temperature":{"sampleSpan":"High samples span {duration}."},"backup":{"unconfirmedTitle":"{hostname}: Backup outcome unconfirmed","confirmedTitle":"{hostname}: Backup complete","confirmedBody":"Backup completed successfully.","errorTitle":"{hostname}: Backup error reported","errorBody":"The backup report contains an error.","unconfirmedBody":"The backup outcome is not confirmed.","warningTitle":"{hostname}: Backup completed with warnings","warningBody":"Backup completed with warnings.","diagnosticsOmitted":"Additional diagnostic lines or text omitted: {count}. Original report retained."}}} + "runtime": {"notifications":{"templates":{"state_change":{"title":"{hostname}: {category} changed to {current}{entity_suffix}","body":"{category} status changed from {previous} to {current}.\n{reason}","label":"Health state changed"},"new_error":{"title":"{hostname}: New {severity} - {category}{entity_suffix}","body":"{reason}","label":"New health issue"},"error_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"The {category} issue has been resolved.\n{reason}\n🚦 Previous severity: {original_severity}\n⏱️ Duration: {duration}","label":"Recovery notification"},"error_escalated":{"title":"{hostname}: Escalated to {severity} - {category}{entity_suffix}","body":"{reason}","label":"Health issue escalated"},"health_degraded":{"title":"{title_or_default}","body":"{reason}","label":"Health check degraded"},"lxc_updates_available":{"title":"{hostname}: {count} LXC(s) with package updates available","body":"📊 {count} LXC(s) with pending package updates (📦 {total_packages} total, 🔒 {security_count} security):\n\n{ct_list}","label":"LXC updates available (experimental)"},"lxc_update_applied":{"title":"{hostname}: LXC {ct_name} ({vmid}) update {result}","body":"{details}","label":"LXC update applied"},"oci_update_completed":{"title":"{hostname}: {app_name} updated","body":"{app_name} was updated to its new image and its data was kept.\nContainers: {containers}","label":"OCI application updated"},"oci_update_failed":{"title":"{hostname}: {app_name} update did not complete","body":"The update of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application update failed"},"oci_recreate_completed":{"title":"{hostname}: {app_name} recreated","body":"{app_name} was recreated with its saved configuration and its data was kept.\nContainers: {containers}","label":"OCI application recreated"},"oci_recreate_failed":{"title":"{hostname}: {app_name} recreation did not complete","body":"The recreation of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application recreation failed"},"oci_modify_completed":{"title":"{hostname}: {app_name} modified","body":"{app_name} was modified with its new options and its data was kept.\nContainers: {containers}","label":"OCI application modified"},"oci_modify_failed":{"title":"{hostname}: {app_name} modification did not complete","body":"The modification of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application modification failed"},"oci_watchdog_restarted":{"title":"{hostname}: {app_name} restarted after it stopped","body":"{app_name} stopped on its own and the watchdog started it again.\nContainers: {containers}","label":"OCI application restarted by the watchdog"},"oci_watchdog_failed":{"title":"{hostname}: {app_name} could not be kept running","body":"The watchdog could not keep {app_name} running: it stopped again or did not start after several attempts, and it is left stopped.\nContainers: {containers}\nStart it by hand once the cause is solved; the watchdog then watches it again.","label":"OCI application left stopped by the watchdog"},"app_update_available":{"title":"{hostname}: {app_name} update available on CT {vmid}","body":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","label":"App update available"},"docker_stack_update_available":{"title":"{hostname}: Docker updates available on CT {vmid}","body":"Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n{details}","label":"Docker updates available"},"vm_start":{"title":"{hostname}: VM {vmname} ({vmid}) started","body":"Virtual machine {vmname} (ID: {vmid}) is now running.","label":"VM started"},"vm_start_warning":{"title":"{hostname}: VM {vmname} ({vmid}) started with warnings","body":"Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"VM started (warnings)"},"vm_stop":{"title":"{hostname}: VM {vmname} ({vmid}) stopped","body":"Virtual machine {vmname} (ID: {vmid}) has been stopped.","label":"VM stopped"},"vm_shutdown":{"title":"{hostname}: VM {vmname} ({vmid}) shut down","body":"Virtual machine {vmname} (ID: {vmid}) has been cleanly shut down.","label":"VM shutdown"},"vm_fail":{"title":"{hostname}: VM {vmname} ({vmid}) FAILED","body":"Virtual machine {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"VM FAILED"},"vm_restart":{"title":"{hostname}: VM {vmname} ({vmid}) restarted","body":"Virtual machine {vmname} (ID: {vmid}) has been restarted.","label":"VM restarted"},"ct_start":{"title":"{hostname}: CT {vmname} ({vmid}) started","body":"Container {vmname} (ID: {vmid}) is now running.","label":"CT started"},"ct_start_warning":{"title":"{hostname}: CT {vmname} ({vmid}) started with warnings","body":"Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"CT started (warnings)"},"ct_stop":{"title":"{hostname}: CT {vmname} ({vmid}) stopped","body":"Container {vmname} (ID: {vmid}) has been stopped.","label":"CT stopped"},"ct_shutdown":{"title":"{hostname}: CT {vmname} ({vmid}) shut down","body":"Container {vmname} (ID: {vmid}) has been cleanly shut down.","label":"CT shutdown"},"ct_restart":{"title":"{hostname}: CT {vmname} ({vmid}) restarted","body":"Container {vmname} (ID: {vmid}) has been restarted.","label":"CT restarted"},"ct_fail":{"title":"{hostname}: CT {vmname} ({vmid}) FAILED","body":"Container {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"CT FAILED"},"migration_start":{"title":"{hostname}: Migration started — {vmname} ({vmid})","body":"Live migration of {vmname} (ID: {vmid}) to node {target_node} has started.","label":"Migration started"},"migration_complete":{"title":"{hostname}: Migration complete — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) successfully migrated to node {target_node}.","label":"Migration complete"},"migration_warning":{"title":"{hostname}: Migration complete with warnings — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}","label":"Migration (warnings)"},"migration_fail":{"title":"{hostname}: Migration FAILED — {vmname} ({vmid})","body":"Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}","label":"Migration FAILED"},"replication_fail":{"title":"{hostname}: Replication FAILED — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Replication FAILED"},"replication_complete":{"title":"{hostname}: Replication complete — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) completed successfully.","label":"Replication complete"},"backup_start":{"title":"{hostname}: Backup started on {storage}","body":"Backup job started on storage {storage}.\n{reason}","label":"Backup started"},"backup_complete":{"title":"{hostname} → {storage}: Backup complete — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}","label":"Backup complete"},"backup_warning":{"title":"{hostname} → {storage}: Backup complete with warnings — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) on {storage} completed but encountered warnings.\nWarnings: {reason}","label":"Backup (warnings)"},"backup_fail":{"title":"{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}","label":"Backup FAILED"},"host_backup_start":{"title":"{hostname}: Host backup started → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}","label":"Host backup started"},"host_backup_complete":{"title":"{hostname}: Host backup complete → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}","label":"Host backup complete"},"host_backup_fail":{"title":"{hostname}: Host backup FAILED → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}","label":"Host backup FAILED"},"snapshot_complete":{"title":"{hostname}: Snapshot created — {vmname} ({vmid})","body":"Snapshot \"{snapshot_name}\" created for {vmname} (ID: {vmid}).","label":"Snapshot created"},"snapshot_fail":{"title":"{hostname}: Snapshot FAILED — {vmname} ({vmid})","body":"Snapshot creation for {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Snapshot FAILED"},"cpu_high":{"title":"{hostname}: High CPU usage — {value}%","body":"CPU usage has reached {value}% on {cores} cores.\n{details}","label":"High CPU usage"},"ram_high":{"title":"{hostname}: High memory usage — {value}%","body":"Memory usage: {used} / {total} ({value}%).\n{details}","label":"High memory usage"},"temp_high":{"title":"{hostname}: High sensor temperature — {value}°C","body":"Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}","label":"High temperature"},"disk_space_low":{"title":"{hostname}: Low disk space on {mount}","body":"Filesystem {mount}: {used}% used ({available} available).\nFree up disk space to avoid service disruption.","label":"Low disk space"},"disk_io_error":{"title":"{hostname}: Disk failure detected — {device}","body":"I/O error or disk failure detected on device {device}.\n{reason}","label":"Disk failure / I/O error"},"storage_unavailable":{"title":"{hostname}: Storage unavailable — {storage_name}","body":"PVE storage \"{storage_name}\" (type: {storage_type}) is not accessible.\nReason: {reason}","label":"Storage unavailable"},"smart_test_complete":{"title":"{hostname}: SMART test completed — {device}","body":"SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}","label":"SMART test completed"},"smart_test_failed":{"title":"{hostname}: SMART test FAILED — {device}","body":"SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}","label":"SMART test FAILED"},"gpu_mode_switch":{"title":"{hostname}: GPU mode changed to {new_mode}","body":"GPU passthrough mode has been switched.\nGPU: {gpu_name} ({gpu_pci})\nPrevious mode: {old_mode}\nNew mode: {new_mode}\n{details}","label":"GPU mode switched"},"gpu_passthrough_blocked":{"title":"{hostname}: {guest_type} {guest_id} blocked at startup","body":"PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\nReason: {reason}\n{details}","label":"GPU passthrough blocked"},"pci_passthrough_conflict":{"title":"{hostname}: PCIe device conflict detected — {device_pci}","body":"A PCIe device is assigned to multiple guests.\nDevice: {device_pci}\nConflicting guests: {guest_list}\nAction required: Stop one of the guests or reassign the device.","label":"PCIe device conflict"},"load_high":{"title":"{hostname}: High system load — {value}","body":"System load average is {value} on {cores} cores.\n{details}","label":"High system load"},"network_down":{"title":"{hostname}: Network connectivity lost{entity_suffix}","body":"The node has lost network connectivity.\nReason: {reason}","label":"Network connectivity lost"},"network_latency":{"title":"{hostname}: High network latency — {value}ms","body":"Latency to gateway: {value}ms (threshold: {threshold}ms).\nThis may indicate network congestion or hardware issues.","label":"High network latency"},"auth_fail":{"title":"{hostname}: Authentication failure","body":"Failed login attempt detected.\nSource IP: {source_ip}\nUser: {username}\nService: {service}","label":"Authentication failure"},"ip_block":{"title":"{hostname}: IP blocked by Fail2Ban","body":"IP address {source_ip} has been banned.\nJail: {jail}\nFailed attempts: {failures}","label":"IP blocked by Fail2Ban"},"firewall_issue":{"title":"{hostname}: Firewall issue detected{entity_suffix}","body":"A firewall configuration issue has been detected.\nReason: {reason}","label":"Firewall issue detected"},"user_permission_change":{"title":"{hostname}: User permission changed","body":"User: {username}\nChange: {change_details}","label":"User permission changed"},"split_brain":{"title":"{hostname}: Cluster event reported","body":"A cluster event was reported:\n{reason}","label":"Cluster event"},"node_disconnect":{"title":"{hostname}: Node {node_name} disconnected","body":"Node {node_name} has disconnected from the cluster.","label":"Node disconnected"},"node_reconnect":{"title":"{hostname}: Node {node_name} reconnected","body":"Node {node_name} has rejoined the cluster successfully.","label":"Node reconnected"},"system_startup":{"title":"{hostname}: {reason}","body":"{summary}","label":"System startup report"},"system_shutdown":{"title":"{hostname}: System shutting down","body":"The node is shutting down.\n{reason}","label":"System shutting down"},"system_reboot":{"title":"{hostname}: System rebooting","body":"The node is rebooting.\n{reason}","label":"System rebooting"},"system_restore_completed":{"title":"{hostname}: Host restore finished","body":"Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}","label":"Host restore completed"},"system_problem":{"title":"{hostname}: System problem detected{entity_suffix}","body":"A system-level problem has been detected.\nReason: {reason}","label":"System problem detected"},"service_fail":{"title":"{hostname}: Service failed — {service_name}","body":"System service \"{service_name}\" has failed.\nReason: {reason}","label":"Service failed"},"oom_kill":{"title":"{hostname}: OOM Kill — {process}","body":"Process \"{process}\" was killed by the Out-of-Memory manager.\n{reason}","label":"Out of memory kill"},"service_fail_batch":{"title":"{hostname}: {service_count} services failed{entity_suffix}","body":"{reason}","label":"Service fail batch"},"cron_output":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Cron job output (per-cron stdout via mail)"},"system_mail":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Smartd / mail bounces (PVE system mail)"},"apt_listchanges":{"title":"{hostname}: {pve_title}","body":"Upstream package information forwarded by Proxmox VE through apt-listchanges. The following text comes from the package maintainer and is not a ProxMenux recommendation.\n\n{reason}","label":"apt-listchanges package notices"},"webhook_test":{"title":"{hostname}: Webhook test received","body":"PVE webhook connectivity test successful.\n{reason}","label":"Webhook test"},"update_available":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity: {security_count}\nProxmox: {pve_count}\nKernel: {kernel_count}\nImportant packages:\n{important_list}","label":"Updates available (legacy)"},"unknown_persistent":{"title":"{hostname}: Check unavailable - {category}{entity_suffix}","body":"Health check for {category} has been unavailable for 3+ cycles.\n{reason}","label":"Check unavailable"},"health_persistent":{"title":"{hostname}: {count} active health issue(s){entity_suffix}","body":"The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.","label":"Active health issues (daily)"},"health_issue_new":{"title":"{hostname}: New health issue — {category}{entity_suffix}","body":"New {severity} issue detected in: {category}\nDetails: {reason}","label":"New health issue"},"health_issue_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"{category} issue has been resolved.\n{reason}\nDuration: {duration}","label":"Health issue resolved"},"update_summary":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity updates: {security_count}\nProxmox-related updates: {pve_count}\nKernel updates: {kernel_count}\nImportant packages:\n{important_list}","label":"Host package updates"},"pve_update":{"title":"{hostname}: Proxmox VE {new_version} available","body":"A new Proxmox VE release is available.\nCurrent: {current_version}\nNew: {new_version}\n{details}","label":"Proxmox VE update available"},"update_complete":{"title":"{hostname}: System update completed","body":"System packages have been successfully updated.\n{details}","label":"Host update completed"},"ai_model_migrated":{"title":"{hostname}: AI model updated","body":"The AI model for notifications has been automatically updated.\nProvider: {provider}\nPrevious model: {old_model}\nNew model: {new_model}\n\n{message}","label":"AI model auto-updated"},"proxmenux_update":{"title":"{hostname}: ProxMenux {new_version} available","body":"A new version of ProxMenux is available.\nCurrent: {current_version}\nNew: {new_version}","label":"ProxMenux update available"},"mount_stale":{"title":"{hostname}: stale remote mount {mount_target}","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is stale{lxc_scope}.\nStat timed out or returned an error: {error}\n\nApps writing to this path will silently land on the underlying filesystem and may fill the disk. Remount or fix connectivity ASAP.","label":"Remote mount stale"},"mount_readonly":{"title":"{hostname}: remote mount {mount_target} is read-only","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is mounted read-only{lxc_scope}. Writes will fail. If this was unintentional, remount with rw.","label":"Remote mount read-only"},"lxc_disk_low":{"title":"{hostname}: CT {vmid} rootfs at {usage_percent}%","body":"CT {vmid} ({name}) rootfs is at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nA full LXC rootfs prevents the container from booting cleanly. Either expand the rootfs (pct resize {vmid} rootfs +1G) or free space inside the container.","label":"LXC rootfs near full"},"vm_disk_low":{"title":"{hostname}: VM {vmid} filesystems at {usage_percent}%","body":"VM {vmid} ({name}) guest filesystems are at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nReported by the QEMU guest agent. Includes every persistent filesystem the guest mounts on a block device — virtual disks and PCI-passthrough drives alike. Free up space inside the guest or expand the affected storage before writes start to fail.","label":"VM filesystems near full"},"lxc_mount_low":{"title":"{hostname}: CT {vmid} mount {mount} at {usage_percent}%","body":"Mount {mount} inside CT {vmid} ({name}) is at {usage_percent}% used.\nFilesystem type: {fstype}\n\nA full mount inside a container often blocks the application silently — writes either fail or, worse, land on the rootfs and trigger the rootfs alert next. Free up space on the mount or expand it.","label":"LXC mount near full"},"pve_storage_full":{"title":"{hostname}: PVE storage {storage_name} at {usage_percent}%","body":"Proxmox storage \"{storage_name}\" (type: {storage_type}) is at {usage_percent}% used.\n\nOnce full, no new VM/CT can be provisioned and existing guests may fail to write. Move/delete unused volumes or expand the underlying pool/LV/RBD image.","label":"PVE storage near full"},"zfs_pool_full":{"title":"{hostname}: ZFS pool {pool_name} at {usage_percent}%","body":"ZFS pool \"{pool_name}\" is at {usage_percent}% capacity.\n\nZFS performance and write reliability degrade sharply above ~80% capacity (CoW needs free space for new blocks). Free up snapshots, prune old datasets, or add more vdevs to the pool.","label":"ZFS pool near full"},"post_install_update":{"title":"{hostname}: {count} ProxMenux optimization update(s) available","body":"{count} ProxMenux optimization update(s) available on this host.\n\n🛠️ Available versions:\n{tool_list}\n\n💡 Apply from:\n • ProxMenux Monitor → Settings → ProxMenux Optimizations\n • Or open the ProxMenux menu → Settings post-install Proxmox → \"Apply available updates\"","label":"ProxMenux optimization updates available"},"secure_gateway_update_available":{"title":"{hostname}: {app_name} update available{update_title_suffix}","body":"{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) pending in its container.\n{version_line}\n\n💡 Open ProxMenux Monitor > Security > Secure Gateway and click \"Update\" to apply.\n\n🗂️ Packages:\n{package_list}","label":"Secure Gateway update available"},"nvidia_driver_update_available":{"title":"{hostname}: NVIDIA driver update available — v{latest_version}","body":"A newer maintenance release is available for the installed NVIDIA driver branch.\n🔹 Currently installed: v{current_version}\n🟢 Latest available: v{latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\nReinstalling rebuilds the DKMS module against the running kernel and requires a reboot to load the new driver.","label":"NVIDIA driver update available"},"coral_driver_update_available":{"title":"{hostname}: Coral TPU driver update available — {latest_version}","body":"A newer {variant_label} is available.\n🔹 Currently installed: {current_version}\n🟢 Latest available: {latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\n{reboot_note}","label":"Coral TPU driver update available"},"burst_auth_fail":{"title":"{hostname}: +{count} more auth failures in {window}","body":"+{count} additional authentication failures detected in {window} ({total_count} total).\nSources: {entity_list}","label":"Auth failures burst"},"burst_ip_block":{"title":"{hostname}: Fail2Ban banned +{count} more IPs in {window}","body":"+{count} additional IPs banned by Fail2Ban in {window} ({total_count} total).\nIPs: {entity_list}","label":"IP block burst"},"burst_disk_io":{"title":"{hostname}: +{count} more disk I/O errors on {entity_list}","body":"+{count} additional I/O errors detected in {window} ({total_count} total).\nDevices: {entity_list}","label":"Disk I/O burst"},"burst_cluster":{"title":"{hostname}: Cluster flapping detected (+{count} more changes)","body":"Cluster state changed +{count} more times in {window} ({total_count} total).\nNodes: {entity_list}","label":"Cluster flapping burst"},"burst_service_fail":{"title":"{hostname}: +{count} more services failed in {window}","body":"+{count} additional service failures detected in {window} ({total_count} total).\nThis typically indicates a node reboot or PVE service restart.\n\nAdditional failures:\n{details}","label":"Service fail burst"},"burst_system":{"title":"{hostname}: +{count} more system problems in {window}","body":"+{count} additional system problems detected in {window} ({total_count} total).\n\nAdditional issues:\n{details}","label":"System problems burst"},"burst_generic":{"title":"{hostname}: +{count} more {event_type} events in {window}","body":"+{count} additional events of type {event_type} in {window} ({total_count} total).\n\nAdditional events:\n{details}","label":"Generic burst"},"kernel_warning":{"title":"{hostname}: Kernel diagnostic event detected","body":"The kernel recorded a diagnostic event: {kernel_details}","label":"Kernel warnings and diagnostic traces"}},"lxcUpdate":{"status":{"success":"succeeded","failure":"failed","partial":"completed partially","deferred":"deferred","skipped":"skipped"},"source":{"scheduled":"Scheduled","manual":"Manual"},"sourceLabel":"Source","targets":"Targets","osPending":"OS packages pending: {before} → {after}","osUnverified":"OS packages: update command executed; result not verified","applications":"Applications: {items}","applicationsUnverified":"Applications: updater executed; no tracked version change was observed","dockerEngineChange":"Docker Engine: {before} → {after}","dockerEngineVerified":"Docker Engine: verified at {version}","dockerEngineUnverified":"Docker Engine: update command executed; version not verified","dockerImagesPending":"Docker images pending: {before} → {after}","dockerImagesChanged":"Docker images changed: {images}","deferredTargets":"Deferred targets: {targets}","reason":"Reason: {reason}","restartRequired":"Restart required: {value}","yes":"yes","no":"no","verificationPending":"Verification pending until the container is running","verificationWarning":"Verification warning: {error}","duration":"Duration: {duration}"},"fallback":{"unknownTitle":"{hostname}: {event_type}","healthCheckDegraded":"Health check degraded","none":"none","temperatureAlertTitle":"{hostname}: Sensor temperature alert","temperatureAlertBody":"Temperature alert recorded without a complete measurement payload.","recordedReason":"Recorded reason: {reason}","recordedDetails":"Recorded details: {details}"},"fields":{"vmid":"VM/CT","name":"Name","device":"Device","sourceIp":"Source IP","node":"Node","category":"Category","service":"Service","jail":"Jail","user":"User","count":"Count","window":"Window","affected":"Affected"},"vzdump":{"size":"Size: {value}","duration":"Duration: {value}","file":"File","backups":"{count} backups","failed":"{count} failed","total":"Total: {value}","time":"Time: {value}"},"startup":{"issuesTitle":"{hostname}: System startup - {count} issue(s) detected","completeTitle":"{hostname}: System startup completed","operational":"All systems operational.","vmCountOne":"{count} VM","vmCountMany":"{count} VMs","ctCountOne":"{count} CT","ctCountMany":"{count} CTs","started":"✅ {counts} started","vmFailed":"❌ VM failed: {name} - {reason}","ctFailed":"❌ CT failed: {name} - {reason}","unknownError":"unknown error","storageUnavailable":"⚠️ Storage: {count} unavailable ({names})","servicesFailed":"⚠️ Services: {count} failed ({names})","duration":"⏱️ Startup completed in {minutes} min"},"appUpdates":{"singleTitle":"{hostname}: {app_name} update available on CT {vmid}","singleBody":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","emptyTitle":"{hostname}: Application updates available","emptyBody":"Application updates are available.","batchTitle":"{hostname}: {count} application updates available","batchLeadOneContainer":"{count} applications in {container_count} LXC container have a newer version:","batchLeadManyContainers":"{count} applications in {container_count} LXC containers have a newer version:","additional":"… {count} additional application(s)","app":"app","unknown":"unknown"},"healthDegraded":{"categories":{"cpu":"CPU usage and temperature","memory":"Memory and swap","storage":"Storage mounts and space","disks":"Disk I/O and errors","network":"Network interfaces","vms":"VMs and containers","services":"PVE services","logs":"System logs","updates":"System updates","security":"Security"},"severity":{"critical":"Critical","warning":"Warning"},"singleTitle":"{hostname}: {severity} health status – {category}","multipleTitle":"{hostname}: {count} health checks degraded","multipleLine":"• {severity}: {category}\n {reason}","reasons":{"cpuSustained":"CPU is above {threshold}% for {duration}s."}},"digest":{"title":"{hostname}: 24h summary ({timestamp})","readFailed":"digest read failed: {error}","empty":"No INFO events buffered for this digest window.","lead":"{count} INFO events grouped by category:\n","more":" • … and {count} more","footer":"(Critical/Warning events arrived at the time they happened, not in this digest.)","quietTitle":"{hostname}: {count} events buffered during Quiet Hours","groups":{"vm_ct":"VM a CT","backup":"Backup","resources":"Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"Services","health":"Health","updates":"Updates","hardware":"Hardware","system":"System","other":"Other"}},"test":{"title":"ProxMenux Test","welcome":"Welcome to ProxMenux Monitor!","verify":"This is a test message to verify your notification channel is working correctly.","configuration":"Channel configuration:","iconsEnabled":"Icons: enabled","iconsDisabled":"Icons: disabled","aiEnabled":"AI: enabled ({info})","aiDisabled":"AI: disabled","alerts":"You will receive alerts about system events, health status changes, and security incidents.","photoCaption":"You can use this image as the profile photo for your notification bot."},"channels":{"discord":{"category":"Category","host":"Host","severity":"Severity"},"email":{"report":"{group} Report","details":"Details","host":"Host","footer":"ProxMenux Notification Service","severity":{"critical":"Critical","warning":"Warning","info":"Information","ok":"Resolved","default":"Notice"},"groups":{"vm_ct":"Virtual Machine / Container","backup":"Backup & Snapshot","resources":"System Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"System Services","health":"Health Monitor","updates":"System Updates","system":"System","hardware":"Hardware","other":"System Notification"},"fields":{"vmCtId":"VM/CT ID","name":"Name","action":"Action","targetNode":"Target Node","reason":"Reason","storage":"Storage","status":"Status","size":"Size","duration":"Duration","snapshot":"Snapshot","metric":"Metric","currentValue":"Current Value","threshold":"Threshold","cpuCores":"CPU Cores","memory":"Memory","temperature":"Temperature","mountPoint":"Mount Point","usage":"Usage","available":"Available","device":"Device","severity":"Severity","storageName":"Storage Name","type":"Type","interface":"Interface","latency":"Latency","event":"Event","sourceIp":"Source IP","username":"Username","service":"Service","jail":"Jail","failures":"Failures","change":"Change","node":"Node","quorum":"Quorum","nodesAffected":"Nodes Affected","process":"Process","category":"Category","previousSeverity":"Previous Severity","activeIssues":"Active Issues","totalUpdates":"Total Updates","securityUpdates":"Security Updates","proxmoxUpdates":"Proxmox Updates","kernelUpdates":"Kernel Updates","importantPackages":"Important Packages","currentVersion":"Current Version","newVersion":"New Version"},"status":{"failed":"Failed","completed":"Completed","started":"Started","unconfirmed":"Unconfirmed","completed_with_warnings":"Completed with warnings"}}},"temperature":{"sampleSpan":"High samples span {duration}."},"backup":{"unconfirmedTitle":"{hostname}: Backup outcome unconfirmed","confirmedTitle":"{hostname}: Backup complete","confirmedBody":"Backup completed successfully.","errorTitle":"{hostname}: Backup error reported","errorBody":"The backup report contains an error.","unconfirmedBody":"The backup outcome is not confirmed.","warningTitle":"{hostname}: Backup completed with warnings","warningBody":"Backup completed with warnings.","diagnosticsOmitted":"Additional diagnostic lines or text omitted: {count}. Original report retained."}}}, + "monitorLinks": { + "thisNode": "This node", + "checking": "Checking nodes…", + "loadFailed": "Could not load the node list.", + "empty": "No other nodes yet. Add one to open its Monitor from here.", + "groups": { + "cluster": "Cluster", + "standalone": "Standalone nodes", + "pbs": "Proxmox Backup Server" + }, + "noResponse": "No response", + "pbsInterface": "PBS interface", + "openNewTab": "Open in a new tab", + "manage": "Add or manage nodes…", + "dialogTitle": "Linked nodes", + "dialogDescription": "Cluster nodes and configured Proxmox Backup Servers are detected automatically. Each Monitor keeps its own sign-in.", + "addTitle": "Add a node", + "name": "Name", + "address": "Monitor address", + "type": "Type", + "add": "Add", + "save": "Save", + "remove": "Remove", + "visible": "Visible", + "autoAddress": "Automatic", + "detected": "Detected", + "added": "Added", + "saveFailed": "Could not save the node list.", + "backTo": "Back to {name}", + "cameFromTitle": "Add Monitor node?", + "cameFromBody": "Add {name} to the linked nodes of this Monitor.", + "notNow": "Not now" + } } diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 322ea52a..a9754ca6 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -50,12 +50,21 @@ "navigation": "Navegación de PBS", "cpu": "CPU", "memory": "Memoria", - "uptime": "Tiempo de actividad" + "uptime": "Tiempo de actividad", + "capacityUnavailable": "Capacidad no disponible.", + "datastoresUnavailable": "{count} no disponibles", + "datastoresOnline": "Todos disponibles", + "moreDatastores": "+{count} datastores más", + "estimatedFull": "Lleno previsto", + "estimatedFullUnknown": "Aún no hay datos suficientes", + "duration": "Duración", + "noJobs": "No hay programaciones de sincronización, verificación ni prune." }, "_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.", "app": { "title": "ProxMenux Monitor", "description": "Proxmox System Dashboard", + "descriptionPbs": "Proxmox Backup Server Dashboard", "loading": "Cargando...", "connecting": "Conexión al ProxMenux Monitor", "unknown": "Desconocido", @@ -193,6 +202,7 @@ "dayMany": "{count} días" }, "proxmoxVersion": "Versión Proxmox:", + "pbsVersion": "Versión PBS:", "kernel": "Núcleo:", "availableUpdates": "Actualizaciones disponibles:", "packages": "paquetes", @@ -1881,6 +1891,7 @@ "network": "Interfaces de red", "vms": "Máquinas virtuales y contenedores", "services": "Servicios PVE", + "pbsServices": "Servicios PBS", "logs": "Registros del sistema", "updates": "Actualizaciones del sistema", "security": "Seguridad y certificados", @@ -4874,7 +4885,8 @@ "disks": "E/S de disco y errores", "network": "Interfaces de red", "vms": "Máquinas virtuales y contenedores", - "services": "servicios PVE", + "services": "Servicios PVE", + "pbsServices": "Servicios PBS", "logs": "Registros del sistema", "updates": "Actualizaciones del sistema", "security": "Seguridad y certificados" @@ -7321,5 +7333,38 @@ "diagnosticsOmitted": "Líneas o texto de diagnóstico omitidos: {count}. Se conserva el informe original." } } + }, + "monitorLinks": { + "thisNode": "Este nodo", + "checking": "Comprobando nodos…", + "loadFailed": "No se pudo cargar la lista de nodos.", + "empty": "Aún no hay otros nodos. Añade uno para abrir su Monitor desde aquí.", + "groups": { + "cluster": "Clúster", + "standalone": "Nodos independientes", + "pbs": "Proxmox Backup Server" + }, + "noResponse": "Sin respuesta", + "pbsInterface": "Interfaz de PBS", + "openNewTab": "Abrir en una pestaña nueva", + "manage": "Añadir o gestionar nodos…", + "dialogTitle": "Nodos enlazados", + "dialogDescription": "Los nodos del clúster y los Proxmox Backup Server configurados se detectan automáticamente. Cada Monitor mantiene su propio inicio de sesión.", + "addTitle": "Añadir un nodo", + "name": "Nombre", + "address": "Dirección del Monitor", + "type": "Tipo", + "add": "Añadir", + "save": "Guardar", + "remove": "Eliminar", + "visible": "Visible", + "autoAddress": "Automática", + "detected": "Detectado", + "added": "Añadido", + "saveFailed": "No se pudo guardar la lista de nodos.", + "backTo": "Volver a {name}", + "cameFromTitle": "¿Añadir nodo Monitor?", + "cameFromBody": "Añade {name} a los nodos enlazados de este Monitor.", + "notNow": "Ahora no" } } diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 40560c21..783978f6 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -163,6 +163,8 @@ cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found" cp "$SCRIPT_DIR/pbs_manager.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/flask_pbs_routes.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/monitor_links.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/flask_monitor_links_routes.py" "$APP_DIR/usr/bin/" cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found" cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found" diff --git a/AppImage/scripts/flask_monitor_links_routes.py b/AppImage/scripts/flask_monitor_links_routes.py new file mode 100644 index 00000000..24c7d3ee --- /dev/null +++ b/AppImage/scripts/flask_monitor_links_routes.py @@ -0,0 +1,58 @@ +"""Links between ProxMenux Monitors for the node selector in the header.""" + +from flask import Blueprint, jsonify, request +import monitor_links +from jwt_middleware import require_auth, require_admin_scope + +monitor_links_bp = Blueprint("monitor_links", __name__) + + +@monitor_links_bp.after_request +def no_cache(response): + response.headers["Cache-Control"] = "no-store" + return response + + +@monitor_links_bp.route("/api/monitor-links") +@require_auth +def list_links(): + return jsonify(monitor_links.cached_links(include_hidden=request.args.get("all") == "1")) + + +@monitor_links_bp.route("/api/monitor-links", methods=["POST"]) +@require_admin_scope +def add_link(): + try: + key = monitor_links.add_link(request.get_json(silent=True)) + monitor_links.refresh() + return jsonify(success=True, key=key) + except monitor_links.LinkError as exc: + return jsonify(error=str(exc)), 400 + except OSError: + return jsonify(error="Could not save the node list."), 500 + + +@monitor_links_bp.route("/api/monitor-links/", methods=["PATCH"]) +@require_admin_scope +def update_link(key): + try: + monitor_links.update_link(key, request.get_json(silent=True)) + monitor_links.refresh() + return jsonify(success=True) + except monitor_links.LinkError as exc: + return jsonify(error=str(exc)), 400 + except OSError: + return jsonify(error="Could not save the node list."), 500 + + +@monitor_links_bp.route("/api/monitor-links/", methods=["DELETE"]) +@require_admin_scope +def delete_link(key): + try: + monitor_links.delete_link(key) + monitor_links.refresh() + return jsonify(success=True) + except monitor_links.LinkError as exc: + return jsonify(error=str(exc)), 400 + except OSError: + return jsonify(error="Could not save the node list."), 500 diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 2354141d..907ad3b6 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -1156,6 +1156,14 @@ def setup_pve_webhook_core() -> dict: 'fallback_commands': [], 'error': None, } + try: # Proxmox Backup Server has no /etc/pve notification configuration + from pbs_manager import is_pbs_host + on_pbs = is_pbs_host() + except Exception: + on_pbs = False + if on_pbs: + result['not_applicable'] = True + return result try: if not _pve_cluster_fs_mounted(): @@ -1339,6 +1347,13 @@ def cleanup_pve_webhook_core() -> dict: Only removes blocks named 'proxmenux-webhook' / 'proxmenux-default'. """ result = {'cleaned': False, 'error': None} + try: # Proxmox Backup Server has no /etc/pve notification configuration + from pbs_manager import is_pbs_host + on_pbs = is_pbs_host() + except Exception: + on_pbs = False + if on_pbs: + return {'cleaned': True, 'error': None, 'not_applicable': True} try: if not _pve_cluster_fs_mounted(): diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 3ffa0c4c..ca789d38 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -91,6 +91,7 @@ from flask_health_routes import health_bp # noqa: E402 from flask_auth_routes import auth_bp # noqa: E402 from flask_proxmenux_routes import proxmenux_bp # noqa: E402 from flask_pbs_routes import pbs_bp # noqa: E402 +from flask_monitor_links_routes import monitor_links_bp # noqa: E402 from pbs_manager import get_platform, is_pbs_host # noqa: E402 from flask_security_routes import security_bp # noqa: E402 from flask_notification_routes import notification_bp # noqa: E402 @@ -198,6 +199,7 @@ app.register_blueprint(auth_bp) app.register_blueprint(health_bp) app.register_blueprint(proxmenux_bp) app.register_blueprint(pbs_bp) +app.register_blueprint(monitor_links_bp) app.register_blueprint(security_bp) app.register_blueprint(notification_bp) app.register_blueprint(oci_bp) @@ -12214,6 +12216,17 @@ def _get_node_metrics_payload(timeframe, max_age=_NODE_METRICS_TTL): lock.release() +def _node_rrd_command(local_node, timeframe, cf=None): + """Node RRD query; Proxmox Backup Server keeps the same fields under its own API.""" + if is_pbs_host(): + return ['proxmox-backup-debug', 'api', 'get', '/nodes/localhost/rrd', + '--timeframe', timeframe, '--cf', cf or 'AVERAGE', '--output-format', 'json'] + command = ['pvesh', 'get', f'/nodes/{local_node}/rrddata', '--timeframe', timeframe] + if cf: + command += ['--cf', cf] + return command + ['--output-format', 'json'] + + def _compute_node_metrics_payload(timeframe: str) -> dict: """Fetch and shape one node RRD snapshot within a bounded query budget.""" valid_timeframes = ('hour', 'day', 'week', 'month', 'year') @@ -12237,8 +12250,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict: try: rrd_result = subprocess.run( - ['pvesh', 'get', f'/nodes/{local_node}/rrddata', - '--timeframe', timeframe, '--output-format', 'json'], + _node_rrd_command(local_node, timeframe), capture_output=True, text=True, timeout=_NODE_METRICS_TIMEOUT, ) except subprocess.TimeoutExpired: @@ -12332,9 +12344,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict: return None try: extra = subprocess.run( - ['pvesh', 'get', f'/nodes/{local_node}/rrddata', - '--timeframe', timeframe, '--cf', cf, - '--output-format', 'json'], + _node_rrd_command(local_node, timeframe, cf), capture_output=True, text=True, timeout=remaining, ) if extra.returncode == 0 and extra.stdout: @@ -23339,8 +23349,8 @@ if __name__ == '__main__': # the 24 h PollingCollector cycle. Runs in a daemon thread with # a delay so it doesn't block startup — notification_manager needs # a moment to bind channels before we emit. - # The guest services below read VMs, LXCs and the PVE node metrics, - # none of which exist on a Proxmox Backup Server. + # The guest services below read VMs and LXCs, which do not exist on a + # Proxmox Backup Server. pve_host = not is_pbs_host() try: def _startup_emit_pending_app_updates(): @@ -23359,13 +23369,19 @@ if __name__ == '__main__': # Prewarm only the Overview's default day range; other ranges are lazy. try: - if pve_host: - metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer') - metrics_thread.start() - print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)") + metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer') + metrics_thread.start() + print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)") except Exception as e: print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}") + # Linked Monitors for the header node selector, renewed every 5 minutes. + try: + import monitor_links + monitor_links.start_prewarmer() + except Exception as e: + print(f"[ProxMenux] Monitor links prewarmer failed to start: {e}") + # ── VM/CT modal-cache prewarmer ── # Keeps _vm_details_cache / _vm_backups_cache / app snapshots / # _vm_schedule_cache warm from the backend so the "Loading diff --git a/AppImage/scripts/monitor_links.py b/AppImage/scripts/monitor_links.py new file mode 100644 index 00000000..35ff58a2 --- /dev/null +++ b/AppImage/scripts/monitor_links.py @@ -0,0 +1,321 @@ +"""Other ProxMenux Monitors reachable from this one: cluster nodes, PBS servers +and entries added by hand. + +Only links are kept here. No credential is stored or sent: the browser opens +the other Monitor, which keeps its own sign-in. +""" + +import ipaddress +import json +import os +import re +import secrets +import socket +import ssl +import subprocess +import threading +import time +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pbs_manager + +LINKS_FILE = Path(os.environ.get("PROXMENUX_MONITOR_LINKS", "/usr/local/share/proxmenux/monitor_links.json")) +SSL_CONFIG_FILE = Path(os.environ.get("PROXMENUX_SSL_CONFIG", "/etc/proxmenux/ssl_config.json")) +STORAGE_CFG = Path("/etc/pve/storage.cfg") +MONITOR_PORT = 8008 +PBS_UI_PORT = 8007 +PROBE_TIMEOUT = 3 +PROBE_TTL = 300 +MAX_MANUAL = 64 +PRODUCTS = ("pve", "pbs") + +_lock = threading.Lock() +_probe_cache = {} +_snapshot = {"at": 0.0, "data": None} +_refresh_lock = threading.Lock() + + +class LinkError(ValueError): + pass + + +def _load(): + try: + data = json.loads(LINKS_FILE.read_text()) + except (OSError, ValueError): + return {"manual": [], "overrides": {}} + manual = data.get("manual") if isinstance(data.get("manual"), list) else [] + overrides = data.get("overrides") if isinstance(data.get("overrides"), dict) else {} + return {"manual": [row for row in manual if isinstance(row, dict)], "overrides": overrides} + + +def _save(data): + LINKS_FILE.parent.mkdir(parents=True, exist_ok=True) + temporary = LINKS_FILE.with_suffix(".tmp") + temporary.write_text(json.dumps(data, indent=2)) + os.chmod(temporary, 0o600) + os.replace(temporary, LINKS_FILE) + + +def normalize_url(value): + """Return the origin of an http(s) address, or raise LinkError.""" + if not isinstance(value, str) or not value.strip(): + raise LinkError("Enter the address of the Monitor.") + text = value.strip() + if "://" not in text: + text = "https://" + text + parsed = urllib.parse.urlsplit(text) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + raise LinkError("The address must start with http:// or https:// and name a host.") + if parsed.username or parsed.password: + raise LinkError("The address must not contain a user or password.") + try: + port = parsed.port + except ValueError as exc: + raise LinkError("The port of the address is not valid.") from exc + host = parsed.hostname + if ":" in host: + host = f"[{host}]" + return f"{parsed.scheme}://{host}" + (f":{port}" if port else "") + + +def _local_scheme(): + try: + return "https" if json.loads(SSL_CONFIG_FILE.read_text()).get("enabled") else "http" + except (OSError, ValueError, AttributeError): + return "http" + + +def _host_url(host, port, scheme=None): + if ":" in host and not host.startswith("["): + host = f"[{host}]" + return f"{scheme or _local_scheme()}://{host}:{port}" + + +def _is_private(host): + """Only addresses in private ranges are probed without being asked to.""" + try: + addresses = {info[4][0] for info in socket.getaddrinfo(host, None)} + except OSError: + return False + try: + return bool(addresses) and all(ipaddress.ip_address(a.split("%")[0]).is_private for a in addresses) + except ValueError: + return False + + +def _cluster_nodes(): + try: + result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"], + capture_output=True, text=True, timeout=8) + rows = json.loads(result.stdout) if result.returncode == 0 else [] + except (OSError, subprocess.SubprocessError, ValueError): + return [] + nodes = [] + for row in rows if isinstance(rows, list) else []: + if isinstance(row, dict) and row.get("type") == "node" and not row.get("local") and row.get("name") and row.get("ip"): + nodes.append({"key": f"cluster:{row['name']}", "name": str(row["name"]), "product": "pve", + "group": "cluster", "host": str(row["ip"])}) + return nodes + + +def _storage_pbs(): + try: + text = STORAGE_CFG.read_text() + except OSError: + return [] + servers, current = [], None + for line in text.splitlines(): + header = re.match(r"^(\w+):\s*(\S+)", line) + if header: + current = {"name": header.group(2), "port": PBS_UI_PORT} if header.group(1) == "pbs" else None + if current is not None: + servers.append(current) + continue + option = re.match(r"^\s+(server|port)\s+(\S+)", line) + if current is not None and option: + current[option.group(1)] = option.group(2) + return [row for row in servers if row.get("server")] + + +def _pbs_servers(): + found = {} + for row in _storage_pbs(): + host = row["server"] + port = row.get("port") if str(row.get("port", "")).isdigit() else PBS_UI_PORT + found.setdefault(host, {"name": row["name"], "native_url": _host_url(host, port, "https")}) + try: + for row in pbs_manager.public_servers(): + host = urllib.parse.urlsplit(row.get("url", "")).hostname + if host: + found.setdefault(host, {"name": row.get("name") or host, "native_url": normalize_url(row["url"])}) + except Exception: + pass + return [{"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs", + "host": host, "native_url": info["native_url"]} for host, info in found.items()] + + +def _probe(url): + """True when a ProxMenux Monitor answers at url; nothing is sent but the request.""" + now = time.monotonic() + with _lock: + cached = _probe_cache.get(url) + if cached and cached[0] > now: + return cached[1] + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE # liveness only: no credential or token is ever sent + online = False + try: + request = urllib.request.Request(url + "/api/auth/status", headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=PROBE_TIMEOUT, context=context) as response: + online = response.status == 200 and "auth_enabled" in json.loads(response.read(4096)) + except Exception: + online = False + with _lock: + if len(_probe_cache) > 256: + _probe_cache.clear() + _probe_cache[url] = (now + PROBE_TTL, online) + return online + + +def _resolve(entry, override): + """Pick the Monitor address and its state for one discovered or manual entry.""" + if override.get("url"): + return override["url"], "online" if _probe(override["url"]) else "offline" + if entry.get("url"): + return entry["url"], "online" if _probe(entry["url"]) else "offline" + host = entry["host"] + if not _is_private(host): + return None, "unknown" + for scheme in (_local_scheme(), "https" if _local_scheme() == "http" else "http"): + url = _host_url(host, MONITOR_PORT, scheme) + if _probe(url): + return url, "online" + return _host_url(host, MONITOR_PORT), "offline" + + +def list_links(include_hidden=False): + platform = pbs_manager.get_platform() + data = _load() + entries = [] + if platform.get("product") != "pbs": + entries += _cluster_nodes() + _pbs_servers() + for row in data["manual"]: + entries.append({"key": f"manual:{row.get('id')}", "name": row.get("name") or row.get("url"), + "product": row.get("product") if row.get("product") in PRODUCTS else "pve", + "group": "pbs" if row.get("product") == "pbs" else "standalone", + "url": row.get("url"), "manual": True}) + visible = [e for e in entries if include_hidden or not data["overrides"].get(e["key"], {}).get("hidden")] + with ThreadPoolExecutor(max_workers=8) as executor: + resolved = list(executor.map(lambda e: _resolve(e, data["overrides"].get(e["key"], {})), visible)) + links = [] + for entry, (url, state) in zip(visible, resolved): + override = data["overrides"].get(entry["key"], {}) + links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"], + "group": entry["group"], "url": url, "status": state, + "native_url": entry.get("native_url"), "manual": bool(entry.get("manual")), + "hidden": bool(override.get("hidden")), "custom_url": bool(override.get("url"))}) + return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")}, + "links": links} + + +def refresh(): + """Recompute the list with fresh probes and keep it for the header.""" + with _refresh_lock: + with _lock: + _probe_cache.clear() + data = list_links(include_hidden=True) + _snapshot.update(at=time.monotonic(), data=data) + return data + + +def _refresh_quietly(): + try: + refresh() + except Exception: + pass + + +def cached_links(include_hidden=False): + """The last list at once; a stale one is renewed in the background.""" + data = _snapshot["data"] + if data is None: + data = refresh() + elif time.monotonic() - _snapshot["at"] > PROBE_TTL and not _refresh_lock.locked(): + threading.Thread(target=_refresh_quietly, daemon=True, name="monitor-links-refresh").start() + if include_hidden: + return data + return {**data, "links": [row for row in data["links"] if not row["hidden"]]} + + +def start_prewarmer(): + def loop(): + time.sleep(10) + while True: + _refresh_quietly() + time.sleep(PROBE_TTL) + threading.Thread(target=loop, daemon=True, name="monitor-links-prewarmer").start() + + +def add_link(payload): + if not isinstance(payload, dict): + raise LinkError("Invalid request.") + name = str(payload.get("name") or "").strip()[:64] + url = normalize_url(payload.get("url")) + product = payload.get("product") if payload.get("product") in PRODUCTS else "pve" + with _lock: + data = _load() + if len(data["manual"]) >= MAX_MANUAL: + raise LinkError(f"At most {MAX_MANUAL} nodes can be added.") + if any(row.get("url") == url for row in data["manual"]): + raise LinkError("This address is already in the list.") + identifier = secrets.token_hex(6) + data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname, + "url": url, "product": product}) + _save(data) + return f"manual:{identifier}" + + +def update_link(key, payload): + if not isinstance(payload, dict): + raise LinkError("Invalid request.") + with _lock: + data = _load() + if key.startswith("manual:"): + row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None) + if row is None: + raise LinkError("Node not found.") + if "name" in payload: + row["name"] = str(payload["name"] or "").strip()[:64] or row["name"] + if payload.get("url"): + row["url"] = normalize_url(payload["url"]) + if payload.get("product") in PRODUCTS: + row["product"] = payload["product"] + elif re.fullmatch(r"(cluster|pbs):[\w.:\-\[\]]{1,253}", key): + override = data["overrides"].setdefault(key, {}) + if "url" in payload: + if payload["url"]: + override["url"] = normalize_url(payload["url"]) + else: + override.pop("url", None) + if "hidden" in payload: + override["hidden"] = bool(payload["hidden"]) + if not override: + data["overrides"].pop(key, None) + else: + raise LinkError("Node not found.") + _save(data) + + +def delete_link(key): + with _lock: + data = _load() + remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key] + if len(remaining) == len(data["manual"]): + raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.") + data["manual"] = remaining + _save(data) diff --git a/AppImage/scripts/tests/test_fwbr_udev_rule_order.py b/AppImage/scripts/tests/test_fwbr_udev_rule_order.py index 70f61b5f..231fc61d 100644 --- a/AppImage/scripts/tests/test_fwbr_udev_rule_order.py +++ b/AppImage/scripts/tests/test_fwbr_udev_rule_order.py @@ -26,18 +26,18 @@ class FwbrUdevRuleOrderTests(unittest.TestCase): def test_auto_post_install_uses_late_rule_and_removes_legacy_rule(self): source = network_optimization_block(AUTO_POST_INSTALL) self.assertIn('local FUNC_VERSION="1.2"', source) - self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source) - self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source) + self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source) + self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source) self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source) - self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source) + self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source) def test_customizable_post_install_uses_late_rule_and_removes_legacy_rule(self): source = network_optimization_block(CUSTOM_POST_INSTALL) self.assertIn('local FUNC_VERSION="1.2"', source) - self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source) - self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source) + self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source) + self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source) self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source) - self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source) + self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source) def test_uninstall_removes_new_and_legacy_rule_names(self): source = UNINSTALL_TOOLS.read_text() diff --git a/AppImage/scripts/tests/test_monitor_links.py b/AppImage/scripts/tests/test_monitor_links.py new file mode 100644 index 00000000..48036393 --- /dev/null +++ b/AppImage/scripts/tests/test_monitor_links.py @@ -0,0 +1,150 @@ +import json +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / "AppImage/scripts")) + +import monitor_links as links + + +class MonitorLinksTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + storage = self.root / "storage.cfg" + storage.write_text("dir: local\n\tpath /var/lib/vz\n\npbs: Lab\n\tdatastore store1\n\tserver 10.0.0.49\n\n" + "pbs: Cloud\n\tdatastore x\n\tserver pbs.example.com\n\tport 8443\n") + patches = [ + mock.patch.object(links, "LINKS_FILE", self.root / "links.json"), + mock.patch.object(links, "SSL_CONFIG_FILE", self.root / "ssl.json"), + mock.patch.object(links, "STORAGE_CFG", storage), + mock.patch.object(links.pbs_manager, "get_platform", return_value={"product": "pve", "hostname": "amd"}), + mock.patch.object(links.pbs_manager, "public_servers", return_value=[]), + ] + for patch in patches: + patch.start() + self.addCleanup(patch.stop) + links._probe_cache.clear() + cluster = [{"type": "cluster", "name": "Casa"}, + {"type": "node", "name": "amd", "ip": "10.0.0.55", "local": 1}, + {"type": "node", "name": "constructor", "ip": "10.0.0.50", "local": 0}] + self.run_patch = mock.patch.object(links.subprocess, "run", + return_value=SimpleNamespace(returncode=0, stdout=json.dumps(cluster))) + self.run_patch.start() + self.addCleanup(self.run_patch.stop) + + def test_normalize_url_keeps_only_the_origin_and_rejects_credentials(self): + self.assertEqual(links.normalize_url("192.168.0.49:8008/dashboard?x=1"), "https://192.168.0.49:8008") + self.assertEqual(links.normalize_url("http://pve.lan"), "http://pve.lan") + for bad in ("", "ftp://host", "https://user:pass@host", "https://host:99999", "javascript:alert(1)"): + with self.subTest(bad=bad), self.assertRaises(links.LinkError): + links.normalize_url(bad) + + def test_discovers_other_cluster_nodes_and_pbs_without_the_local_node(self): + probed = [] + with mock.patch.object(links, "_probe", side_effect=lambda url: probed.append(url) or url.endswith("10.0.0.50:8008")), \ + mock.patch.object(links, "_is_private", side_effect=lambda host: host.startswith("10.")): + result = links.list_links() + by_key = {row["key"]: row for row in result["links"]} + self.assertEqual(result["self"]["name"], "amd") + self.assertNotIn("cluster:amd", by_key) + self.assertEqual(by_key["cluster:constructor"]["status"], "online") + self.assertEqual(by_key["pbs:10.0.0.49"]["native_url"], "https://10.0.0.49:8007") + self.assertEqual(by_key["pbs:10.0.0.49"]["status"], "offline") + # A public PBS is listed with its own interface but never probed unasked. + self.assertEqual(by_key["pbs:pbs.example.com"]["status"], "unknown") + self.assertEqual(by_key["pbs:pbs.example.com"]["native_url"], "https://pbs.example.com:8443") + self.assertFalse(any("example.com" in url for url in probed)) + + def test_pbs_host_lists_only_manual_entries(self): + links.pbs_manager.get_platform.return_value = {"product": "pbs", "hostname": "pbs-lab"} + links.add_link({"name": "amd", "url": "https://10.0.0.55:8008", "product": "pve"}) + with mock.patch.object(links, "_probe", return_value=True): + result = links.list_links() + self.assertEqual([row["name"] for row in result["links"]], ["amd"]) + self.links_run_not_called() + + def links_run_not_called(self): + self.assertFalse(any(call.args and call.args[0][:1] == ["pvesh"] for call in links.subprocess.run.call_args_list)) + + def test_manual_entries_overrides_and_hidden_detected_nodes(self): + key = links.add_link({"name": "lab", "url": "10.0.0.46:8008", "product": "pve"}) + with self.assertRaises(links.LinkError): + links.add_link({"url": "https://10.0.0.46:8008"}) + links.update_link("cluster:constructor", {"url": "https://constructor.lan:8008"}) + links.update_link("pbs:10.0.0.49", {"hidden": True}) + with mock.patch.object(links, "_probe", return_value=False), \ + mock.patch.object(links, "_is_private", return_value=True): + visible = {row["key"]: row for row in links.list_links()["links"]} + everything = {row["key"]: row for row in links.list_links(include_hidden=True)["links"]} + self.assertEqual(visible[key]["url"], "https://10.0.0.46:8008") + self.assertEqual(visible["cluster:constructor"]["url"], "https://constructor.lan:8008") + self.assertNotIn("pbs:10.0.0.49", visible) + self.assertTrue(everything["pbs:10.0.0.49"]["hidden"]) + with self.assertRaises(links.LinkError): + links.delete_link("cluster:constructor") + links.delete_link(key) + self.assertEqual(json.loads((self.root / "links.json").read_text())["manual"], []) + self.assertEqual((self.root / "links.json").stat().st_mode & 0o777, 0o600) + + def test_cached_list_is_served_without_new_probes_and_renewed_when_stale(self): + links._snapshot.update(at=0.0, data=None) + self.addCleanup(links._snapshot.update, at=0.0, data=None) + links.update_link("pbs:10.0.0.49", {"hidden": True}) + with mock.patch.object(links, "_probe", return_value=True) as probe, \ + mock.patch.object(links, "_is_private", return_value=True): + first = links.cached_links() + calls = probe.call_count + again = links.cached_links() + self.assertEqual(probe.call_count, calls) + self.assertEqual(first, again) + self.assertNotIn("pbs:10.0.0.49", [row["key"] for row in first["links"]]) + self.assertIn("pbs:10.0.0.49", [row["key"] for row in links.cached_links(include_hidden=True)["links"]]) + links._snapshot["at"] -= links.PROBE_TTL + 1 + with mock.patch.object(links.threading, "Thread") as thread: + stale = links.cached_links() + self.assertEqual(stale, first) + thread.assert_called_once() + + def test_unknown_keys_are_rejected(self): + for key in ("other:x", "cluster:../../etc", "manual:nope"): + with self.subTest(key=key), self.assertRaises(links.LinkError): + links.update_link(key, {"hidden": True}) + + def test_probe_sends_no_credentials_and_needs_a_monitor_answer(self): + seen = [] + + class Response: + status = 200 + + def __init__(self, body): + self.body = body + + def read(self, _size): + return self.body + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def urlopen(request, timeout, context): + seen.append(request) + return Response(b'{"auth_enabled": true}' if "monitor" in request.full_url else b"") + + with mock.patch.object(links.urllib.request, "urlopen", side_effect=urlopen): + self.assertTrue(links._probe("https://monitor:8008")) + self.assertFalse(links._probe("https://other:8008")) + self.assertTrue(all(request.full_url.endswith("/api/auth/status") for request in seen)) + self.assertTrue(all("Authorization" not in request.headers for request in seen)) + + +if __name__ == "__main__": + unittest.main() diff --git a/lang/es.json b/lang/es.json index de4d581e..c0605465 100644 --- a/lang/es.json +++ b/lang/es.json @@ -37,6 +37,7 @@ "/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (ruta por defecto de vzdump)", "/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (por defecto en Proxmox)", "1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx para todos. No se necesita un grupo compartido.", + "====== PBS UPDATE COMPLETED ======": "====== ACTUALIZACIÓN PBS COMPLETADA ======", "====== PVE UPDATE COMPLETED ======": "====== ACTUALIZACIÓN PVE COMPLETADA ======", ">> Create a new backup job <<": ">> Crear un trabajo de backup nuevo <<", ">> Save and return <<": ">> Guardar y volver <<", @@ -86,6 +87,7 @@ "A modern wiki and knowledge base for teams": "Una wiki y base de conocimiento moderna para equipos", "A new ProxMenux version is available:": "Una nueva versión de ProxMenux está disponible:", "A new kernel is staged for the next boot:": "Se prepara un nuevo kernel para el siguiente arranque:", + "A newer kernel is installed. Reboot before the next backup window to use it.": "Hay un kernel más reciente instalado. Reinicie antes de la próxima ventana de backups para usarlo.", "A newer version is available:": "Hay una versión más nueva disponible:", "A pending operation exists for": "Existe una operación pendiente", "A pending stack assembly already exists; it is not overwritten": "Ya existe un ensamblado de pila pendiente; no se sobrescribe", @@ -168,6 +170,7 @@ "APT language configuration updated": "Configuración del idioma APT actualizada", "APT language downloads restored": "Descargas del idioma APT restauradas", "APT package lists updated": "Listas de paquetes APT actualizadas", + "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "La actualización de APT ha fallado. Revise la suscripción y los repositorios de PBS en Administration > Repositories. Log:", "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "No se pudieron actualizar los índices de APT. Comprueba la suscripción y los repositorios de PBS en su interfaz nativa; no se ha cambiado ninguna fuente.", "APT update failed. Check repository access; NVIDIA installation stopped.": "La actualización de APT ha fallado. Verifica el acceso a los repositorios; la instalación de NVIDIA se ha detenido.", "Aborted": "Abortado", @@ -536,6 +539,7 @@ "Available Borg targets:": "Destinos Borg disponibles:", "Available Disks on Host": "Discos disponibles en el host", "Available ISO Images": "Imágenes ISO disponibles", + "Available PBS Version": "Versión PBS disponible", "Available PBS repositories:": "Repositorios de PBS disponibles:", "Available PVE Version": "Versión PVE disponible", "Available Shares:": "Recursos compartidos disponibles:", @@ -1518,6 +1522,7 @@ "Current NVIDIA inventory resolved: a refresh is required": "Actual inventario NVIDIA resuelto: un refresco es necesario", "Current NVIDIA inventory resolved: no refresh is required": "Inventario NVIDIA actual resuelto: ningún refresco es necesario", "Current Network Configuration": "Configuración de red actual", + "Current PBS Version": "Versión PBS actual", "Current PVE Version": "Versión PVE actual", "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Los scripts de host ProxMenux actuales registran recursos compartidos remotos como almacenamientos en Proxmox utilizando pvesm.", "Current ProxMenux version:": "Versión actual de ProxMenux:", @@ -1683,6 +1688,7 @@ "Detected Coral hardware:": "Hardware Coral detectado:", "Detected GPU(s):": "GPU detectadas:", "Detected Intel CPU": "CPU Intel detectada", + "Detected Proxmox Backup Server": "Proxmox Backup Server detectado", "Detected Proxmox VE": "Proxmox VE detectado", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Proxmox VE 8.x detectado: instalación de Ceph Squid para Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Detectado Proxmox VE 9.x - Instalación de Ceph Squid para Debian Trixie", @@ -1843,6 +1849,7 @@ "Do you want to proceed with the system update?": "¿Quieres continuar con la actualización del sistema?", "Do you want to proceed?": "¿Quieres continuar?", "Do you want to reboot now?": "¿Quieres reiniciar ahora?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "¿Desea eliminar el banner de suscripción de Proxmox de la interfaz web de Proxmox Backup Server?", "Do you want to remove the stale entry from vfio.conf and continue?": "¿Quiere eliminar la entrada obsoleta de vfio.conf y continuar?", "Do you want to remove the stale entry from vfio.conf?": "¿Quiere eliminar la entrada obsoleta de vfio.conf?", "Do you want to restart the network service now to apply changes?": "¿Quieres reiniciar el servicio de red ahora para aplicar los cambios?", @@ -2267,6 +2274,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "No se pudo clonar el repositorio log2ram. Compruebe /tmp/log2ram_install.log", "Failed to configure EFI disk": "No se pudo configurar el disco EFI", "Failed to configure IOMMU automatically.": "No se pudo configurar IOMMU automáticamente.", + "Failed to configure Proxmox Backup Server repositories": "No se han podido configurar los repositorios de Proxmox Backup Server", "Failed to configure Proxmox repositories": "No se pudieron configurar los repositorios de Proxmox", "Failed to configure TPM device in VM": "No se pudo configurar el dispositivo TPM en VM", "Failed to configure repositories.": "No se pudieron configurar los repositorios.", @@ -4152,6 +4160,7 @@ "No active Proxmox storage accepts OCI template archives.": "Ningún almacenamiento activo de Proxmox acepta archivos de plantilla OCI.", "No active exports or showmount not available": "No hay exportaciones activas o showmount no disponible", "No active session": "Ninguna sesión activa", + "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "No hay suscripción activa ni un repositorio de PBS utilizable. El modo no interactivo no puede cambiar las fuentes de APT; configúrelas en Administration > Repositories.", "No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "No hay suscripción activa ni un repositorio de PVE utilizable. El modo no interactivo no puede cambiar las fuentes de APT; configúralas en Nodo > Actualizaciones > Repositorios.", "No additional GPU can be added.": "No se puede agregar ninguna GPU adicional.", "No additional device needs to be added.": "No es necesario agregar ningún dispositivo adicional.", @@ -4525,7 +4534,7 @@ "PBS extraction failed": "Error en la extracción de PBS", "PBS host or IP address:": "Host de PBS o dirección IP:", "PBS package update": "Actualización de paquetes de PBS", - "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Actualización de paquetes de PBS completada. Comprueba si es necesario reiniciar antes del próximo período de copias de seguridad.", + "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Actualización de paquetes de PBS completada. Comprueba si hace falta reiniciar antes de la próxima ventana de backups.", "PBS package upgrade failed. Review the APT output before retrying.": "La actualización de paquetes de PBS ha fallado. Revisa la salida de APT antes de volver a intentarlo.", "PBS upload": "Subida a PBS", "PBS version and packages": "Versión y paquetes de PBS", @@ -4866,6 +4875,9 @@ "Proxmox 9 system update allready": "Actualización del sistema Proxmox 9 ya", "Proxmox APT repositories configured": "Repositorios Proxmox APT configurados", "Proxmox Backup Server (PBS) destinations": "Destinos de Proxmox Backup Server (PBS)", + "Proxmox Backup Server APT repositories configured": "Repositorios APT de Proxmox Backup Server configurados", + "Proxmox Backup Server Update": "Actualización de Proxmox Backup Server", + "Proxmox Backup Server update completed.": "Actualización de Proxmox Backup Server completada.", "Proxmox CIFS Storage Status:": "Estado de almacenamiento CIFS de Proxmox:", "Proxmox NFS Storage Status:": "Estado de almacenamiento NFS de Proxmox:", "Proxmox System Update": "Actualización del sistema Proxmox", @@ -5218,6 +5230,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "El asistente de repositorios no está disponible. La instalación de NVIDIA se ha detenido.", "Repository policy returned an unexpected result.": "La política de repositorios ha devuelto un resultado inesperado.", "Repository ready.": "Repositorio listo.", + "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.": "Las fuentes de repositorio han cambiado, pero la actualización de la lista de paquetes de APT ha fallado. Operación detenida; las fuentes no se han revertido. Revise Administration > Repositories y vuelva a ejecutar apt-get update antes de continuar.", "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.": "Las fuentes de los repositorios han cambiado, pero la actualización de la lista de paquetes de APT ha fallado. La operación se ha detenido; las fuentes no se han revertido. Revisa Nodo > Actualizaciones > Repositorios y vuelve a ejecutar apt-get update antes de continuar.", "Repository switch declined; no APT source changed.": "Cambio de repositorio rechazado; no se ha modificado ninguna fuente de APT.", "Repository:": "Repositorio:", @@ -6758,6 +6771,7 @@ "This CIFS share is mounted with restrictive permissions.": "Este recurso compartido CIFS está montado con permisos restrictivos.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU se considera incompatible con el passthrough de GPU a una VM en ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Este recurso compartido NFS está completamente restringido: ni siquiera la raíz del host puede escribir en él.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Este Proxmox Backup Server no tiene una suscripción activa, ¿cambiar al repositorio sin suscripción? Se deshabilitará la fuente Enterprise de PBS, que no es accesible.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Esta máquina virtual requiere pasos de instalación adicionales; consulte la guía de instalación en:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Esta cuenta es de {registry}. Escribe una imagen de ese registro.", "This action will:": "Esta acción:", @@ -6841,6 +6855,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Este script no se puede ejecutar desde la terminal web de Proxmox.", "This script must be run as root.": "Este script debe ejecutarse como root.", "This script must be run on a Proxmox host.": "Este script debe ejecutarse en un host Proxmox.", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Este script aplicará las siguientes optimizaciones y ajustes avanzados a su Proxmox Backup Server", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Este script aplicará las siguientes optimizaciones y ajustes avanzados a su servidor Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Este script actualizará su sistema Proxmox VE con opciones avanzadas:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Esta sesión se ejecuta en el terminal del Monitor. Ejecutarlo desde aquí cortaría la conexión a mitad de la instalación y dejaría el cambio en un estado roto.", @@ -7165,6 +7180,7 @@ "Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Asistente de actualización: Proxmox VE 8 → 9 (Trixie)", "Upgrade canceled by user": "Actualización cancelada por el usuario", "Upgrade one node at a time": "Actualice un nodo a la vez", + "Upgrade the system, offering the": "Actualizar el sistema, ofreciendo el", "Upgrade to Proxmox VE 9": "Actualice a Proxmox VE 9", "Upgrading NVIDIA utils (Arch)...": "Actualizando las utilidades de NVIDIA (Arch)...", "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "¿Cargar una copia cifrada de la clave a PBS para poder recuperarla en un host reinstalado con solo una frase de contraseña?", @@ -8084,6 +8100,7 @@ "was": "era", "was installed, but the kernel reports:": "se instaló, pero el kernel informa:", "when finished": "al terminar", + "when the server has no subscription (you will be asked)": "cuando el servidor no tiene suscripción (se le preguntará)", "will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "volverá a vincular la GPU a vfio-pci en el próximo reinicio, rompiendo el controlador que está a punto de instalarse.", "wipefs failed on": "los borrados fallaron", "with": "con", diff --git a/scripts/global/remove-banner-pve-v3.sh b/scripts/global/remove-banner-pve-v3.sh index 4ddf7af2..92df1028 100644 --- a/scripts/global/remove-banner-pve-v3.sh +++ b/scripts/global/remove-banner-pve-v3.sh @@ -283,6 +283,10 @@ reload_services() { systemctl is-active --quiet pvedaemon 2>/dev/null && { systemctl reload pvedaemon 2>/dev/null || true } + # Proxmox Backup Server serves the same toolkit from its own proxy. + systemctl is-active --quiet proxmox-backup-proxy 2>/dev/null && { + systemctl reload proxmox-backup-proxy 2>/dev/null || systemctl restart proxmox-backup-proxy 2>/dev/null || true + } } main() { @@ -316,9 +320,12 @@ EOFAPT # Main function to remove subscription banner remove_subscription_banner_v3() { local pve_version - pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown") - - msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying minimal banner patch")" + if command -v pveversion >/dev/null 2>&1; then + pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown") + msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying minimal banner patch")" + else + msg_info "$(translate "Detected Proxmox Backup Server") - $(translate "applying minimal banner patch")" + fi diff --git a/scripts/global/repository-functions.sh b/scripts/global/repository-functions.sh index d6114085..7a2f3b86 100644 --- a/scripts/global/repository-functions.sh +++ b/scripts/global/repository-functions.sh @@ -62,3 +62,48 @@ ensure_repositories() { fi return 0 } + +# The no-subscription switch of a Proxmox Backup Server: the same policy and +# API as on PVE, offered only when the host has no active subscription. +ensure_pbs_repositories() { + local suite decision refresh_status + if [[ "$(pmx_platform_field supported)" != true ]]; then + msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')" + return 1 + fi + suite=$(pmx_platform_field codename) + decision=$(repository_policy plan "$suite" pbs) || return 1 + case "$decision" in + preserve) return 0 ;; + offer) ;; + *) msg_error "$(translate 'Repository policy returned an unexpected result.')"; return 1 ;; + esac + if ! declare -F hybrid_yesno >/dev/null || { [[ ! -t 0 ]] && ! { declare -F is_web_mode >/dev/null && is_web_mode; }; }; then + msg_error "$(translate 'No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.')" + return 1 + fi + if ! hybrid_yesno "$(translate 'Proxmox repository')" \ + "$(translate 'This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.')" 14 90; then + msg_error "$(translate 'Repository switch declined; no APT source changed.')" + return 1 + fi + decision=$(repository_policy apply "$suite" pbs) || return 1 + [[ "$decision" == changed || "$decision" == preserve ]] || return 1 + if [[ "$decision" == changed ]]; then + if declare -F pmx_record_execution >/dev/null; then + local journal_function="${PMX_JOURNAL_FUNCTION:-}" journal_version="${PMX_JOURNAL_VERSION:-}" + pmx_journal_context "ensure_pbs_repositories" "" "${PMX_JOURNAL_SOURCE:-}" + pmx_record_execution "Switch to the PBS no-subscription repository" \ + "proxmox-backup-debug api set /nodes/localhost/apt/repositories --handle no-subscription" + PMX_JOURNAL_FUNCTION="$journal_function" PMX_JOURNAL_VERSION="$journal_version" + fi + if apt-get update; then + return 0 + else + refresh_status=$? + msg_error "$(translate '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.')" + return "$refresh_status" + fi + fi + return 0 +} diff --git a/scripts/global/repository_policy.py b/scripts/global/repository_policy.py index 7e0dca70..95f40d68 100644 --- a/scripts/global/repository_policy.py +++ b/scripts/global/repository_policy.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -"""Proxmox repository policy via its own parsed APT repository API. +"""Proxmox VE and Proxmox Backup Server repository policy via their own parsed APT +repository API. Only `notfound` or `expired` permits an offered switch. Never print raw subscription or repository API responses: they can contain subscription keys or credentials. @@ -12,6 +13,19 @@ import sys ENDPOINT = '/nodes/localhost/apt/repositories' PVE_CHANNELS = {'pve-enterprise', 'pve-no-subscription', 'pve-test', 'pvetest'} +# Proxmox VE and Proxmox Backup Server share the repository API; each has its +# own command line, channels and place in the web interface. +PRODUCTS = { + 'pve': {'api': ['pvesh'], 'name': 'Proxmox VE', 'short': 'PVE', + 'channels': PVE_CHANNELS, 'enterprise': 'pve-enterprise', 'public': 'pve-no-subscription', + 'test': {'pve-no-subscription', 'pve-test', 'pvetest'}, + 'ui': 'Node > Updates > Repositories', 'ceph': True}, + 'pbs': {'api': ['proxmox-backup-debug', 'api'], 'name': 'Proxmox Backup Server', 'short': 'PBS', + 'channels': {'pbs-enterprise', 'pbs-no-subscription', 'pbs-test', 'pbstest'}, + 'enterprise': 'pbs-enterprise', 'public': 'pbs-no-subscription', + 'test': {'pbs-no-subscription', 'pbs-test', 'pbstest'}, + 'ui': 'Administration > Repositories', 'ceph': False}, +} def run(args): @@ -21,7 +35,16 @@ def run(args): raise ValueError(f'Unable to query or change Proxmox repository state ({args[0]}). Check the service and retry.') from exc -def subscription_status(): +def subscription_status(product='pve'): + if product == 'pbs': + # Only the status field is read; the answer also carries the server ID. + try: + status = json.loads(run(['proxmox-backup-manager', 'subscription', 'get', '--output-format', 'json'])).get('status') + 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.') + 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. output = run(['pvesubscription', 'get']) @@ -31,9 +54,10 @@ def subscription_status(): return statuses[0] -def repository_state(suite): +def repository_state(suite, product='pve'): + p = PRODUCTS[product] try: - data = json.loads(run(['pvesh', 'get', ENDPOINT, '--output-format', 'json'])) + data = json.loads(run([*p['api'], 'get', ENDPOINT, '--output-format', 'json'])) if not isinstance(data, dict) or not isinstance(data['files'], list) or not isinstance(data['errors'], list) or not isinstance(data['digest'], str) or not isinstance(data['standard-repos'], list): raise ValueError() if data['errors']: @@ -86,13 +110,14 @@ def repository_state(suite): entries.append((file['path'], index, row)) return data, entries except (KeyError, TypeError, ValueError, IndexError) as exc: - raise ValueError('Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in Node > Updates > Repositories; no repository changed.') from exc + raise ValueError(f"Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {p['ui']}; no repository changed.") from exc -def evaluate(suite, apply=False): +def evaluate(suite, apply=False, product='pve'): if suite not in ('bookworm', 'trixie'): raise ValueError('Unsupported Proxmox suite; no repository changed.') - data, entries = repository_state(suite) + p = PRODUCTS[product] + data, entries = repository_state(suite, product) pve = [] ceph = [] debian = False @@ -102,40 +127,40 @@ def evaluate(suite, apply=False): if not row['Enabled'] or 'deb' not in row['Types']: continue components = set(row['Components']) - if components & PVE_CHANNELS: + if components & p['channels']: if suite not in row['Suites']: - wrong_pve.append(f"{' '.join(sorted(components & PVE_CHANNELS))} in {path} (suite {' '.join(row['Suites'])})") + wrong_pve.append(f"{' '.join(sorted(components & p['channels']))} in {path} (suite {' '.join(row['Suites'])})") pve.append((path, index, row)) - if 'main' in components and suite in row['Suites'] and not components & PVE_CHANNELS: + if 'main' in components and suite in row['Suites'] and not components & p['channels']: debian = True - if 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']): + if p['ceph'] and 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']): if suite not in row['Suites']: wrong_ceph.append(f"{path} (suite {' '.join(row['Suites'])})") 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 Proxmox VE on {suite}, but these Proxmox VE repositories are for another suite: " - f"{'; '.join(wrong_pve)}. Disable or remove them in Node > Updates > Repositories and run the update again; no repository changed.") + 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.") 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.") - if any(set(row['Components']) & {'pve-no-subscription', 'pve-test', 'pvetest'} for _, _, row in pve): + if any(set(row['Components']) & p['test'] for _, _, row in pve): return 'preserve' # Entitlement lookup is only needed when considering a switch. Existing # public/test channels are the operator's choice, even if lookup fails. - status = subscription_status() # fail closed before any write + status = subscription_status(product) # fail closed before any write if status == 'active': if not pve: - raise ValueError('Host has an active subscription but no active PVE repository. Configure its Enterprise source in Node > Updates > Repositories; no repository changed.') + raise ValueError(f"Host has an active subscription but no active {p['short']} repository. Configure its Enterprise source in {p['ui']}; no repository changed.") return 'preserve' if not debian: raise ValueError('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.') - # No active PVE source or only inaccessible Enterprise. A mixed stanza + # 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 'pve-enterprise' in item[2]['Components']] + ceph + disable = [item for item in pve if p['enterprise'] in item[2]['Components']] + ceph for _, _, row in disable: - expected = {'pve-enterprise'} if 'pve-enterprise' in row['Components'] else {'enterprise'} + 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.') handles = [r.get('handle') for r in data['standard-repos'] if r.get('handle') == 'no-subscription'] @@ -149,9 +174,9 @@ def evaluate(suite, apply=False): try: for path, index, original in disable: next(file for file in expected if file['path'] == path)['repositories'][index]['Enabled'] = False - run(['pvesh', 'create', ENDPOINT, '--path', path, '--index', str(index), + run([*p['api'], 'create', ENDPOINT, '--path', path, '--index', str(index), '--enabled', '0', '--digest', data['digest']]) - refreshed, current = repository_state(suite) + refreshed, current = repository_state(suite, product) # Per-file digests change when Proxmox serializes the disabled entry. inventory = lambda files: sorted( ({key: value for key, value in file.items() if key != 'digest'} for file in files), @@ -159,23 +184,25 @@ def evaluate(suite, apply=False): if inventory(refreshed['files']) != inventory(expected): raise ValueError('Could not verify the expected repository inventory after Enterprise disable; possible concurrent edit. Inspect the Proxmox repository UI before retrying.') data = refreshed - run(['pvesh', 'set', ENDPOINT, '--handle', 'no-subscription', '--digest', data['digest']]) - _, current = repository_state(suite) + run([*p['api'], 'set', ENDPOINT, '--handle', 'no-subscription', '--digest', data['digest']]) + _, current = repository_state(suite, product) if not any(row['Enabled'] and 'deb' in row['Types'] and suite in row['Suites'] - and 'pve-no-subscription' in row['Components'] for _, _, row in current) \ + and p['public'] in row['Components'] for _, _, row in current) \ or any(p == path and i == index and row['Enabled'] 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('Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect Node > Updates > Repositories before retrying; no automatic rollback was attempted.') from 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 return 'changed' def main(): try: - if len(sys.argv) != 3 or sys.argv[1] not in ('plan', 'apply'): - raise ValueError('Usage: repository_policy.py plan|apply bookworm|trixie') - print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply')) + 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): + raise ValueError('Usage: repository_policy.py plan|apply bookworm|trixie [pve|pbs]') + 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) return 1 diff --git a/scripts/global/update-pbs.sh b/scripts/global/update-pbs.sh new file mode 100644 index 00000000..54574676 --- /dev/null +++ b/scripts/global/update-pbs.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# ========================================================== +# Proxmox Backup Server Update Script (post-install flows) +# ========================================================== + +LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" +BASE_DIR="/usr/local/share/proxmenux" +UTILS_FILE="$BASE_DIR/utils.sh" +APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C" + +if [[ -f "$UTILS_FILE" ]]; then + source "$UTILS_FILE" +fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi +source "$LOCAL_SCRIPTS/global/common-functions.sh" +source "$LOCAL_SCRIPTS/global/repository-functions.sh" + +load_language +initialize_cache + +update_pbs() { + local FUNC_VERSION="1.0" + pmx_journal_context "update_pbs" "$FUNC_VERSION" + + if ! pmx_is_pbs || [[ "$(pmx_platform_field supported)" != true ]]; then + msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')" + return 1 + fi + + local start_time + start_time=$(date +%s) + local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log" + local screen_capture="/tmp/proxmenux_screen_capture_$$.txt" + local OS_CODENAME + OS_CODENAME=$(pmx_platform_field codename) + + { + echo "=== ProxMenux — Proxmox Backup Server update ===" + echo "Started: $(date -Iseconds)" + echo "Host: $(hostname)" + echo "Running: proxmox-backup-server $(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)" + } > "$log_file" + + if ! ensure_pbs_repositories; then + msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")" + rm -f "$screen_capture" + return 1 + fi + + 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")" + return 1 + fi + + local update_output update_exit_code + update_output=$(apt-get -o APT::Update::Error-Mode=any update 2>&1) + update_exit_code=$? + { + echo + echo "--- apt-get update (exit $update_exit_code) ---" + printf '%s\n' "$update_output" + } >> "$log_file" + if [ $update_exit_code -ne 0 ]; then + msg_error "$(translate "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:") $log_file" + return 1 + fi + msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture" + + local current_version available_version + current_version=$(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null) + available_version=$(apt-cache policy proxmox-backup-server 2>/dev/null | awk '/Candidate:/ {print $2; exit}') + + local upgradable upgradable_raw upgradable_list security_updates + upgradable_raw=$($APT_ENV apt list --upgradable 2>/dev/null \ + | sed '1d' \ + | sed '/^\s*$/d') + upgradable=$(printf '%s' "$upgradable_raw" | grep -c . ) + security_updates=$(printf '%s\n' "$upgradable_raw" | grep -ci '\-security') + upgradable_list=$(printf '%s\n' "$upgradable_raw" | pmx_format_upgradable) + + { + echo + echo "--- Packages to upgrade ($upgradable) ---" + [ "$upgradable" -gt 0 ] && printf '%s\n' "$upgradable_list" + } >> "$log_file" + + local menu_text + menu_text="$(translate "System Update Information")\n\n" + menu_text+="$(translate "Current PBS Version"): $current_version\n" + if [ -n "$available_version" ] && [ "$available_version" != "$current_version" ]; then + menu_text+="$(translate "Available PBS Version"): $available_version\n" + fi + menu_text+="\n$(translate "Package Updates Available"): $upgradable\n" + menu_text+="$(translate "Security Updates"): $security_updates\n\n" + + if [ "$upgradable" -eq 0 ]; then + menu_text+="$(translate "System is already up to date")" + whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70 + clear + show_proxmenux_logo + msg_title "$(translate "$SCRIPT_TITLE")" + cat "$screen_capture" + msg_ok "$(translate "System is already up to date. No update needed.")" + echo -e "\nSystem is already up to date." >> "$log_file" + rm -f "$screen_capture" + return 0 + fi + + menu_text+="$(translate "Packages to be upgraded"):\n$upgradable_list\n\n" + menu_text+="$(translate "Do you want to proceed with the system update?")" + local proceed=0 + whiptail --title "$(translate "Proxmox Backup Server Update")" --scrolltext --yesno "$menu_text" 24 78 || proceed=1 + + clear + show_proxmenux_logo + msg_title "$(translate "$SCRIPT_TITLE")" + cat "$screen_capture" + + if [ $proceed -ne 0 ]; then + msg_info2 "$(translate "Update cancelled by user")" + rm -f "$screen_capture" + return 0 + fi + + pmx_record_execution "Upgrade Proxmox Backup Server packages" "apt -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade" + local dpkg_mark upgrade_exit_code + dpkg_mark=$(date '+%Y-%m-%d %H:%M:%S') + echo -e "\n--- apt full-upgrade ---" >> "$log_file" + DEBIAN_FRONTEND=noninteractive apt -y \ + -o Dpkg::Options::='--force-confdef' \ + -o Dpkg::Options::='--force-confold' \ + full-upgrade 2> >(tee -a "$log_file" >&2) + upgrade_exit_code=$? + echo -e + { + echo + echo "--- Packages changed (exit $upgrade_exit_code) ---" + pmx_dpkg_changes_since "$dpkg_mark" + } >> "$log_file" + + clear + show_proxmenux_logo + msg_title "$(translate "$SCRIPT_TITLE")" + cat "$screen_capture" + rm -f "$screen_capture" + + if [ $upgrade_exit_code -ne 0 ]; then + 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 + + pmx_record_execution "Remove unused packages" "apt-get -y autoremove" + apt-get -y autoremove > /dev/null 2>&1 || true + apt-get -y autoclean > /dev/null 2>&1 || true + msg_ok "$(translate "Cleanup finished")" + + local end_time duration minutes seconds + end_time=$(date +%s) + duration=$((end_time - start_time)) + minutes=$((duration / 60)) + seconds=$((duration % 60)) + + echo -e "${TAB}${BGN}$(translate "====== PBS UPDATE COMPLETED ======")${CL}" + echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}" + echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}" + echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}" + echo -e "${TAB}${GN}🗄️ $(translate "Proxmox Backup Server")${CL}: ${BL}$(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null) (Debian $OS_CODENAME)${CL}" + + { + echo + echo "Finished: $(date -Iseconds) — ${minutes}m ${seconds}s" + echo "Running: proxmox-backup-server $(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)" + } >> "$log_file" + + if [ -f /var/run/reboot-required ] || [ "$(uname -r)" != "$(ls -1 /boot/vmlinuz-* 2>/dev/null | sed 's|/boot/vmlinuz-||' | sort -V | tail -1)" ]; then + msg_warn "$(translate "A newer kernel is installed. Reboot before the next backup window to use it.")" + fi + msg_ok "$(translate "Proxmox Backup Server update completed.")" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + update_pbs +fi diff --git a/scripts/menus/menu_post_install.sh b/scripts/menus/menu_post_install.sh index 447583e7..d4e63cda 100644 --- a/scripts/menus/menu_post_install.sh +++ b/scripts/menus/menu_post_install.sh @@ -28,6 +28,12 @@ fi load_language initialize_cache +PMX_ON_PBS=false +if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then + source "$LOCAL_SCRIPTS/global/platform-functions.sh" + pmx_is_pbs && PMX_ON_PBS=true +fi + # ========================================================== confirm_and_run() { local name="$1" @@ -55,8 +61,13 @@ confirm_automated_script() { local script_info="" - 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" + 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" + 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 "Install and configure") \Z4Log2RAM\Z0 $(translate "(only on SSD/NVMe) to protect your disk")\n" @@ -229,6 +240,12 @@ declare -a COMMUNITY_SCRIPTS=( "Proxmox VE Post Install|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/post-pve-install.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\"" "Proxmox VE Microcode|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/microcode.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\"" ) +if [[ "$PMX_ON_PBS" == true ]]; then + COMMUNITY_SCRIPTS=( + "Proxmox Backup Server Post Install|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/post-pbs-install.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\"" + "Proxmox Backup Server Microcode|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/pbs-microcode.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\"" + ) +fi # ========================================================== @@ -271,6 +288,12 @@ post_install_label() { "Proxmox VE Microcode") echo "Proxmox VE Microcode" ;; + "Proxmox Backup Server Post Install") + echo "Proxmox Backup Server Post Install" + ;; + "Proxmox Backup Server Microcode") + echo "Proxmox Backup Server Microcode" + ;; "Community Scripts") translate "Community Scripts" ;; diff --git a/scripts/menus/pbs_menu.sh b/scripts/menus/pbs_menu.sh index 86f30ca2..99a8d76c 100644 --- a/scripts/menus/pbs_menu.sh +++ b/scripts/menus/pbs_menu.sh @@ -2,8 +2,8 @@ # ========================================================== # ProxMenux - Proxmox Backup Server menu # ========================================================== -# PBS exposes compatible host tools; PVE guest/repository optimizations -# stay in the PVE menu. +# PBS exposes compatible host tools and its own post-install profile; +# PVE guest optimizations stay in the PVE menu. # ========================================================== BASE_DIR="/usr/local/share/proxmenux" LOCAL_SCRIPTS="$BASE_DIR/scripts" @@ -31,20 +31,22 @@ pbs_show() { while true; do choice=$(dialog --clear --backtitle "ProxMenux" --title "Proxmox Backup Server" \ - --menu "$(translate 'Select an option:')" 18 75 8 \ - 1 "$(translate 'PBS version and packages')" \ - 2 "$(translate 'PBS datastores')" \ - 3 "$(translate 'Recent PBS tasks')" \ - 4 "$(translate 'System Utilities Installer')" \ - 5 "$(translate 'Update PBS using configured repositories')" \ + --menu "$(translate 'Select an option:')" 19 75 9 \ + 1 "$(translate "Settings post-install Proxmox")" \ + 2 "$(translate 'PBS version and packages')" \ + 3 "$(translate 'PBS datastores')" \ + 4 "$(translate 'Recent PBS tasks')" \ + 5 "$(translate 'System Utilities Installer')" \ + 6 "$(translate 'Update PBS using configured repositories')" \ s "$(translate 'Settings')" \ 0 "$(translate 'Exit')" 2>&1 >/dev/tty) || break case "$choice" in - 1) pbs_show "$(translate 'PBS version and packages')" proxmox-backup-manager versions --verbose ;; - 2) pbs_show "$(translate 'PBS datastores')" proxmox-backup-manager datastore list ;; - 3) pbs_show "$(translate 'Recent PBS tasks')" proxmox-backup-manager task list --all true --limit 20 ;; - 4) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;; - 5) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;; + 1) exec bash "$LOCAL_SCRIPTS/menus/menu_post_install.sh" ;; + 2) pbs_show "$(translate 'PBS version and packages')" proxmox-backup-manager versions --verbose ;; + 3) pbs_show "$(translate 'PBS datastores')" proxmox-backup-manager datastore list ;; + 4) pbs_show "$(translate 'Recent PBS tasks')" proxmox-backup-manager task list --all true --limit 20 ;; + 5) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;; + 6) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;; s) exec bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;; 0) break ;; esac diff --git a/scripts/post_install/auto_post_install.sh b/scripts/post_install/auto_post_install.sh index 25e6289f..83f84a82 100644 --- a/scripts/post_install/auto_post_install.sh +++ b/scripts/post_install/auto_post_install.sh @@ -59,7 +59,12 @@ initialize_cache if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then source "$LOCAL_SCRIPTS/global/platform-functions.sh" - pmx_require_pve || exit 1 +fi +# Proxmox Backup Server runs the same host optimizations; repositories, +# package upgrade and the subscription notice follow its own tooling. +PMX_ON_PBS=false +if declare -F pmx_is_pbs >/dev/null && pmx_is_pbs; then + PMX_ON_PBS=true fi # Global variables @@ -142,6 +147,10 @@ $(translate "Do you want to continue anyway?")" 13 70 apt_upgrade() { + if [[ "$PMX_ON_PBS" == true ]]; then + bash "$LOCAL_SCRIPTS/global/update-pbs.sh" + return + fi local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -171,6 +180,20 @@ apt_upgrade() { remove_subscription_banner() { local FUNC_VERSION="1.0" # 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" \ + --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." + return 1 + fi + if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then + msg_error "$(translate "Subscription banner removal failed")" + return 1 + fi + register_tool "subscription_banner" true "$FUNC_VERSION" + return 0 + fi local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -225,6 +248,17 @@ setup_proxmox_repositories() { msg_info2 "$(translate "Configuring Proxmox APT repositories...")" + if [[ "$PMX_ON_PBS" == true ]]; then + if ! ensure_pbs_repositories; then + msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")" + register_tool "proxmox_repos" false "$FUNC_VERSION" + return 1 + fi + register_tool "proxmox_repos" true "$FUNC_VERSION" + msg_success "$(translate "Proxmox Backup Server APT repositories configured")" + return 0 + fi + if ! ensure_repositories; then msg_error "$(translate "Failed to configure Proxmox repositories")" register_tool "proxmox_repos" false "$FUNC_VERSION" @@ -524,7 +558,7 @@ force_apt_ipv4() { # ========================================================== apply_network_optimizations() { - local FUNC_VERSION="1.1" + local FUNC_VERSION="1.2" pmx_journal_context "apply_network_optimizations" "$FUNC_VERSION" # description: Tune TCP buffers, somaxconn, IPv4 hardening and disable rp_filter on fw bridges (PVE 9 compatible). msg_info "$(translate "Optimizing network settings...")" @@ -622,14 +656,16 @@ RemainAfterExit=yes WantedBy=multi-user.target EOF - pmx_write_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules <<'EOF' + pmx_remove_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules + + pmx_write_file /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules <<'EOF' ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwbr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwln*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwpr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="tap*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" EOF - chmod 0644 /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules - chown root:root /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules + chmod 0644 /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules + chown root:root /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules systemctl daemon-reload >/dev/null 2>&1 || true udevadm control --reload-rules >/dev/null 2>&1 || true diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index ca60b752..39274146 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -53,7 +53,12 @@ initialize_cache if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then source "$LOCAL_SCRIPTS/global/platform-functions.sh" - pmx_require_pve || exit 1 +fi +# On Proxmox Backup Server the menu lists only the options that apply to +# it; repositories, upgrade and the subscription notice use its tooling. +PMX_ON_PBS=false +if declare -F pmx_is_pbs >/dev/null && pmx_is_pbs; then + PMX_ON_PBS=true fi # Load shared global functions @@ -122,6 +127,17 @@ setup_proxmox_repositories() { msg_info2 "$(translate "Configuring Proxmox APT repositories...")" + if [[ "$PMX_ON_PBS" == true ]]; then + if ! ensure_pbs_repositories; then + msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")" + register_tool "proxmox_repos" false "$FUNC_VERSION" + return 1 + fi + register_tool "proxmox_repos" true "$FUNC_VERSION" + msg_success "$(translate "Proxmox Backup Server APT repositories configured")" + return 0 + fi + if ! ensure_repositories; then msg_error "$(translate "Failed to configure Proxmox repositories")" register_tool "proxmox_repos" false "$FUNC_VERSION" @@ -248,6 +264,10 @@ EOF apt_upgrade() { + if [[ "$PMX_ON_PBS" == true ]]; then + bash "$LOCAL_SCRIPTS/global/update-pbs.sh" + return + fi local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -2376,6 +2396,14 @@ EOF remove_subscription_banner() { local FUNC_VERSION="1.0" # description: Patch the Proxmox web UI to suppress the "no valid subscription" dialog (PVE 8 + 9 variants supported). + if [[ "$PMX_ON_PBS" == true ]]; then + if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then + msg_error "$(translate "Subscription banner removal failed")" + return 1 + fi + register_tool "subscription_banner" true "$FUNC_VERSION" + return 0 + fi local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -3143,8 +3171,11 @@ EOF } - ensure_aliases - msg_ok "$(translate "Aliases added to .bashrc")" + # The aliases call Proxmox VE tools. + if [[ "$PMX_ON_PBS" != true ]]; then + ensure_aliases + msg_ok "$(translate "Aliases added to .bashrc")" + fi msg_success "$(translate "Figurine installation and configuration completed successfully.")" register_tool "figurine" true "$FUNC_VERSION" @@ -3993,6 +4024,19 @@ main_menu() { "Optional|Install and configure Log2RAM|LOG2RAM" ) + if [[ "$PMX_ON_PBS" == true ]]; then + local -A pve_only=( + [GUESTAGENT]=1 [VFIO_IOMMU]=1 [OPENVSWITCH]=1 [VZDUMP]=1 [PVEAM]=1 + [CEPH]=1 [ENABLE_HA]=1 [OVHRTM]=1 [PIGZ]=1 + ) + local pbs_options=() option_key + for option in "${options[@]}"; do + option_key="${option##*|}" + [[ -n "${pve_only[$option_key]:-}" ]] || pbs_options+=("$option") + done + options=("${pbs_options[@]}") + fi + IFS=$'\n' sorted_options=($(for option in "${options[@]}"; do IFS='|' read -r category description function_name <<< "$option" printf "%d|%s|%s|%s\n" "${category_order[$category]:-999}" "$category" "$description" "$function_name" diff --git a/scripts/post_install/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh index 63b22fa1..1724f849 100644 --- a/scripts/post_install/uninstall-tools.sh +++ b/scripts/post_install/uninstall-tools.sh @@ -266,6 +266,12 @@ uninstall_subscription_banner() { fi fi + # Proxmox Backup Server ships the same widget toolkit without the PVE packages. + local -a ui_packages=(pve-manager proxmox-widget-toolkit libjs-extjs libpve-http-server-perl) + if [[ "$(dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null)" == "install ok installed" ]]; then + ui_packages=(proxmox-widget-toolkit libjs-extjs) + fi + if [[ "$restored" == false ]]; then msg_info "$(translate "Performing complete package reinstallation...")" @@ -274,7 +280,7 @@ uninstall_subscription_banner() { # Reinstall packages with force-confnew to restore original configs if apt-get --reinstall -o Dpkg::Options::="--force-confnew" install \ - pve-manager proxmox-widget-toolkit libjs-extjs libpve-http-server-perl -y >/dev/null 2>&1; then + "${ui_packages[@]}" -y >/dev/null 2>&1; then msg_ok "$(translate "Reinstalled Proxmox packages successfully")" restored=true else @@ -286,7 +292,7 @@ uninstall_subscription_banner() { # Second pass reinstallation to ensure everything is clean apt-get update >/dev/null 2>&1 - apt-get --reinstall install proxmox-widget-toolkit pve-manager libjs-extjs libpve-http-server-perl -y >/dev/null 2>&1 || true + apt-get --reinstall install "${ui_packages[@]}" -y >/dev/null 2>&1 || true fi msg_info "$(translate "Cleaning cached files...")" diff --git a/tests/test_repository_policy_pbs.py b/tests/test_repository_policy_pbs.py new file mode 100644 index 00000000..e0a71d90 --- /dev/null +++ b/tests/test_repository_policy_pbs.py @@ -0,0 +1,103 @@ +"""Fixture-only PBS policy checks: never call host proxmox-backup-* tools or edit host APT sources.""" +import importlib.util +import json +from pathlib import Path +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +POLICY = ROOT / 'scripts/global/repository_policy.py' + + +def repo(components, *, uri='https://enterprise.proxmox.com/debian/pbs', suite='trixie', enabled=True): + return {'Types': ['deb'], 'URIs': [uri], 'Suites': [suite], + 'Components': components.split(), 'Enabled': enabled} + + +class PBSRepositoryPolicyTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location('repository_policy', POLICY) + cls.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.module) + + def setUp(self): + self.calls = [] + self.status = 'notfound' + self.repositories = { + 'files': [ + {'path': '/etc/apt/sources.list.d/pbs-enterprise.sources', 'repositories': [repo('pbs-enterprise')]}, + {'path': '/etc/apt/sources.list.d/debian.sources', + 'repositories': [repo('main contrib', uri='http://deb.debian.org/debian')]}, + ], + 'errors': [], 'digest': 'fixture-digest', + 'standard-repos': [{'handle': 'enterprise'}, {'handle': 'no-subscription'}, {'handle': 'test'}], + } + + def run_policy(self, apply=False): + def run(args): + self.calls.append(args) + if args[:3] == ['proxmox-backup-manager', 'subscription', 'get']: + return json.dumps({'status': self.status, 'serverid': 'FIXTURE-SECRET'}) + self.assertEqual(args[:2], ['proxmox-backup-debug', 'api']) + action = args[2] + if action == 'get': + return json.dumps(self.repositories) + self.assertEqual(args[args.index('--digest') + 1], self.repositories['digest']) + if action == 'create': + path = args[args.index('--path') + 1] + index = int(args[args.index('--index') + 1]) + next(f for f in self.repositories['files'] if f['path'] == path)['repositories'][index]['Enabled'] = False + elif action == 'set': + self.repositories['files'].append({'path': '/etc/apt/sources.list.d/proxmox.sources', + 'repositories': [repo('pbs-no-subscription', uri='http://download.proxmox.com/debian/pbs')]}) + self.repositories['digest'] += '+' + return '' + with patch.object(self.module, 'run', side_effect=run): + return self.module.evaluate('trixie', apply=apply, product='pbs') + + def writes(self): + return [c for c in self.calls if c[:2] == ['proxmox-backup-debug', 'api'] and c[2] in ('create', 'set')] + + def test_fresh_pbs_offers_switch_without_writing(self): + self.assertEqual(self.run_policy(), 'offer') + self.assertEqual(self.writes(), []) + self.assertFalse(any(c[0] in ('pvesh', 'pvesubscription') for c in self.calls)) + + def test_consented_switch_disables_pbs_enterprise_then_adds_no_subscription(self): + self.assertEqual(self.run_policy(apply=True), 'changed') + writes = self.writes() + self.assertEqual([c[2] for c in writes], ['create', 'set']) + self.assertEqual(writes[0][writes[0].index('--path') + 1], '/etc/apt/sources.list.d/pbs-enterprise.sources') + self.assertEqual(writes[0][writes[0].index('--enabled') + 1], '0') + self.assertEqual(writes[1][writes[1].index('--handle') + 1], 'no-subscription') + + def test_active_subscription_preserves_enterprise(self): + self.status = 'active' + self.assertEqual(self.run_policy(apply=True), 'preserve') + self.assertEqual(self.writes(), []) + + def test_existing_no_subscription_is_preserved_without_lookup(self): + self.repositories['files'][0]['repositories'] = [repo('pbs-no-subscription')] + self.assertEqual(self.run_policy(apply=True), 'preserve') + self.assertFalse(any(c[0] == 'proxmox-backup-manager' for c in self.calls)) + + def test_unrecognized_subscription_status_cannot_mutate(self): + self.status = 'invalid' + with self.assertRaisesRegex(ValueError, 'proxmox-backup-manager'): + self.run_policy(apply=True) + self.assertEqual(self.writes(), []) + + def test_pve_channels_do_not_count_as_pbs_sources(self): + self.repositories['files'][0]['repositories'] = [repo('pve-no-subscription')] + self.assertEqual(self.run_policy(), 'offer') + + def test_wrong_suite_points_to_pbs_interface(self): + self.repositories['files'][0]['repositories'] = [repo('pbs-enterprise', suite='bookworm')] + with self.assertRaisesRegex(ValueError, 'Administration > Repositories'): + self.run_policy() + self.assertEqual(self.writes(), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/web/app/[locale]/docs/post-install/page.tsx b/web/app/[locale]/docs/post-install/page.tsx index e4bc8304..89332b87 100644 --- a/web/app/[locale]/docs/post-install/page.tsx +++ b/web/app/[locale]/docs/post-install/page.tsx @@ -89,10 +89,12 @@ export default async function PostInstallPage({ docs: { postInstall: { routes: Route[] related: { items: RelatedItem[] } + pbs?: { items: string[] } } } } const routes = messages.docs.postInstall.routes const relatedItems = messages.docs.postInstall.related.items + const pbsItems = messages.docs.postInstall.pbs?.items ?? [] const code = (chunks: React.ReactNode) => {chunks} const strong = (chunks: React.ReactNode) => {chunks} @@ -210,6 +212,14 @@ export default async function PostInstallPage({ {t.rich("community.body", { em, code, a: communityAnchor, link: externalRepoLink })}

+

{t("pbs.heading")}

+

{t.rich("pbs.body", { strong })}

+
    + {pbsItems.map((_, i) => ( +
  • {t.rich(`pbs.items.${i}`, { strong, em })}
  • + ))} +
+

{t("related.heading")}

    {relatedItems.map((item) => ( diff --git a/web/messages/en/docs/post-install/index.json b/web/messages/en/docs/post-install/index.json index e6e60c1f..46ab94f8 100644 --- a/web/messages/en/docs/post-install/index.json +++ b/web/messages/en/docs/post-install/index.json @@ -78,6 +78,17 @@ "heading": "Community scripts", "body": "The menu also exposes two third-party scripts from the community-scripts project: Proxmox VE Post Install and Proxmox VE Microcode. These are wrappers that wget | bash the original authors' scripts. They are not maintained by ProxMenux. See External Repositories for details and trust considerations before running them." }, + "pbs": { + "heading": "On Proxmox Backup Server", + "body": "On a Proxmox Backup Server host, the same menu opens from Settings post-install Proxmox in the PBS menu. Automated, Customizable, Apply Available Updates and Uninstall Optimizations work as on Proxmox VE, with these differences:", + "items": [ + "Repositories and upgrade: when the server has no subscription and only the Enterprise PBS repository is active, ProxMenux asks before switching to the PBS no-subscription repository. The switch goes through the server's own repository API, the same one behind Administration > Repositories. With an active subscription, or with a no-subscription or test repository already configured, the sources stay as they are.", + "Customizable list: the options that only apply to Proxmox VE are not listed: guest agent, VFIO IOMMU, Open vSwitch, vzdump backup speed, pigz, Appliance Manager, Ceph, High Availability and OVH Real Time Monitoring.", + "Subscription banner: the patch applies to the Proxmox Backup Server web interface, and Uninstall Optimizations restores it.", + "Figurine: installs without the Proxmox VE aliases.", + "Community scripts: the section offers Proxmox Backup Server Post Install and Proxmox Backup Server Microcode in place of the Proxmox VE scripts." + ] + }, "related": { "heading": "Related", "items": [ diff --git a/web/messages/es/docs/post-install/index.json b/web/messages/es/docs/post-install/index.json index 05bd570f..699abbdd 100644 --- a/web/messages/es/docs/post-install/index.json +++ b/web/messages/es/docs/post-install/index.json @@ -78,6 +78,17 @@ "heading": "Community scripts", "body": "El menú también expone dos scripts de terceros del proyecto community-scripts: Proxmox VE Post Install y Proxmox VE Microcode. Son wrappers que hacen wget | bash de los scripts de los autores originales. No los mantiene ProxMenux. Mira External Repositories para detalles y consideraciones de confianza antes de ejecutarlos." }, + "pbs": { + "heading": "En Proxmox Backup Server", + "body": "En un host Proxmox Backup Server, el mismo menú se abre desde Settings post-install Proxmox en el menú de PBS. Automatizado, Personalizable, Apply Available Updates y Uninstall Optimizations funcionan igual que en Proxmox VE, con estas diferencias:", + "items": [ + "Repositorios y actualización: cuando el servidor no tiene suscripción y solo está activo el repositorio Enterprise de PBS, ProxMenux pregunta antes de cambiar al repositorio sin suscripción de PBS. El cambio se hace con la API de repositorios del propio servidor, la misma que usa Administration > Repositories. Con una suscripción activa, o con un repositorio sin suscripción o de pruebas ya configurado, las fuentes se mantienen como están.", + "Lista personalizable: no aparecen las opciones que solo se aplican a Proxmox VE: agente de invitado, VFIO IOMMU, Open vSwitch, velocidad de backups vzdump, pigz, Appliance Manager, Ceph, alta disponibilidad y OVH Real Time Monitoring.", + "Banner de suscripción: el parche se aplica a la interfaz web de Proxmox Backup Server, y Uninstall Optimizations lo restaura.", + "Figurine: se instala sin los alias de Proxmox VE.", + "Scripts de la comunidad: la sección ofrece Proxmox Backup Server Post Install y Proxmox Backup Server Microcode en lugar de los scripts de Proxmox VE." + ] + }, "related": { "heading": "Relacionado", "items": [ From 74c0321956db1ebc722ecacef5b5b348a21ff3aa Mon Sep 17 00:00:00 2001 From: ProxMenuxBot Date: Sat, 17 Oct 2026 16:20:37 +0000 Subject: [PATCH 22/43] chore(i18n): auto-fill missing translations in messages/{locale}/common.json Source: 98ae1f3 Triggered by: push --- AppImage/messages/de/common.json | 55 +++++++++++++++++++++++++++++--- AppImage/messages/fr/common.json | 55 +++++++++++++++++++++++++++++--- AppImage/messages/it/common.json | 55 +++++++++++++++++++++++++++++--- AppImage/messages/pt/common.json | 55 +++++++++++++++++++++++++++++--- AppImage/messages/sk/common.json | 55 +++++++++++++++++++++++++++++--- AppImage/messages/sv/common.json | 55 +++++++++++++++++++++++++++++--- 6 files changed, 300 insertions(+), 30 deletions(-) diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 9fa0c7cf..37f27e74 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -8,7 +8,8 @@ "unknown": "Unbekannt", "notAvailable": "N/A", "serverOffline": "Server offline", - "supportProject": "Unterstützen Sie das Projekt und tragen Sie dazu bei" + "supportProject": "Unterstützen Sie das Projekt und tragen Sie dazu bei", + "descriptionPbs": "Proxmox Backup Server Dashboard" }, "common": { "notAvailable": "N/A", @@ -165,7 +166,8 @@ }, "runningCountOne": "{count} läuft", "runningCountFew": "{count} laufen", - "runningCountMany": "{count} laufen" + "runningCountMany": "{count} laufen", + "pbsVersion": "PBS-Version:" }, "storage": { "loadingTitle": "Speicherdaten werden geladen...", @@ -1831,7 +1833,8 @@ "logs": "Systemprotokolle", "updates": "Systemaktualisierungen", "security": "Sicherheit & Zertifikate", - "temperature": "Temperatur" + "temperature": "Temperatur", + "pbsServices": "PBS Services" } }, "remoteStorage": { @@ -4824,7 +4827,8 @@ "services": "PVE-Dienste", "logs": "Systemprotokolle", "updates": "Systemaktualisierungen", - "security": "Sicherheit & Zertifikate" + "security": "Sicherheit & Zertifikate", + "pbsServices": "PBS-Dienste" }, "checks": { "cpu_usage": "CPU-Auslastung", @@ -7320,6 +7324,47 @@ "navigation": "PBS-Navigation", "cpu": "CPU", "memory": "Arbeitsspeicher", - "uptime": "Betriebszeit" + "uptime": "Betriebszeit", + "capacityUnavailable": "Kapazität nicht verfügbar.", + "datastoresUnavailable": "{count} nicht verfügbar", + "datastoresOnline": "Alle verfügbar", + "moreDatastores": "+{count} weitere Datenspeicher", + "estimatedFull": "Voraussichtlich voll", + "estimatedFullUnknown": "Noch nicht genügend Daten", + "duration": "Dauer", + "noJobs": "Es sind keine Synchronisierungs-, Überprüfungs- oder Löschzeitpläne konfiguriert." + }, + "monitorLinks": { + "groups": { + "cluster": "Cluster", + "standalone": "Eigenständige Knoten", + "pbs": "Proxmox Sicherungsserver" + }, + "thisNode": "Dieser Knoten", + "checking": "Knoten werden überprüft…", + "loadFailed": "Die Knotenliste konnte nicht geladen werden.", + "empty": "Noch keine anderen Knoten.Fügen Sie einen hinzu, um den Monitor von hier aus zu öffnen.", + "noResponse": "Keine Antwort", + "pbsInterface": "PBS-Schnittstelle", + "openNewTab": "In einem neuen Tab öffnen", + "manage": "Knoten hinzufügen oder verwalten…", + "dialogTitle": "Verknüpfte Knoten", + "dialogDescription": "Clusterknoten und konfigurierte Proxmox-Sicherungsserver werden automatisch erkannt.Jeder Monitor behält seine eigene Anmeldung.", + "addTitle": "Einen Knoten hinzufügen", + "name": "Name", + "address": "Adresse überwachen", + "type": "Geben Sie ein", + "add": "Hinzufügen", + "save": "Speichern", + "remove": "Entfernen", + "visible": "Sichtbar", + "autoAddress": "Automatisch", + "detected": "Erkannt", + "added": "Hinzugefügt", + "saveFailed": "Die Knotenliste konnte nicht gespeichert werden.", + "backTo": "Zurück zu {name}", + "cameFromTitle": "Monitorknoten hinzufügen?", + "cameFromBody": "{name} zu den verknüpften Knoten dieses Monitors hinzufügen.", + "notNow": "Nicht jetzt" } } diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 7dea3a29..2b95595a 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -8,7 +8,8 @@ "unknown": "Inconnu", "notAvailable": "N/A", "serverOffline": "Serveur hors ligne", - "supportProject": "Soutenir et contribuer au projet" + "supportProject": "Soutenir et contribuer au projet", + "descriptionPbs": "Tableau de bord du serveur de sauvegarde Proxmox" }, "common": { "notAvailable": "N/A", @@ -165,7 +166,8 @@ }, "runningCountOne": "{count} en cours d'exécution", "runningCountFew": "{count} en cours d'exécution", - "runningCountMany": "{count} en cours d'exécution" + "runningCountMany": "{count} en cours d'exécution", + "pbsVersion": "Version PBS :" }, "storage": { "loadingTitle": "Chargement des données de stockage...", @@ -1831,7 +1833,8 @@ "logs": "Journaux système", "updates": "Mises à jour du système", "security": "Sécurité et certificats", - "temperature": "Température" + "temperature": "Température", + "pbsServices": "Services PBS" } }, "remoteStorage": { @@ -4824,7 +4827,8 @@ "services": "Services PVE", "logs": "Journaux système", "updates": "Mises à jour du système", - "security": "Sécurité et certificats" + "security": "Sécurité et certificats", + "pbsServices": "services PBS" }, "checks": { "cpu_usage": "Utilisation du processeur", @@ -7320,6 +7324,47 @@ "navigation": "Navigation PBS", "cpu": "CPU", "memory": "Mémoire", - "uptime": "Durée de fonctionnement" + "uptime": "Durée de fonctionnement", + "capacityUnavailable": "Capacité non disponible.", + "datastoresUnavailable": "{count} indisponible", + "datastoresOnline": "Tous disponibles", + "moreDatastores": "+{count} banques de données supplémentaires", + "estimatedFull": "Estimé complet", + "estimatedFullUnknown": "Pas encore assez de données", + "duration": "Durée", + "noJobs": "Aucune planification de synchronisation, de vérification ou d'élagage n'est configurée." + }, + "monitorLinks": { + "groups": { + "cluster": "Cluster", + "standalone": "nœuds autonomes", + "pbs": "Serveur de sauvegarde Proxmox" + }, + "thisNode": "Ce nœud", + "checking": "Vérification des nœuds…", + "loadFailed": "Impossible de charger la liste des nœuds.", + "empty": "aucun autre nœud pour l'instant.Ajoutez-en un pour ouvrir son moniteur à partir d'ici.", + "noResponse": "Pas de réponse", + "pbsInterface": "interface PBS", + "openNewTab": "Ouvrir dans un nouvel onglet", + "manage": "Ajouter ou gérer des nœuds…", + "dialogTitle": "nœuds liés", + "dialogDescription": "Les nœuds de cluster et les serveurs de sauvegarde Proxmox configurés sont détectés automatiquement.Chaque moniteur conserve sa propre connexion.", + "addTitle": "ajouter un nœud", + "name": "Nom", + "address": "Adresse du moniteur", + "type": "Type", + "add": "Ajouter", + "save": "Enregistrer", + "remove": "Supprimer", + "visible": "Visible", + "autoAddress": "Automatique", + "detected": "Détecté", + "added": "ajouté", + "saveFailed": "Impossible d'enregistrer la liste des nœuds.", + "backTo": "Retour à {name}", + "cameFromTitle": "Ajouter un nœud Moniteur ?", + "cameFromBody": "Ajoutez {name} aux nœuds liés de ce moniteur.", + "notNow": "Pas maintenant" } } diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 9fb2d83e..3a09e45d 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -8,7 +8,8 @@ "unknown": "Sconosciuto", "notAvailable": "N/A", "serverOffline": "Server offline", - "supportProject": "Sostieni e contribuisci al progetto" + "supportProject": "Sostieni e contribuisci al progetto", + "descriptionPbs": "Dashboard del server di backup Proxmox" }, "common": { "notAvailable": "N/A", @@ -165,7 +166,8 @@ }, "runningCountOne": "{count} in esecuzione", "runningCountFew": "{count} in esecuzione", - "runningCountMany": "{count} in esecuzione" + "runningCountMany": "{count} in esecuzione", + "pbsVersion": "Versione PBS:" }, "storage": { "loadingTitle": "Caricamento dati storage...", @@ -1831,7 +1833,8 @@ "logs": "Log di sistema", "updates": "Aggiornamenti di sistema", "security": "Sicurezza e certificati", - "temperature": "Temperatura" + "temperature": "Temperatura", + "pbsServices": "Servizi PBS" } }, "remoteStorage": { @@ -4824,7 +4827,8 @@ "services": "Servizi PVE", "logs": "Log di sistema", "updates": "Aggiornamenti di sistema", - "security": "Sicurezza e certificati" + "security": "Sicurezza e certificati", + "pbsServices": "servizi PBS" }, "checks": { "cpu_usage": "Utilizzo CPU", @@ -7320,6 +7324,47 @@ "navigation": "Navigazione PBS", "cpu": "CPU", "memory": "Memoria", - "uptime": "Tempo di attività" + "uptime": "Tempo di attività", + "capacityUnavailable": "Capacità non disponibile.", + "datastoresUnavailable": "{count} non disponibile", + "datastoresOnline": "Tutti disponibili", + "moreDatastores": "+{count} altri archivi dati", + "estimatedFull": "Pieno stimato", + "estimatedFullUnknown": "dati non ancora sufficienti", + "duration": "Durata", + "noJobs": "non è configurata alcuna pianificazione di sincronizzazione, verifica o eliminazione." + }, + "monitorLinks": { + "groups": { + "cluster": "cluster", + "standalone": "nodi autonomi", + "pbs": "Server di backup Proxmox" + }, + "thisNode": "questo nodo", + "checking": "Controllo dei nodi…", + "loadFailed": "impossibile caricare l'elenco dei nodi.", + "empty": "ancora nessun altro nodo.Aggiungine uno per aprire il suo Monitor da qui.", + "noResponse": "Nessuna risposta", + "pbsInterface": "interfaccia PBS", + "openNewTab": "apri in una nuova scheda", + "manage": "aggiungi o gestisci nodi...", + "dialogTitle": "nodi collegati", + "dialogDescription": "i nodi del cluster e i server di backup Proxmox configurati vengono rilevati automaticamente.Ogni monitor mantiene il proprio accesso.", + "addTitle": "aggiungi un nodo", + "name": "Nome", + "address": "monitora l'indirizzo", + "type": "digita", + "add": "aggiungi", + "save": "salva", + "remove": "rimuovere", + "visible": "visibile", + "autoAddress": "automatico", + "detected": "rilevato", + "added": "aggiunto", + "saveFailed": "impossibile salvare l'elenco dei nodi.", + "backTo": "torna a {name}", + "cameFromTitle": "aggiungi il nodo Monitor?", + "cameFromBody": "aggiungi {name} ai nodi collegati di questo monitor.", + "notNow": "Non ora" } } diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index d60c1806..90a1f595 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -8,7 +8,8 @@ "unknown": "Desconhecido", "notAvailable": "N/D", "serverOffline": "Servidor off-line", - "supportProject": "Apoie e contribua para o projeto" + "supportProject": "Apoie e contribua para o projeto", + "descriptionPbs": "Painel do servidor de backup Proxmox" }, "common": { "notAvailable": "N/D", @@ -165,7 +166,8 @@ }, "runningCountOne": "{count} em execução", "runningCountFew": "{count} em execução", - "runningCountMany": "{count} em execução" + "runningCountMany": "{count} em execução", + "pbsVersion": "Versão PBS:" }, "storage": { "loadingTitle": "Carregando dados de armazenamento...", @@ -1831,7 +1833,8 @@ "logs": "Registros do sistema", "updates": "Atualizações do sistema", "security": "Segurança e Certificados", - "temperature": "Temperatura" + "temperature": "Temperatura", + "pbsServices": "Serviços PBS" } }, "remoteStorage": { @@ -4824,7 +4827,8 @@ "services": "Serviços PVE", "logs": "Registros do sistema", "updates": "Atualizações do sistema", - "security": "Segurança e certificados" + "security": "Segurança e certificados", + "pbsServices": "serviços PBS" }, "checks": { "cpu_usage": "Uso da CPU", @@ -7320,6 +7324,47 @@ "navigation": "Navegação PBS", "cpu": "CPU", "memory": "Memória", - "uptime": "Tempo de atividade" + "uptime": "Tempo de atividade", + "capacityUnavailable": "Capacidade não disponível.", + "datastoresUnavailable": "{count} indisponível", + "datastoresOnline": "Todos disponíveis", + "moreDatastores": "+{count} mais armazenamentos de dados", + "estimatedFull": "estimativa completa", + "estimatedFullUnknown": "Not enough data yet", + "duration": "Duração", + "noJobs": "nenhuma programação de sincronização, verificação ou remoção está configurada." + }, + "monitorLinks": { + "groups": { + "cluster": "Cluster", + "standalone": "nós autônomos", + "pbs": "Servidor de backup Proxmox" + }, + "thisNode": "Este nó", + "checking": "Verificando nós…", + "loadFailed": "não foi possível carregar a lista de nós.", + "empty": "Nenhum outro nó ainda.Adicione um para abrir seu Monitor aqui.", + "noResponse": "Sem resposta", + "pbsInterface": "interface PBS", + "openNewTab": "abrir em uma nova guia", + "manage": "Adicionar ou gerenciar nós…", + "dialogTitle": "nós vinculados", + "dialogDescription": "os nós do cluster e os servidores de backup Proxmox configurados são detectados automaticamente.Cada monitor mantém seu próprio login.", + "addTitle": "Adicionar um nó", + "name": "Nome", + "address": "endereço do monitor", + "type": "Tipo", + "add": "Adicionar", + "save": "Salvar", + "remove": "Remover", + "visible": "Visível", + "autoAddress": "Automático", + "detected": "Detectado", + "added": "Adicionado", + "saveFailed": "não foi possível salvar a lista de nós.", + "backTo": "Voltar para {name}", + "cameFromTitle": "Adicionar nó Monitor?", + "cameFromBody": "Adicione {name} aos nós vinculados deste Monitor.", + "notNow": "agora não" } } diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 692fcce6..34a6107a 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -7,7 +7,8 @@ "unknown": "Neznáme", "notAvailable": "Nedostupné", "serverOffline": "Server je offline", - "supportProject": "Podporte projekt alebo prispejte vlastnými úpravami" + "supportProject": "Podporte projekt alebo prispejte vlastnými úpravami", + "descriptionPbs": "Proxmox Backup Server Dashboard" }, "common": { "notAvailable": "Nedostupné", @@ -164,7 +165,8 @@ "errors": { "serverUnavailableTitle": "Server Monitora nie je dostupný", "serverUnavailableDescription": "Nedá sa pripojiť k serveru Monitora. Skontrolujte, či služba beží, a skúste to znova." - } + }, + "pbsVersion": "Verzia PBS:" }, "storage": { "loadingTitle": "Načítavam úložiská...", @@ -1830,7 +1832,8 @@ "logs": "Systémové záznamy", "updates": "Aktualizácie systému", "security": "Bezpečnosť a certifikáty", - "temperature": "Teplota" + "temperature": "Teplota", + "pbsServices": "Služby PBS" } }, "remoteStorage": { @@ -4823,7 +4826,8 @@ "services": "Služby PVE", "logs": "Systémové záznamy", "updates": "Aktualizácie systému", - "security": "Bezpečnosť a certifikáty" + "security": "Bezpečnosť a certifikáty", + "pbsServices": "Služby PBS" }, "checks": { "cpu_usage": "Využitie CPU", @@ -7319,6 +7323,47 @@ "navigation": "Navigácia PBS", "cpu": "CPU", "memory": "Pamäť", - "uptime": "Čas prevádzky" + "uptime": "Čas prevádzky", + "capacityUnavailable": "Kapacita nie je k dispozícii.", + "datastoresUnavailable": "{count} nie je k dispozícii", + "datastoresOnline": "Všetky dostupné", + "moreDatastores": "+{count} ďalších dátových úložísk", + "estimatedFull": "Odhadovaný plný", + "estimatedFullUnknown": "Zatiaľ nie je dostatok údajov", + "duration": "Trvanie", + "noJobs": "Nie sú nakonfigurované žiadne plány synchronizácie, overovania ani odstraňovania." + }, + "monitorLinks": { + "groups": { + "cluster": "Cluster", + "standalone": "Samostatné uzly", + "pbs": "Záložný server Proxmox" + }, + "thisNode": "Tento uzol", + "checking": "Kontrolujú sa uzly…", + "loadFailed": "Nepodarilo sa načítať zoznam uzlov.", + "empty": "Zatiaľ žiadne ďalšie uzly.Pridajte jeden, aby ste odtiaľto otvorili jeho monitor.", + "noResponse": "Žiadna odpoveď", + "pbsInterface": "rozhranie PBS", + "openNewTab": "Otvoriť na novej karte", + "manage": "Pridať alebo spravovať uzly…", + "dialogTitle": "Prepojené uzly", + "dialogDescription": "Uzly klastra a nakonfigurované záložné servery Proxmox sa detegujú automaticky.Každý monitor si uchováva svoje vlastné prihlásenie.", + "addTitle": "Pridať uzol", + "name": "Meno", + "address": "Adresa monitora", + "type": "Typ", + "add": "Pridať", + "save": "Uložiť", + "remove": "Odstrániť", + "visible": "Viditeľné", + "autoAddress": "Automaticky", + "detected": "Zistené", + "added": "Pridané", + "saveFailed": "Nepodarilo sa uložiť zoznam uzlov.", + "backTo": "Späť na {name}", + "cameFromTitle": "Pridať uzol monitora?", + "cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.", + "notNow": "Teraz nie" } } diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 5a74449a..20df8611 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -8,7 +8,8 @@ "unknown": "Okänd", "notAvailable": "N/A", "serverOffline": "Servern är frånkopplad", - "supportProject": "Stöd projektet och bidra" + "supportProject": "Stöd projektet och bidra", + "descriptionPbs": "Proxmox Backup Server Dashboard" }, "common": { "notAvailable": "N/A", @@ -165,7 +166,8 @@ }, "runningCountOne": "{count} körs", "runningCountFew": "{count} körs", - "runningCountMany": "{count} körs" + "runningCountMany": "{count} körs", + "pbsVersion": "PBS-version:" }, "storage": { "loadingTitle": "Laddar lagringsdata...", @@ -1831,7 +1833,8 @@ "logs": "Systemloggar", "updates": "Systemuppdateringar", "security": "Säkerhet och certifikat", - "temperature": "Temperatur" + "temperature": "Temperatur", + "pbsServices": "PBS Services" } }, "remoteStorage": { @@ -4824,7 +4827,8 @@ "services": "PVE-tjänster", "logs": "Systemloggar", "updates": "Systemuppdateringar", - "security": "Säkerhet & certifikat" + "security": "Säkerhet & certifikat", + "pbsServices": "PBS-tjänster" }, "checks": { "cpu_usage": "CPU-användning", @@ -7320,6 +7324,47 @@ "navigation": "PBS-navigering", "cpu": "CPU", "memory": "Minne", - "uptime": "Drifttid" + "uptime": "Drifttid", + "capacityUnavailable": "Kapacitet inte tillgänglig.", + "datastoresUnavailable": "{count} inte tillgänglig", + "datastoresOnline": "Alla tillgängliga", + "moreDatastores": "+{count} fler databutiker", + "estimatedFull": "Beräknad full", + "estimatedFullUnknown": "Inte tillräckligt med data ännu", + "duration": "Varaktighet", + "noJobs": "Inga synkroniserings-, verifierings- eller beskärningsscheman är konfigurerade." + }, + "monitorLinks": { + "groups": { + "cluster": "Kluster", + "standalone": "Fristående noder", + "pbs": "Proxmox Backup Server" + }, + "thisNode": "Denna nod", + "checking": "Kontrollerar noder...", + "loadFailed": "Kunde inte ladda nodlistan.", + "empty": "Inga andra noder än.Lägg till en för att öppna monitorn härifrån.", + "noResponse": "Inget svar", + "pbsInterface": "PBS-gränssnitt", + "openNewTab": "Öppna i en ny flik", + "manage": "Lägg till eller hantera noder...", + "dialogTitle": "Länkade noder", + "dialogDescription": "Klusternoder och konfigurerade Proxmox Backup-servrar upptäcks automatiskt.Varje bildskärm behåller sin egen inloggning.", + "addTitle": "Lägg till en nod", + "name": "Namn", + "address": "Övervaka adress", + "type": "Typ", + "add": "Lägg till", + "save": "Spara", + "remove": "Ta bort", + "visible": "Synlig", + "autoAddress": "Automatisk", + "detected": "Upptäckt", + "added": "Tillagd", + "saveFailed": "Kunde inte spara nodlistan.", + "backTo": "Tillbaka till {name}", + "cameFromTitle": "Lägg till monitornod?", + "cameFromBody": "Lägg till {name} till de länkade noderna på denna monitor.", + "notNow": "Inte nu" } } From 6157f34ced40c214eaf2a88cafd0ffad162ec902 Mon Sep 17 00:00:00 2001 From: ProxMenuxBot Date: Sat, 17 Oct 2026 16:21:00 +0000 Subject: [PATCH 23/43] chore(lang): auto-rebuild translation cache Source: 98ae1f3 Triggered by: push --- lang/de.json | 18 ++++++++++++++++++ lang/es.json | 1 + lang/fr.json | 18 ++++++++++++++++++ lang/it.json | 18 ++++++++++++++++++ lang/pt.json | 18 ++++++++++++++++++ lang/sk.json | 18 ++++++++++++++++++ lang/sv.json | 16 ++++++++++++++++ 7 files changed, 107 insertions(+) diff --git a/lang/de.json b/lang/de.json index 8d643f1a..2a460ed1 100644 --- a/lang/de.json +++ b/lang/de.json @@ -37,6 +37,7 @@ "/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (Proxmox-Standard-vzdump-Pfad)", "/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (Proxmox-Standard)", "1777 = sticky bit + rwx for all. No shared group needed.": "1777 = Sticky Bit + RWX für alle. Keine gemeinsame Gruppe erforderlich.", + "====== PBS UPDATE COMPLETED ======": "====== PBS-UPDATE ABGESCHLOSSEN ======", "====== PVE UPDATE COMPLETED ======": "====== PVE-UPDATE ABGESCHLOSSEN ======", ">> Create a new backup job <<": ">> Einen neuen Sicherungsauftrag erstellen <<", ">> Save and return <<": ">> Speichern und zurück <<", @@ -87,6 +88,7 @@ "A multi-container application is not recreated: its containers are updated together.": "Eine Multi-Container-Anwendung wird nicht neu erstellt: Ihre Container werden zusammen aktualisiert.", "A new ProxMenux version is available:": "Eine neue ProxMenux-Version ist verfügbar:", "A new kernel is staged for the next boot:": "Ein neuer Kernel wird für den nächsten Start bereitgestellt:", + "A newer kernel is installed. Reboot before the next backup window to use it.": "Ein neuerer Kernel ist installiert. Starten Sie vor dem nächsten Backup-Fenster einen Neustart, um es zu verwenden.", "A newer version is available:": "Eine neuere Version ist verfügbar:", "A pending operation exists for": "Eine ausstehende operation existiert für", "A pending stack assembly already exists; it is not overwritten": "Eine ausstehende Stapelanordnung existiert bereits; sie wird nicht überschrieben", @@ -169,6 +171,7 @@ "APT language configuration updated": "APT-Sprachkonfiguration aktualisiert", "APT language downloads restored": "APT-Sprachdownloads wiederhergestellt", "APT package lists updated": "APT-Paketlisten aktualisiert", + "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "APT-Aktualisierung fehlgeschlagen. Überprüfen Sie das PBS-Abonnement und die Repositorys unter Administration > Repositorys. Protokoll:", "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "APT-Aktualisierung fehlgeschlagen. Prüfen Sie das PBS-Abonnement und die Repositories in der nativen PBS-Oberfläche; es wurden keine Paketquellen geändert.", "APT update failed. Check repository access; NVIDIA installation stopped.": "APT-Update fehlgeschlagen. Überprüfen Sie den Repository-Zugriff. Die Installation von NVIDIA wurde gestoppt.", "Aborted": "Abgebrochen", @@ -538,6 +541,7 @@ "Available Borg targets:": "Verfügbare Borg-Ziele:", "Available Disks on Host": "Verfügbare Festplatten auf dem Host", "Available ISO Images": "Verfügbare ISO-Images", + "Available PBS Version": "Verfügbare PBS-Version", "Available PBS repositories:": "Verfügbare PBS-Repositories:", "Available PVE Version": "Verfügbare PVE-Version", "Available Shares:": "Verfügbare Aktien:", @@ -1520,6 +1524,7 @@ "Current NVIDIA inventory resolved: a refresh is required": "Aktuelles NVIDIA-Inventar behoben: ein Refresh ist required", "Current NVIDIA inventory resolved: no refresh is required": "Aktuelles NVIDIA-Inventar behoben: kein Refresh ist required", "Current Network Configuration": "Aktuelle Netzwerkkonfiguration", + "Current PBS Version": "Aktuelle PBS-Version", "Current PVE Version": "Aktuelle PVE-Version", "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Aktuelle ProxMenux-Hostskripte registrieren Remote-Freigaben mithilfe von pvesm als Proxmox-Speicher.", "Current ProxMenux version:": "Aktuelle ProxMenux-Version:", @@ -1685,6 +1690,7 @@ "Detected Coral hardware:": "Erkannte Coral-Hardware:", "Detected GPU(s):": "Erkannte GPU(s):", "Detected Intel CPU": "Intel-CPU erkannt", + "Detected Proxmox Backup Server": "Proxmox-Sicherungsserver erkannt", "Detected Proxmox VE": "Proxmox VE erkannt", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Proxmox VE 8.x erkannt – Ceph Squid für Debian installieren", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Proxmox VE 9.x erkannt – Ceph Squid für Debian Trixie installieren", @@ -1845,6 +1851,7 @@ "Do you want to proceed with the system update?": "Möchten Sie mit der Systemaktualisierung fortfahren?", "Do you want to proceed?": "Möchten Sie fortfahren?", "Do you want to reboot now?": "Möchten Sie jetzt neu starten?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Möchten Sie das Proxmox-Abonnementbanner von der Proxmox Backup Server-Weboberfläche entfernen?", "Do you want to remove the stale entry from vfio.conf and continue?": "Möchten Sie den veralteten Eintrag aus vfio.conf entfernen und fortfahren?", "Do you want to remove the stale entry from vfio.conf?": "Möchten Sie den veralteten Eintrag aus vfio.conf entfernen?", "Do you want to restart the network service now to apply changes?": "Möchten Sie den Netzwerkdienst jetzt neu starten, um die Änderungen zu übernehmen?", @@ -2270,6 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Das Klonen des log2ram-Repositorys ist fehlgeschlagen. Überprüfen Sie /tmp/log2ram_install.log", "Failed to configure EFI disk": "Die EFI-Festplatte konnte nicht konfiguriert werden", "Failed to configure IOMMU automatically.": "IOMMU konnte nicht automatisch konfiguriert werden.", + "Failed to configure Proxmox Backup Server repositories": "Proxmox Backup Server-Repositorys konnten nicht konfiguriert werden", "Failed to configure Proxmox repositories": "Proxmox-Repositorys konnten nicht konfiguriert werden", "Failed to configure TPM device in VM": "Das TPM-Gerät konnte in der VM nicht konfiguriert werden", "Failed to configure repositories.": "Die Repositorys konnten nicht konfiguriert werden.", @@ -4157,6 +4165,7 @@ "No active Proxmox storage accepts OCI template archives.": "Kein aktiver Proxmox-Speicher akzeptiert OCI-Vorlagenarchive.", "No active exports or showmount not available": "Keine aktiven Exporte oder Showmount nicht verfügbar", "No active session": "Keine aktive Sitzung", + "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Kein aktives Abonnement und kein verwendbares PBS-Repository. Im nicht interaktiven Modus können APT-Quellen nicht geändert werden. Konfigurieren Sie sie unter Administration > Repositorys.", "No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Kein aktives Abonnement und kein nutzbares PVE-Repository. Im nicht interaktiven Modus können APT-Quellen nicht geändert werden. Konfigurieren Sie sie unter Knoten > Updates > Repositorys.", "No additional GPU can be added.": "Es kann keine zusätzliche GPU hinzugefügt werden.", "No additional device needs to be added.": "Es muss kein zusätzliches Gerät hinzugefügt werden.", @@ -4874,7 +4883,11 @@ "Proxmology logo applied": "Proxmology-Logo angewendet", "Proxmox 9 system update allready": "Proxmox 9 Systemupdate bereits", "Proxmox APT repositories configured": "Proxmox APT-Repositorys konfiguriert", + "Proxmox Backup Server": "Proxmox Sicherungsserver", "Proxmox Backup Server (PBS) destinations": "Proxmox Backup Server (PBS)-Ziele", + "Proxmox Backup Server APT repositories configured": "Proxmox Backup Server APT-Repositorys konfiguriert", + "Proxmox Backup Server Update": "Proxmox Backup-Server-Update", + "Proxmox Backup Server update completed.": "Aktualisierung des Proxmox-Sicherungsservers abgeschlossen.", "Proxmox CIFS Storage Status:": "Proxmox CIFS-Speicherstatus:", "Proxmox NFS Storage Status:": "Proxmox NFS-Speicherstatus:", "Proxmox System Update": "Proxmox-Systemupdate", @@ -5234,6 +5247,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "Repository-Helfer nicht verfügbar. Die Installation von NVIDIA wurde gestoppt.", "Repository policy returned an unexpected result.": "Die Repository-Richtlinie hat ein unerwartetes Ergebnis zurückgegeben.", "Repository ready.": "Repository bereit.", + "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.": "Repository-Quellen geändert, aber die Aktualisierung der APT-Paketliste ist fehlgeschlagen. Betrieb gestoppt;Quellen wurden nicht zurückgesetzt. Überprüfen Sie „Administration > Repositorys“ und wiederholen Sie apt-get update, bevor Sie fortfahren.", "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.": "Die Repository-Quellen wurden geändert, aber die Aktualisierung der APT-Paketliste ist fehlgeschlagen. Vorgang gestoppt; die Quellen wurden nicht zurückgesetzt. Überprüfen Sie Knoten > Updates > Repositorys und wiederholen Sie apt-get update, bevor Sie fortfahren.", "Repository switch declined; no APT source changed.": "Repository-Wechsel abgelehnt; es wurde keine APT-Quelle geändert.", "Repository:": "Repository:", @@ -6783,6 +6797,7 @@ "This CIFS share is mounted with restrictive permissions.": "Diese CIFS-Freigabe wird mit restriktiven Berechtigungen gemountet.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Diese GPU gilt als inkompatibel mit GPU-Passthrough zu einer VM in ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Diese NFS-Freigabe ist vollständig eingeschränkt – selbst der Host-Root kann nicht darauf schreiben.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Dieser Proxmox-Sicherungsserver hat kein aktives Abonnement. Wechseln Sie zum Repository ohne Abonnement? Die unzugängliche Enterprise PBS-Quelle wird deaktiviert.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Für diese VM sind zusätzliche Installationsschritte erforderlich. Weitere Informationen finden Sie im Installationshandbuch unter:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Dieses Konto ist für {registry}. Geben Sie ein Bild aus dieser Registrierung ein.", "This action will:": "Diese Aktion wird:", @@ -6866,6 +6881,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Dieses Skript kann nicht über das Proxmox-Webterminal ausgeführt werden.", "This script must be run as root.": "Dieses Skript muss als Root ausgeführt werden.", "This script must be run on a Proxmox host.": "Dieses Skript muss auf einem Proxmox-Host ausgeführt werden.", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Dieses Skript wendet die folgenden Optimierungen und erweiterten Anpassungen auf Ihren Proxmox-Sicherungsserver an", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Dieses Skript wendet die folgenden Optimierungen und erweiterten Anpassungen auf Ihren Proxmox VE-Server an", "This script will update your Proxmox VE system with advanced options:": "Dieses Skript aktualisiert Ihr Proxmox VE-System mit erweiterten Optionen:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Diese Sitzung wird im Monitor-Terminal ausgeführt. Wenn Sie es von hier aus ausführen, wird die Verbindung während der Installation unterbrochen und der Switch bleibt in einem defekten Zustand.", @@ -7192,6 +7208,7 @@ "Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Upgrade-Assistent: Proxmox VE 8 → 9 (Trixie)", "Upgrade canceled by user": "Upgrade vom Benutzer abgebrochen", "Upgrade one node at a time": "Aktualisieren Sie jeweils einen Knoten", + "Upgrade the system, offering the": "Aktualisieren Sie das System und bieten Sie das an", "Upgrade to Proxmox VE 9": "Upgrade auf Proxmox VE 9", "Upgrading NVIDIA utils (Arch)...": "NVIDIA-Dienstprogramme werden aktualisiert (Arch)...", "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Eine verschlüsselte Kopie des Schlüssels auf PBS hochladen, damit Sie ihn auf einem neu installierten Host mit nur einer Passphrase wiederherstellen können?", @@ -8111,6 +8128,7 @@ "was": "war", "was installed, but the kernel reports:": "wurde installiert, aber der Kernel meldet:", "when finished": "wenn fertig", + "when the server has no subscription (you will be asked)": "Wenn der Server kein Abonnement hat (Sie werden gefragt)", "will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "Bindet die GPU beim nächsten Neustart erneut an vfio-pci, wodurch der zu installierende Treiber beschädigt wird.", "wipefs failed on": "Wipefs sind fehlgeschlagen", "with": "mit", diff --git a/lang/es.json b/lang/es.json index c0605465..92976bfb 100644 --- a/lang/es.json +++ b/lang/es.json @@ -4874,6 +4874,7 @@ "Proxmology logo applied": "Logotipo de Proxmology aplicado", "Proxmox 9 system update allready": "Actualización del sistema Proxmox 9 ya", "Proxmox APT repositories configured": "Repositorios Proxmox APT configurados", + "Proxmox Backup Server": "Servidor de respaldo Proxmox", "Proxmox Backup Server (PBS) destinations": "Destinos de Proxmox Backup Server (PBS)", "Proxmox Backup Server APT repositories configured": "Repositorios APT de Proxmox Backup Server configurados", "Proxmox Backup Server Update": "Actualización de Proxmox Backup Server", diff --git a/lang/fr.json b/lang/fr.json index 2d3be751..94ee253c 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -37,6 +37,7 @@ "/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (chemin vzdump par défaut de Proxmox)", "/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (Proxmox par défaut)", "1777 = sticky bit + rwx for all. No shared group needed.": "1777 = bit collant + rwx pour tous. Aucun groupe partagé n’est nécessaire.", + "====== PBS UPDATE COMPLETED ======": "====== MISE À JOUR PBS TERMINÉE ======", "====== PVE UPDATE COMPLETED ======": "====== MISE À JOUR PVE TERMINÉE ======", ">> Create a new backup job <<": ">> Créer une nouvelle tâche de sauvegarde <<", ">> Save and return <<": ">> Enregistrer et retourner <<", @@ -87,6 +88,7 @@ "A multi-container application is not recreated: its containers are updated together.": "Une application multi-conteneurs n'est pas recréée : ses conteneurs sont mis à jour ensemble.", "A new ProxMenux version is available:": "Une nouvelle version de ProxMenux est disponible :", "A new kernel is staged for the next boot:": "Un nouveau noyau est préparé pour le prochain démarrage :", + "A newer kernel is installed. Reboot before the next backup window to use it.": "Un noyau plus récent est installé. Redémarrez avant la prochaine fenêtre de sauvegarde pour l'utiliser.", "A newer version is available:": "Une version plus récente est disponible :", "A pending operation exists for": "Un opera en attente existe pour", "A pending stack assembly already exists; it is not overwritten": "Une pile en attente existe déjà; elle n'est pas écrasée", @@ -169,6 +171,7 @@ "APT language configuration updated": "Configuration du langage APT mise à jour", "APT language downloads restored": "Téléchargements de langues APT restaurés", "APT package lists updated": "Listes de packages APT mises à jour", + "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "L’actualisation APT a échoué. Vérifiez l'abonnement PBS et les référentiels dans Administration > Référentiels. Enregistrer:", "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Échec de l'actualisation APT. Vérifiez l'abonnement et les dépôts de PBS dans son interface native ; aucune source n'a été modifiée.", "APT update failed. Check repository access; NVIDIA installation stopped.": "La mise à jour APT a échoué. Vérifiez l'accès au référentiel ; l'installation de NVIDIA s'est arrêtée.", "Aborted": "Avorté", @@ -538,6 +541,7 @@ "Available Borg targets:": "Cibles Borg disponibles :", "Available Disks on Host": "Disques disponibles sur l'hôte", "Available ISO Images": "Images ISO disponibles", + "Available PBS Version": "version PBS disponible", "Available PBS repositories:": "Dépôts PBS disponibles :", "Available PVE Version": "Version PVE disponible", "Available Shares:": "Actions disponibles :", @@ -1520,6 +1524,7 @@ "Current NVIDIA inventory resolved: a refresh is required": "L'inventaire actuel de NVIDIA résolu: un rafraîchissement est required", "Current NVIDIA inventory resolved: no refresh is required": "L'inventaire actuel de NVIDIA résolu: aucun rafraîchissement n'est required", "Current Network Configuration": "Configuration réseau actuelle", + "Current PBS Version": "version actuelle de PBS", "Current PVE Version": "Version PVE actuelle", "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Les scripts hôtes ProxMenux actuels enregistrent les partages distants en tant que stockages Proxmox à l'aide de pvesm.", "Current ProxMenux version:": "Version actuelle de ProxMenux :", @@ -1685,6 +1690,7 @@ "Detected Coral hardware:": "Matériel Coral détecté :", "Detected GPU(s):": "GPU(s) détecté(s) :", "Detected Intel CPU": "Processeur Intel détecté", + "Detected Proxmox Backup Server": "Serveur de sauvegarde Proxmox détecté", "Detected Proxmox VE": "Proxmox VE détecté", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Proxmox VE 8.x détecté - Installation de Ceph Squid pour Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Proxmox VE 9.x détecté - Installation de Ceph Squid pour Debian Trixie", @@ -1845,6 +1851,7 @@ "Do you want to proceed with the system update?": "Voulez-vous procéder à la mise à jour du système ?", "Do you want to proceed?": "Voulez-vous continuer ?", "Do you want to reboot now?": "Voulez-vous redémarrer maintenant ?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Voulez-vous supprimer la bannière d'abonnement Proxmox de l'interface Web du serveur de sauvegarde Proxmox ?", "Do you want to remove the stale entry from vfio.conf and continue?": "Voulez-vous supprimer l'entrée obsolète de vfio.conf et continuer ?", "Do you want to remove the stale entry from vfio.conf?": "Voulez-vous supprimer l'entrée obsolète de vfio.conf ?", "Do you want to restart the network service now to apply changes?": "Voulez-vous redémarrer le service réseau maintenant pour appliquer les modifications ?", @@ -2270,6 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Échec du clonage du référentiel log2ram. Vérifiez /tmp/log2ram_install.log", "Failed to configure EFI disk": "Échec de la configuration du disque EFI", "Failed to configure IOMMU automatically.": "Échec de la configuration automatique d'IOMMU.", + "Failed to configure Proxmox Backup Server repositories": "Échec de la configuration des référentiels du serveur de sauvegarde Proxmox", "Failed to configure Proxmox repositories": "Échec de la configuration des référentiels Proxmox", "Failed to configure TPM device in VM": "Échec de la configuration du périphérique TPM dans la VM", "Failed to configure repositories.": "Échec de la configuration des référentiels.", @@ -4157,6 +4165,7 @@ "No active Proxmox storage accepts OCI template archives.": "Aucun stockage Proxmox actif n'accepte les archives de modèles OCI.", "No active exports or showmount not available": "Aucune exportation active ou showmount non disponible", "No active session": "Aucune session active", + "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Aucun abonnement actif et aucun référentiel PBS utilisable. Le mode non interactif ne peut pas modifier les sources APT ;configurez-les dans Administration > Dépôts.", "No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Aucun abonnement actif et aucun référentiel PVE utilisable. Le mode non interactif ne peut pas modifier les sources APT ; configurez-les dans Nœud > Mises à jour > Dépôts.", "No additional GPU can be added.": "Aucun GPU supplémentaire ne peut être ajouté.", "No additional device needs to be added.": "Aucun appareil supplémentaire ne doit être ajouté.", @@ -4874,7 +4883,11 @@ "Proxmology logo applied": "Logo Proxmologie appliqué", "Proxmox 9 system update allready": "La mise à jour du système Proxmox 9 est déjà terminée", "Proxmox APT repositories configured": "Dépôts Proxmox APT configurés", + "Proxmox Backup Server": "Serveur de sauvegarde Proxmox", "Proxmox Backup Server (PBS) destinations": "Destinations Proxmox Backup Server (PBS)", + "Proxmox Backup Server APT repositories configured": "Dépôts APT du serveur de sauvegarde Proxmox configurés", + "Proxmox Backup Server Update": "Mise à jour du serveur de sauvegarde Proxmox", + "Proxmox Backup Server update completed.": "Mise à jour du serveur de sauvegarde Proxmox terminée.", "Proxmox CIFS Storage Status:": "État de stockage Proxmox CIFS :", "Proxmox NFS Storage Status:": "Statut de stockage Proxmox NFS :", "Proxmox System Update": "Mise à jour du système Proxmox", @@ -5234,6 +5247,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "Assistant du référentiel indisponible. L'installation de NVIDIA s'est arrêtée.", "Repository policy returned an unexpected result.": "La stratégie du référentiel a renvoyé un résultat inattendu.", "Repository ready.": "Référentiel prêt.", + "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.": "les sources du référentiel ont été modifiées, mais l'actualisation de la liste des packages APT a échoué. Opération arrêtée ;les sources n’ont pas été annulées. Inspectez Administration > Référentiels et réessayez apt-get update avant de continuer.", "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.": "Les sources du référentiel ont été modifiées, mais l'actualisation de la liste des paquets APT a échoué. Opération arrêtée ; les sources n'ont pas été restaurées. Vérifiez Nœud > Mises à jour > Dépôts et relancez apt-get update avant de continuer.", "Repository switch declined; no APT source changed.": "Le changement de référentiel a été refusé ; aucune source APT n'a été modifiée.", "Repository:": "Dépôt:", @@ -6782,6 +6796,7 @@ "This CIFS share is mounted with restrictive permissions.": "Ce partage CIFS est monté avec des autorisations restrictives.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Ce GPU est considéré comme incompatible avec le relais GPU vers une VM dans ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Ce partage NFS est entièrement restreint : même la racine de l'hôte ne peut pas y écrire.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Ce serveur de sauvegarde Proxmox n'a pas d'abonnement actif, passer au référentiel sans abonnement ?La source Enterprise PBS inaccessible sera désactivée.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Cette VM nécessite des étapes d'installation supplémentaires. Consultez le guide d'installation à l'adresse :\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Ce compte concerne {registry}. Entrez une image de ce registre.", "This action will:": "Cette action va :", @@ -6865,6 +6880,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Ce script ne peut pas être exécuté depuis le terminal web Proxmox.", "This script must be run as root.": "Ce script doit être exécuté en tant que root.", "This script must be run on a Proxmox host.": "Ce script doit être exécuté sur un hôte Proxmox.", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Ce script appliquera les optimisations et ajustements avancés suivants à votre serveur de sauvegarde Proxmox", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Ce script appliquera les optimisations et ajustements avancés suivants à votre serveur Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Ce script mettra à jour votre système Proxmox VE avec des options avancées :", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Cette session est en cours d'exécution dans le terminal Monitor. L’exécuter à partir d’ici couperait la connexion en cours d’installation et laisserait le commutateur dans un état cassé.", @@ -7191,6 +7207,7 @@ "Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Assistant de mise à niveau : Proxmox VE 8 → 9 (Trixie)", "Upgrade canceled by user": "Mise à niveau annulée par l'utilisateur", "Upgrade one node at a time": "Mettre à niveau un nœud à la fois", + "Upgrade the system, offering the": "mettre à niveau le système, en offrant le", "Upgrade to Proxmox VE 9": "Mise à niveau vers Proxmox VE 9", "Upgrading NVIDIA utils (Arch)...": "Mise à niveau des utilitaires NVIDIA (Arch)...", "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Télécharger une copie cryptée de la clé sur PBS afin de pouvoir la récupérer sur un hôte réinstallé avec juste une phrase secrète ?", @@ -8110,6 +8127,7 @@ "was": "était", "was installed, but the kernel reports:": "a été installé, mais le noyau rapporte :", "when finished": "à la fin", + "when the server has no subscription (you will be asked)": "lorsque le serveur n'a pas d'abonnement (il vous sera demandé)", "will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "reliera le GPU à vfio-pci au prochain redémarrage, cassant ainsi le pilote qui est sur le point d'être installé.", "wipefs failed on": "les wipefs ont échoué", "with": "avec", diff --git a/lang/it.json b/lang/it.json index 13e3c788..17313126 100644 --- a/lang/it.json +++ b/lang/it.json @@ -37,6 +37,7 @@ "/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (percorso vzdump predefinito di Proxmox)", "/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (predefinito di Proxmox)", "1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx per tutti. Non è necessario alcun gruppo condiviso.", + "====== PBS UPDATE COMPLETED ======": "====== AGGIORNAMENTO PBS COMPLETATO ======", "====== PVE UPDATE COMPLETED ======": "====== AGGIORNAMENTO PVE COMPLETATO ======", ">> Create a new backup job <<": ">> Crea un nuovo job di backup <<", ">> Save and return <<": ">> Salva e torna indietro <<", @@ -87,6 +88,7 @@ "A multi-container application is not recreated: its containers are updated together.": "Un’applicazione con più container non viene ricreata: i suoi container vengono aggiornati insieme.", "A new ProxMenux version is available:": "È disponibile una nuova versione di ProxMenux:", "A new kernel is staged for the next boot:": "Un nuovo kernel è predisposto per il prossimo avvio:", + "A newer kernel is installed. Reboot before the next backup window to use it.": "è installato un kernel più recente. Riavviare prima della finestra di backup successiva per utilizzarlo.", "A newer version is available:": "È disponibile una versione più recente:", "A pending operation exists for": "Operazione in sospeso per il", "A pending stack assembly already exists; it is not overwritten": "Esiste già un assemblaggio dello stack in sospeso; non verrà sovrascritto", @@ -169,6 +171,7 @@ "APT language configuration updated": "Configurazione lingue APT aggiornata", "APT language downloads restored": "Download dei file lingua APT ripristinati", "APT package lists updated": "Elenchi pacchetti APT aggiornati", + "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "aggiornamento APT non riuscito. Controllare l'abbonamento e i repository PBS in Amministrazione > Repository. Tronco d'albero:", "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Aggiornamento degli indici APT non riuscito. Controlla l'abbonamento e i repository PBS nell'interfaccia nativa di PBS; nessuna sorgente è stata modificata.", "APT update failed. Check repository access; NVIDIA installation stopped.": "Aggiornamento APT non riuscito. Controlla l’accesso al repository; installazione NVIDIA interrotta.", "Aborted": "Interrotto", @@ -538,6 +541,7 @@ "Available Borg targets:": "Destinazioni Borg disponibili:", "Available Disks on Host": "Dischi disponibili sull'host", "Available ISO Images": "Immagini ISO disponibili", + "Available PBS Version": "versione PBS disponibile", "Available PBS repositories:": "Repository PBS disponibili:", "Available PVE Version": "Versione PVE disponibile", "Available Shares:": "Condivisioni disponibili:", @@ -1520,6 +1524,7 @@ "Current NVIDIA inventory resolved: a refresh is required": "Inventario NVIDIA attuale acquisito: è necessario un aggiornamento", "Current NVIDIA inventory resolved: no refresh is required": "Inventario NVIDIA corrente acquisito: non serve aggiornarlo", "Current Network Configuration": "Configurazione di rete corrente", + "Current PBS Version": "versione attuale di PBS", "Current PVE Version": "Versione PVE attuale", "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Gli attuali script host ProxMenux registrano le condivisioni remote come storage Proxmox con pvesm.", "Current ProxMenux version:": "Versione attuale di ProxMenux:", @@ -1685,6 +1690,7 @@ "Detected Coral hardware:": "Hardware Coral rilevato:", "Detected GPU(s):": "GPU rilevate:", "Detected Intel CPU": "Rilevata CPU Intel", + "Detected Proxmox Backup Server": "Messaggio tecnico per Proxmox e IT.Traduzione: rilevato server di backup Proxmox", "Detected Proxmox VE": "Rilevato Proxmox VE", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Rilevato Proxmox VE 8.x - Installazione di Ceph Squid per Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Rilevato Proxmox VE 9.x - Installazione di Ceph Squid per Debian Trixie", @@ -1845,6 +1851,7 @@ "Do you want to proceed with the system update?": "Vuoi procedere con l'aggiornamento del sistema?", "Do you want to proceed?": "Vuoi procedere?", "Do you want to reboot now?": "Vuoi riavviare ora?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Vuoi rimuovere il banner di abbonamento Proxmox dall'interfaccia web del server di backup Proxmox?", "Do you want to remove the stale entry from vfio.conf and continue?": "Vuoi rimuovere la voce obsoleta da vfio.conf e continuare?", "Do you want to remove the stale entry from vfio.conf?": "Vuoi rimuovere la voce obsoleta da vfio.conf?", "Do you want to restart the network service now to apply changes?": "Riavviare ora il servizio di rete per applicare le modifiche?", @@ -2270,6 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Impossibile clonare il repository log2ram. Controlla /tmp/log2ram_install.log", "Failed to configure EFI disk": "Impossibile configurare il disco EFI", "Failed to configure IOMMU automatically.": "Impossibile configurare IOMMU automaticamente.", + "Failed to configure Proxmox Backup Server repositories": "Messaggio tecnico per Proxmox e IT.Traduzione: impossibile configurare i repository del server di backup Proxmox", "Failed to configure Proxmox repositories": "Impossibile configurare i repository Proxmox", "Failed to configure TPM device in VM": "Impossibile configurare il dispositivo TPM nella VM", "Failed to configure repositories.": "Impossibile configurare i repository.", @@ -4157,6 +4165,7 @@ "No active Proxmox storage accepts OCI template archives.": "Nessuno spazio di archiviazione Proxmox attivo accetta archivi modello OCI.", "No active exports or showmount not available": "Nessuna esportazione attiva o showmount non disponibile", "No active session": "Nessuna sessione attiva", + "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "nessun abbonamento attivo e nessun repository PBS utilizzabile. La modalità non interattiva non può modificare le origini APT;configurarli in Amministrazione > Repository.", "No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Nessun abbonamento attivo e nessun repository PVE utilizzabile. La modalità non interattiva non può modificare le origini APT; configurarle in Nodo > Aggiornamenti > Repository.", "No additional GPU can be added.": "Non è possibile aggiungere alcuna GPU aggiuntiva.", "No additional device needs to be added.": "Non è necessario aggiungere alcun dispositivo aggiuntivo.", @@ -4874,7 +4883,11 @@ "Proxmology logo applied": "Logo Proxmology applicato", "Proxmox 9 system update allready": "Il sistema è già aggiornato a Proxmox 9", "Proxmox APT repositories configured": "Repository APT Proxmox configurati", + "Proxmox Backup Server": "Server di backup Proxmox", "Proxmox Backup Server (PBS) destinations": "Destinazioni Proxmox Backup Server (PBS)", + "Proxmox Backup Server APT repositories configured": "Proxmox Repository APT del server di backup configurati", + "Proxmox Backup Server Update": "Aggiornamento del server di backup Proxmox", + "Proxmox Backup Server update completed.": "aggiornamento del server di backup Proxmox completato.", "Proxmox CIFS Storage Status:": "Stato storage CIFS Proxmox:", "Proxmox NFS Storage Status:": "Stato storage NFS Proxmox:", "Proxmox System Update": "Aggiornamento sistema Proxmox", @@ -5234,6 +5247,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "Funzione di gestione dei repository non disponibile. L’installazione di NVIDIA è stata interrotta.", "Repository policy returned an unexpected result.": "La policy del repository ha restituito un risultato imprevisto.", "Repository ready.": "Repository pronto.", + "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.": "Messaggio tecnico per Proxmox e IT.Traduzione: le origini del repository sono cambiate, ma l'aggiornamento dell'elenco dei pacchetti APT non è riuscito. Operazione interrotta;le fonti non sono state ripristinate. Ispezionare Amministrazione > Repository e riprovare apt-get update prima di continuare.", "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.": "Le origini del repository sono cambiate, ma l'aggiornamento dell'elenco dei pacchetti APT non è riuscito. Operazione interrotta; le origini non sono state ripristinate. Controlla Nodo > Aggiornamenti > Repository e riprova apt-get update prima di continuare.", "Repository switch declined; no APT source changed.": "Cambio di repository rifiutato; nessuna origine APT è stata modificata.", "Repository:": "Repository:", @@ -6783,6 +6797,7 @@ "This CIFS share is mounted with restrictive permissions.": "Questa condivisione CIFS è montata con autorizzazioni restrittive.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Questa GPU è considerata incompatibile con il passthrough GPU a una VM in ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Questa condivisione NFS è completamente limitata: nemmeno l'utente root dell'host può scrivervi.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "questo server di backup Proxmox non ha un abbonamento attivo, passare al repository senza abbonamento? L'origine Enterprise PBS inaccessibile verrà disabilitata.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Questa VM richiede ulteriori passaggi di installazione; vedere la guida:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Questo account è per {registry}. Inserisci un'immagine da quel registro.", "This action will:": "Questa azione:", @@ -6866,6 +6881,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Questo script non può essere eseguito dal terminale web Proxmox.", "This script must be run as root.": "Questo script deve essere eseguito come root.", "This script must be run on a Proxmox host.": "Questo script deve essere eseguito su un host Proxmox.", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al server di backup Proxmox", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al server Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Questo script aggiornerà il sistema Proxmox VE con opzioni avanzate:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Questa sessione è in esecuzione nel terminale Monitor. Eseguirlo da qui interromperebbe la connessione a metà installazione e lascerebbe il cambio di canale incompleto.", @@ -7192,6 +7208,7 @@ "Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Assistente aggiornamento: Proxmox VE 8 → 9 (Trixie)", "Upgrade canceled by user": "Aggiornamento annullato dall'utente", "Upgrade one node at a time": "Aggiorna un nodo alla volta", + "Upgrade the system, offering the": "aggiorna il sistema, offrendo il", "Upgrade to Proxmox VE 9": "Aggiorna a Proxmox VE 9", "Upgrading NVIDIA utils (Arch)...": "Aggiornamento utilità NVIDIA (Arch)...", "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Caricare una copia crittografata della chiave su PBS per recuperarla su un host reinstallato con la sola passphrase?", @@ -8111,6 +8128,7 @@ "was": "era", "was installed, but the kernel reports:": "è stato installato, ma il kernel segnala:", "when finished": "al termine", + "when the server has no subscription (you will be asked)": "quando il server non ha abbonamento (ti verrà chiesto)", "will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "riassocerà la GPU a vfio-pci al prossimo riavvio, impedendo il funzionamento del driver che sta per essere installato.", "wipefs failed on": "wipefs non è riuscito su", "with": "con", diff --git a/lang/pt.json b/lang/pt.json index e95b4413..469dca2d 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -37,6 +37,7 @@ "/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (caminho vzdump padrão do Proxmox)", "/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (padrão Proxmox)", "1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx para todos. Nenhum grupo compartilhado é necessário.", + "====== PBS UPDATE COMPLETED ======": "====== ATUALIZAÇÃO PBS CONCLUÍDA ======", "====== PVE UPDATE COMPLETED ======": "====== ATUALIZAÇÃO PVE CONCLUÍDA ======", ">> Create a new backup job <<": ">> Crie uma nova tarefa de backup <<", ">> Save and return <<": ">> Salvar e retornar <<", @@ -87,6 +88,7 @@ "A multi-container application is not recreated: its containers are updated together.": "Um aplicativo com vários contêineres não é recriado: seus contêineres são atualizados juntos.", "A new ProxMenux version is available:": "Uma nova versão do ProxMenux está disponível:", "A new kernel is staged for the next boot:": "Um novo kernel está preparado para a próxima inicialização:", + "A newer kernel is installed. Reboot before the next backup window to use it.": "Um kernel mais recente está instalado. Reinicie antes da próxima janela de backup para usá-lo.", "A newer version is available:": "Uma versão mais recente está disponível:", "A pending operation exists for": "Existe uma operation pendente para", "A pending stack assembly already exists; it is not overwritten": "Já existe um conjunto de pilha pendente; não é substituído", @@ -169,6 +171,7 @@ "APT language configuration updated": "Configuração de idioma APT atualizada", "APT language downloads restored": "Downloads de idiomas APT restaurados", "APT package lists updated": "Listas de pacotes APT atualizadas", + "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "falha na atualização do APT.Verifique a assinatura e os repositórios do PBS em Administração > Repositórios. Registro:", "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Falha ao atualizar os índices APT. Verifique a subscrição e os repositórios PBS na interface nativa do PBS; nenhuma fonte foi alterada.", "APT update failed. Check repository access; NVIDIA installation stopped.": "Falha na atualização do APT. Verifique o acesso ao repositório; a instalação do NVIDIA foi interrompida.", "Aborted": "Abortado", @@ -538,6 +541,7 @@ "Available Borg targets:": "Alvos Borg disponíveis:", "Available Disks on Host": "Discos disponíveis no host", "Available ISO Images": "Imagens ISO disponíveis", + "Available PBS Version": "versão PBS disponível", "Available PBS repositories:": "Repositórios PBS disponíveis:", "Available PVE Version": "Versão PVE disponível", "Available Shares:": "Ações disponíveis:", @@ -1520,6 +1524,7 @@ "Current NVIDIA inventory resolved: a refresh is required": "Inventário NVIDIA atual resolvido: uma atualização é required", "Current NVIDIA inventory resolved: no refresh is required": "Inventário NVIDIA atual resolvido: nenhuma atualização é required", "Current Network Configuration": "Configuração de rede atual", + "Current PBS Version": "versão atual do PBS", "Current PVE Version": "Versão atual do PVE", "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Os scripts atuais do host ProxMenux registram compartilhamentos remotos como armazenamentos Proxmox usando pvesm.", "Current ProxMenux version:": "Versão atual do ProxMenux:", @@ -1685,6 +1690,7 @@ "Detected Coral hardware:": "Hardware Coral detectado:", "Detected GPU(s):": "GPU(s) detectada(s):", "Detected Intel CPU": "CPU Intel detectada", + "Detected Proxmox Backup Server": "Servidor de backup Proxmox detectado", "Detected Proxmox VE": "Proxmox VE detectado", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Detectado Proxmox VE 8.x - Instalando Ceph Squid para Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Detectado Proxmox VE 9.x - Instalando Ceph Squid para Debian Trixie", @@ -1845,6 +1851,7 @@ "Do you want to proceed with the system update?": "Deseja prosseguir com a atualização do sistema?", "Do you want to proceed?": "Você quer prosseguir?", "Do you want to reboot now?": "Você quer reiniciar agora?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Você deseja remover o banner de assinatura Proxmox da interface da web do Servidor de Backup Proxmox?", "Do you want to remove the stale entry from vfio.conf and continue?": "Deseja remover a entrada obsoleta do vfio.conf e continuar?", "Do you want to remove the stale entry from vfio.conf?": "Você deseja remover a entrada obsoleta do vfio.conf?", "Do you want to restart the network service now to apply changes?": "Deseja reiniciar o serviço de rede agora para aplicar as alterações?", @@ -2270,6 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Falha ao clonar o repositório log2ram. Verifique /tmp/log2ram_install.log", "Failed to configure EFI disk": "Falha ao configurar o disco EFI", "Failed to configure IOMMU automatically.": "Falha ao configurar o IOMMU automaticamente.", + "Failed to configure Proxmox Backup Server repositories": "Falha ao configurar repositórios do servidor de backup Proxmox", "Failed to configure Proxmox repositories": "Falha ao configurar repositórios Proxmox", "Failed to configure TPM device in VM": "Falha ao configurar o dispositivo TPM na VM", "Failed to configure repositories.": "Falha ao configurar repositórios.", @@ -4157,6 +4165,7 @@ "No active Proxmox storage accepts OCI template archives.": "Nenhum armazenamento Proxmox ativo aceita arquivos de modelo OCI.", "No active exports or showmount not available": "Nenhuma exportação ativa ou showmount não disponível", "No active session": "Nenhuma sessão ativa", + "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Nenhuma assinatura ativa e nenhum repositório PBS utilizável. O modo não interativo não pode alterar fontes APT;configure-os em Administração > Repositórios.", "No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Nenhuma assinatura ativa e nenhum repositório PVE utilizável. O modo não interativo não pode alterar fontes APT; configure-as em Nó > Atualizações > Repositórios.", "No additional GPU can be added.": "Nenhuma GPU adicional pode ser adicionada.", "No additional device needs to be added.": "Nenhum dispositivo adicional precisa ser adicionado.", @@ -4874,7 +4883,11 @@ "Proxmology logo applied": "Logotipo da Proxmologia aplicado", "Proxmox 9 system update allready": "Atualização do sistema Proxmox 9 já", "Proxmox APT repositories configured": "repositórios Proxmox APT configurados", + "Proxmox Backup Server": "Servidor de backup Proxmox", "Proxmox Backup Server (PBS) destinations": "Destinos Proxmox Backup Server (PBS)", + "Proxmox Backup Server APT repositories configured": "repositórios APT do servidor de backup Proxmox configurados", + "Proxmox Backup Server Update": "Atualização do servidor de backup Proxmox", + "Proxmox Backup Server update completed.": "atualização do servidor de backup Proxmox concluída.", "Proxmox CIFS Storage Status:": "Status de armazenamento CIFS do Proxmox:", "Proxmox NFS Storage Status:": "Status de armazenamento NFS do Proxmox:", "Proxmox System Update": "Atualização do sistema Proxmox", @@ -5234,6 +5247,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "Auxiliar de repositório indisponível. A instalação do NVIDIA foi interrompida.", "Repository policy returned an unexpected result.": "A política do repositório retornou um resultado inesperado.", "Repository ready.": "Repositório pronto.", + "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.": "as fontes do repositório foram alteradas, mas a atualização da lista de pacotes APT falhou. Operação interrompida;fontes não foram revertidas. Inspecione Administração > Repositórios e tente apt-get update novamente antes de continuar.", "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.": "As fontes do repositório foram alteradas, mas a atualização da lista de pacotes APT falhou. Operação interrompida; as fontes não foram revertidas. Verifique Nó > Atualizações > Repositórios e tente apt-get update novamente antes de continuar.", "Repository switch declined; no APT source changed.": "Troca de repositório recusada; nenhuma fonte APT foi alterada.", "Repository:": "Repositório:", @@ -6783,6 +6797,7 @@ "This CIFS share is mounted with restrictive permissions.": "Este compartilhamento CIFS é montado com permissões restritivas.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU é considerada incompatível com a passagem de GPU para uma VM no ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Este compartilhamento NFS é totalmente restrito — até mesmo o host root não pode gravar nele.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Este servidor de backup Proxmox não tem assinatura ativa, mudar para o repositório sem assinatura? A fonte inacessível do Enterprise PBS será desativada.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Esta VM requer etapas extras de instalação. Consulte o guia de instalação em:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Esta conta é para {registry}. Insira uma imagem desse registro.", "This action will:": "Esta ação irá:", @@ -6866,6 +6881,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Este script não pode ser executado no terminal web Proxmox.", "This script must be run as root.": "Este script deve ser executado como root.", "This script must be run on a Proxmox host.": "Este script deve ser executado em um host Proxmox.", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Este script aplicará as seguintes otimizações e ajustes avançados ao seu servidor de backup Proxmox", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Este script aplicará as seguintes otimizações e ajustes avançados ao seu servidor Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Este script atualizará seu sistema Proxmox VE com opções avançadas:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Esta sessão está sendo executada no terminal Monitor. Executá-lo a partir daqui cortaria a conexão no meio da instalação e deixaria o switch quebrado.", @@ -7192,6 +7208,7 @@ "Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Assistente de atualização: Proxmox VE 8 → 9 (Trixie)", "Upgrade canceled by user": "Atualização cancelada pelo usuário", "Upgrade one node at a time": "Atualize um nó por vez", + "Upgrade the system, offering the": "Atualize o sistema, oferecendo o", "Upgrade to Proxmox VE 9": "Atualize para Proxmox VE 9", "Upgrading NVIDIA utils (Arch)...": "Atualizando utilitários NVIDIA (Arch)...", "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Carregar uma cópia criptografada da chave para o PBS para que você possa recuperá-la em um host reinstalado com apenas uma senha?", @@ -8111,6 +8128,7 @@ "was": "era", "was installed, but the kernel reports:": "foi instalado, mas o kernel informa:", "when finished": "quando terminado", + "when the server has no subscription (you will be asked)": "quando o servidor não tem assinatura (será solicitado)", "will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "irá religar a GPU ao vfio-pci na próxima reinicialização, quebrando o driver que está prestes a ser instalado.", "wipefs failed on": "Wipefs falhou em", "with": "com", diff --git a/lang/sk.json b/lang/sk.json index c29bb126..a6e9042a 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -37,6 +37,7 @@ "/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (predvolená cesta Proxmox vzdump)", "/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (predvolené v Proxmoxe)", "1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx pre všetkých. Zdieľaná skupina nie je potrebná.", + "====== PBS UPDATE COMPLETED ======": "====== AKTUALIZÁCIA PBS DOKONČENÁ ======", "====== PVE UPDATE COMPLETED ======": "====== AKTUALIZÁCIA PVE DOKONČENÁ ======", ">> Create a new backup job <<": ">> Vytvoriť novú úlohu zálohovania <<", ">> Save and return <<": ">> Uložiť a vrátiť <<", @@ -87,6 +88,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 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á", @@ -169,6 +171,7 @@ "APT language configuration updated": "Nastavenie jazykov pre APT bolo aktualizované", "APT language downloads restored": "Sťahovanie jazykov cez APT bolo obnovené", "APT package lists updated": "Zoznamy APT balíkov aktualizované", + "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "Obnovenie APT zlyhalo. Skontrolujte predplatné a repozitáre PBS v časti Správa > Repozitáre. Denník:", "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Obnovenie indexov APT zlyhalo. Skontrolujte predplatné a repozitáre PBS v pôvodnom rozhraní PBS; žiadne zdroje sa nezmenili.", "APT update failed. Check repository access; NVIDIA installation stopped.": "Aktualizácia APT zlyhala. Skontrolujte prístup k úložisku; inštalácia NVIDIA sa zastavila.", "Aborted": "Zrušené", @@ -538,6 +541,7 @@ "Available Borg targets:": "Dostupné Borg ciele:", "Available Disks on Host": "Dostupné disky na hostovi", "Available ISO Images": "Dostupné ISO obrazy", + "Available PBS Version": "Dostupná verzia PBS", "Available PBS repositories:": "Dostupné PBS repozitáre:", "Available PVE Version": "Dostupná verzia PVE", "Available Shares:": "Dostupné zdieľania:", @@ -1520,6 +1524,7 @@ "Current NVIDIA inventory resolved: a refresh is required": "Aktuálne NVIDIA inventár vyriešený: obnovovanie je required", "Current NVIDIA inventory resolved: no refresh is required": "Aktuálne NVIDIA inventár vyriešený: žiadne obnovenie je required", "Current Network Configuration": "Aktuálne nastavenie siete", + "Current PBS Version": "Aktuálna verzia PBS", "Current PVE Version": "Aktuálna verzia PVE", "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Aktuálne host skripty ProxMenux registrujú vzdialené zdieľania ako úložiská Proxmoxu pomocou pvesm.", "Current ProxMenux version:": "Aktuálna verzia ProxMenux:", @@ -1685,6 +1690,7 @@ "Detected Coral hardware:": "Nájdený Coral hardvér:", "Detected GPU(s):": "Nájdené GPU:", "Detected Intel CPU": "Zistený Intel procesor", + "Detected Proxmox Backup Server": "Zistený záložný server Proxmox", "Detected Proxmox VE": "Zistený Proxmox VE", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Zistený Proxmox VE 8.x - inštalujem Ceph Squid pre Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Zistený Proxmox VE 9.x - inštalujem Ceph Squid pre Debian Trixie", @@ -1845,6 +1851,7 @@ "Do you want to proceed with the system update?": "Chcete pokračovať v aktualizácii systému?", "Do you want to proceed?": "Chcete pokračovať?", "Do you want to reboot now?": "Chcete reštartovať teraz?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Chcete odstrániť banner predplatného Proxmox z webového rozhrania záložného servera Proxmox?", "Do you want to remove the stale entry from vfio.conf and continue?": "Chcete odstrániť zastaraný záznam z vfio.conf a pokračovať?", "Do you want to remove the stale entry from vfio.conf?": "Chcete odstrániť zastaraný záznam z vfio.conf?", "Do you want to restart the network service now to apply changes?": "Chcete teraz reštartovať sieťovú službu, aby sa zmeny prejavili?", @@ -2270,6 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Repozitár log2ram sa nepodarilo stiahnuť. Skontrolujte /tmp/log2ram_install.log", "Failed to configure EFI disk": "EFI disk sa nepodarilo nastaviť", "Failed to configure IOMMU automatically.": "IOMMU sa nepodarilo nastaviť automaticky.", + "Failed to configure Proxmox Backup Server repositories": "Nepodarilo sa nakonfigurovať archívy záložného servera Proxmox", "Failed to configure Proxmox repositories": "Repozitáre Proxmoxu sa nepodarilo nastaviť", "Failed to configure TPM device in VM": "TPM zariadenie sa vo VM nepodarilo nastaviť", "Failed to configure repositories.": "Repozitáre sa nepodarilo nastaviť.", @@ -4157,6 +4165,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 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.", @@ -4876,7 +4885,11 @@ "Proxmology logo applied": "Logo Proxmology bolo použité", "Proxmox 9 system update allready": "Systém Proxmox 9 je už aktualizovaný", "Proxmox APT repositories configured": "APT repozitáre Proxmoxu sú nastavené", + "Proxmox Backup Server": "Záložný server Proxmox", "Proxmox Backup Server (PBS) destinations": "Ciele Proxmox Backup Servera (PBS)", + "Proxmox Backup Server APT repositories configured": "Záložné úložiská APT servera Proxmox sú nakonfigurované", + "Proxmox Backup Server Update": "Aktualizácia záložného servera Proxmox", + "Proxmox Backup Server update completed.": "Aktualizácia záložného servera Proxmox bola dokončená.", "Proxmox CIFS Storage Status:": "Stav CIFS úložiska v Proxmoxe:", "Proxmox NFS Storage Status:": "Stav NFS úložiska v Proxmoxe:", "Proxmox System Update": "Aktualizácia systému Proxmox", @@ -5236,6 +5249,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 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:", @@ -6784,6 +6798,7 @@ "This CIFS share is mounted with restrictive permissions.": "Toto CIFS zdieľanie je pripojené s obmedzujúcimi oprávneniami.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Táto GPU sa v ProxMenux považuje za nekompatibilnú s GPU passthrough do VM.", "This NFS share is fully restricted — even the host root cannot write to it.": "Toto NFS zdieľanie je úplne obmedzené - ani root na hostovi doň nevie zapisovať.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Tento záložný server Proxmox nemá žiadne aktívne predplatné, prepnúť na úložisko bez predplatného? Neprístupný zdroj Enterprise PBS bude zakázaný.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Táto VM vyžaduje ďalšie kroky inštalácie. Návod nájdete tu:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Tento účet je pre {registry}. Zadajte obrázok z tohto registra.", "This action will:": "Táto akcia vykoná:", @@ -6867,6 +6882,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Tento skript sa nedá spustiť z webového terminálu Proxmoxu.", "This script must be run as root.": "Tento skript musí byť spustený ako root.", "This script must be run on a Proxmox host.": "Tento skript musí byť spustený na Proxmox serveri.", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Tento skript použije nasledujúce optimalizácie a pokročilé úpravy na váš záložný server Proxmox", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Tento skript použije tieto optimalizácie a pokročilé úpravy na vašom serveri Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Tento skript aktualizuje váš Proxmox VE systém s pokročilými možnosťami:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Táto relácia beží v termináli Monitora. Ak by sa to spustilo odtiaľto, pripojenie sa počas inštalácie preruší a prepnutie môže zostať v zlom stave.", @@ -7193,6 +7209,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 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?", @@ -8112,6 +8129,7 @@ "was": "bolo", "was installed, but the kernel reports:": "bol nainštalovaný, ale jadro hlási:", "when finished": "po dokončení", + "when the server has no subscription (you will be asked)": "keď server nemá predplatné (budete požiadaný)", "will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "pri ďalšom reštarte znova naviaže GPU na vfio-pci, čím sa preruší ovládač, ktorý sa má nainštalovať.", "wipefs failed on": "wipefs zlyhalo na", "with": "za", diff --git a/lang/sv.json b/lang/sv.json index 377b3f16..57a2c152 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -37,6 +37,7 @@ "/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (Proxmox standard vzdump sökväg)", "/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (Proxmox standard)", "1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx för alla. Ingen delad grupp behövs.", + "====== PBS UPDATE COMPLETED ======": "====== PBS UPPDATERING AVSLUTAD ======", "====== PVE UPDATE COMPLETED ======": "====== PVE UPPDATERING AVSLUTAD ======", ">> Create a new backup job <<": ">> Skapa ett nytt säkerhetskopieringsjobb <<", ">> Save and return <<": ">> Spara och returnera <<", @@ -87,6 +88,7 @@ "A multi-container application is not recreated: its containers are updated together.": "Ett program med flera behållare återskapas inte: dess behållare uppdateras tillsammans.", "A new ProxMenux version is available:": "En ny ProxMenux-version är tillgänglig:", "A new kernel is staged for the next boot:": "En ny kärna är iscensatt för nästa uppstart:", + "A newer kernel is installed. Reboot before the next backup window to use it.": "En nyare kärna är installerad. Starta om innan nästa säkerhetskopieringsfönster för att använda det.", "A newer version is available:": "En nyare version finns tillgänglig:", "A pending operation exists for": "En väntande operation finns för", "A pending stack assembly already exists; it is not overwritten": "En väntande stack församling existerar redan; den är inte överskriven", @@ -169,6 +171,7 @@ "APT language configuration updated": "APT-språkkonfigurationen uppdaterad", "APT language downloads restored": "APT-språknedladdningar återställda", "APT package lists updated": "APT-paketlistor uppdaterade", + "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "APT-uppdatering misslyckades. Kontrollera PBS-prenumerationen och arkiven i Administration > Repositories. Logga:", "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "APT-uppdateringen misslyckades. Kontrollera PBS-prenumerationen och paketförråden i PBS eget gränssnitt; inga paketkällor ändrades.", "APT update failed. Check repository access; NVIDIA installation stopped.": "APT-uppdateringen misslyckades. Kontrollera åtkomsten till arkiven; NVIDIA-installationen stoppades.", "Aborted": "Avbruten", @@ -538,6 +541,7 @@ "Available Borg targets:": "Tillgängliga Borg-mål:", "Available Disks on Host": "Tillgängliga diskar på värd", "Available ISO Images": "Tillgängliga ISO-bilder", + "Available PBS Version": "Tillgänglig PBS-version", "Available PBS repositories:": "Tillgängliga PBS-lager:", "Available PVE Version": "Tillgänglig PVE-version", "Available Shares:": "Tillgängliga andelar:", @@ -1520,6 +1524,7 @@ "Current NVIDIA inventory resolved: a refresh is required": "Nuvarande NVIDIA inventering löst: en uppfriskning är required", "Current NVIDIA inventory resolved: no refresh is required": "Nuvarande NVIDIA inventering löst: ingen uppfriskning är required", "Current Network Configuration": "Aktuell nätverkskonfiguration", + "Current PBS Version": "Aktuell PBS-version", "Current PVE Version": "Aktuell PVE-version", "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Aktuella ProxMenux-värdskript registrerar fjärrresurser som Proxmox-lagringar med hjälp av pvesm.", "Current ProxMenux version:": "Aktuell ProxMenux-version:", @@ -1685,6 +1690,7 @@ "Detected Coral hardware:": "Upptäckt Coral-hårdvara:", "Detected GPU(s):": "Upptäckta GPU:er:", "Detected Intel CPU": "Upptäckte Intel CPU", + "Detected Proxmox Backup Server": "Upptäckte Proxmox Backup Server", "Detected Proxmox VE": "Detekterade Proxmox VE", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Upptäckte Proxmox VE 8.x - Installerar Ceph Squid för Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Upptäckte Proxmox VE 9.x - Installerar Ceph Squid för Debian Trixie", @@ -1845,6 +1851,7 @@ "Do you want to proceed with the system update?": "Vill du fortsätta med systemuppdateringen?", "Do you want to proceed?": "Vill du fortsätta?", "Do you want to reboot now?": "Vill du starta om nu?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Vill du ta bort Proxmox-prenumerationsbannern från Proxmox Backup Server-webbgränssnittet?", "Do you want to remove the stale entry from vfio.conf and continue?": "Vill du ta bort den inaktuella posten från vfio.conf och fortsätta?", "Do you want to remove the stale entry from vfio.conf?": "Vill du ta bort den inaktuella posten från vfio.conf?", "Do you want to restart the network service now to apply changes?": "Vill du starta om nätverkstjänsten nu för att tillämpa ändringar?", @@ -2270,6 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Det gick inte att klona log2ram-förrådet. Kontrollera /tmp/log2ram_install.log", "Failed to configure EFI disk": "Det gick inte att konfigurera EFI-disken", "Failed to configure IOMMU automatically.": "Det gick inte att konfigurera IOMMU automatiskt.", + "Failed to configure Proxmox Backup Server repositories": "Det gick inte att konfigurera Proxmox Backup Server-förråd", "Failed to configure Proxmox repositories": "Det gick inte att konfigurera Proxmox-förråd", "Failed to configure TPM device in VM": "Det gick inte att konfigurera TPM-enhet i virtuell dator", "Failed to configure repositories.": "Det gick inte att konfigurera förråd.", @@ -4157,6 +4165,7 @@ "No active Proxmox storage accepts OCI template archives.": "Ingen aktiv Proxmox-lagring accepterar OCI-mallarkiv.", "No active exports or showmount not available": "Inga aktiva exporter eller showmount är inte tillgängligt", "No active session": "Ingen aktiv session", + "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Inget aktivt abonnemang och inget användbart PBS-förråd. Icke-interaktivt läge kan inte ändra APT-källor;konfigurera dem i Administration > Repositories.", "No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Ingen aktiv prenumeration och inget användbart PVE-arkiv. Icke-interaktivt läge kan inte ändra APT-källor; konfigurera dem i Node > Updates > Repositories.", "No additional GPU can be added.": "Ingen ytterligare GPU kan läggas till.", "No additional device needs to be added.": "Ingen ytterligare enhet behöver läggas till.", @@ -4875,6 +4884,8 @@ "Proxmox 9 system update allready": "Proxmox 9 systemuppdatering redan", "Proxmox APT repositories configured": "Proxmox APT-förråd konfigurerade", "Proxmox Backup Server (PBS) destinations": "Destinationer för Proxmox Backup Server (PBS)", + "Proxmox Backup Server APT repositories configured": "Proxmox Backup Server APT-förråd konfigurerade", + "Proxmox Backup Server update completed.": "Proxmox Backup Server-uppdatering slutförd.", "Proxmox CIFS Storage Status:": "Proxmox CIFS-lagringsstatus:", "Proxmox NFS Storage Status:": "Proxmox NFS-lagringsstatus:", "Proxmox System Update": "Proxmox systemuppdatering", @@ -5234,6 +5245,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "Arkivhjälparen är inte tillgänglig. NVIDIA-installationen stoppades.", "Repository policy returned an unexpected result.": "Arkivpolicyn gav ett oväntat resultat.", "Repository ready.": "Förvaret klart.", + "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.": "Lagringskällorna har ändrats, men uppdateringen av APT-paketlistan misslyckades. Drift stoppad;källor rullades inte tillbaka. Inspektera Administration > Repositories och försök apt-get update igen innan du fortsätter.", "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.": "Arkivkällorna har ändrats, men uppdateringen av APT-paketlistan misslyckades. Åtgärden stoppades; källorna återställdes inte. Kontrollera Node > Updates > Repositories och försök apt-get update igen innan du fortsätter.", "Repository switch declined; no APT source changed.": "Arkivbytet avvisades; ingen APT-källa har ändrats.", "Repository:": "Förvar:", @@ -6783,6 +6795,7 @@ "This CIFS share is mounted with restrictive permissions.": "Denna CIFS-andel är monterad med restriktiva behörigheter.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Denna GPU anses vara inkompatibel med GPU-genomföring till en virtuell dator i ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Denna NFS-resurs är helt begränsad — även värdroten kan inte skriva till den.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Denna Proxmox Backup Server har ingen aktiv prenumeration, byta till arkivet utan prenumeration? Den otillgängliga Enterprise PBS-källan kommer att inaktiveras.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Denna virtuella dator kräver extra installationssteg, se installationsguiden på:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Detta konto är för {registry}. Ange en bild från det registret.", "This action will:": "Denna åtgärd kommer att:", @@ -6866,6 +6879,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Detta skript kan inte köras från Proxmox webbterminal.", "This script must be run as root.": "Detta skript måste köras som root.", "This script must be run on a Proxmox host.": "Detta skript måste köras på en Proxmox-värd.", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Detta skript kommer att tillämpa följande optimeringar och avancerade justeringar på din Proxmox Backup Server", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Detta skript kommer att tillämpa följande optimeringar och avancerade justeringar på din Proxmox VE-server", "This script will update your Proxmox VE system with advanced options:": "Detta skript kommer att uppdatera ditt Proxmox VE-system med avancerade alternativ:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Den här sessionen körs i Monitor-terminalen. Om du kör den härifrån bryts anslutningen mitt under installationen och läget förblir felaktigt.", @@ -7192,6 +7206,7 @@ "Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Uppgraderingsassistent: Proxmox VE 8 → 9 (Ttrixie)", "Upgrade canceled by user": "Uppgraderingen avbröts av användaren", "Upgrade one node at a time": "Uppgradera en nod i taget", + "Upgrade the system, offering the": "Uppgradera systemet och erbjuder", "Upgrade to Proxmox VE 9": "Uppgradera till Proxmox VE 9", "Upgrading NVIDIA utils (Arch)...": "Uppgraderar NVIDIA utils (Arch)...", "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Ladda upp en krypterad kopia av nyckeln till PBS så att du kan återställa den på en ominstallerad värd med bara en lösenfras?", @@ -8111,6 +8126,7 @@ "was": "var", "was installed, but the kernel reports:": "installerades, men kärnan rapporterar:", "when finished": "När den är färdig", + "when the server has no subscription (you will be asked)": "när servern inte har något abonnemang (du kommer att bli tillfrågad)", "will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "kommer att binda om GPU:n till vfio-pci vid nästa omstart, vilket bryter drivrutinen som är på väg att installeras.", "wipefs failed on": "wipefs misslyckades", "with": "med", From 592354b0e0e1bc8b1d842958310837bce369aa0f Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 17 Oct 2026 18:28:54 +0200 Subject: [PATCH 24/43] keep the Proxmox Backup Server name in PBS and node selector texts --- AppImage/messages/de/common.json | 6 +++--- AppImage/messages/fr/common.json | 8 ++++---- AppImage/messages/it/common.json | 8 ++++---- AppImage/messages/pt/common.json | 8 ++++---- AppImage/messages/sk/common.json | 6 +++--- AppImage/messages/sv/common.json | 4 ++-- lang/de.json | 12 ++++++------ lang/es.json | 2 +- lang/fr.json | 18 +++++++++--------- lang/it.json | 20 ++++++++++---------- lang/pt.json | 18 +++++++++--------- lang/sk.json | 18 +++++++++--------- 12 files changed, 64 insertions(+), 64 deletions(-) diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 37f27e74..eb4fc2ec 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -7338,18 +7338,18 @@ "groups": { "cluster": "Cluster", "standalone": "Eigenständige Knoten", - "pbs": "Proxmox Sicherungsserver" + "pbs": "Proxmox Backup Server" }, "thisNode": "Dieser Knoten", "checking": "Knoten werden überprüft…", "loadFailed": "Die Knotenliste konnte nicht geladen werden.", - "empty": "Noch keine anderen Knoten.Fügen Sie einen hinzu, um den Monitor von hier aus zu öffnen.", + "empty": "Noch keine anderen Knoten. Fügen Sie einen hinzu, um seinen Monitor von hier aus zu öffnen.", "noResponse": "Keine Antwort", "pbsInterface": "PBS-Schnittstelle", "openNewTab": "In einem neuen Tab öffnen", "manage": "Knoten hinzufügen oder verwalten…", "dialogTitle": "Verknüpfte Knoten", - "dialogDescription": "Clusterknoten und konfigurierte Proxmox-Sicherungsserver werden automatisch erkannt.Jeder Monitor behält seine eigene Anmeldung.", + "dialogDescription": "Clusterknoten und konfigurierte Proxmox Backup Server werden automatisch erkannt. Jeder Monitor behält seine eigene Anmeldung.", "addTitle": "Einen Knoten hinzufügen", "name": "Name", "address": "Adresse überwachen", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 2b95595a..5b30c969 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -9,7 +9,7 @@ "notAvailable": "N/A", "serverOffline": "Serveur hors ligne", "supportProject": "Soutenir et contribuer au projet", - "descriptionPbs": "Tableau de bord du serveur de sauvegarde Proxmox" + "descriptionPbs": "Tableau de bord Proxmox Backup Server" }, "common": { "notAvailable": "N/A", @@ -7338,18 +7338,18 @@ "groups": { "cluster": "Cluster", "standalone": "nœuds autonomes", - "pbs": "Serveur de sauvegarde Proxmox" + "pbs": "Proxmox Backup Server" }, "thisNode": "Ce nœud", "checking": "Vérification des nœuds…", "loadFailed": "Impossible de charger la liste des nœuds.", - "empty": "aucun autre nœud pour l'instant.Ajoutez-en un pour ouvrir son moniteur à partir d'ici.", + "empty": "Aucun autre nœud pour l'instant. Ajoutez-en un pour ouvrir son Monitor depuis ici.", "noResponse": "Pas de réponse", "pbsInterface": "interface PBS", "openNewTab": "Ouvrir dans un nouvel onglet", "manage": "Ajouter ou gérer des nœuds…", "dialogTitle": "nœuds liés", - "dialogDescription": "Les nœuds de cluster et les serveurs de sauvegarde Proxmox configurés sont détectés automatiquement.Chaque moniteur conserve sa propre connexion.", + "dialogDescription": "Les nœuds du cluster et les Proxmox Backup Server configurés sont détectés automatiquement. Chaque Monitor conserve sa propre connexion.", "addTitle": "ajouter un nœud", "name": "Nom", "address": "Adresse du moniteur", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 3a09e45d..b595a4cc 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -9,7 +9,7 @@ "notAvailable": "N/A", "serverOffline": "Server offline", "supportProject": "Sostieni e contribuisci al progetto", - "descriptionPbs": "Dashboard del server di backup Proxmox" + "descriptionPbs": "Dashboard di Proxmox Backup Server" }, "common": { "notAvailable": "N/A", @@ -7338,18 +7338,18 @@ "groups": { "cluster": "cluster", "standalone": "nodi autonomi", - "pbs": "Server di backup Proxmox" + "pbs": "Proxmox Backup Server" }, "thisNode": "questo nodo", "checking": "Controllo dei nodi…", "loadFailed": "impossibile caricare l'elenco dei nodi.", - "empty": "ancora nessun altro nodo.Aggiungine uno per aprire il suo Monitor da qui.", + "empty": "Ancora nessun altro nodo. Aggiungine uno per aprire il suo Monitor da qui.", "noResponse": "Nessuna risposta", "pbsInterface": "interfaccia PBS", "openNewTab": "apri in una nuova scheda", "manage": "aggiungi o gestisci nodi...", "dialogTitle": "nodi collegati", - "dialogDescription": "i nodi del cluster e i server di backup Proxmox configurati vengono rilevati automaticamente.Ogni monitor mantiene il proprio accesso.", + "dialogDescription": "I nodi del cluster e i Proxmox Backup Server configurati vengono rilevati automaticamente. Ogni Monitor mantiene il proprio accesso.", "addTitle": "aggiungi un nodo", "name": "Nome", "address": "monitora l'indirizzo", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 90a1f595..b64fa2d5 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -9,7 +9,7 @@ "notAvailable": "N/D", "serverOffline": "Servidor off-line", "supportProject": "Apoie e contribua para o projeto", - "descriptionPbs": "Painel do servidor de backup Proxmox" + "descriptionPbs": "Painel do Proxmox Backup Server" }, "common": { "notAvailable": "N/D", @@ -7338,18 +7338,18 @@ "groups": { "cluster": "Cluster", "standalone": "nós autônomos", - "pbs": "Servidor de backup Proxmox" + "pbs": "Proxmox Backup Server" }, "thisNode": "Este nó", "checking": "Verificando nós…", "loadFailed": "não foi possível carregar a lista de nós.", - "empty": "Nenhum outro nó ainda.Adicione um para abrir seu Monitor aqui.", + "empty": "Ainda não há outros nós. Adicione um para abrir o seu Monitor a partir daqui.", "noResponse": "Sem resposta", "pbsInterface": "interface PBS", "openNewTab": "abrir em uma nova guia", "manage": "Adicionar ou gerenciar nós…", "dialogTitle": "nós vinculados", - "dialogDescription": "os nós do cluster e os servidores de backup Proxmox configurados são detectados automaticamente.Cada monitor mantém seu próprio login.", + "dialogDescription": "Os nós do cluster e os Proxmox Backup Server configurados são detectados automaticamente. Cada Monitor mantém o seu próprio login.", "addTitle": "Adicionar um nó", "name": "Nome", "address": "endereço do monitor", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 34a6107a..4b91a2cd 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -7337,18 +7337,18 @@ "groups": { "cluster": "Cluster", "standalone": "Samostatné uzly", - "pbs": "Záložný server Proxmox" + "pbs": "Proxmox Backup Server" }, "thisNode": "Tento uzol", "checking": "Kontrolujú sa uzly…", "loadFailed": "Nepodarilo sa načítať zoznam uzlov.", - "empty": "Zatiaľ žiadne ďalšie uzly.Pridajte jeden, aby ste odtiaľto otvorili jeho monitor.", + "empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.", "noResponse": "Žiadna odpoveď", "pbsInterface": "rozhranie PBS", "openNewTab": "Otvoriť na novej karte", "manage": "Pridať alebo spravovať uzly…", "dialogTitle": "Prepojené uzly", - "dialogDescription": "Uzly klastra a nakonfigurované záložné servery Proxmox sa detegujú automaticky.Každý monitor si uchováva svoje vlastné prihlásenie.", + "dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.", "addTitle": "Pridať uzol", "name": "Meno", "address": "Adresa monitora", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 20df8611..7b8cab64 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -7343,13 +7343,13 @@ "thisNode": "Denna nod", "checking": "Kontrollerar noder...", "loadFailed": "Kunde inte ladda nodlistan.", - "empty": "Inga andra noder än.Lägg till en för att öppna monitorn härifrån.", + "empty": "Inga andra noder än. Lägg till en för att öppna dess Monitor härifrån.", "noResponse": "Inget svar", "pbsInterface": "PBS-gränssnitt", "openNewTab": "Öppna i en ny flik", "manage": "Lägg till eller hantera noder...", "dialogTitle": "Länkade noder", - "dialogDescription": "Klusternoder och konfigurerade Proxmox Backup-servrar upptäcks automatiskt.Varje bildskärm behåller sin egen inloggning.", + "dialogDescription": "Klusternoder och konfigurerade Proxmox Backup Server identifieras automatiskt. Varje Monitor behåller sin egen inloggning.", "addTitle": "Lägg till en nod", "name": "Namn", "address": "Övervaka adress", diff --git a/lang/de.json b/lang/de.json index 2a460ed1..1bc1956d 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1690,7 +1690,7 @@ "Detected Coral hardware:": "Erkannte Coral-Hardware:", "Detected GPU(s):": "Erkannte GPU(s):", "Detected Intel CPU": "Intel-CPU erkannt", - "Detected Proxmox Backup Server": "Proxmox-Sicherungsserver erkannt", + "Detected Proxmox Backup Server": "Proxmox Backup Server erkannt", "Detected Proxmox VE": "Proxmox VE erkannt", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Proxmox VE 8.x erkannt – Ceph Squid für Debian installieren", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Proxmox VE 9.x erkannt – Ceph Squid für Debian Trixie installieren", @@ -4883,11 +4883,11 @@ "Proxmology logo applied": "Proxmology-Logo angewendet", "Proxmox 9 system update allready": "Proxmox 9 Systemupdate bereits", "Proxmox APT repositories configured": "Proxmox APT-Repositorys konfiguriert", - "Proxmox Backup Server": "Proxmox Sicherungsserver", + "Proxmox Backup Server": "Proxmox Backup Server", "Proxmox Backup Server (PBS) destinations": "Proxmox Backup Server (PBS)-Ziele", "Proxmox Backup Server APT repositories configured": "Proxmox Backup Server APT-Repositorys konfiguriert", - "Proxmox Backup Server Update": "Proxmox Backup-Server-Update", - "Proxmox Backup Server update completed.": "Aktualisierung des Proxmox-Sicherungsservers abgeschlossen.", + "Proxmox Backup Server Update": "Aktualisierung von Proxmox Backup Server", + "Proxmox Backup Server update completed.": "Aktualisierung von Proxmox Backup Server abgeschlossen.", "Proxmox CIFS Storage Status:": "Proxmox CIFS-Speicherstatus:", "Proxmox NFS Storage Status:": "Proxmox NFS-Speicherstatus:", "Proxmox System Update": "Proxmox-Systemupdate", @@ -6797,7 +6797,7 @@ "This CIFS share is mounted with restrictive permissions.": "Diese CIFS-Freigabe wird mit restriktiven Berechtigungen gemountet.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Diese GPU gilt als inkompatibel mit GPU-Passthrough zu einer VM in ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Diese NFS-Freigabe ist vollständig eingeschränkt – selbst der Host-Root kann nicht darauf schreiben.", - "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Dieser Proxmox-Sicherungsserver hat kein aktives Abonnement. Wechseln Sie zum Repository ohne Abonnement? Die unzugängliche Enterprise PBS-Quelle wird deaktiviert.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Dieser Proxmox Backup Server hat kein aktives Abonnement. Zum Repository ohne Abonnement wechseln? Die nicht erreichbare Enterprise-PBS-Quelle wird deaktiviert.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Für diese VM sind zusätzliche Installationsschritte erforderlich. Weitere Informationen finden Sie im Installationshandbuch unter:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Dieses Konto ist für {registry}. Geben Sie ein Bild aus dieser Registrierung ein.", "This action will:": "Diese Aktion wird:", @@ -6881,7 +6881,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Dieses Skript kann nicht über das Proxmox-Webterminal ausgeführt werden.", "This script must be run as root.": "Dieses Skript muss als Root ausgeführt werden.", "This script must be run on a Proxmox host.": "Dieses Skript muss auf einem Proxmox-Host ausgeführt werden.", - "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Dieses Skript wendet die folgenden Optimierungen und erweiterten Anpassungen auf Ihren Proxmox-Sicherungsserver an", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Dieses Skript wendet die folgenden Optimierungen und erweiterten Anpassungen auf Ihren Proxmox Backup Server an", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Dieses Skript wendet die folgenden Optimierungen und erweiterten Anpassungen auf Ihren Proxmox VE-Server an", "This script will update your Proxmox VE system with advanced options:": "Dieses Skript aktualisiert Ihr Proxmox VE-System mit erweiterten Optionen:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Diese Sitzung wird im Monitor-Terminal ausgeführt. Wenn Sie es von hier aus ausführen, wird die Verbindung während der Installation unterbrochen und der Switch bleibt in einem defekten Zustand.", diff --git a/lang/es.json b/lang/es.json index 92976bfb..0ddda5c8 100644 --- a/lang/es.json +++ b/lang/es.json @@ -4874,7 +4874,7 @@ "Proxmology logo applied": "Logotipo de Proxmology aplicado", "Proxmox 9 system update allready": "Actualización del sistema Proxmox 9 ya", "Proxmox APT repositories configured": "Repositorios Proxmox APT configurados", - "Proxmox Backup Server": "Servidor de respaldo Proxmox", + "Proxmox Backup Server": "Proxmox Backup Server", "Proxmox Backup Server (PBS) destinations": "Destinos de Proxmox Backup Server (PBS)", "Proxmox Backup Server APT repositories configured": "Repositorios APT de Proxmox Backup Server configurados", "Proxmox Backup Server Update": "Actualización de Proxmox Backup Server", diff --git a/lang/fr.json b/lang/fr.json index 94ee253c..5fa19db0 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1690,7 +1690,7 @@ "Detected Coral hardware:": "Matériel Coral détecté :", "Detected GPU(s):": "GPU(s) détecté(s) :", "Detected Intel CPU": "Processeur Intel détecté", - "Detected Proxmox Backup Server": "Serveur de sauvegarde Proxmox détecté", + "Detected Proxmox Backup Server": "Proxmox Backup Server détecté", "Detected Proxmox VE": "Proxmox VE détecté", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Proxmox VE 8.x détecté - Installation de Ceph Squid pour Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Proxmox VE 9.x détecté - Installation de Ceph Squid pour Debian Trixie", @@ -1851,7 +1851,7 @@ "Do you want to proceed with the system update?": "Voulez-vous procéder à la mise à jour du système ?", "Do you want to proceed?": "Voulez-vous continuer ?", "Do you want to reboot now?": "Voulez-vous redémarrer maintenant ?", - "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Voulez-vous supprimer la bannière d'abonnement Proxmox de l'interface Web du serveur de sauvegarde Proxmox ?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Voulez-vous supprimer la bannière d'abonnement Proxmox de l'interface web de Proxmox Backup Server ?", "Do you want to remove the stale entry from vfio.conf and continue?": "Voulez-vous supprimer l'entrée obsolète de vfio.conf et continuer ?", "Do you want to remove the stale entry from vfio.conf?": "Voulez-vous supprimer l'entrée obsolète de vfio.conf ?", "Do you want to restart the network service now to apply changes?": "Voulez-vous redémarrer le service réseau maintenant pour appliquer les modifications ?", @@ -2277,7 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Échec du clonage du référentiel log2ram. Vérifiez /tmp/log2ram_install.log", "Failed to configure EFI disk": "Échec de la configuration du disque EFI", "Failed to configure IOMMU automatically.": "Échec de la configuration automatique d'IOMMU.", - "Failed to configure Proxmox Backup Server repositories": "Échec de la configuration des référentiels du serveur de sauvegarde Proxmox", + "Failed to configure Proxmox Backup Server repositories": "Échec de la configuration des dépôts de Proxmox Backup Server", "Failed to configure Proxmox repositories": "Échec de la configuration des référentiels Proxmox", "Failed to configure TPM device in VM": "Échec de la configuration du périphérique TPM dans la VM", "Failed to configure repositories.": "Échec de la configuration des référentiels.", @@ -4883,11 +4883,11 @@ "Proxmology logo applied": "Logo Proxmologie appliqué", "Proxmox 9 system update allready": "La mise à jour du système Proxmox 9 est déjà terminée", "Proxmox APT repositories configured": "Dépôts Proxmox APT configurés", - "Proxmox Backup Server": "Serveur de sauvegarde Proxmox", + "Proxmox Backup Server": "Proxmox Backup Server", "Proxmox Backup Server (PBS) destinations": "Destinations Proxmox Backup Server (PBS)", - "Proxmox Backup Server APT repositories configured": "Dépôts APT du serveur de sauvegarde Proxmox configurés", - "Proxmox Backup Server Update": "Mise à jour du serveur de sauvegarde Proxmox", - "Proxmox Backup Server update completed.": "Mise à jour du serveur de sauvegarde Proxmox terminée.", + "Proxmox Backup Server APT repositories configured": "Dépôts APT de Proxmox Backup Server configurés", + "Proxmox Backup Server Update": "Mise à jour de Proxmox Backup Server", + "Proxmox Backup Server update completed.": "Mise à jour de Proxmox Backup Server terminée.", "Proxmox CIFS Storage Status:": "État de stockage Proxmox CIFS :", "Proxmox NFS Storage Status:": "Statut de stockage Proxmox NFS :", "Proxmox System Update": "Mise à jour du système Proxmox", @@ -6796,7 +6796,7 @@ "This CIFS share is mounted with restrictive permissions.": "Ce partage CIFS est monté avec des autorisations restrictives.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Ce GPU est considéré comme incompatible avec le relais GPU vers une VM dans ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Ce partage NFS est entièrement restreint : même la racine de l'hôte ne peut pas y écrire.", - "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Ce serveur de sauvegarde Proxmox n'a pas d'abonnement actif, passer au référentiel sans abonnement ?La source Enterprise PBS inaccessible sera désactivée.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Ce Proxmox Backup Server n'a pas d'abonnement actif. Passer au dépôt sans abonnement ? La source Enterprise PBS inaccessible sera désactivée.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Cette VM nécessite des étapes d'installation supplémentaires. Consultez le guide d'installation à l'adresse :\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Ce compte concerne {registry}. Entrez une image de ce registre.", "This action will:": "Cette action va :", @@ -6880,7 +6880,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Ce script ne peut pas être exécuté depuis le terminal web Proxmox.", "This script must be run as root.": "Ce script doit être exécuté en tant que root.", "This script must be run on a Proxmox host.": "Ce script doit être exécuté sur un hôte Proxmox.", - "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Ce script appliquera les optimisations et ajustements avancés suivants à votre serveur de sauvegarde Proxmox", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Ce script appliquera les optimisations et réglages avancés suivants à votre Proxmox Backup Server", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Ce script appliquera les optimisations et ajustements avancés suivants à votre serveur Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Ce script mettra à jour votre système Proxmox VE avec des options avancées :", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Cette session est en cours d'exécution dans le terminal Monitor. L’exécuter à partir d’ici couperait la connexion en cours d’installation et laisserait le commutateur dans un état cassé.", diff --git a/lang/it.json b/lang/it.json index 17313126..db7b48a6 100644 --- a/lang/it.json +++ b/lang/it.json @@ -1690,7 +1690,7 @@ "Detected Coral hardware:": "Hardware Coral rilevato:", "Detected GPU(s):": "GPU rilevate:", "Detected Intel CPU": "Rilevata CPU Intel", - "Detected Proxmox Backup Server": "Messaggio tecnico per Proxmox e IT.Traduzione: rilevato server di backup Proxmox", + "Detected Proxmox Backup Server": "Proxmox Backup Server rilevato", "Detected Proxmox VE": "Rilevato Proxmox VE", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Rilevato Proxmox VE 8.x - Installazione di Ceph Squid per Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Rilevato Proxmox VE 9.x - Installazione di Ceph Squid per Debian Trixie", @@ -1851,7 +1851,7 @@ "Do you want to proceed with the system update?": "Vuoi procedere con l'aggiornamento del sistema?", "Do you want to proceed?": "Vuoi procedere?", "Do you want to reboot now?": "Vuoi riavviare ora?", - "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Vuoi rimuovere il banner di abbonamento Proxmox dall'interfaccia web del server di backup Proxmox?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Vuoi rimuovere il banner dell'abbonamento Proxmox dall'interfaccia web di Proxmox Backup Server?", "Do you want to remove the stale entry from vfio.conf and continue?": "Vuoi rimuovere la voce obsoleta da vfio.conf e continuare?", "Do you want to remove the stale entry from vfio.conf?": "Vuoi rimuovere la voce obsoleta da vfio.conf?", "Do you want to restart the network service now to apply changes?": "Riavviare ora il servizio di rete per applicare le modifiche?", @@ -2277,7 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Impossibile clonare il repository log2ram. Controlla /tmp/log2ram_install.log", "Failed to configure EFI disk": "Impossibile configurare il disco EFI", "Failed to configure IOMMU automatically.": "Impossibile configurare IOMMU automaticamente.", - "Failed to configure Proxmox Backup Server repositories": "Messaggio tecnico per Proxmox e IT.Traduzione: impossibile configurare i repository del server di backup Proxmox", + "Failed to configure Proxmox Backup Server repositories": "Impossibile configurare i repository di Proxmox Backup Server", "Failed to configure Proxmox repositories": "Impossibile configurare i repository Proxmox", "Failed to configure TPM device in VM": "Impossibile configurare il dispositivo TPM nella VM", "Failed to configure repositories.": "Impossibile configurare i repository.", @@ -4883,11 +4883,11 @@ "Proxmology logo applied": "Logo Proxmology applicato", "Proxmox 9 system update allready": "Il sistema è già aggiornato a Proxmox 9", "Proxmox APT repositories configured": "Repository APT Proxmox configurati", - "Proxmox Backup Server": "Server di backup Proxmox", + "Proxmox Backup Server": "Proxmox Backup Server", "Proxmox Backup Server (PBS) destinations": "Destinazioni Proxmox Backup Server (PBS)", - "Proxmox Backup Server APT repositories configured": "Proxmox Repository APT del server di backup configurati", - "Proxmox Backup Server Update": "Aggiornamento del server di backup Proxmox", - "Proxmox Backup Server update completed.": "aggiornamento del server di backup Proxmox completato.", + "Proxmox Backup Server APT repositories configured": "Repository APT di Proxmox Backup Server configurati", + "Proxmox Backup Server Update": "Aggiornamento di Proxmox Backup Server", + "Proxmox Backup Server update completed.": "Aggiornamento di Proxmox Backup Server completato.", "Proxmox CIFS Storage Status:": "Stato storage CIFS Proxmox:", "Proxmox NFS Storage Status:": "Stato storage NFS Proxmox:", "Proxmox System Update": "Aggiornamento sistema Proxmox", @@ -5247,7 +5247,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "Funzione di gestione dei repository non disponibile. L’installazione di NVIDIA è stata interrotta.", "Repository policy returned an unexpected result.": "La policy del repository ha restituito un risultato imprevisto.", "Repository ready.": "Repository pronto.", - "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.": "Messaggio tecnico per Proxmox e IT.Traduzione: le origini del repository sono cambiate, ma l'aggiornamento dell'elenco dei pacchetti APT non è riuscito. Operazione interrotta;le fonti non sono state ripristinate. Ispezionare Amministrazione > Repository e riprovare apt-get update prima di continuare.", + "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.": "Le sorgenti dei repository sono cambiate, ma l'aggiornamento dell'elenco dei pacchetti APT non è riuscito. Operazione interrotta; le sorgenti non sono state ripristinate. Controlla Administration > Repositories e riesegui apt-get update prima di continuare.", "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.": "Le origini del repository sono cambiate, ma l'aggiornamento dell'elenco dei pacchetti APT non è riuscito. Operazione interrotta; le origini non sono state ripristinate. Controlla Nodo > Aggiornamenti > Repository e riprova apt-get update prima di continuare.", "Repository switch declined; no APT source changed.": "Cambio di repository rifiutato; nessuna origine APT è stata modificata.", "Repository:": "Repository:", @@ -6797,7 +6797,7 @@ "This CIFS share is mounted with restrictive permissions.": "Questa condivisione CIFS è montata con autorizzazioni restrittive.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Questa GPU è considerata incompatibile con il passthrough GPU a una VM in ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Questa condivisione NFS è completamente limitata: nemmeno l'utente root dell'host può scrivervi.", - "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "questo server di backup Proxmox non ha un abbonamento attivo, passare al repository senza abbonamento? L'origine Enterprise PBS inaccessibile verrà disabilitata.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Questo Proxmox Backup Server non ha un abbonamento attivo. Passare al repository senza abbonamento? La sorgente Enterprise PBS non accessibile verrà disattivata.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Questa VM richiede ulteriori passaggi di installazione; vedere la guida:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Questo account è per {registry}. Inserisci un'immagine da quel registro.", "This action will:": "Questa azione:", @@ -6881,7 +6881,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Questo script non può essere eseguito dal terminale web Proxmox.", "This script must be run as root.": "Questo script deve essere eseguito come root.", "This script must be run on a Proxmox host.": "Questo script deve essere eseguito su un host Proxmox.", - "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al server di backup Proxmox", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al tuo Proxmox Backup Server", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al server Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Questo script aggiornerà il sistema Proxmox VE con opzioni avanzate:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Questa sessione è in esecuzione nel terminale Monitor. Eseguirlo da qui interromperebbe la connessione a metà installazione e lascerebbe il cambio di canale incompleto.", diff --git a/lang/pt.json b/lang/pt.json index 469dca2d..b7d5011d 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1690,7 +1690,7 @@ "Detected Coral hardware:": "Hardware Coral detectado:", "Detected GPU(s):": "GPU(s) detectada(s):", "Detected Intel CPU": "CPU Intel detectada", - "Detected Proxmox Backup Server": "Servidor de backup Proxmox detectado", + "Detected Proxmox Backup Server": "Proxmox Backup Server detectado", "Detected Proxmox VE": "Proxmox VE detectado", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Detectado Proxmox VE 8.x - Instalando Ceph Squid para Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Detectado Proxmox VE 9.x - Instalando Ceph Squid para Debian Trixie", @@ -1851,7 +1851,7 @@ "Do you want to proceed with the system update?": "Deseja prosseguir com a atualização do sistema?", "Do you want to proceed?": "Você quer prosseguir?", "Do you want to reboot now?": "Você quer reiniciar agora?", - "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Você deseja remover o banner de assinatura Proxmox da interface da web do Servidor de Backup Proxmox?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Deseja remover o banner de assinatura do Proxmox da interface web do Proxmox Backup Server?", "Do you want to remove the stale entry from vfio.conf and continue?": "Deseja remover a entrada obsoleta do vfio.conf e continuar?", "Do you want to remove the stale entry from vfio.conf?": "Você deseja remover a entrada obsoleta do vfio.conf?", "Do you want to restart the network service now to apply changes?": "Deseja reiniciar o serviço de rede agora para aplicar as alterações?", @@ -2277,7 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Falha ao clonar o repositório log2ram. Verifique /tmp/log2ram_install.log", "Failed to configure EFI disk": "Falha ao configurar o disco EFI", "Failed to configure IOMMU automatically.": "Falha ao configurar o IOMMU automaticamente.", - "Failed to configure Proxmox Backup Server repositories": "Falha ao configurar repositórios do servidor de backup Proxmox", + "Failed to configure Proxmox Backup Server repositories": "Falha ao configurar os repositórios do Proxmox Backup Server", "Failed to configure Proxmox repositories": "Falha ao configurar repositórios Proxmox", "Failed to configure TPM device in VM": "Falha ao configurar o dispositivo TPM na VM", "Failed to configure repositories.": "Falha ao configurar repositórios.", @@ -4883,11 +4883,11 @@ "Proxmology logo applied": "Logotipo da Proxmologia aplicado", "Proxmox 9 system update allready": "Atualização do sistema Proxmox 9 já", "Proxmox APT repositories configured": "repositórios Proxmox APT configurados", - "Proxmox Backup Server": "Servidor de backup Proxmox", + "Proxmox Backup Server": "Proxmox Backup Server", "Proxmox Backup Server (PBS) destinations": "Destinos Proxmox Backup Server (PBS)", - "Proxmox Backup Server APT repositories configured": "repositórios APT do servidor de backup Proxmox configurados", - "Proxmox Backup Server Update": "Atualização do servidor de backup Proxmox", - "Proxmox Backup Server update completed.": "atualização do servidor de backup Proxmox concluída.", + "Proxmox Backup Server APT repositories configured": "Repositórios APT do Proxmox Backup Server configurados", + "Proxmox Backup Server Update": "Atualização do Proxmox Backup Server", + "Proxmox Backup Server update completed.": "Atualização do Proxmox Backup Server concluída.", "Proxmox CIFS Storage Status:": "Status de armazenamento CIFS do Proxmox:", "Proxmox NFS Storage Status:": "Status de armazenamento NFS do Proxmox:", "Proxmox System Update": "Atualização do sistema Proxmox", @@ -6797,7 +6797,7 @@ "This CIFS share is mounted with restrictive permissions.": "Este compartilhamento CIFS é montado com permissões restritivas.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU é considerada incompatível com a passagem de GPU para uma VM no ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Este compartilhamento NFS é totalmente restrito — até mesmo o host root não pode gravar nele.", - "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Este servidor de backup Proxmox não tem assinatura ativa, mudar para o repositório sem assinatura? A fonte inacessível do Enterprise PBS será desativada.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Este Proxmox Backup Server não tem assinatura ativa. Mudar para o repositório sem assinatura? A fonte Enterprise do PBS, inacessível, será desativada.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Esta VM requer etapas extras de instalação. Consulte o guia de instalação em:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Esta conta é para {registry}. Insira uma imagem desse registro.", "This action will:": "Esta ação irá:", @@ -6881,7 +6881,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Este script não pode ser executado no terminal web Proxmox.", "This script must be run as root.": "Este script deve ser executado como root.", "This script must be run on a Proxmox host.": "Este script deve ser executado em um host Proxmox.", - "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Este script aplicará as seguintes otimizações e ajustes avançados ao seu servidor de backup Proxmox", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Este script aplicará as seguintes otimizações e ajustes avançados ao seu Proxmox Backup Server", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Este script aplicará as seguintes otimizações e ajustes avançados ao seu servidor Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Este script atualizará seu sistema Proxmox VE com opções avançadas:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Esta sessão está sendo executada no terminal Monitor. Executá-lo a partir daqui cortaria a conexão no meio da instalação e deixaria o switch quebrado.", diff --git a/lang/sk.json b/lang/sk.json index a6e9042a..ccb23c92 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -1690,7 +1690,7 @@ "Detected Coral hardware:": "Nájdený Coral hardvér:", "Detected GPU(s):": "Nájdené GPU:", "Detected Intel CPU": "Zistený Intel procesor", - "Detected Proxmox Backup Server": "Zistený záložný server Proxmox", + "Detected Proxmox Backup Server": "Zistený Proxmox Backup Server", "Detected Proxmox VE": "Zistený Proxmox VE", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Zistený Proxmox VE 8.x - inštalujem Ceph Squid pre Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Zistený Proxmox VE 9.x - inštalujem Ceph Squid pre Debian Trixie", @@ -1851,7 +1851,7 @@ "Do you want to proceed with the system update?": "Chcete pokračovať v aktualizácii systému?", "Do you want to proceed?": "Chcete pokračovať?", "Do you want to reboot now?": "Chcete reštartovať teraz?", - "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Chcete odstrániť banner predplatného Proxmox z webového rozhrania záložného servera Proxmox?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Chcete odstrániť banner predplatného Proxmox z webového rozhrania Proxmox Backup Server?", "Do you want to remove the stale entry from vfio.conf and continue?": "Chcete odstrániť zastaraný záznam z vfio.conf a pokračovať?", "Do you want to remove the stale entry from vfio.conf?": "Chcete odstrániť zastaraný záznam z vfio.conf?", "Do you want to restart the network service now to apply changes?": "Chcete teraz reštartovať sieťovú službu, aby sa zmeny prejavili?", @@ -2277,7 +2277,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Repozitár log2ram sa nepodarilo stiahnuť. Skontrolujte /tmp/log2ram_install.log", "Failed to configure EFI disk": "EFI disk sa nepodarilo nastaviť", "Failed to configure IOMMU automatically.": "IOMMU sa nepodarilo nastaviť automaticky.", - "Failed to configure Proxmox Backup Server repositories": "Nepodarilo sa nakonfigurovať archívy záložného servera Proxmox", + "Failed to configure Proxmox Backup Server repositories": "Repozitáre Proxmox Backup Server sa nepodarilo nastaviť", "Failed to configure Proxmox repositories": "Repozitáre Proxmoxu sa nepodarilo nastaviť", "Failed to configure TPM device in VM": "TPM zariadenie sa vo VM nepodarilo nastaviť", "Failed to configure repositories.": "Repozitáre sa nepodarilo nastaviť.", @@ -4885,11 +4885,11 @@ "Proxmology logo applied": "Logo Proxmology bolo použité", "Proxmox 9 system update allready": "Systém Proxmox 9 je už aktualizovaný", "Proxmox APT repositories configured": "APT repozitáre Proxmoxu sú nastavené", - "Proxmox Backup Server": "Záložný server Proxmox", + "Proxmox Backup Server": "Proxmox Backup Server", "Proxmox Backup Server (PBS) destinations": "Ciele Proxmox Backup Servera (PBS)", - "Proxmox Backup Server APT repositories configured": "Záložné úložiská APT servera Proxmox sú nakonfigurované", - "Proxmox Backup Server Update": "Aktualizácia záložného servera Proxmox", - "Proxmox Backup Server update completed.": "Aktualizácia záložného servera Proxmox bola dokončená.", + "Proxmox Backup Server APT repositories configured": "APT repozitáre Proxmox Backup Server sú nastavené", + "Proxmox Backup Server Update": "Aktualizácia Proxmox Backup Server", + "Proxmox Backup Server update completed.": "Aktualizácia Proxmox Backup Server bola dokončená.", "Proxmox CIFS Storage Status:": "Stav CIFS úložiska v Proxmoxe:", "Proxmox NFS Storage Status:": "Stav NFS úložiska v Proxmoxe:", "Proxmox System Update": "Aktualizácia systému Proxmox", @@ -6798,7 +6798,7 @@ "This CIFS share is mounted with restrictive permissions.": "Toto CIFS zdieľanie je pripojené s obmedzujúcimi oprávneniami.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Táto GPU sa v ProxMenux považuje za nekompatibilnú s GPU passthrough do VM.", "This NFS share is fully restricted — even the host root cannot write to it.": "Toto NFS zdieľanie je úplne obmedzené - ani root na hostovi doň nevie zapisovať.", - "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Tento záložný server Proxmox nemá žiadne aktívne predplatné, prepnúť na úložisko bez predplatného? Neprístupný zdroj Enterprise PBS bude zakázaný.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Tento Proxmox Backup Server nemá aktívne predplatné. Prepnúť na repozitár bez predplatného? Nedostupný zdroj Enterprise PBS bude vypnutý.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Táto VM vyžaduje ďalšie kroky inštalácie. Návod nájdete tu:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Tento účet je pre {registry}. Zadajte obrázok z tohto registra.", "This action will:": "Táto akcia vykoná:", @@ -6882,7 +6882,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Tento skript sa nedá spustiť z webového terminálu Proxmoxu.", "This script must be run as root.": "Tento skript musí byť spustený ako root.", "This script must be run on a Proxmox host.": "Tento skript musí byť spustený na Proxmox serveri.", - "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Tento skript použije nasledujúce optimalizácie a pokročilé úpravy na váš záložný server Proxmox", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Tento skript použije nasledujúce optimalizácie a pokročilé úpravy na váš Proxmox Backup Server", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Tento skript použije tieto optimalizácie a pokročilé úpravy na vašom serveri Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Tento skript aktualizuje váš Proxmox VE systém s pokročilými možnosťami:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Táto relácia beží v termináli Monitora. Ak by sa to spustilo odtiaľto, pripojenie sa počas inštalácie preruší a prepnutie môže zostať v zlom stave.", From 1e97f732acc807caaa372c785c3d8814babbbd3a Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:40:19 +0200 Subject: [PATCH 25/43] Avoid unnecessary probes while opening OCI menus --- .../test_oci_removal_firewall_wording.py | 6 +- .github/workflows/test-oci-registries.yml | 6 +- oci/remote/oci_work_backup.py | 16 ++- oci/src/proxmenux_oci/management.py | 17 +-- oci/tests/test_menu_performance.py | 109 ++++++++++++++++++ 5 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 oci/tests/test_menu_performance.py diff --git a/.github/scripts/tests/test_oci_removal_firewall_wording.py b/.github/scripts/tests/test_oci_removal_firewall_wording.py index bde90937..8041f923 100644 --- a/.github/scripts/tests/test_oci_removal_firewall_wording.py +++ b/.github/scripts/tests/test_oci_removal_firewall_wording.py @@ -56,7 +56,8 @@ class RemovalWordings(unittest.TestCase): state = ModuleType('oci_installation_state') state.parse_config = lambda raw: {} scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '', - 'translate': translated or (lambda value: value), 're': re} + 'translate': translated or (lambda value: value), 're': re, + '_selected_configuration': remover.guest_config, 'subprocess': subprocess} with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover, 'oci_installation_state': state}): return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101) @@ -87,7 +88,8 @@ class RemovalWordings(unittest.TestCase): with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover, 'oci_installation_state': state}): scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '', - 'translate': lambda value: value, 're': re} + 'translate': lambda value: value, 're': re, + '_selected_configuration': remover.guest_config, 'subprocess': subprocess} preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102) self.assertIn(WHOLE, preview) diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 7625fe1c..cab053e6 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -35,4 +35,8 @@ jobs: - name: Run OCI Modify latency regressions env: PYTHONDONTWRITEBYTECODE: '1' - run: python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v + run: | + python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v + python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v + python3 -m unittest discover -s oci/tests -p test_work_backup.py -v + python3 -m unittest discover -s oci/tests -p test_recreation.py -v diff --git a/oci/remote/oci_work_backup.py b/oci/remote/oci_work_backup.py index b37a2c26..251ffe71 100644 --- a/oci/remote/oci_work_backup.py +++ b/oci/remote/oci_work_backup.py @@ -192,21 +192,25 @@ def application(root, vmid): return primary_id, members or [vmid] -def status(root, vmid): +def status(root, vmid, only_needed=False): """What the menu needs to know before an operation: whether the backup fits on the system disk, the storage chosen earlier and the ones that - have room.""" + have room. The menu can skip unused alternatives once a destination fits; + the default retains the complete status used by the maintenance CLI.""" primary_id, vmids = application(root, vmid) record = instances.read(root, primary_id) default = instances.location(root, primary_id).parent needed, free = required(vmids), available(default) saved = (record.get('deployment') or {}).get(KEY) - path = storage_path(saved) if saved else None + fits = free >= needed + path = storage_path(saved) if saved and not (only_needed and fits) else None saved_free = available(path) if path is not None and path.is_dir() else None - return {'needed': needed, 'free': free, 'fits': free >= needed, 'saved': saved, - 'saved_fits': saved_free is not None and saved_free >= needed, + saved_fits = saved_free is not None and saved_free >= needed + candidates = [] if only_needed and (fits or saved_fits) else file_storages() + return {'needed': needed, 'free': free, 'fits': fits, 'saved': saved, + 'saved_fits': saved_fits, 'candidates': [{'storage': item['storage'], 'free': item['free']} - for item in file_storages() if item['free'] >= needed]} + for item in candidates if item['free'] >= needed]} def choose(root, vmid, storage): diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index 9b71d1b1..db75d597 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -332,7 +332,7 @@ def _work_backup_ready(project, ui, vmid): import oci_instances as instances import oci_work_backup try: - state = oci_work_backup.status(instances.ROOT, vmid) + state = oci_work_backup.status(instances.ROOT, vmid, only_needed=True) except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError): # What cannot be measured here is told by the engine when it stops. return True @@ -416,8 +416,6 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): return _toggle_watchdog(project, ui, row['vmid'], watched) if action == 'remove': return _remove(project, ui, row['vmid']) - if action != 'modify' and not _work_backup_ready(project, ui, row['vmid']): - return False record = instances.read(instances.ROOT, row['vmid']) import oci_instance_reconcile as reconcile try: @@ -462,13 +460,13 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): wizard.close() if not approved: return False - # Opening or cancelling the editor needs no disk/storage probes. - # Keep the existing backup checks after the user confirms edits. - if not _work_backup_ready(project, ui, row['vmid']): - return False elif not ui.review(translate('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If the update fails after the container was changed, the backup is restored. An update that stops halfway is recovered from the OCI management menu.'), translate('Update OCI'), question=translate('Update now?'), default=True): return False + # Opening/cancelling a confirmation needs no disk or storage probes. + # Both accepted operations retain this check before the backend starts. + if not _work_backup_ready(project, ui, row['vmid']): + return False command = [sys.executable, str(project / 'remote/oci_update_current.py'), str(row['vmid']), *lifecycle_args] # Host directories are configured by the user, who knows they are @@ -518,7 +516,10 @@ def _removal_summary(project, vmid): record = instances.read(instances.ROOT, member) except (OSError, ValueError, KeyError): record = {} - config = oci_remove.guest_config(member) + try: + config = _selected_configuration(member) + except (OSError, RuntimeError, subprocess.SubprocessError): + config = None cfg = parse_config(config) if config else {} name = (cfg.get('hostname') or (record.get('stack_member') or {}).get('name') or record.get('deployment', {}).get('hostname') or '') diff --git a/oci/tests/test_menu_performance.py b/oci/tests/test_menu_performance.py new file mode 100644 index 00000000..94981ceb --- /dev/null +++ b/oci/tests/test_menu_performance.py @@ -0,0 +1,109 @@ +"""Menu speedups avoid unused probes, retaining confirmation and capacity checks.""" +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import Mock,patch +ROOT=Path(__file__).resolve().parents[1] +sys.path[:0]=[str(ROOT/'src'),str(ROOT/'remote')] +from proxmenux_oci import management as menu +import oci_instances as instances +import oci_instance_reconcile as reconcile +import oci_remove as remover +import oci_work_backup as backup + + +class UpdateEntryTests(unittest.TestCase): + def run_update(self,approved,ready): + events=[] + record={'status':'installed','vmid':110,'deployment':{},'template':{}} + row={'vmid':110,'reason':'matched','stack':False,'pending':False,'status':'installed'} + ui=Mock();ui.unattended=True + ui.review.side_effect=lambda *a,**kw:events.append('review') or approved + with patch.object(menu,'carry_records',return_value={}) as carry, \ + patch.object(menu,'check_selected',return_value=row), \ + patch.object(menu,'_selected_configuration',return_value=b'current config'), \ + patch.object(instances,'read',return_value=record),patch.object(reconcile,'propose',return_value=None), \ + patch.object(menu,'_work_backup_ready',side_effect=lambda *a:events.append('backup') or ready), \ + patch.object(menu,'_run_lifecycle',side_effect=lambda *a:events.append('lifecycle') or True): + result=menu.manage_instance(ROOT,ui,row,action='update') + carry.assert_called_once_with(ROOT,vmids=[110],verify=True) + return result,events + + def test_declining_update_does_not_probe_disks_or_storages(self): + result,events=self.run_update(False,False) + self.assertFalse(result);self.assertEqual(events,['review']) + + def test_accepted_update_checks_backup_space_before_starting(self): + result,events=self.run_update(True,True) + self.assertTrue(result);self.assertEqual(events,['review','backup','lifecycle']) + + def test_insufficient_space_still_prevents_the_update(self): + result,events=self.run_update(True,False) + self.assertFalse(result);self.assertEqual(events,['review','backup']) + + +class BackupDestinationTests(unittest.TestCase): + def status(self,local_free,saved_free=None,only_needed=True): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);saved= root/'nas';saved.mkdir() + record={'deployment':{backup.KEY:'nas'}} + with patch.object(backup,'application',return_value=(110,[110,111])), \ + patch.object(instances,'read',return_value=record), \ + patch.object(backup,'required',return_value=100) as required, \ + patch.object(backup,'available',side_effect=lambda p:saved_free if Path(p)==saved else local_free), \ + patch.object(backup,'storage_path',return_value=saved if saved_free is not None else None) as path, \ + patch.object(backup,'file_storages',return_value=[{'storage':'enough','free':200},{'storage':'small','free':50}]) as alternatives: + result=backup.status(root,110,only_needed=only_needed) + required.assert_called_once_with([110,111]) + return result,path.call_count,alternatives.call_count + + def test_local_room_avoids_saved_storage_and_alternative_queries(self): + result,saved,alternatives=self.status(200,saved_free=1000) + self.assertTrue(result['fits']);self.assertEqual((saved,alternatives),(0,0)) + + def test_saved_destination_that_fits_avoids_alternative_queries(self): + result,saved,alternatives=self.status(50,saved_free=200) + self.assertTrue(result['saved_fits']);self.assertEqual((saved,alternatives),(1,0)) + + def test_when_neither_fits_all_available_destinations_are_checked(self): + result,saved,alternatives=self.status(50,saved_free=40) + self.assertFalse(result['fits']);self.assertFalse(result['saved_fits']) + self.assertEqual(result['candidates'],[{'storage':'enough','free':200}]) + self.assertEqual(alternatives,1) + + def test_missing_saved_storage_still_offers_alternatives(self): + result,saved,alternatives=self.status(50) + self.assertEqual(result['candidates'],[{'storage':'enough','free':200}]) + self.assertEqual(alternatives,1) + + def test_complete_status_keeps_legacy_fields_and_all_candidates(self): + result,saved,alternatives=self.status(200,saved_free=300,only_needed=False) + self.assertTrue(result['fits']);self.assertTrue(result['saved_fits']) + self.assertEqual((saved,alternatives),(1,1)) + self.assertEqual(result['candidates'],[{'storage':'enough','free':200}]) + + +class RemovalPreviewTests(unittest.TestCase): + def preview(self,reader): + record={'vmid':110,'deployment':{},'template':{'catalog_ui':{'title':'Example'}}} + with patch.object(remover,'members_of',return_value=(110,record,[110])), \ + patch.object(instances,'read',return_value=record), \ + patch.object(menu,'_selected_configuration',side_effect=reader), \ + patch.object(remover,'guest_config',side_effect=AssertionError('Duplicate pct process')), \ + patch.object(remover,'host_directories',return_value=[]), \ + patch.object(remover,'private_bridge',return_value=None): + return menu._removal_summary(ROOT,110) + + def test_preview_reuses_verified_config_and_keeps_volume_and_host_path_details(self): + config=b'hostname: example\nrootfs: local:subvol-110-disk-0,size=8G\nmp0: /mnt/media,mp=/media,backup=0\n' + text=self.preview(lambda v:config) + self.assertIn('8G',text);self.assertIn('/mnt/media',text) + self.assertIn('not targeted for removal',text) + + def test_missing_guest_can_still_be_reviewed_without_a_second_probe(self): + text=self.preview(lambda v:(_ for _ in ()).throw(RuntimeError('guest missing'))) + self.assertIn('CT 110',text);self.assertIn('rootfs -',text) + + +if __name__=='__main__':unittest.main() From 6496318452ec950b4eaff377bef7a79b957e004d Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:47:32 +0200 Subject: [PATCH 26/43] Shorten Escape key delay in OCI dialog menus --- .github/workflows/test-oci-registries.yml | 1 + oci/src/proxmenux_oci/ui.py | 4 +++ oci/tests/test_dialog_escape.py | 34 +++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 oci/tests/test_dialog_escape.py diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index cab053e6..2d2bf69e 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -38,5 +38,6 @@ jobs: run: | python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v + python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v python3 -m unittest discover -s oci/tests -p test_work_backup.py -v python3 -m unittest discover -s oci/tests -p test_recreation.py -v diff --git a/oci/src/proxmenux_oci/ui.py b/oci/src/proxmenux_oci/ui.py index 8238d3c2..6ff5955f 100644 --- a/oci/src/proxmenux_oci/ui.py +++ b/oci/src/proxmenux_oci/ui.py @@ -221,6 +221,10 @@ class DialogUI: def _run(self, widget: list[str], title: str | None = None) -> subprocess.CompletedProcess[str]: environment = os.environ.copy() + # ncurses otherwise waits a full second to distinguish Escape from + # the prefix of a cursor/function key. Keep a short sequence window, + # while respecting an explicit terminal-specific setting. + environment.setdefault("ESCDELAY", "150") if environment.get("TERM", "").casefold() in {"", "dumb", "unknown"}: environment["TERM"] = "xterm-256color" # dialog draws on the terminal and writes the selection to stderr. diff --git a/oci/tests/test_dialog_escape.py b/oci/tests/test_dialog_escape.py new file mode 100644 index 00000000..f5063878 --- /dev/null +++ b/oci/tests/test_dialog_escape.py @@ -0,0 +1,34 @@ +"""Escape delay is scoped to dialog and preserves terminal-specific overrides.""" +import os +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src')) +from proxmenux_oci.ui import DialogUI, UserCancelled + + +class DialogEscapeTests(unittest.TestCase): + def test_escape_uses_short_delay_without_changing_parent_environment(self): + with patch.dict(os.environ, {'TERM': 'xterm'}, clear=True), \ + patch('proxmenux_oci.ui.subprocess.run', return_value=subprocess.CompletedProcess([], 255, stderr='')) as run: + self.assertIsNone(DialogUI().choose('Menu', [('1', 'First')])) + self.assertEqual(run.call_args.kwargs['env']['ESCDELAY'], '150') + self.assertNotIn('ESCDELAY', os.environ) + + def test_explicit_delay_is_preserved(self): + with patch.dict(os.environ, {'ESCDELAY': '900'}), \ + patch('proxmenux_oci.ui.subprocess.run', return_value=subprocess.CompletedProcess([], 0, stderr='1')) as run: + self.assertEqual(DialogUI().choose('Menu', [('1', 'First')]), '1') + self.assertEqual(run.call_args.kwargs['env']['ESCDELAY'], '900') + + def test_escape_in_search_input_keeps_cancellation_behavior(self): + with patch('proxmenux_oci.ui.subprocess.run', return_value=subprocess.CompletedProcess([], 255, stderr='')): + with self.assertRaises(UserCancelled): + DialogUI().ask('Search') + + +if __name__ == '__main__': + unittest.main() From 99adc343e8805fa2d64566808a81559988cc3f9d Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:50:42 +0200 Subject: [PATCH 27/43] Adapt isolated removal preview tests to verified configuration reader --- .github/scripts/tests/test_oci_recovery_inventory_wording.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/tests/test_oci_recovery_inventory_wording.py b/.github/scripts/tests/test_oci_recovery_inventory_wording.py index 77e7afaa..338698ae 100644 --- a/.github/scripts/tests/test_oci_recovery_inventory_wording.py +++ b/.github/scripts/tests/test_oci_recovery_inventory_wording.py @@ -5,6 +5,7 @@ import json from pathlib import Path import re import runpy +import subprocess import sys import tempfile from types import ModuleType, SimpleNamespace @@ -52,7 +53,8 @@ class InventoryMessages(unittest.TestCase): state = ModuleType('oci_installation_state') state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines()) scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '', - 'translate': translate, 're': re} + 'translate': translate, 're': re, 'subprocess': subprocess, + '_selected_configuration': remover.guest_config} summary = extracted(MENU, '_removal_summary', scope) with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover, 'oci_installation_state': state}): From ac42bea70fb06f975098235b8321092c141238c3 Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:57:19 +0200 Subject: [PATCH 28/43] Preserve Modify fallback and detached disks after native edits --- .github/workflows/test-oci-registries.yml | 3 + oci/README.md | 17 +- oci/remote/oci_instance_transaction.py | 56 ++++- oci/remote/oci_native_modify.py | 34 ++- oci/remote/oci_update_current.py | 7 +- oci/tests/test_modify_review.py | 219 ++++++++++++++++++++ oci/tests/test_modify_review_transaction.py | 148 +++++++++++++ oci/tests/test_native_modify.py | 14 +- 8 files changed, 468 insertions(+), 30 deletions(-) create mode 100644 oci/tests/test_modify_review.py create mode 100644 oci/tests/test_modify_review_transaction.py diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index ca96aed2..fbff4ff7 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -34,6 +34,9 @@ jobs: run: | python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v python3 -m unittest discover -s oci/tests -p test_native_modify.py -v + python3 -m unittest discover -s oci/tests -p test_modify_review.py -v + python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v + python3 -m unittest discover -s oci/tests -p test_stage_release.py -v - name: Run offline registry account tests env: PYTHONDONTWRITEBYTECODE: '1' diff --git a/oci/README.md b/oci/README.md index 27ea49b3..a18414a4 100644 --- a/oci/README.md +++ b/oci/README.md @@ -142,7 +142,7 @@ An application made of several images is one entry of the catalog and one instal | Operation | What changes | What is kept | |---|---|---| | **Update** | The rootfs is replaced by the image the saved tag publishes today | Record, container disks, host directories, network and devices | -| **Modify** | Choose a setting, review it, then apply native LXC changes to the existing container; runtime settings may require a restart | Installed image, rootfs, persistent data and unrelated Proxmox settings | +| **Modify** | Edit and confirm the configuration; supported native settings apply directly, while other changes use the existing recreation flow | Native edits keep the installed image and rootfs; both paths preserve persistent data | | **Recreate** (multi-container) | Every container is rebuilt from the image it was installed with | Data, network and devices | | **Watchdog** | Whether the application is started again when it stops on its own | Everything; only the choice is saved | | **Remove** | The LXC, or every container of the application, its disks and its record | Host directories, with their content | @@ -159,20 +159,9 @@ Settings changed in Proxmox VE after the installation, such as memory, cores or A rolling tag is not an unattended update: the digest changes only through an explicit update. -For standalone applications, CPU and RAM changes apply live. Network settings, -environment overrides, directory mounts, additional managed volumes and supported -native devices use a controlled stop/start when needed. Modify does not query the -registry or download an image for these edits, even if the cached archive has -already been cleaned. Removing a managed mount detaches its disk without deleting -its data. An optional retained backup does not force recreation. +For standalone applications, CPU and RAM changes apply live. Supported network settings, environment overrides, directory mounts, additional managed volumes and native devices use a controlled stop/start when needed. Modify does not query the registry or download an image for these native edits, even if the cached archive has already been cleaned. Removing a managed mount detaches its disk without deleting its data; subsequent image updates park that unused disk and return it to the container. An optional retained backup does not force recreation. -The native path checks the saved configuration and installation identity, keeps a -private before/after journal, and restores the original settings if applying them -or checking the restarted service fails. It refuses to overwrite concurrent edits. -Application-specific installer adaptations (such as changing an NVIDIA runtime or -post-start configuration) are not silently converted into a full recreation: -unsupported same-image changes stop with an explanation before modifying the CT. -Image changes and coordinated stack operations retain their separate update flow. +The native path checks the saved configuration and installation identity, keeps a private before/after journal, and restores the original settings if applying them or checking the restarted service fails. It refuses to overwrite concurrent edits. Changes that require installer adaptations, such as NVIDIA runtime changes, GPU permission/environment setup, file mounts, completion notes or a refreshed catalog template, use the existing validated recreation flow. The route is chosen before applying any settings, so one proposal is not partially applied by both paths. The recreation flow reuses a verified cached archive when it matches the resolved image; it downloads an image when a suitable archive is unavailable. Image changes and coordinated stack operations retain their separate update flow. ## From a Compose file or a docker run command to an oci-compose diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index 25f9bf6c..bd8f09d6 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -320,6 +320,36 @@ def mounts(config): return result +def unused_volumes(config): + return {key: value for key, value in parse_config(config).items() + if re.fullmatch(r'unused[0-9]+', key)} + + +def move_retained_unused(state, to_stage): + """Unused disks are not in vzdump. Park and return them separately, using + PVE's volume reassignment API, never --delete (which frees an unused disk). + The journal's keys and both ownership markers also make retries safe.""" + keys = set(state.get('retained_unused', {})) + if not keys: + return + vmid, stage = state['vmid'], state['stage'] + guest_marker = state['record']['installation_id'] + holder_marker = 'proxmenux-transaction=' + state['id'] + source, target = (vmid, stage) if to_stage else (stage, vmid) + source_marker, target_marker = ((guest_marker, holder_marker) if to_stage + else (holder_marker, guest_marker)) + for key in sorted(keys): + present = unused_volumes(owned(source, source_marker)) + destination = unused_volumes(owned(target, target_marker)) + if key not in present: + if key in destination: + continue # Already reassigned before an interruption. + raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced')) + if key in destination or (to_stage and present.keys() - keys): + raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced')) + run('pct', 'move-volume', str(source), key, '--target-vmid', str(target), '--target-volume', key) + + def is_file_bind(mount, files=()): return mount['type'] == 'host-bind' and (mount['source'] in files or Path(mount['source']).is_file()) @@ -492,7 +522,16 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel runtime_settings.check(config, deployment, record['vmid']) coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS) preserved_keys = {'nameserver', 'searchdomain', 'timezone', 'protection'} if in_place else set() - if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - preserved_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k) or (in_place and re.fullmatch(r'unused[0-9]+', k))}: + detached = unused_volumes(config) + if baseline and detached != unused_volumes(record['observed'].get('config', '').encode()): + raise ValueError(translate('The container disks do not match the saved record')) + used_volumes = {value.split(',', 1)[0] for key, value in cfg.items() + if key == 'rootfs' or re.fullmatch(r'mp[0-9]+', key)} + if (len(set(detached.values())) != len(detached) + or any(not re.fullmatch(r'[A-Za-z0-9_.-]+:[^\s,]+', value) or value in used_volumes + for value in detached.values())): + raise ValueError(translate('The container disks do not match the saved record')) + if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - preserved_keys - {k for k in keys if re.fullmatch(r'(mp|dev|unused)[0-9]+', k)}: raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile')) unprivileged = deployment.get('security', {}).get('unprivileged', True) is not False if (desired.get('security', {}).get('unprivileged', True) is not False) != unprivileged \ @@ -549,7 +588,9 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel or mount.get('ro', '0') != ('1' if old[target].get('read_only') else '0')): raise ValueError(translate('A container mount has a source, backup or permission different from the saved record')) for target in old.keys() & new.keys(): - if any(old[target].get(k) != new[target].get(k) for k in ('type', 'source', 'size_gb')): + directory_bind = old[target]['type'] == new[target]['type'] == 'host-bind' + fixed = ('type', 'size_gb') if directory_bind else ('type', 'source', 'size_gb') + if any(old[target].get(k) != new[target].get(k) for k in fixed): raise ValueError(translate('Changing the storage or size of a disk requires a migration; empty disks are not created')) protected = ('/bin', '/sbin', '/etc', '/usr', '/lib', '/lib64', '/proc', '/sys', '/dev', '/run') for target in new.keys() - old.keys(): @@ -1043,6 +1084,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i for p, m in actual.items() if p in required and not m['volume'].startswith('/')]) state = {'schema_version': 1, 'id': directory.name, 'vmid': vmid, 'operation': operation, 'record': record, 'candidate_contract': candidate, 'before_config': before.decode(), + 'retained_unused': unused_volumes(before), 'original_description': description, 'archive': str(archive), 'archive_sha256': filehash(archive), 'registry_digest': registry_digest or image['manifest_digest'], @@ -1116,6 +1158,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i else: owned(stage, 'proxmenux-transaction=' + state['id']) run('pct', 'move-volume', str(vmid), mount['key'], '--target-vmid', str(stage), '--target-volume', mount['key']) + move_retained_unused(state, to_stage=True) checkpoint(journal, state, 'data-parked') if show: msg_ok(translate('Data volumes protected')) @@ -1125,8 +1168,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i msg_info(translate('Installing the new image...') if update else translate('Recreating the container...')) current = owned(vmid, record['installation_id']) expected = b''.join(line for line in before.splitlines(keepends=True) - if not re.match(rb'mp[0-9]+: ', line)) - if mounts(current) or current != expected: + if not re.match(rb'(mp|unused)[0-9]+: ', line)) + if mounts(current) or unused_volumes(current) or current != expected: raise ValueError(translate('A concurrent change was detected; the container is not removed')) host_mounts.verify_sources(original_sources) gpu_devices.verify(original_gpu) @@ -1141,6 +1184,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i install_candidate(root, journal, state, progress) restore_description(vmid, state) restore_firewall(vmid, state) + move_retained_unused(state, to_stage=False) if coordinated: for key, value in state['preserved_stack_config'].items(): run('pct', 'set', str(vmid), '--' + key, value) @@ -1340,8 +1384,9 @@ def recover(root, journal): stop(vmid) current = owned(vmid, record['installation_id']) gpu_devices.check_recovery_entries(current, state) - if any(re.fullmatch(r'unused[0-9]+', k) for k in parse_config(current)): + if unused_volumes(current).keys() - state.get('retained_unused', {}).keys(): raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced')) + move_retained_unused(state, to_stage=True) known_binds = {(m['source'], m['container_path']) for plan in (state['record']['deployment'], state['candidate_contract']['deployment']) for m in plan.get('mounts', []) if m['type'] == 'host-bind'} @@ -1369,6 +1414,7 @@ def recover(root, journal): host_mounts.verify_sources(state.get('original_host_sources', {})) run('pct', 'restore', str(vmid), backup, '--force', '1', '--storage', state['record']['deployment']['rootfs']['storage']) + move_retained_unused(state, to_stage=False) state['restore_config_sha256'] = sha(owned(vmid, state['record']['installation_id'])) checkpoint(journal, state, 'checking-recovery') if show: diff --git a/oci/remote/oci_native_modify.py b/oci/remote/oci_native_modify.py index 8538995c..8cefa086 100644 --- a/oci/remote/oci_native_modify.py +++ b/oci/remote/oci_native_modify.py @@ -86,6 +86,24 @@ def changes(record, candidate, config): if 'mounts' in changed: if any(transaction.is_file_bind(m) for plan in (before, after) for m in plan.get('mounts', [])): return None + if transaction.nested_mounts.without(config) != config: + # The installer also regenerates the recursive bind entries and + # hooks associated with these mounts. + return None + old_mounts = {m['container_path']: m for m in before.get('mounts', [])} + for mount in after.get('mounts', []): + original = old_mounts.get(mount['container_path']) + # Permission grants and installer-specific mount attributes need + # their normal installer, including when only their source changes. + if mount.get('grant_access'): + return None + if original: + fields = {k for k in original.keys() | mount.keys() if original.get(k) != mount.get(k)} + native_fields = {'read_only', 'backup', 'create_if_missing'} + if original['type'] == mount['type'] == 'host-bind': + native_fields.add('source') + if fields - native_fields: + return None return changed @@ -174,8 +192,14 @@ def plan(record, candidate, config): original = next(m for m in old['mounts'] if m['container_path'] == target) if mount == original: continue fields = dict(p.split('=', 1) for p in actual[target]['value'].split(',')[1:] if '=' in p) - fields['ro'] = '1' if mount.get('read_only') else '0' - values[actual[target]['key']] = actual[target]['volume']+','+','.join(k+'='+v for k, v in fields.items()) + source = actual[target]['volume'] + if mount['type'] == 'host-bind': + source = host_mounts.valid_path(mount['source']) + if not Path(source).is_dir() and not mount.get('create_if_missing'): + raise ValueError(translate('The host directory does not exist')) + fields.update(mp=target, backup='1' if mount['backup'] else '0', + ro='1' if mount.get('read_only') else '0') + values[actual[target]['key']] = source+','+','.join(k+'='+v for k, v in fields.items()) continue key = next('mp'+str(n) for n in range(256) if 'mp'+str(n) not in used) used.add(key) @@ -203,6 +227,12 @@ def verify_values(config, operation): if environment(config) != dict(item.split('=', 1) for item in value.split('\0')): raise ValueError(translate('Proxmox could not apply the container settings')) elif re.fullmatch(r'(mp|dev|net)[0-9]+', key): + if re.fullmatch(r'mp[0-9]+', key): + source = value.split(',', 1)[0] + actual_source = cfg.get(key, '').split(',', 1)[0] + allocating = re.fullmatch(r'[A-Za-z0-9_-]+:[0-9]+', source) + if (not allocating and actual_source != source) or (allocating and not actual_source.startswith(source.split(':')[0]+':')): + raise ValueError(translate('Proxmox could not apply the container settings')) desired = dict(p.split('=', 1) for p in value.split(',') if '=' in p) actual = dict(p.split('=', 1) for p in cfg.get(key, '').split(',') if '=' in p) defaults = {'uid': '0', 'gid': '0', 'deny-write': '0', 'ro': '0', 'backup': '0', 'mode': '0660', 'firewall': '0'} diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py index ca2bb142..b57f7049 100644 --- a/oci/remote/oci_update_current.py +++ b/oci/remote/oci_update_current.py @@ -224,10 +224,9 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non backup_native_settings(vmid, keep_backup) return oci_native_modify.apply(instances.ROOT, record, desired, config, acknowledge_external_data=acknowledge_external_data) - if (operation == 'recreate' - and record['template']['container_contract']['image']['reference'] - == desired['template']['container_contract']['image']['reference']): - raise ValueError(translate('These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.')) + # An unsupported native edit still uses the existing installer. Decide + # the route before changing anything; actual validation/API failures + # must not silently switch to a destructive fallback. transaction.preflight(record, desired, config) msg_ok(translate('Container checked')) current = record['observed']['image'] if operation == 'update' else None diff --git a/oci/tests/test_modify_review.py b/oci/tests/test_modify_review.py new file mode 100644 index 00000000..e9d4e41d --- /dev/null +++ b/oci/tests/test_modify_review.py @@ -0,0 +1,219 @@ +"""Maintainer review: fallback, complete mount edits and retained unused disks.""" +import contextlib +import copy +from pathlib import Path +import re +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'remote')) +import oci_instances as instances +import oci_instance_transaction as transaction +import oci_native_modify as native +import oci_update_current as update +import oci_operation_notice +from test_modify_image_reuse import record, CONFIG + + +def installation(): + result=record() + result['deployment'].update(rootfs={'storage':'local-zfs','size_gb':8}, onboot=True, + security={'unprivileged':True}) + result['observed']['image']['defaults']={} + config=CONFIG+b'unprivileged: 1\nonboot: 1\nnet0: name=eth0,bridge=vmbr0,ip=dhcp,hwaddr=AA:BB:CC:DD:EE:FF\n' + result['observed'].update(config=config.decode(), config_sha256=transaction.sha(config)) + return result,config + + +class FallbackTests(unittest.TestCase): + def test_all_installer_adaptations_fall_back_before_any_native_write(self): + saved,config=installation() + def nvidia(c):c['deployment']['devices']=[{'kind':'nvidia-runtime','host_path':'/dev/nvidia0'}] + def groups(c):c['deployment']['devices']=[{'host_path':'/dev/dri/renderD128','append_host_device_gid_to_environment':'GROUPS'}] + def permissions(c): + c['deployment']['devices']=[{'host_path':'/dev/dri/renderD128'}] + c['deployment']['device_permissions']={'uid':1000} + mutations=[nvidia,groups,permissions, + lambda c:c['deployment'].update(completion_notes=['Installer note']), + lambda c:c['deployment'].update(post_start_configurations=[{'id':'adapter'}]), + lambda c:c['template']['catalog_ui'].update(title='Refreshed catalog template'), + lambda c:c['deployment'].update(mounts=[{'type':'host-bind','source':'/host/file','container_path':'/config/file','backup':False,'size_gb':None}])] + for mutation in mutations: + with self.subTest(mutation=mutation),tempfile.TemporaryDirectory() as folder: + candidate=copy.deepcopy(saved);mutation(candidate) + root=Path(folder);instances.write(instances.location(root,115),saved) + with patch.object(instances,'ROOT',root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ + patch.object(instances,'command',return_value=config), \ + patch.object(transaction,'external_changes',return_value={}),patch.object(transaction,'preflight'), \ + patch.object(transaction,'is_file_bind',side_effect=lambda m:m.get('source')=='/host/file'), \ + patch.object(native,'apply',side_effect=AssertionError('partial native apply')), \ + patch.object(update,'resolve_archive',return_value=(Path('/cached.tar'),'sha256:resolved')) as resolve, \ + patch.object(transaction,'apply') as recreate, \ + patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()): + update.update(115,proposal={'operation':'recreate','candidate':candidate}) + recreate.assert_called_once();resolve.assert_called_once() + self.assertEqual(resolve.call_args.kwargs['local_archives'],[saved['observed']['archive_path']]) + + def test_native_validation_failure_never_falls_back_to_recreation(self): + saved,config=installation();candidate=copy.deepcopy(saved) + candidate['deployment']['environment']=[{'name':'INVALID NAME','value':'value'}] + with tempfile.TemporaryDirectory() as folder: + root=Path(folder);instances.write(instances.location(root,115),saved) + with patch.object(instances,'ROOT',root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ + patch.object(instances,'command',return_value=config), \ + patch.object(transaction,'preflight'),patch.object(transaction,'external_changes',return_value={}), \ + patch.object(transaction,'apply') as recreate,patch.object(update,'resolve_archive') as resolve, \ + patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()): + with self.assertRaises(ValueError):update.update(115,proposal={'operation':'recreate','candidate':candidate}) + recreate.assert_not_called();resolve.assert_not_called() + + def test_native_api_failure_never_falls_back_after_starting_the_edit(self): + saved,config=installation();candidate=copy.deepcopy(saved) + candidate['deployment']['environment']=[{'name':'VALID','value':'value'}] + with tempfile.TemporaryDirectory() as folder: + root=Path(folder);instances.write(instances.location(root,115),saved) + with patch.object(instances,'ROOT',root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ + patch.object(instances,'command',return_value=config), \ + patch.object(transaction,'preflight'),patch.object(transaction,'external_changes',return_value={}), \ + patch.object(transaction,'apply') as recreate,patch.object(update,'resolve_archive') as resolve, \ + patch.object(native,'apply',side_effect=RuntimeError('native API failed')), \ + patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()): + with self.assertRaisesRegex(RuntimeError,'native API failed'): + update.update(115,proposal={'operation':'recreate','candidate':candidate}) + recreate.assert_not_called();resolve.assert_not_called() + + +class MountTests(unittest.TestCase): + def setUp(self): + self.record,self.config=installation() + self.mount={'type':'host-bind','source':'/mnt/old','container_path':'/media', + 'size_gb':None,'backup':False,'read_only':False,'create_if_missing':False} + self.record['deployment']['mounts']=[self.mount] + self.config+=b'mp3: /mnt/old,mp=/media,backup=0,ro=0,replicate=0\n' + self.record['observed'].update(config=self.config.decode(),config_sha256=transaction.sha(self.config)) + self.candidate=copy.deepcopy(self.record) + + def test_existing_bind_source_and_flags_are_applied_with_other_options_preserved(self): + self.candidate['deployment']['mounts'][0].update(source='/mnt/new',read_only=True) + with patch.object(Path,'is_dir',return_value=True): + plan=native.plan(self.record,self.candidate,self.config) + self.assertEqual(plan['values'],{'mp3':'/mnt/new,mp=/media,backup=0,ro=1,replicate=0'}) + self.assertTrue(plan['restart']);self.assertEqual(plan['delete'],[]) + + def test_missing_replacement_directory_is_rejected_unless_creation_was_requested(self): + self.candidate['deployment']['mounts'][0]['source']='/mnt/new' + with patch.object(Path,'is_dir',return_value=False): + with self.assertRaisesRegex(ValueError,'directory does not exist'): + native.plan(self.record,self.candidate,self.config) + self.candidate['deployment']['mounts'][0]['create_if_missing']=True + self.assertIn('/mnt/new,',native.plan(self.record,self.candidate,self.config)['values']['mp3']) + + def test_verification_detects_a_source_that_was_not_applied(self): + operation={'values':{'mp3':'/mnt/new,mp=/media,backup=0,ro=0'},'delete':[]} + with self.assertRaisesRegex(ValueError,'could not apply'):native.verify_values(self.config,operation) + native.verify_values(self.config.replace(b'/mnt/old',b'/mnt/new'),operation) + + def test_unknown_mount_options_and_permission_grants_use_installer(self): + for fields in ({'grant_access':True},{'mount_options':['noexec']},{'type':'managed-volume','source':'local-zfs','size_gb':2,'backup':True}): + candidate=copy.deepcopy(self.candidate);candidate['deployment']['mounts'][0].update(fields) + self.assertIsNone(native.changes(self.record,candidate,self.config)) + + def test_recursive_bind_adaptations_use_installer(self): + self.candidate['deployment']['mounts'][0]['read_only']=True + derived=b'lxc.mount.entry: /mnt/old media none rbind,rslave,create=dir 0 0\n' + self.assertIsNone(native.changes(self.record,self.candidate,self.config+derived)) + + def test_storage_and_disk_size_changes_keep_existing_migration_guard(self): + self.record['deployment']['mounts'][0].update(type='managed-volume',source='local-zfs',size_gb=1,backup=True) + self.config=self.config.replace(b'/mnt/old,mp=/media,backup=0',b'local-zfs:subvol-115-disk-1,mp=/media,backup=1') + self.record['observed']['config']=self.config.decode() + for fields in ({'size_gb':2},{'source':'other-storage'}): + candidate=copy.deepcopy(self.record);candidate['deployment']['mounts'][0].update(fields) + self.assertIsNone(native.changes(self.record,candidate,self.config)) + with self.assertRaisesRegex(ValueError,'migration'):transaction.preflight(self.record,candidate,self.config) + + +class DetachedPreflightTests(unittest.TestCase): + def test_removing_a_mount_then_updating_accepts_the_recorded_unused_disk(self): + saved,config=installation() + saved['deployment']['mounts']=[{'type':'managed-volume','source':'local-zfs','size_gb':1, + 'container_path':'/extra','backup':True,'read_only':False}] + config+=b'mp4: local-zfs:subvol-115-disk-1,mp=/extra,backup=1,size=1G\n' + saved['observed']['config']=config.decode() + candidate=copy.deepcopy(saved);candidate['deployment']['mounts']=[] + self.assertEqual(native.plan(saved,candidate,config)['delete'],['mp4']) + detached=config.replace(b'mp4: local-zfs:subvol-115-disk-1,mp=/extra,backup=1,size=1G\n',b'unused0: local-zfs:subvol-115-disk-1\n') + candidate['observed'].update(config=detached.decode(),config_sha256=transaction.sha(detached)) + cfg,_,_=transaction.preflight(candidate,copy.deepcopy(candidate),detached) + self.assertIn('unused0',cfg) + + def test_unrecorded_detached_disks_are_not_adopted_silently(self): + saved,config=installation() + with self.assertRaisesRegex(ValueError,'disks do not match|discard those changes'): + transaction.preflight(saved,copy.deepcopy(saved),config+b'unused0: local-zfs:subvol-115-disk-1\n') + + def test_invalid_duplicate_or_mounted_unused_disks_are_rejected(self): + for extra in (b'unused0: /etc/passwd\n',b'unused0: local-zfs:subvol-115-disk-0\n', + b'unused0: local-zfs:subvol-115-disk-1\nunused1: local-zfs:subvol-115-disk-1\n'): + saved,config=installation();config+=extra;saved['observed']['config']=config.decode() + with self.assertRaisesRegex(ValueError,'disks do not match'):transaction.preflight(saved,copy.deepcopy(saved),config) + + +class DetachedTransferTests(unittest.TestCase): + def setUp(self): + saved,config=installation() + self.configs={115:config+b'unused0: local-zfs:subvol-115-disk-1\nunused2: local-zfs:subvol-115-disk-2\n', + 900:b'description: proxmenux-transaction=test-tx\nrootfs: local-zfs:subvol-900-disk-0\n'} + self.state={'vmid':115,'stage':900,'id':'test-tx','record':saved, + 'retained_unused':transaction.unused_volumes(self.configs[115])} + self.calls=[] + def owned(vmid,marker): + self.assertIn(marker.encode(),self.configs[vmid]);return self.configs[vmid] + def run(*args): + self.calls.append(args);self.assertEqual(args[:2],('pct','move-volume')) + source,key,target=int(args[2]),args[3],int(args[5]);self.assertEqual(key,args[7]) + line=next(line for line in self.configs[source].splitlines(keepends=True) if line.startswith((key+': ').encode())) + self.configs[source]=self.configs[source].replace(line,b'') + self.configs[target]+=line.replace(('subvol-'+str(source)+'-').encode(),('subvol-'+str(target)+'-').encode()) + for p in (patch.object(transaction,'owned',side_effect=owned),patch.object(transaction,'run',side_effect=run)): + p.start();self.addCleanup(p.stop) + + def test_unused_disks_are_parked_and_returned_without_freeing_any_volume(self): + transaction.move_retained_unused(self.state,True) + self.assertEqual(transaction.unused_volumes(self.configs[115]),{}) + self.assertEqual(set(transaction.unused_volumes(self.configs[900])),{'unused0','unused2'}) + transaction.move_retained_unused(self.state,False) + self.assertEqual(transaction.unused_volumes(self.configs[115]),self.state['retained_unused']) + self.assertEqual(transaction.unused_volumes(self.configs[900]),{}) + self.assertEqual(len(self.calls),4) + + def test_an_interrupted_transfer_can_be_retried_in_both_directions(self): + original=transaction.run.side_effect + def interrupt(*args): + original(*args);raise RuntimeError('interrupted after reassignment') + with patch.object(transaction,'run',side_effect=interrupt): + with self.assertRaises(RuntimeError):transaction.move_retained_unused(self.state,True) + transaction.move_retained_unused(self.state,True) + with patch.object(transaction,'run',side_effect=interrupt): + with self.assertRaises(RuntimeError):transaction.move_retained_unused(self.state,False) + transaction.move_retained_unused(self.state,False) + self.assertEqual(transaction.unused_volumes(self.configs[115]),self.state['retained_unused']) + + def test_unrelated_unused_disks_are_not_moved(self): + self.configs[115]+=b'unused3: local-zfs:subvol-115-disk-3\n' + with self.assertRaises(ValueError):transaction.move_retained_unused(self.state,True) + self.assertEqual(self.calls,[]) + + def test_a_missing_disk_or_target_collision_blocks_the_transfer(self): + del self.state['retained_unused']['unused2'] + self.configs[115]=self.configs[115].replace(b'unused2: local-zfs:subvol-115-disk-2\n',b'') + self.configs[900]+=b'unused0: local-zfs:subvol-900-disk-9\n' + with self.assertRaises(ValueError):transaction.move_retained_unused(self.state,True) + self.configs[115]=self.configs[115].replace(b'unused0: local-zfs:subvol-115-disk-1\n',b'') + self.configs[900]=self.configs[900].replace(b'unused0: local-zfs:subvol-900-disk-9\n',b'') + with self.assertRaises(ValueError):transaction.move_retained_unused(self.state,False) + + +if __name__=='__main__':unittest.main() diff --git a/oci/tests/test_modify_review_transaction.py b/oci/tests/test_modify_review_transaction.py new file mode 100644 index 00000000..08bf79d3 --- /dev/null +++ b/oci/tests/test_modify_review_transaction.py @@ -0,0 +1,148 @@ +"""Exercise the actual update and recovery orchestration with a simulated PVE API. + +Unused volumes must leave the old CT before destroy/restore and return before +validation/commit, including an interruption during volume reassignment. +""" +import copy +import json +from pathlib import Path +import re +import socket +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'remote')) +import oci_instances as instances +import oci_instance_transaction as transaction +from test_modify_review import installation +from test_modify_image_reuse import NEW + + +class UpdateRetainedDiskTests(unittest.TestCase): + def setUp(self): + temp=tempfile.TemporaryDirectory();self.addCleanup(temp.cleanup) + self.root=Path(temp.name)/'instances' + self.archive=Path(temp.name)/'image.tar';self.archive.write_bytes(b'verified fixture') + self.backup=Path(temp.name)/'backup.tar.zst';self.backup.write_bytes(b'verified backup') + self.saved,config=installation() + self.base_config=config + config+=b'unused0: local-zfs:subvol-115-disk-1\nunused2: local-zfs:subvol-115-disk-2\n' + self.saved['observed'].update(config=config.decode(),config_sha256=transaction.sha(config)) + self.saved['observed']['archive_path']=str(self.archive) + self.image=copy.deepcopy(self.saved['observed']['image']);self.image['manifest_digest']=NEW + self.configs={115:config};self.commands=[];self.fail_install=False;self.interrupt_return=False + instances.write(instances.location(self.root,115),self.saved) + def config_line(vmid,key,value): + before=self.configs[vmid] + result=b''.join(line for line in before.splitlines(keepends=True) if not line.startswith((key+': ').encode())) + self.configs[vmid]=result+(key+': '+value+'\n').encode() + def run(*args): + self.commands.append(args) + if args[0]=='zstd': + self.assertEqual(args[1:],('-t',str(self.backup))) + return b'' + if args[:2]==('pvesh','get'): + if args[2]=='/cluster/nextid':return b'900' + return json.dumps([{'vmid':vmid,'type':'lxc','node':socket.gethostname().split('.')[0]} + for vmid in self.configs]).encode() if args[2]=='/cluster/resources' else b'[]' + self.assertEqual(args[0],'pct',args) + action,vmid=args[1],int(args[2]) + if action=='config':return self.configs[vmid] + if action=='status':return b'status: running' + if action in ('start','shutdown','stop'):return b'' + if action=='create': + marker=args[args.index('--description')+1] + self.configs[vmid]=f'description: {marker}\nrootfs: local-zfs:subvol-{vmid}-disk-0\n'.encode() + elif action=='destroy': + self.assertFalse(transaction.unused_volumes(self.configs[vmid]),'would destroy retained data') + self.assertFalse(transaction.mounts(self.configs[vmid]),'would destroy attached data') + del self.configs[vmid] + elif action=='set': + for index in range(3,len(args),2):config_line(vmid,args[index][2:],args[index+1]) + elif action=='move-volume': + key,target,target_key=args[3],int(args[5]),args[7] + volume=transaction.unused_volumes(self.configs[vmid])[key] + line=(key+': '+volume+'\n').encode() + self.configs[vmid]=self.configs[vmid].replace(line,b'') + volume=volume.replace('subvol-'+str(vmid)+'-','subvol-'+str(target)+'-') + config_line(target,target_key,volume) + if self.interrupt_return and target==115: + self.interrupt_return=False + raise RuntimeError('interrupted return after PVE reassignment') + elif action=='restore': + # vzdump does not contain detached disks; restore skips unusedN. + self.assertFalse(transaction.unused_volumes(self.configs.get(vmid,b''))) + self.configs[vmid]=self.base_config + else:self.fail('Unexpected PVE command: '+str(args)) + return b'' + def install(root,journal,state,progress=None): + if self.fail_install:raise RuntimeError('installer failed after old rootfs was removed') + self.configs[state['vmid']]=self.base_config + def observe(vmid,identity,archive,digest,image=None): + observed=copy.deepcopy(self.saved['observed']) + observed.update(config=self.configs[vmid].decode(),config_sha256=transaction.sha(self.configs[vmid]), + image=copy.deepcopy(image or self.image),resolved_registry_digest=digest) + return observed + original_exists=Path.exists + def exists(path): + match=re.fullmatch(r'/etc/pve/lxc/([0-9]+)\.conf',str(path)) + return int(match[1]) in self.configs if match else original_exists(path) + patches=[patch.object(transaction,'run',side_effect=run),patch.object(transaction,'open_log'), + patch.object(transaction,'log'),patch.object(transaction,'stop'), + patch.object(transaction,'check_archive'),patch.object(transaction,'image_from_archive',return_value=self.image), + patch.object(transaction,'verified_backup',return_value=self.backup), + patch.object(transaction,'install_candidate',side_effect=install), + patch.object(transaction,'healthcheck',return_value='running'), + patch.object(transaction,'candidate_host_sources',return_value={}), + patch.object(transaction,'prune_backups'),patch.object(transaction.image_cache,'prune',return_value=[]), + patch.object(transaction,'cleanup_restored_format_dirs'),patch.object(transaction.runtime_settings,'restore'), + patch.object(transaction.work_backup,'locate',side_effect=lambda directory,*a:directory), + patch.object(instances,'observe',side_effect=observe),patch.object(Path,'exists',exists)] + for p in patches:p.start();self.addCleanup(p.stop) + self.addCleanup(transaction._run.update,journal=None) + + def apply(self,**kwargs):return transaction.apply(self.root,115,self.archive,'update',registry_digest=NEW,**kwargs) + + def journal(self):return next(instances.location(self.root,115).parent.glob('transactions/*/transaction.json')) + + def assert_retained(self): + self.assertEqual(transaction.unused_volumes(self.configs[115]),{ + 'unused0':'local-zfs:subvol-115-disk-1','unused2':'local-zfs:subvol-115-disk-2'}) + self.assertNotIn(900,self.configs) + self.assertFalse(any(args[:2]==('pct','set') and '--delete' in args for args in self.commands)) + + def test_update_keeps_detached_disks_and_the_next_update_preflight_accepts_them(self): + self.apply();self.assert_retained() + saved=instances.read(self.root,115) + self.assertEqual(saved['status'],'installed') + self.assertEqual(saved['observed']['image']['manifest_digest'],NEW) + transaction.preflight(saved,copy.deepcopy(saved),self.configs[115]) + self.assertEqual(json.loads(self.journal().read_text())['phase'],'committed') + + def test_failed_install_restores_both_backup_and_detached_disks(self): + self.fail_install=True + with self.assertRaisesRegex(RuntimeError,'installer failed'):self.apply() + transaction.recover(self.root,self.journal()) + self.assert_retained() + self.assertEqual(instances.read(self.root,115)['observed']['image'],self.saved['observed']['image']) + self.assertEqual(json.loads(self.journal().read_text())['phase'],'rolled-back') + + def test_recovery_after_parking_data_returns_the_original_unused_disks(self): + with self.assertRaisesRegex(RuntimeError,'Lab interruption'):self.apply(interrupt_after='data-parked') + transaction.recover(self.root,self.journal());self.assert_retained() + + def test_recovery_handles_an_interruption_while_returning_unused_disks(self): + self.interrupt_return=True + with self.assertRaisesRegex(RuntimeError,'interrupted return'):self.apply() + transaction.recover(self.root,self.journal());self.assert_retained() + + def test_failed_runtime_check_recovers_disks_already_returned_to_the_candidate(self): + with patch.object(transaction.gpu_devices,'validate_runtime',side_effect=[RuntimeError('invalid runtime'), None]): + with self.assertRaisesRegex(RuntimeError,'invalid runtime'):self.apply() + transaction.recover(self.root,self.journal()) + self.assert_retained() + + +if __name__=='__main__':unittest.main() diff --git a/oci/tests/test_native_modify.py b/oci/tests/test_native_modify.py index 62e37885..cf42493e 100644 --- a/oci/tests/test_native_modify.py +++ b/oci/tests/test_native_modify.py @@ -236,14 +236,18 @@ class NativeApplyTests(unittest.TestCase): update.update(115,proposal={'operation':'recreate','candidate':self.candidate}) self.assertEqual(instances.read(self.root,115)['deployment']['environment'],self.candidate['deployment']['environment']) - def test_unsupported_same_image_adaptation_never_silently_recreates(self): + def test_unsupported_same_image_adaptation_uses_the_existing_recreation_flow(self): self.candidate['deployment']['post_start_configurations']=[{'id':'unsupported-adapter'}] with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ patch.object(native.transaction,'external_changes',return_value={}), \ - patch.object(update,'resolve_archive',side_effect=AssertionError('registry path')), \ - patch.object(native.transaction,'apply',side_effect=AssertionError('recreation')): - with self.assertRaisesRegex(ValueError,'application-specific adaptation'): - update.update(115,proposal={'operation':'recreate','candidate':self.candidate}) + patch.object(update,'resolve_archive',return_value=(Path('/verified.tar'),'sha256:test')) as resolve, \ + patch.object(native.transaction,'apply') as recreate, \ + patch.object(native,'apply',side_effect=AssertionError('partial native edit')), \ + patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()): + update.update(115,proposal={'operation':'recreate','candidate':self.candidate}) + resolve.assert_called_once() + recreate.assert_called_once() + self.assertEqual(recreate.call_args.args[3],'recreate') def test_optional_backup_does_not_force_recreation(self): with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \ From e938a25da3da458fccdb3a91ea02818460aef9ea Mon Sep 17 00:00:00 2001 From: MacRimi <123239993+MacRimi@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:46:16 +0200 Subject: [PATCH 29/43] fix(oci): keep Proxmox tags editable on native stack members - Accept tag changes in the native stack check before recreate or update - Keep identity and every other setting checked as before - Add a regression test for the native stack check --- oci/remote/oci_instances.py | 8 +++++--- oci/remote/oci_stack_native.py | 2 +- oci/tests/test_external_tags.py | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/oci/remote/oci_instances.py b/oci/remote/oci_instances.py index f039b956..0f4d19ce 100644 --- a/oci/remote/oci_instances.py +++ b/oci/remote/oci_instances.py @@ -230,8 +230,9 @@ def identity(config): return match.group(1) if match else None -def same_config_except_notes(record, config): - """Accept a presentation-only note edit, never a changed OCI identity or LXC setting.""" +def same_config_except_notes(record, config, ignore_tags=False): + """Accept a presentation-only note edit, never a changed OCI identity or LXC setting. + With `ignore_tags`, Proxmox tags may differ too: the operation keeps them as they are.""" previous = record['observed']['config'].encode() expected = record['installation_id'] if identity(previous) != expected or identity(config) != expected: @@ -242,7 +243,8 @@ def same_config_except_notes(record, config): notes = [line for line in lines if line.startswith(b'description: ')] if len(notes) != 1: return None - return b''.join(line for line in lines if not line.startswith(b'description: ')) + dropped = (b'description: ', b'tags: ') if ignore_tags else (b'description: ',) + return b''.join(line for line in lines if not line.startswith(dropped)) original = without_notes(previous) return original is not None and original == without_notes(config) diff --git a/oci/remote/oci_stack_native.py b/oci/remote/oci_stack_native.py index b679ee74..2b564205 100644 --- a/oci/remote/oci_stack_native.py +++ b/oci/remote/oci_stack_native.py @@ -192,7 +192,7 @@ class NativeAdapter: config = instances.command('pct', 'config', str(vmid)) if instances.identity(config) != record['installation_id']: raise ValueError(translate('The identity of a member was replaced')) - if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config): + if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config, ignore_tags=True): raise ValueError(translate('A member configuration changed during the preparation')) else: if Path('/etc/pve/lxc/%s.conf' % vmid).exists(): diff --git a/oci/tests/test_external_tags.py b/oci/tests/test_external_tags.py index a28dd420..0f09c5e3 100644 --- a/oci/tests/test_external_tags.py +++ b/oci/tests/test_external_tags.py @@ -166,6 +166,20 @@ die() { printf '%s\\n' "$*" >&2; exit 1; } self.assertEqual(state['preserved_stack_config']['tags'], tags or '') @unittest.skipUnless(shutil.which('bash') and shutil.which('jq'), 'bash and jq are needed') + def test_native_stack_check_accepts_tag_edits_and_nothing_else(self): + saved = record() + instances = transaction.instances + for new in ('homelab;oci;proxmenux', 'proxmenux;oci', '', None): + with self.subTest(new=new): + self.assertTrue(instances.same_config_except_notes(saved, config(new), ignore_tags=True)) + self.assertFalse(instances.same_config_except_notes(saved, config(new))) + changed = config('homelab').replace(b'cores: 2', b'cores: 4') + self.assertFalse(instances.same_config_except_notes(saved, changed, ignore_tags=True)) + other = config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222') + self.assertFalse(instances.same_config_except_notes(saved, other, ignore_tags=True)) + source = (ROOT / 'remote/oci_stack_native.py').read_text() + self.assertIn('same_config_except_notes(record, config, ignore_tags=True)', source) + def test_installer_still_refuses_unsupported_kept_settings(self): installer = (ROOT / 'remote/install_oci.sh').read_text() function = re.search(r'^apply_kept_settings\(\) \{\n.*?^\}\n', installer, re.M | re.S).group(0) From e38c085e5f0e80e99189bcd00924faab85ea2580 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 17 Oct 2026 20:47:24 +0200 Subject: [PATCH 30/43] accept non-HTTP answers from guessed service ports --- .github/scripts/build_translation_cache.py | 12 +++ .github/workflows/test-oci-registries.yml | 1 + oci/remote/install_oci.sh | 14 ++- oci/remote/oci_instance_transaction.py | 36 ++++++-- .../test_guessed_endpoint_healthcheck.py | 87 +++++++++++++++++++ 5 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 oci/tests/test_guessed_endpoint_healthcheck.py diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py index 95825a29..b5797010 100644 --- a/.github/scripts/build_translation_cache.py +++ b/.github/scripts/build_translation_cache.py @@ -38,6 +38,18 @@ DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:" # provider, then restore the exact source spelling afterwards. Keep longer # terms first so ``gasket`` cannot consume part of ``gasket-dkms``. PROTECTED_TECHNICAL_TERMS = ( + # Product names, kept whole. Left to a translator, "Proxmox Backup Server" + # became "Proxmox Sicherungsserver" and "Serveur de sauvegarde Proxmox", + # and when a provider rightly handed it back unchanged the string was + # retried and then dropped as untranslated. + "Proxmox Backup Server", + "Proxmox Datacenter Manager", + "Proxmox Mail Gateway", + "Proxmox VE", + "ProxMenux Monitor", + "ProxMenux Optimizations", + "Secure Gateway", + "Tailscale", "google/gasket-driver", "feranick/gasket-driver", "libedgetpu1-std", diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index ff0032cb..16ff314f 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -45,3 +45,4 @@ jobs: run: | python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v + python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 7d6da264..5f1802ba 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -359,13 +359,18 @@ cleanup_canonical_inputs() { # A derived check accepts any HTTP answer below 500: the application is up, # even when its first page is a redirect or asks for a login. healthcheck_probe() { + local code status=0 if [[ ${HC_ANY_STATUS:-false} == true ]]; then - local code - code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || return 1 - [[ $code =~ ^[1-4][0-9][0-9]$ ]] + code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || status=$? + (( status == 0 )) && [[ $code =~ ^[1-4][0-9][0-9]$ ]] && return 0 else - curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null + curl "${CURL_ARGS[@]}" "$HC_URL" >/dev/null 2>&1 || status=$? + (( status == 0 )) && return 0 fi + # A port taken from the compose file may serve a database or SSH: an answer + # in another protocol (HTTP/0.9, weird or empty reply, reset) means it is up. + [[ ${HC_ACCEPT_NON_HTTP:-false} == true ]] \ + && (( status == 1 || status == 8 || status == 52 || status == 56 )) } cleanup_failed_install() { @@ -2267,6 +2272,7 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then [[ -n $IP ]] || die "$(translate "The healthcheck cannot run without an IP address")" HC_URL="${HC_SCHEME}://${IP}:${HC_PORT}${HC_PATH}" HC_ANY_STATUS=$(jq -r '.accept_any_status // false' <<<"$STARTUP_HEALTHCHECK") + HC_ACCEPT_NON_HTTP=$(jq -r '.accept_non_http // false' <<<"$STARTUP_HEALTHCHECK") if [[ $HC_ANY_STATUS == true ]]; then CURL_ARGS=(-sS --noproxy '*' -o /dev/null -w '%{http_code}' --max-time "$HC_REQUEST_TIMEOUT") else diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index cdde917b..b5cef528 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -352,6 +352,22 @@ def profile_keys(deployment): return keys +GUESSED_ENDPOINT = 'compose-first-tcp-port-fallback' +# curl exit codes for a port that accepted the connection and answered in a +# protocol other than HTTP: HTTP/0.9 refused, weird reply, empty reply, reset. +NON_HTTP_ANSWER = (1, 8, 52, 56) + + +def probe_http(args): + """Run one curl probe and return its output and exit code.""" + log('$ ' + shlex.join(str(arg) for arg in args)) + try: + result = subprocess.run(args, capture_output=True, timeout=60, close_fds=True) + except subprocess.TimeoutExpired: + return b'', -1 + return result.stdout, result.returncode + + def effective_healthcheck(template): """The template's own health check or, when it has none, one derived from its web address: any HTTP answer below 500 means the application is up.""" @@ -367,9 +383,14 @@ def effective_healthcheck(template): path = str(endpoint.get('path') or '/') if (endpoint.get('scheme') in ('http', 'https') and str(endpoint.get('port', '')).isdigit() and path.startswith('/') and not any(c.isspace() for c in path)): - return {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path, - 'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4, - 'verify_tls': False, 'accept_any_status': True} + check = {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path, + 'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4, + 'verify_tls': False, 'accept_any_status': True} + # A port taken from the compose file because the recipe names no + # web address may serve a database or SSH: any answer counts. + if endpoint.get('source') == GUESSED_ENDPOINT: + check['accept_non_http'] = True + return check # Without a web address, the new image must at least keep the container running. return {'type': 'running', 'timeout_seconds': 90, 'stability_seconds': 20} @@ -733,11 +754,10 @@ def healthcheck(vmid, template): args += ['-w', '%{http_code}'] if not check.get('verify_tls', False): args.append('-k') - try: - answer = run(*args, f'{scheme}://{ip}:{port}{path}') - ok = not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None - except RuntimeError: - pass + answer, status = probe_http([*args, f'{scheme}://{ip}:{port}{path}']) + ok = status == 0 and (not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None) + if not ok and check.get('accept_non_http') and status in NON_HTTP_ANSWER: + ok = True if ok: stable_since = stable_since or time.monotonic() if time.monotonic() - stable_since >= stability: diff --git a/oci/tests/test_guessed_endpoint_healthcheck.py b/oci/tests/test_guessed_endpoint_healthcheck.py new file mode 100644 index 00000000..8ce9003f --- /dev/null +++ b/oci/tests/test_guessed_endpoint_healthcheck.py @@ -0,0 +1,87 @@ +"""A web address guessed from the compose file may belong to a service that does +not speak HTTP. Updates, Modify and recovery must accept its answer instead of +waiting for an HTTP reply that never comes.""" + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) + +import oci_instance_transaction as transaction + +INSTALLER = ROOT / 'remote/install_oci.sh' +GUESSED = {'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'port': 3306, 'path': '/', + 'source': 'compose-first-tcp-port-fallback'}]}} +DECLARED = {'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'port': 8080, 'path': '/'}]}} + + +def probe_function(): + source = INSTALLER.read_text() + start = source.index('healthcheck_probe() {') + end = source.index('\n}\n', start) + 3 + return source[start:end] + + +class GuessedEndpointCheckTests(unittest.TestCase): + def test_only_a_guessed_endpoint_accepts_other_protocols(self): + self.assertTrue(transaction.effective_healthcheck(GUESSED)['accept_non_http']) + self.assertNotIn('accept_non_http', transaction.effective_healthcheck(DECLARED)) + + def run_probe(self, exit_code, code='000', accept_non_http='true', any_status='true'): + with tempfile.TemporaryDirectory() as directory: + fake = Path(directory) / 'curl' + fake.write_text(f'#!/bin/sh\nprintf %s {code}\nexit {exit_code}\n') + fake.chmod(0o755) + script = (probe_function() + '\nCURL_ARGS=()\nHC_URL=http://192.0.2.1:3306/\n' + f'HC_ANY_STATUS={any_status}\nHC_ACCEPT_NON_HTTP={accept_non_http}\nhealthcheck_probe\n') + env = {**os.environ, 'PATH': f'{directory}:{os.environ["PATH"]}'} + return subprocess.run(['bash', '-c', script], env=env, capture_output=True).returncode + + def test_installer_probe_accepts_a_non_http_answer_only_when_guessed(self): + for status in transaction.NON_HTTP_ANSWER: + with self.subTest(status=status): + self.assertEqual(self.run_probe(status), 0) + self.assertNotEqual(self.run_probe(status, accept_non_http='false'), 0) + # A closed port, a timeout or a server error still fail. + for status, code in ((7, '000'), (28, '000'), (0, '503')): + with self.subTest(status=status, code=code): + self.assertNotEqual(self.run_probe(status, code), 0) + self.assertEqual(self.run_probe(0, '200'), 0) + self.assertEqual(self.run_probe(0, any_status='false', accept_non_http='false'), 0) + + def test_recovery_check_accepts_a_database_answer(self): + check = transaction.effective_healthcheck(GUESSED) + check.update(timeout_seconds=6, stability_seconds=0) + template = {'proxmox': {'installer_profile': {'startup_healthcheck': check}}} + + def run(*args): + if args[:2] == ('pct', 'status'): + return b'status: running\n' + if args[:2] == ('pct', 'config'): + return b'' + if args[0] == 'lxc-info': + return b'192.0.2.10\n' + raise AssertionError(args) + + with patch.object(transaction, 'run', side_effect=run), \ + patch.object(transaction, 'probe_http', return_value=(b'000', 1)), \ + patch.object(transaction, 'log'): + self.assertEqual(transaction.healthcheck(113, template), 'http://192.0.2.10:3306/') + declared = transaction.effective_healthcheck(DECLARED) + declared.update(timeout_seconds=4, stability_seconds=0) + template = {'proxmox': {'installer_profile': {'startup_healthcheck': declared}}} + with patch.object(transaction, 'run', side_effect=run), \ + patch.object(transaction, 'probe_http', return_value=(b'000', 1)), \ + patch.object(transaction, 'log'), patch.object(transaction.time, 'sleep'): + with self.assertRaises(ValueError): + transaction.healthcheck(113, template) + + +if __name__ == '__main__': + unittest.main() From 152c3ddfe05daf116b19f586b1c6919d2935086f Mon Sep 17 00:00:00 2001 From: ProxMenuxBot Date: Sat, 17 Oct 2026 18:48:23 +0000 Subject: [PATCH 31/43] chore(lang): auto-rebuild translation cache Source: e38c085 Triggered by: push --- lang/sv.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lang/sv.json b/lang/sv.json index 57a2c152..3874f7bd 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -4883,8 +4883,10 @@ "Proxmology logo applied": "Proxmology-logotyp applicerad", "Proxmox 9 system update allready": "Proxmox 9 systemuppdatering redan", "Proxmox APT repositories configured": "Proxmox APT-förråd konfigurerade", + "Proxmox Backup Server": "Proxmox Backup Server", "Proxmox Backup Server (PBS) destinations": "Destinationer för Proxmox Backup Server (PBS)", "Proxmox Backup Server APT repositories configured": "Proxmox Backup Server APT-förråd konfigurerade", + "Proxmox Backup Server Update": "Proxmox Backup Server Uppdatering", "Proxmox Backup Server update completed.": "Proxmox Backup Server-uppdatering slutförd.", "Proxmox CIFS Storage Status:": "Proxmox CIFS-lagringsstatus:", "Proxmox NFS Storage Status:": "Proxmox NFS-lagringsstatus:", From 1bc38884cea7625cf8ad79c91891206cc3b434c5 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 17 Oct 2026 20:59:40 +0200 Subject: [PATCH 32/43] run the runtime HOME regressions in the OCI workflow --- .github/workflows/test-oci-registries.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 16ff314f..f04a7677 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -46,3 +46,4 @@ jobs: python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v + python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v From 7d17220294323d6ace78e12aa26a74bfd5af70de Mon Sep 17 00:00:00 2001 From: MacRimi <123239993+MacRimi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:20:56 +0200 Subject: [PATCH 33/43] fix(lang): use directorios compartidos in the Spanish Modify menu --- lang/es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/es.json b/lang/es.json index 9a7aa1db..60edefc6 100644 --- a/lang/es.json +++ b/lang/es.json @@ -5792,7 +5792,7 @@ "ShareCoveX panel": "Panel de ShareCoveX", "Shared Directory Ready:": "Directorio compartido listo:", "Shared Group": "Grupo compartido", - "Shared directories": "Carpetas compartidas", + "Shared directories": "Directorios compartidos", "Shared directory created:": "Directorio compartido creado:", "Shared directory for consume and export": "Directorio compartido para consumo y exportación", "Shared directory for copy/sync operations": "Directorio compartido para operaciones de copia/sincronización", From 2c8ebcaa9f8954549a0d6f3758b648cece0ba9c4 Mon Sep 17 00:00:00 2001 From: MacRimi <123239993+MacRimi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:21:30 +0200 Subject: [PATCH 34/43] fix(oci): count only paths added by hand in the Modify menu - Leave the application's own volumes out of the Extra paths count - Add a regression test for the count --- oci/src/proxmenux_oci/recreation.py | 3 ++- oci/tests/test_modify_menu.py | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/oci/src/proxmenux_oci/recreation.py b/oci/src/proxmenux_oci/recreation.py index f0d6f691..e1f3d897 100644 --- a/oci/src/proxmenux_oci/recreation.py +++ b/oci/src/proxmenux_oci/recreation.py @@ -120,7 +120,8 @@ def modification_options(candidate): ('memory', row(translate('RAM in MiB'), resources['memory_mb'])), ('network', row(translate('Network'), f"{network.get('bridge', '-')} · {'DHCP' if address == 'dhcp' else address}")), - ('paths', row(translate('Extra paths'), len(deployment.get('mounts', [])))), + # Only the paths added by hand: the application's own volumes are not extra. + ('paths', row(translate('Extra paths'), sum(1 for m in deployment.get('mounts', []) if m.get('custom')))), ] if profile.get('shared_directories'): options.append(('shares', translate('Shared directories'))) diff --git a/oci/tests/test_modify_menu.py b/oci/tests/test_modify_menu.py index 2709da4f..601c588e 100644 --- a/oci/tests/test_modify_menu.py +++ b/oci/tests/test_modify_menu.py @@ -147,6 +147,13 @@ class ModifyMenuTests(unittest.TestCase): recreation.edit_recreation(record(), ui) self.assertNotIn('private-test-secret', json.dumps(ui.events)) + def test_extra_paths_counts_only_paths_added_by_hand(self, _language): + candidate = record() + self.assertTrue(dict(recreation.modification_options(candidate))['paths'].rstrip().endswith(' 0')) + candidate['deployment']['mounts'].append({'container_path': '/media-extra', 'type': 'host-bind', + 'source': '/mnt/media', 'custom': True}) + self.assertTrue(dict(recreation.modification_options(candidate))['paths'].rstrip().endswith(' 1')) + def test_optional_categories_follow_the_application_profile(self, _language): candidate = record() options = dict(recreation.modification_options(candidate)) From d8ae19c76e1c7e444feef496e2a84cf6ec08b4d5 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 17 Oct 2026 21:49:00 +0200 Subject: [PATCH 35/43] open Manage straight to the list and simplify registry rows --- .../tests/test_oci_selection_setup_wording.py | 16 ++++++--- .github/workflows/test-oci-registries.yml | 1 + oci/src/proxmenux_oci/cli.py | 2 -- oci/src/proxmenux_oci/management.py | 19 +++++++++-- oci/src/proxmenux_oci/registry_accounts.py | 2 +- oci/tests/test_manage_sync_notice.py | 34 +++++++++++++++++++ 6 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 oci/tests/test_manage_sync_notice.py diff --git a/.github/scripts/tests/test_oci_selection_setup_wording.py b/.github/scripts/tests/test_oci_selection_setup_wording.py index 9d35d7d5..7baa2ad5 100644 --- a/.github/scripts/tests/test_oci_selection_setup_wording.py +++ b/.github/scripts/tests/test_oci_selection_setup_wording.py @@ -42,13 +42,13 @@ class SelectionSetupWording(TestCase): shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [], _clean_orphans=lambda project: None, recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None, - carry_records=lambda project, **options: {}, + carry_records=lambda project, **options: {}, _records_unread=lambda project: False, translate=lambda s: s) fn(Path('/fixture'), ui) ui.message.assert_called_once_with(EMPTY, 'OCI management') ui.choose.assert_not_called() - def _menu(self, inventories, selections): + def _menu(self, inventories, selections, unread=True): rows = iter(inventories) ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections)) refresh = Mock(return_value={}) @@ -57,7 +57,8 @@ class SelectionSetupWording(TestCase): shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows), _clean_orphans=lambda project: None, recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None, - carry_records=refresh, manage_instance=manage, menu_size=lambda: 10, + carry_records=refresh, _records_unread=lambda project: unread, + manage_instance=manage, menu_size=lambda: 10, STATUS_LABELS={}, translate=lambda s: s) fn(Path('/fixture'), ui) return ui, refresh, manage @@ -67,7 +68,14 @@ class SelectionSetupWording(TestCase): ui, refresh, manage = self._menu([[row], [row]], ['101', None]) self.assertEqual(ui.choose.call_count, 2) manage.assert_called_once() - refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True) + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True, + heading='Manage installed OCI applications') + + def test_the_list_opens_without_the_sync_notice_once_every_configuration_is_cached(self): + row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} + _, refresh, _ = self._menu([[row]], [None], unread=False) + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=False, + heading='Manage installed OCI applications') def test_removing_the_last_application_returns_without_a_message(self): row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 085d9783..a3edecdf 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -50,6 +50,7 @@ jobs: PYTHONDONTWRITEBYTECODE: '1' run: | python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v + python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v python3 -m unittest discover -s oci/tests -p test_recreation.py -v python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py index 9266f283..14dda6e2 100644 --- a/oci/src/proxmenux_oci/cli.py +++ b/oci/src/proxmenux_oci/cli.py @@ -578,8 +578,6 @@ def interactive(catalog: Catalog) -> int: f"{translate('All applications')} ({len(applications)})") elif selection == "manage": from .management import interactive_management - console.show_logo() - console.msg_title(translate("Manage installed OCI applications")) interactive_management(PROJECT_ROOT, ui) elif selection == "registries": from .registry_accounts import open_registry_accounts diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index 699a279a..11007edd 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -132,7 +132,7 @@ def _run_lifecycle(command, title): return completed.returncode == 0 -def carry_records(project, mount_stopped=True, vmids=None, verify=False, shown=False): +def carry_records(project, mount_stopped=True, vmids=None, verify=False, shown=False, heading=None): """Leave inside each container the copy of its record that a restore on another host needs. It is refreshed after every operation. Returns what was found for each container. Every installed application is looked at, @@ -140,6 +140,9 @@ def carry_records(project, mount_stopped=True, vmids=None, verify=False, shown=F from . import console sys.path.insert(0, str(project / 'remote')) if shown: + if heading: + console.show_logo() + console.msg_title(heading) console.msg_info(translate('Updating the copy of the record...')) try: import oci_carried_record @@ -234,6 +237,17 @@ def interactive_management(project, ui): ui.message(translate('OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.'), translate('OCI management')) +def _records_unread(project): + """Whether an installed application has no cached configuration yet; only + then does the first sync read every container and take a while.""" + sys.path.insert(0, str(project / 'remote')) + import oci_carried_record as carried + return any(not (carried.instances.location(carried.instances.ROOT, row['vmid']).parent + / carried.CONFIG_CACHE).exists() + for row in saved_inventory(project) + if row.get('status') == 'installed' and not row.get('pending')) + + def _clean_orphans(project): """Remove, silently, what is left of containers that exist on no node of the cluster.""" sys.path.insert(0, str(project / 'remote')) @@ -253,7 +267,8 @@ def _interactive_management(project, ui): _clean_orphans(project) recover_automatically(project) offer_recovery(project, ui) - carry_records(project, mount_stopped=False, shown=True) + carry_records(project, mount_stopped=False, shown=_records_unread(project), + heading=translate('Manage installed OCI applications')) # After each operation the list is shown again. Every operation leaves # the copies of the records up to date, so they are not all refreshed again. first = True diff --git a/oci/src/proxmenux_oci/registry_accounts.py b/oci/src/proxmenux_oci/registry_accounts.py index f500520c..2ad9791d 100644 --- a/oci/src/proxmenux_oci/registry_accounts.py +++ b/oci/src/proxmenux_oci/registry_accounts.py @@ -166,7 +166,7 @@ def registry_accounts_menu(ui, engine=ENGINE_ROOT, auth=None): return try: options = [('add', translate('Sign in to a registry'))] - options += [(f'account-{index}', f"{row['registry']:<28} {row['account'] or row['method']} [{translate('saved')}]") + options += [(f'account-{index}', f"{row['registry']:<28} [{translate('saved')}]") for index, row in enumerate(rows)] options += [('storage', translate('Credential storage and access'))] count = translate('{count} saved account.') if len(rows) == 1 else translate('{count} saved accounts.') diff --git a/oci/tests/test_manage_sync_notice.py b/oci/tests/test_manage_sync_notice.py new file mode 100644 index 00000000..ede909b5 --- /dev/null +++ b/oci/tests/test_manage_sync_notice.py @@ -0,0 +1,34 @@ +"""Opening Manage shows the sync notice only while some configuration is still unread.""" +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) +sys.path.insert(0, str(ROOT / 'remote')) + +from proxmenux_oci import management +import oci_carried_record as carried + + +class ManageSyncNoticeTests(unittest.TestCase): + def test_notice_only_while_an_installed_application_has_no_cache(self): + rows = [{'vmid': 101, 'status': 'installed', 'pending': False}, + {'vmid': 102, 'status': 'installed', 'pending': True}, + {'vmid': 103, 'status': 'failed', 'pending': False}] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch.object(carried.instances, 'ROOT', root), \ + patch.object(management, 'saved_inventory', return_value=rows): + self.assertTrue(management._records_unread(ROOT)) + cache = carried.instances.location(root, 101).parent / carried.CONFIG_CACHE + cache.parent.mkdir(parents=True) + cache.write_text('{}') + # The pending and failed applications are never cached by the sync. + self.assertFalse(management._records_unread(ROOT)) + + +if __name__ == '__main__': + unittest.main() From 32b71bdd3d236a6e2f3fce66f0b894c288772eb6 Mon Sep 17 00:00:00 2001 From: MacRimi <123239993+MacRimi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:52:03 +0200 Subject: [PATCH 36/43] test(oci): follow the review inside the Modify menu in the latency test --- oci/tests/test_modify_latency.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/oci/tests/test_modify_latency.py b/oci/tests/test_modify_latency.py index 788215ad..f08359a5 100644 --- a/oci/tests/test_modify_latency.py +++ b/oci/tests/test_modify_latency.py @@ -53,11 +53,12 @@ class SelectedConfigTests(unittest.TestCase): class EditorLatencyTests(unittest.TestCase): - def run_editor(self,proposal=None,backup_ready=True,stale=False,adoption_error=False,approved=True): + def run_editor(self,proposal=None,backup_ready=True,stale=False,adoption_error=False,approved=True,declined=False): events=[];saved=record() row={'vmid':115,'reason':'matched','stack':False,'pending':False,'status':'installed'} def edit(*a,**kw): events.append('editor') + if declined:return None if proposal is None:raise UserCancelled() return proposal def backup(*a,**kw):events.append('backup');return backup_ready @@ -80,7 +81,9 @@ class EditorLatencyTests(unittest.TestCase): self.assertFalse(result);self.assertEqual(events,['editor']) def test_declining_the_review_has_no_backup_or_storage_probes(self): - result,events=self.run_editor({'operation':'recreate','candidate':record()},approved=False) + # The review is part of the Modify menu: declining it and leaving the + # menu returns no proposal, so no probe or operation follows. + result,events=self.run_editor(declined=True) self.assertFalse(result);self.assertEqual(events,['editor']) def test_confirmed_edit_keeps_backup_check_before_the_lifecycle(self): From e3c9162982b027a3a7274ca48679f14b8c678f6f Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 18 Oct 2026 11:40:17 +0200 Subject: [PATCH 37/43] Carry over OCI variables changed in Proxmox and keep the LXC update badge on old checks --- .../test_oci_update_preservation_wording.py | 2 + AppImage/components/virtual-machines.tsx | 9 ++- lang/es.json | 6 ++ oci/remote/oci_instance_reconcile.py | 53 +++++++++++++++- oci/remote/oci_update_current.py | 3 + oci/src/proxmenux_oci/management.py | 36 +++++++---- oci/tests/test_instance_reconcile.py | 61 +++++++++++++++++++ oci/tests/test_modify_menu.py | 21 ++++++- tests/lxc_updates/test_observed_state_ui.cjs | 6 +- 9 files changed, 171 insertions(+), 26 deletions(-) diff --git a/.github/scripts/tests/test_oci_update_preservation_wording.py b/.github/scripts/tests/test_oci_update_preservation_wording.py index 477196e9..e3fdae92 100644 --- a/.github/scripts/tests/test_oci_update_preservation_wording.py +++ b/.github/scripts/tests/test_oci_update_preservation_wording.py @@ -3,6 +3,7 @@ import ast from contextlib import nullcontext import copy import json +import os from pathlib import Path import re import runpy @@ -129,6 +130,7 @@ class UpdateWording(unittest.TestCase): reconcile = ModuleType('oci_instance_reconcile') reconcile.propose = lambda *args: None scope = {'sys': SimpleNamespace(path=[], executable='python3'), + 'os': os, 'tempfile': tempfile, 'Path': Path, 'translate': lambda text: text, 'check_selected': lambda project, row: row, '_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)), 'images': SimpleNamespace(offer_removal=lambda *args: None), diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index a538627a..1bf5b78c 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -11,7 +11,7 @@ import { Badge } from "./ui/badge" import { Progress } from "./ui/progress" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog" -import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Pin, Tag as TagIcon } from 'lucide-react' +import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Pin, Tag as TagIcon } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Checkbox } from "./ui/checkbox" import { Switch } from "./ui/switch" @@ -2863,8 +2863,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { onClick?: () => void, ) => { if (!uc) return null - const stale = uc.stale === true || (!!uc.last_check && isLxcUpdateStale(uc)) - if (!stale && (!uc.available || !uc.count || uc.count <= 0)) return null + if (!uc.available || !uc.count || uc.count <= 0) return null const last = uc.last_check ? new Date(uc.last_check).toLocaleString() : "—" @@ -2912,8 +2911,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { space — matters most on the LXC card row where uptime, IP and other chips compete for width. Count alone with the up-arrow reads unambiguously as "pending updates". */} - {stale ? : } - {stale ? t("vmLxc.updates.checkStale") : uc.count} + + {uc.count} {/* Chevron only when the badge is wired up as a clickable shortcut — its absence on the dashboard card avoids implying interactivity where there isn't any (the whole diff --git a/lang/es.json b/lang/es.json index 077ac4c6..b54e843c 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2988,6 +2988,7 @@ "Inaccessible device during VM startup": "Dispositivo inaccesible durante el inicio de la VM", "Inactive": "Inactivo", "Inactive (entry in fstab, not currently mounted)": "Inactivo (entrada en fstab, no montado actualmente)", + "Include these changes in oci-compose?": "¿Incluir estos cambios en el oci-compose?", "Include these resources in oci-compose?": "¿Incorporar estos recursos al oci-compose?", "Includes /etc/network (may drop SSH immediately)": "Incluye /etc/network (puede eliminar SSH inmediatamente)", "Includes /etc/zfs": "Incluye /etc/zfs", @@ -6781,6 +6782,7 @@ "These Proxmox resources were added outside ProxMenux:": "Se añadieron estos recursos en Proxmox fuera de ProxMenux:", "These VMIDs are not free:": "Estos VMID no están libres:", "These are the changes that will be made": "Estos son los cambios que se harán", + "These changes were made in Proxmox outside ProxMenux:": "Estos cambios se hicieron en Proxmox fuera de ProxMenux:", "These container mount paths have an extension-like suffix:": "Estas rutas de montaje del contenedor tienen un sufijo parecido a una extensión:", "These containers were restored from a backup and this host has no record of their OCI application:": "Estos contenedores se restauraron desde un backup y este host no tiene el registro de su aplicación OCI:", "These host backups run with this job and expect its storage:": "Estos backups del host se ejecutan con este trabajo y esperan su almacenamiento:", @@ -6791,6 +6793,7 @@ "These values are asked during the installation:": "Estos valores se preguntan durante la instalación:", "They are described in the image documentation:": "Están descritos en la documentación de la imagen:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Se añadirán al oci-compose antes de continuar. No se importarán recursos incompatibles o modificados.", + "They will be saved in oci-compose before continuing.": "Se guardarán en el oci-compose antes de continuar.", "This CIFS share is mounted with restrictive permissions.": "Este recurso compartido CIFS está montado con permisos restrictivos.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU se considera incompatible con el passthrough de GPU a una VM en ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Este recurso compartido NFS está completamente restringido: ni siquiera la raíz del host puede escribir en él.", @@ -7644,6 +7647,7 @@ "active VF(s)": "FV activas", "active VFs": "FV activas", "active Virtual Functions. Changing its driver binding would destroy every VF.": "Funciones virtuales activas. Cambiar la vinculación del controlador destruiría todos los VF.", + "added in Proxmox": "añadida en Proxmox", "after copy; source tree is incomplete.": "después de la copia; El árbol fuente está incompleto.", "after update.": "después de la actualización.", "all guests": "todas las VM y CT", @@ -8010,6 +8014,7 @@ "remapped users": "usuarios reasignados", "remove the (now-empty) directory if possible": "elimine el directorio (ahora vacío) si es posible", "removed from Proxmox": "eliminado de Proxmox", + "removed in Proxmox": "eliminada en Proxmox", "removed successfully from Proxmox.": "eliminado correctamente de Proxmox.", "requires running the official installer, which restarts the Monitor service.": "requiere ejecutar el instalador oficial, que reinicia el servicio del Monitor.", "requires the package": "requiere el paquete", @@ -8113,6 +8118,7 @@ "using": "usando", "using direct path (not guaranteed to survive reboots).": "utilizando una ruta directa (no se garantiza que sobreviva a los reinicios).", "vCPUs:": "vCPU:", + "value changed in Proxmox": "valor cambiado en Proxmox", "vfio-pci IDs configured": "ID de vfio-pci configurados", "vfio-pci IDs in /etc/modprobe.d/vfio.conf": "ID de vfio-pci en /etc/modprobe.d/vfio.conf", "video": "vídeo", diff --git a/oci/remote/oci_instance_reconcile.py b/oci/remote/oci_instance_reconcile.py index 07291125..cd8632a7 100644 --- a/oci/remote/oci_instance_reconcile.py +++ b/oci/remote/oci_instance_reconcile.py @@ -9,6 +9,7 @@ import oci_instances as instances import oci_gpu_devices as gpu_devices import oci_host_mounts as host_mounts import oci_instance_transaction as transaction +import oci_native_modify as native_modify from oci_installation_state import parse_config, sha from oci_ui import translate @@ -88,6 +89,43 @@ def _device(key, value): return device +ENVIRONMENT_KEYS = ('env', 'lxc.environment.runtime') + + +def _environment(record, config): + """Variables changed in Proxmox since the last operation. Values are + applied but never listed: they may be passwords.""" + before = native_modify.environment(record['observed']['config'].encode()) + now = native_modify.environment(config) + defaults = {item.split('=', 1)[0] for item in record['observed']['image'].get('defaults', {}).get('Env', []) + if '=' in item} + removed = sorted(before.keys() - now.keys()) + # An image default comes back with a new container; its absence cannot be kept. + if any(name in defaults for name in removed): + raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} env") + changed = sorted(name for name in now if before.get(name) != now[name]) + return {name: now[name] for name in changed}, removed + + +def _adopt_environment(deployment, values, removed): + items = deployment.setdefault('environment', []) + details = [] + for name, value in values.items(): + matches = [item for item in items if item['name'] == name] + if len(matches) > 1: + raise ValueError(translate('Duplicate variable in the contract; review it before editing')) + if matches: + matches[0]['value'] = value + details.append(f"env {name}: {translate('value changed in Proxmox')}") + else: + items.append({'name': name, 'value': value, 'sensitive': True}) + details.append(f"env {name}: {translate('added in Proxmox')}") + for name in removed: + items[:] = [item for item in items if item['name'] != name] + details.append(f"env {name}: {translate('removed in Proxmox')}") + return details + + def propose(record, config): if (record.get('status') != 'installed' or record.get('stack') or record.get('stack_member') or record.get('native_stack_intent') or record.get('deployment', {}).get('stack_managed')): @@ -101,16 +139,20 @@ def propose(record, config): # was checked above, so a presentation-only change needs no adoption. if 'description' in changed: changed.remove('description') + environment = None + if any(key in ENVIRONMENT_KEYS for key in changed): + changed = [key for key in changed if key not in ENVIRONMENT_KEYS] + environment = _environment(record, config) new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)] unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE and key not in transaction.KEPT_AS_IS] if unsupported: raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} {', '.join(unsupported)}") - if not new_keys: + if not new_keys and environment is None: return None candidate = copy.deepcopy(record) deployment = candidate['deployment'] - details = [] + details = _adopt_environment(deployment, *environment) if environment else [] for key in new_keys: if key.startswith('mp'): mount = _mount(key, current[key], record['vmid']) @@ -122,8 +164,13 @@ def propose(record, config): deployment.setdefault('devices', []).append(device) kind = 'GPU' if gpu_devices.gpu_path(device['host_path']) else 'USB/Coral/NPU' details.append(f"{key}: {device['host_path']} ({kind})") + skipped = [*new_keys, *(ENVIRONMENT_KEYS if environment else ())] filtered = b'\n'.join(line for line in config.splitlines() - if not any(line.startswith(key.encode() + b': ') for key in new_keys)) + b'\n' + if not any(line.startswith(key.encode() + b': ') for key in skipped)) + if environment: + filtered += b''.join(b'\n' + line for line in record['observed']['config'].encode().splitlines() + if any(line.startswith(key.encode() + b': ') for key in ENVIRONMENT_KEYS)) + filtered += b'\n' for key, value in transaction.external_changes(record, filtered).items(): section, name = transaction.ADOPTABLE[key] target = deployment.setdefault(section, {}) if section else deployment diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py index b57f7049..f4f94038 100644 --- a/oci/remote/oci_update_current.py +++ b/oci/remote/oci_update_current.py @@ -260,6 +260,9 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non transaction.apply(instances.ROOT, vmid, archive, operation, proposal=proposal, registry_digest=digest, acknowledge_external_data=acknowledge_external_data, keep_backup=file_storage) + marker = os.environ.get('OCI_RECREATED_MARKER') + if marker: + Path(marker).touch() def main(): diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index 27ee09ad..2744032d 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -441,11 +441,12 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): return False if adoption: summary = '\n'.join(adoption['details']) - if not ui.review( - f"{translate('These Proxmox resources were added outside ProxMenux:')}\n\n{summary}\n\n" - + translate('They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.'), + # Variables reordered in Proxmox change nothing that needs a decision. + if summary and not ui.review( + f"{translate('These changes were made in Proxmox outside ProxMenux:')}\n\n{summary}\n\n" + + translate('They will be saved in oci-compose before continuing.'), translate('Review external OCI changes'), - question=translate('Include these resources in oci-compose?'), default=False): + question=translate('Include these changes in oci-compose?'), default=False): return False try: record = reconcile.commit(instances.ROOT, row['vmid'], adoption) @@ -472,15 +473,24 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): if '--acknowledge-external-data' not in command: command.append('--acknowledge-external-data') title = translate('Modify OCI') if proposal else translate('Update OCI') - if proposal is None: - completed = _run_lifecycle(command, title) - else: - # Saved environment/secrets must never be passed on the command line. - with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as file: - json.dump(proposal, file) - file.flush() - completed = _run_lifecycle(command + ['--proposal', file.name], title) - if completed and not getattr(ui, 'unattended', False): + # The downloaded image is only worth mentioning when a new container + # was created from it, not after a change applied to the running one. + with tempfile.TemporaryDirectory() as folder: + marker = Path(folder) / 'recreated' + os.environ['OCI_RECREATED_MARKER'] = str(marker) + try: + if proposal is None: + completed = _run_lifecycle(command, title) + else: + # Saved environment/secrets must never be passed on the command line. + with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as file: + json.dump(proposal, file) + file.flush() + completed = _run_lifecycle(command + ['--proposal', file.name], title) + finally: + os.environ.pop('OCI_RECREATED_MARKER', None) + recreated = marker.exists() + if completed and recreated and not getattr(ui, 'unattended', False): images.offer_removal(ui, [row['vmid']]) return completed action = ui.choose(translate('Interrupted operation'), [('status', translate('View status')), diff --git a/oci/tests/test_instance_reconcile.py b/oci/tests/test_instance_reconcile.py index b0fb25a1..bd9da868 100644 --- a/oci/tests/test_instance_reconcile.py +++ b/oci/tests/test_instance_reconcile.py @@ -155,5 +155,66 @@ class ProposalTests(unittest.TestCase): self.assertEqual(json.loads(history[0].read_text())['deployment']['mounts'], []) +class EnvironmentAdoptionTests(unittest.TestCase): + marker = '11111111-1111-1111-1111-111111111111' + + def record(self, env): + config = f'description: proxmenux-instance={self.marker}\nenv: {env}\n'.encode() + return { + 'vmid': 200, 'status': 'installed', 'installation_id': self.marker, + 'deployment': {'mounts': [], 'devices': [], 'environment': [ + {'name': 'APP_PASSWORD', 'value': 'first-secret', 'sensitive': True}]}, + 'observed': {'config': config.decode(), 'config_sha256': sha(config), + 'archive_path': '/unused.tar', 'resolved_registry_digest': 'sha256:test', + 'image': {'manifest_digest': 'sha256:test', 'defaults': {'Env': ['PATH=/bin']}}}, + } + + def propose(self, record, env): + config = f'description: proxmenux-instance={self.marker}\nenv: {env}\n'.encode() + with (patch.object(reconcile.instances, 'ROOT', Path('/inert')), + patch.object(reconcile.instances, 'identity', return_value=self.marker), + patch.object(reconcile.instances, 'location', return_value=Path(__file__)), + patch.object(reconcile.gpu_devices, 'capture', return_value={}), + patch.object(reconcile.host_mounts, 'capture_sources', return_value={}), + patch.object(reconcile.transaction, 'preflight')): + return reconcile.propose(record, config) + + def test_a_password_changed_in_proxmox_is_adopted_without_showing_it(self): + record = self.record('PATH=/bin\0APP_PASSWORD=first-secret') + proposal = self.propose(record, 'PATH=/bin\0APP_PASSWORD=my-own-secret\0TZ=Europe/Madrid') + environment = proposal['candidate']['deployment']['environment'] + self.assertEqual(environment, [ + {'name': 'APP_PASSWORD', 'value': 'my-own-secret', 'sensitive': True}, + {'name': 'TZ', 'value': 'Europe/Madrid', 'sensitive': True}]) + self.assertEqual(proposal['details'], ['env APP_PASSWORD: value changed in Proxmox', + 'env TZ: added in Proxmox']) + self.assertNotIn('my-own-secret', str(proposal['details'])) + self.assertEqual(record['deployment']['environment'][0]['value'], 'first-secret') + + def test_reordered_variables_need_no_decision(self): + record = self.record('PATH=/bin\0APP_PASSWORD=first-secret') + proposal = self.propose(record, 'APP_PASSWORD=first-secret\0PATH=/bin') + self.assertEqual(proposal['details'], []) + + def test_removing_an_image_default_is_refused(self): + record = self.record('PATH=/bin\0APP_PASSWORD=first-secret') + with self.assertRaisesRegex(ValueError, 'env'): + self.propose(record, 'APP_PASSWORD=first-secret') + + def test_removing_a_saved_variable_removes_it_from_the_contract(self): + record = self.record('PATH=/bin\0APP_PASSWORD=first-secret') + proposal = self.propose(record, 'PATH=/bin') + self.assertEqual(proposal['candidate']['deployment']['environment'], []) + self.assertEqual(proposal['details'], ['env APP_PASSWORD: removed in Proxmox']) + + def test_other_changes_next_to_the_variables_are_still_refused(self): + record = self.record('PATH=/bin') + config = (f'description: proxmenux-instance={self.marker}\nenv: PATH=/bin\0A=1\n' + 'net0: name=eth0,bridge=vmbr1\n').encode() + with (patch.object(reconcile.instances, 'identity', return_value=self.marker), + self.assertRaisesRegex(ValueError, 'net0')): + reconcile.propose(record, config) + + if __name__ == '__main__': unittest.main() diff --git a/oci/tests/test_modify_menu.py b/oci/tests/test_modify_menu.py index 601c588e..0f80a0a7 100644 --- a/oci/tests/test_modify_menu.py +++ b/oci/tests/test_modify_menu.py @@ -169,12 +169,13 @@ class ModifyMenuTests(unittest.TestCase): class ModifyDispatchTests(unittest.TestCase): - def call_manager(self, proposal, lifecycle): + def call_manager(self, proposal, lifecycle, ui=None, offer=None): import oci_instances import oci_instance_reconcile saved = record() row = {'vmid': 115, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'} - with patch.object(management, 'carry_records', return_value={}), \ + with patch.object(management.images, 'offer_removal', offer or Mock()), \ + patch.object(management, 'carry_records', return_value={}), \ patch.object(management, 'check_selected', return_value=row), \ patch.object(management, '_work_backup_ready', return_value=True), \ patch.object(oci_instances, 'read', return_value=saved), \ @@ -182,7 +183,7 @@ class ModifyDispatchTests(unittest.TestCase): patch.object(oci_instance_reconcile, 'propose', return_value=None), \ patch.object(recreation, 'edit_recreation', return_value=proposal), \ patch.object(management, '_run_lifecycle', side_effect=lifecycle): - return management.manage_instance(ROOT, Mock(), row, action='modify') + return management.manage_instance(ROOT, ui or Mock(), row, action='modify') def test_cancel_does_not_start_a_lifecycle_transaction(self): lifecycle = Mock() @@ -203,6 +204,20 @@ class ModifyDispatchTests(unittest.TestCase): self.assertTrue(self.call_manager(proposal, lifecycle)) self.assertEqual(seen, [proposal]) + def test_the_image_question_only_follows_a_recreated_container(self): + proposal = {'operation': 'recreate', 'candidate': record(), 'base_config_sha256': 'fingerprint'} + ui = Mock(unattended=False) + for recreated in (False, True): + with self.subTest(recreated=recreated): + def lifecycle(command, title): + if recreated: + Path(management.os.environ['OCI_RECREATED_MARKER']).touch() + return True + offer = Mock() + self.assertTrue(self.call_manager(proposal, lifecycle, ui, offer)) + self.assertEqual(offer.called, recreated) + self.assertNotIn('OCI_RECREATED_MARKER', management.os.environ) + if __name__ == '__main__': unittest.main() diff --git a/tests/lxc_updates/test_observed_state_ui.cjs b/tests/lxc_updates/test_observed_state_ui.cjs index 7b659bed..4f2de690 100644 --- a/tests/lxc_updates/test_observed_state_ui.cjs +++ b/tests/lxc_updates/test_observed_state_ui.cjs @@ -162,8 +162,10 @@ assert.equal(ui.osStatus({ ...recent, available: false, count: 0 }).osUpdateStat assert.equal(ui.osStatus({ ...recent, error: "probe failed" }).osUpdateStatusKnown, false) assert.equal(ui.aggregate({ app_watches: [{ update_available: true, name: "App" }] }).last_check, null, "an app badge cannot invent an OS check timestamp") -assert.equal(ui.badge(legacy).props.children[0].type, "Clock") -assert.equal(ui.badge(legacy).props.children[1], "vmLxc.updates.checkStale") +assert.equal(ui.badge(legacy).props.children[0].type, "Arrow", + "an old check keeps the same badge as a recent one") +assert.equal(ui.badge(legacy).props.children[1], 51) +assert.equal(ui.badge({ ...legacy, available: false, count: 0 }), null) assert.equal(ui.badge({ ...recent, available: true, count: 2 }).props.children[1], 2) assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null) assert.equal(requests.length, 0) From 2aa6e0ba4470ce522cdea01252599e013c5bdaee Mon Sep 17 00:00:00 2001 From: ProxMenuxBot Date: Sun, 18 Oct 2026 09:41:55 +0000 Subject: [PATCH 38/43] chore(lang): auto-rebuild translation cache Source: e3c9162 Triggered by: push --- lang/de.json | 6 ++++++ lang/fr.json | 6 ++++++ lang/it.json | 6 ++++++ lang/pt.json | 6 ++++++ lang/sk.json | 6 ++++++ lang/sv.json | 6 ++++++ 6 files changed, 36 insertions(+) diff --git a/lang/de.json b/lang/de.json index 9bc18d14..6b118ceb 100644 --- a/lang/de.json +++ b/lang/de.json @@ -2991,6 +2991,7 @@ "Inaccessible device during VM startup": "Während des VM-Starts ist kein Zugriff auf das Gerät möglich", "Inactive": "Inaktiv", "Inactive (entry in fstab, not currently mounted)": "Inaktiv (Eintrag in fstab, derzeit nicht gemountet)", + "Include these changes in oci-compose?": "Diese Änderungen in oci-compose einschließen?", "Include these resources in oci-compose?": "Diese Ressourcen in oci-compose einschließen?", "Includes /etc/network (may drop SSH immediately)": "Enthält /etc/network (kann SSH sofort löschen)", "Includes /etc/zfs": "Enthält /etc/zfs", @@ -6805,6 +6806,7 @@ "These Proxmox resources were added outside ProxMenux:": "Diese Proxmox-Ressourcen wurden außerhalb von ProxMenux hinzugefügt:", "These VMIDs are not free:": "Diese VMIDs sind nicht kostenlos:", "These are the changes that will be made": "Dies sind die Änderungen, die vorgenommen werden", + "These changes were made in Proxmox outside ProxMenux:": "Diese Änderungen wurden in Proxmox außerhalb von ProxMenux vorgenommen:", "These container mount paths have an extension-like suffix:": "Diese Einhängepfade im Container haben eine Endung, die einer Dateierweiterung ähnelt:", "These containers were restored from a backup and this host has no record of their OCI application:": "Diese Container wurden aus einem Backup wiederhergestellt und dieser Host hat keinen Datensatz ihrer OCI-Anwendung:", "These host backups run with this job and expect its storage:": "Diese Host-Backups werden mit diesem Job ausgeführt und erwarten dessen Speicherung:", @@ -6816,6 +6818,7 @@ "These values are asked during the installation:": "Diese Werte werden während der Installation abgefragt:", "They are described in the image documentation:": "Sie sind in der Dokumentation des Images beschrieben:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Sie werden zu oci-compose hinzugefügt, bevor Sie fortfahren. Nicht unterstützte oder geänderte Ressourcen werden nicht importiert.", + "They will be saved in oci-compose before continuing.": "Sie werden in oci-compose gespeichert, bevor Sie fortfahren.", "This CIFS share is mounted with restrictive permissions.": "Diese CIFS-Freigabe wird mit restriktiven Berechtigungen gemountet.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Diese GPU gilt als inkompatibel mit GPU-Passthrough zu einer VM in ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Diese NFS-Freigabe ist vollständig eingeschränkt – selbst der Host-Root kann nicht darauf schreiben.", @@ -7670,6 +7673,7 @@ "active VF(s)": "aktive VF(s)", "active VFs": "aktive VFs", "active Virtual Functions. Changing its driver binding would destroy every VF.": "aktive virtuelle Funktionen. Eine Änderung der Treiberbindung würde jedes VF zerstören.", + "added in Proxmox": "in Proxmox hinzugefügt", "after copy; source tree is incomplete.": "nach Kopie; Der Quellbaum ist unvollständig.", "after update.": "nach dem Update.", "all guests": "alle Gäste", @@ -8037,6 +8041,7 @@ "remapped users": "Benutzer neu zugeordnet", "remove the (now-empty) directory if possible": "Entfernen Sie nach Möglichkeit das (jetzt leere) Verzeichnis", "removed from Proxmox": "aus Proxmox entfernt", + "removed in Proxmox": "in Proxmox entfernt", "removed successfully from Proxmox.": "erfolgreich aus Proxmox entfernt.", "requires running the official installer, which restarts the Monitor service.": "erfordert die Ausführung des offiziellen Installationsprogramms, das den Monitor-Dienst neu startet.", "requires the package": "erfordert das Paket", @@ -8140,6 +8145,7 @@ "using": "verwenden", "using direct path (not guaranteed to survive reboots).": "Verwenden des direkten Pfads (keine Garantie, dass Neustarts überlebt werden).", "vCPUs:": "vCPUs:", + "value changed in Proxmox": "Wert in Proxmox geändert", "vfio-pci IDs configured": "vfio-pci-IDs konfiguriert", "vfio-pci IDs in /etc/modprobe.d/vfio.conf": "vfio-pci-IDs in /etc/modprobe.d/vfio.conf", "video": "Video", diff --git a/lang/fr.json b/lang/fr.json index 6b828c4a..fd53b380 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -2991,6 +2991,7 @@ "Inaccessible device during VM startup": "Périphérique inaccessible lors du démarrage de la VM", "Inactive": "Inactif", "Inactive (entry in fstab, not currently mounted)": "Inactif (entrée dans fstab, non monté actuellement)", + "Include these changes in oci-compose?": "inclure ces modifications dans oci-compose ?", "Include these resources in oci-compose?": "inclure ces ressources dans oci-compose ?", "Includes /etc/network (may drop SSH immediately)": "Inclut /etc/network (peut supprimer SSH immédiatement)", "Includes /etc/zfs": "Inclut /etc/zfs", @@ -6804,6 +6805,7 @@ "These Proxmox resources were added outside ProxMenux:": "Ces ressources Proxmox ont été ajoutées en dehors de ProxMenux :", "These VMIDs are not free:": "Ces VMID ne sont pas libres:", "These are the changes that will be made": "Ce sont les changements qui seront apportés", + "These changes were made in Proxmox outside ProxMenux:": "Ces modifications ont été apportées dans Proxmox en dehors de ProxMenux :", "These container mount paths have an extension-like suffix:": "Ces chemins de montage du conteneur ont un suffixe qui ressemble à une extension :", "These containers were restored from a backup and this host has no record of their OCI application:": "Ces conteneurs ont été restaurés à partir d'une sauvegarde et cet hôte n'a aucun enregistrement de leur application OCI :", "These host backups run with this job and expect its storage:": "Ces sauvegardes d'hôte s'exécutent avec ce travail et attendent son stockage :", @@ -6815,6 +6817,7 @@ "These values are asked during the installation:": "Ces valeurs sont demandées lors de l'installation:", "They are described in the image documentation:": "Ils sont décrits dans la documentation de l'image :", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Ils seront ajoutés à oci-compose avant de continuer. Les ressources non prises en charge ou modifiées ne sont pas importées.", + "They will be saved in oci-compose before continuing.": "Ils seront enregistrés dans oci-compose avant de continuer.", "This CIFS share is mounted with restrictive permissions.": "Ce partage CIFS est monté avec des autorisations restrictives.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Ce GPU est considéré comme incompatible avec le relais GPU vers une VM dans ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Ce partage NFS est entièrement restreint : même la racine de l'hôte ne peut pas y écrire.", @@ -7669,6 +7672,7 @@ "active VF(s)": "FV active(s)", "active VFs": "VF actives", "active Virtual Functions. Changing its driver binding would destroy every VF.": "Fonctions virtuelles actives. Changer la liaison de son pilote détruirait chaque VF.", + "added in Proxmox": "ajouté dans Proxmox", "after copy; source tree is incomplete.": "après copie ; l'arborescence des sources est incomplète.", "after update.": "après mise à jour.", "all guests": "tous les invités", @@ -8036,6 +8040,7 @@ "remapped users": "utilisateurs remappés", "remove the (now-empty) directory if possible": "supprimez le répertoire (maintenant vide) si possible", "removed from Proxmox": "supprimé de Proxmox", + "removed in Proxmox": "supprimé dans Proxmox", "removed successfully from Proxmox.": "supprimé avec succès de Proxmox.", "requires running the official installer, which restarts the Monitor service.": "nécessite l'exécution du programme d'installation officiel, qui redémarre le service Monitor.", "requires the package": "nécessite le paquet", @@ -8139,6 +8144,7 @@ "using": "en utilisant", "using direct path (not guaranteed to survive reboots).": "en utilisant un chemin direct (il n'est pas garanti de survivre aux redémarrages).", "vCPUs:": "Processeurs virtuels :", + "value changed in Proxmox": "valeur modifiée dans Proxmox", "vfio-pci IDs configured": "ID vfio-pci configurés", "vfio-pci IDs in /etc/modprobe.d/vfio.conf": "ID vfio-pci dans /etc/modprobe.d/vfio.conf", "video": "vidéo", diff --git a/lang/it.json b/lang/it.json index e589c0d1..53b3d1b6 100644 --- a/lang/it.json +++ b/lang/it.json @@ -2991,6 +2991,7 @@ "Inaccessible device during VM startup": "Dispositivo inaccessibile durante l'avvio della VM", "Inactive": "Inattivo", "Inactive (entry in fstab, not currently mounted)": "Inattivo (presente in fstab, attualmente non montato)", + "Include these changes in oci-compose?": "includere queste modifiche in oci-compose?", "Include these resources in oci-compose?": "Includere queste risorse in oci-compose?", "Includes /etc/network (may drop SSH immediately)": "Include /etc/network (potrebbe interrompere immediatamente la connessione SSH)", "Includes /etc/zfs": "Include /etc/zfs", @@ -6805,6 +6806,7 @@ "These Proxmox resources were added outside ProxMenux:": "Queste risorse Proxmox sono state aggiunte fuori da ProxMenux:", "These VMIDs are not free:": "Questi VMID non sono disponibili:", "These are the changes that will be made": "Verranno apportate queste modifiche", + "These changes were made in Proxmox outside ProxMenux:": "queste modifiche sono state apportate in Proxmox all'esterno di ProxMenux:", "These container mount paths have an extension-like suffix:": "Questi percorsi di mount del container hanno un suffisso simile a un'estensione:", "These containers were restored from a backup and this host has no record of their OCI application:": "Questi container sono stati ripristinati da un backup e questo host non ha il record della loro applicazione OCI:", "These host backups run with this job and expect its storage:": "Questi backup dell’host vengono eseguiti con questo job e richiedono il suo storage:", @@ -6816,6 +6818,7 @@ "These values are asked during the installation:": "Questi valori verranno richiesti durante l’installazione:", "They are described in the image documentation:": "Sono descritti nella documentazione dell'immagine:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Se confermi e la verifica finale riesce, queste risorse verranno registrate in oci-compose prima di proseguire. Le risorse non supportate o modificate non vengono acquisite.", + "They will be saved in oci-compose before continuing.": "verranno salvati in oci-compose prima di continuare.", "This CIFS share is mounted with restrictive permissions.": "Questa condivisione CIFS è montata con autorizzazioni restrittive.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Questa GPU è considerata incompatibile con il passthrough GPU a una VM in ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Questa condivisione NFS è completamente limitata: nemmeno l'utente root dell'host può scrivervi.", @@ -7670,6 +7673,7 @@ "active VF(s)": "VF attive", "active VFs": "VF attive", "active Virtual Functions. Changing its driver binding would destroy every VF.": "funzioni virtuali attive. La modifica del collegamento del driver distruggerebbe ogni VF.", + "added in Proxmox": "aggiunto in Proxmox", "after copy; source tree is incomplete.": "dopo la copia; l'albero dei sorgenti è incompleto.", "after update.": "dopo l'aggiornamento.", "all guests": "tutti i guest", @@ -8037,6 +8041,7 @@ "remapped users": "utenti rimappati", "remove the (now-empty) directory if possible": "rimuovere la directory (ora vuota) se possibile", "removed from Proxmox": "rimosso da Proxmox", + "removed in Proxmox": "rimosso in Proxmox", "removed successfully from Proxmox.": "rimosso con successo da Proxmox.", "requires running the official installer, which restarts the Monitor service.": "richiede l'esecuzione del programma di installazione ufficiale, che riavvia il servizio Monitor.", "requires the package": "richiede il pacchetto", @@ -8140,6 +8145,7 @@ "using": "utilizzando", "using direct path (not guaranteed to survive reboots).": "utilizzando il percorso diretto (non è garantito che sopravviva ai riavvii).", "vCPUs:": "vCPU:", + "value changed in Proxmox": "valore modificato in Proxmox", "vfio-pci IDs configured": "ID vfio-pci configurati", "vfio-pci IDs in /etc/modprobe.d/vfio.conf": "ID vfio-pci in /etc/modprobe.d/vfio.conf", "video": "video", diff --git a/lang/pt.json b/lang/pt.json index 0710e044..5e33e3a1 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -2991,6 +2991,7 @@ "Inaccessible device during VM startup": "Dispositivo inacessível durante a inicialização da VM", "Inactive": "Inativo", "Inactive (entry in fstab, not currently mounted)": "Inativo (entrada no fstab, não montada no momento)", + "Include these changes in oci-compose?": "Incluir essas alterações no oci-compose?", "Include these resources in oci-compose?": "Incluir esses recursos no oci-compose?", "Includes /etc/network (may drop SSH immediately)": "Inclui /etc/network (pode descartar o SSH imediatamente)", "Includes /etc/zfs": "Inclui /etc/zfs", @@ -6805,6 +6806,7 @@ "These Proxmox resources were added outside ProxMenux:": "Estes recursos Proxmox foram adicionados fora do ProxMenux:", "These VMIDs are not free:": "Estes VMIDs não são gratuitos:", "These are the changes that will be made": "Estas são as mudanças que serão feitas", + "These changes were made in Proxmox outside ProxMenux:": "Essas alterações foram feitas no Proxmox fora do ProxMenux:", "These container mount paths have an extension-like suffix:": "Estes caminhos de montagem do contêiner têm um sufixo parecido com uma extensão:", "These containers were restored from a backup and this host has no record of their OCI application:": "Esses contêineres foram restaurados a partir de um backup e este host não possui registro de seu aplicativo OCI:", "These host backups run with this job and expect its storage:": "Esses backups de host são executados com este trabalho e esperam seu armazenamento:", @@ -6816,6 +6818,7 @@ "These values are asked during the installation:": "Estes valores são questionados durante a instalação:", "They are described in the image documentation:": "Estão descritos na documentação da imagem:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Eles serão adicionados ao oci-compose antes de continuar. Recursos não suportados ou alterados não são importados.", + "They will be saved in oci-compose before continuing.": "Eles serão salvos em oci-compose antes de continuar.", "This CIFS share is mounted with restrictive permissions.": "Este compartilhamento CIFS é montado com permissões restritivas.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU é considerada incompatível com a passagem de GPU para uma VM no ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Este compartilhamento NFS é totalmente restrito — até mesmo o host root não pode gravar nele.", @@ -7670,6 +7673,7 @@ "active VF(s)": "FV ativa(s)", "active VFs": "FVs ativas", "active Virtual Functions. Changing its driver binding would destroy every VF.": "Funções virtuais ativas. Alterar a ligação do driver destruiria todos os VF.", + "added in Proxmox": "adicionado em Proxmox", "after copy; source tree is incomplete.": "após cópia; a árvore de origem está incompleta.", "after update.": "após a atualização.", "all guests": "todos os convidados", @@ -8037,6 +8041,7 @@ "remapped users": "usuários remapeados", "remove the (now-empty) directory if possible": "remova o diretório (agora vazio), se possível", "removed from Proxmox": "removido do Proxmox", + "removed in Proxmox": "removido em Proxmox", "removed successfully from Proxmox.": "removido com sucesso do Proxmox.", "requires running the official installer, which restarts the Monitor service.": "requer a execução do instalador oficial, que reinicia o serviço Monitor.", "requires the package": "requer o pacote", @@ -8140,6 +8145,7 @@ "using": "usando", "using direct path (not guaranteed to survive reboots).": "usando caminho direto (não é garantido que sobreviva às reinicializações).", "vCPUs:": "vCPUs:", + "value changed in Proxmox": "valor alterado em Proxmox", "vfio-pci IDs configured": "IDs vfio-pci configurados", "vfio-pci IDs in /etc/modprobe.d/vfio.conf": "IDs vfio-pci em /etc/modprobe.d/vfio.conf", "video": "vídeo", diff --git a/lang/sk.json b/lang/sk.json index 7c3d7a7c..63a88682 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -2991,6 +2991,7 @@ "Inaccessible device during VM startup": "nedostupné zariadenie počas štartu VM", "Inactive": "Neaktívne", "Inactive (entry in fstab, not currently mounted)": "Neaktívne (záznam je vo fstab, ale teraz nie je pripojený)", + "Include these changes in oci-compose?": "Zahrnúť tieto zmeny do oci-compose?", "Include these resources in oci-compose?": "Zahrnúť tieto zdroje do oci-compose?", "Includes /etc/network (may drop SSH immediately)": "Obsahuje /etc/network (môže okamžite prerušiť SSH)", "Includes /etc/zfs": "Obsahuje /etc/zfs", @@ -6806,6 +6807,7 @@ "These Proxmox resources were added outside ProxMenux:": "Tieto zdroje Proxmox boli pridané mimo ProxMenux:", "These VMIDs are not free:": "Tieto VMID nie sú zadarmo:", "These are the changes that will be made": "Tieto zmeny sa vykonajú", + "These changes were made in Proxmox outside ProxMenux:": "Tieto zmeny boli vykonané v Proxmox mimo ProxMenux:", "These container mount paths have an extension-like suffix:": "Tieto cesty pripojenia v kontajneri majú príponu podobnú koncovke súboru:", "These containers were restored from a backup and this host has no record of their OCI application:": "Tieto kontajnery boli obnovené zo zálohy a tento hostiteľ nemá žiadny záznam o ich aplikácii OCI:", "These host backups run with this job and expect its storage:": "Tieto zálohy hostiteľa sa spúšťajú s touto úlohou a očakávajú jej uloženie:", @@ -6817,6 +6819,7 @@ "These values are asked during the installation:": "Tieto hodnoty sa požadujú počas inštalácie:", "They are described in the image documentation:": "Ich popis nájdete v dokumentácii image:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Pred pokračovaním budú pridané do oci-compose. Nepodporované alebo zmenené zdroje sa neimportujú.", + "They will be saved in oci-compose before continuing.": "Pred pokračovaním budú uložené v oci-compose.", "This CIFS share is mounted with restrictive permissions.": "Toto CIFS zdieľanie je pripojené s obmedzujúcimi oprávneniami.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Táto GPU sa v ProxMenux považuje za nekompatibilnú s GPU passthrough do VM.", "This NFS share is fully restricted — even the host root cannot write to it.": "Toto NFS zdieľanie je úplne obmedzené - ani root na hostovi doň nevie zapisovať.", @@ -7671,6 +7674,7 @@ "active VF(s)": "aktívne VF", "active VFs": "aktívnymi VF", "active Virtual Functions. Changing its driver binding would destroy every VF.": "aktívnymi Virtual Functions. Zmena naviazania ovládača by zrušila každú VF.", + "added in Proxmox": "pridané v Proxmox", "after copy; source tree is incomplete.": "po kopírovaní; zdrojový strom je neúplný.", "after update.": "po aktualizácii.", "all guests": "všetci hostia", @@ -8038,6 +8042,7 @@ "remapped users": "remapovanými používateľmi", "remove the (now-empty) directory if possible": "odstrániť teraz prázdny priečinok, ak to bude možné", "removed from Proxmox": "odstránené z Proxmoxu", + "removed in Proxmox": "odstránené v Proxmox", "removed successfully from Proxmox.": "úspešne odstránené z Proxmoxu.", "requires running the official installer, which restarts the Monitor service.": "vyžaduje spustenie oficiálneho inštalátora, ktorý reštartuje službu Monitor.", "requires the package": "vyžaduje balík", @@ -8141,6 +8146,7 @@ "using": "cez", "using direct path (not guaranteed to survive reboots).": "používa sa priama cesta (nie je zaručené, že prežije reštarty).", "vCPUs:": "vCPU:", + "value changed in Proxmox": "hodnota zmenená v Proxmox", "vfio-pci IDs configured": "vfio-pci ID nastavené", "vfio-pci IDs in /etc/modprobe.d/vfio.conf": "vfio-pci ID v /etc/modprobe.d/vfio.conf", "video": "video", diff --git a/lang/sv.json b/lang/sv.json index 8a5e64aa..c06a631e 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -2991,6 +2991,7 @@ "Inaccessible device during VM startup": "Otillgänglig enhet under VM-start", "Inactive": "Inaktiv", "Inactive (entry in fstab, not currently mounted)": "Inaktiv (post i fstab, inte för närvarande monterad)", + "Include these changes in oci-compose?": "Inkludera dessa ändringar i oci-compose?", "Include these resources in oci-compose?": "Inkludera dessa resurser i oci-compose?", "Includes /etc/network (may drop SSH immediately)": "Inkluderar /etc/network (kan släppa SSH omedelbart)", "Includes /etc/zfs": "Inkluderar /etc/zfs", @@ -6805,6 +6806,7 @@ "These Proxmox resources were added outside ProxMenux:": "Dessa Proxmox-resurser lades till utanför ProxMenux:", "These VMIDs are not free:": "Dessa VMID är inte gratis:", "These are the changes that will be made": "Det är dessa ändringar som kommer att göras", + "These changes were made in Proxmox outside ProxMenux:": "Dessa ändringar gjordes i Proxmox utanför ProxMenux:", "These container mount paths have an extension-like suffix:": "De här monteringssökvägarna i containern har ett suffix som liknar ett filtillägg:", "These containers were restored from a backup and this host has no record of their OCI application:": "Dessa behållare återställdes från en säkerhetskopia och denna värd har ingen post för deras OCI-applikation:", "These host backups run with this job and expect its storage:": "Dessa värdsäkerhetskopior körs med det här jobbet och förväntar sig dess lagring:", @@ -6816,6 +6818,7 @@ "These values are asked during the installation:": "Dessa värden tillfrågas under installationen:", "They are described in the image documentation:": "De beskrivs i bilddokumentationen:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "De kommer att läggas till oci-compose innan du fortsätter. Resurser som inte stöds eller ändras importeras inte.", + "They will be saved in oci-compose before continuing.": "De kommer att sparas i oci-compose innan du fortsätter.", "This CIFS share is mounted with restrictive permissions.": "Denna CIFS-andel är monterad med restriktiva behörigheter.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Denna GPU anses vara inkompatibel med GPU-genomföring till en virtuell dator i ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Denna NFS-resurs är helt begränsad — även värdroten kan inte skriva till den.", @@ -7670,6 +7673,7 @@ "active VF(s)": "aktiva VF(s)", "active VFs": "aktiva VF:er", "active Virtual Functions. Changing its driver binding would destroy every VF.": "aktiva virtuella funktioner. Att ändra dess drivrutinsbindning skulle förstöra varje VF.", + "added in Proxmox": "lagt till i Proxmox", "after copy; source tree is incomplete.": "efter kopia; källträdet är ofullständigt.", "after update.": "efter uppdatering.", "all guests": "alla gäster", @@ -8037,6 +8041,7 @@ "remapped users": "ommappade användare", "remove the (now-empty) directory if possible": "ta bort den (nu tomma) katalogen om möjligt", "removed from Proxmox": "borttagen från Proxmox", + "removed in Proxmox": "borttagen i Proxmox", "removed successfully from Proxmox.": "har tagits bort från Proxmox.", "requires running the official installer, which restarts the Monitor service.": "kräver att det officiella installationsprogrammet körs, vilket startar om Monitor-tjänsten.", "requires the package": "kräver paketet", @@ -8140,6 +8145,7 @@ "using": "använder", "using direct path (not guaranteed to survive reboots).": "använder direkt sökväg (inte garanterat att överleva omstarter).", "vCPUs:": "vCPU:er:", + "value changed in Proxmox": "värde ändrat i Proxmox", "vfio-pci IDs configured": "vfio-pci ID:n konfigurerade", "vfio-pci IDs in /etc/modprobe.d/vfio.conf": "vfio-pci ID i /etc/modprobe.d/vfio.conf", "video": "video", From 9861a85ccd3c8f29df4abc22f6b9ea9efee28377 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 18 Oct 2026 11:49:48 +0200 Subject: [PATCH 39/43] Name the overlapping mount paths and fix new OCI review translations --- lang/fr.json | 8 ++++---- lang/it.json | 10 +++++----- lang/pt.json | 8 ++++---- oci/remote/oci_instance_transaction.py | 7 ++++--- oci/tests/test_instance_reconcile.py | 13 +++++++++++++ 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/lang/fr.json b/lang/fr.json index fd53b380..4a83f430 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -2991,7 +2991,7 @@ "Inaccessible device during VM startup": "Périphérique inaccessible lors du démarrage de la VM", "Inactive": "Inactif", "Inactive (entry in fstab, not currently mounted)": "Inactif (entrée dans fstab, non monté actuellement)", - "Include these changes in oci-compose?": "inclure ces modifications dans oci-compose ?", + "Include these changes in oci-compose?": "Inclure ces modifications dans oci-compose ?", "Include these resources in oci-compose?": "inclure ces ressources dans oci-compose ?", "Includes /etc/network (may drop SSH immediately)": "Inclut /etc/network (peut supprimer SSH immédiatement)", "Includes /etc/zfs": "Inclut /etc/zfs", @@ -6817,7 +6817,7 @@ "These values are asked during the installation:": "Ces valeurs sont demandées lors de l'installation:", "They are described in the image documentation:": "Ils sont décrits dans la documentation de l'image :", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Ils seront ajoutés à oci-compose avant de continuer. Les ressources non prises en charge ou modifiées ne sont pas importées.", - "They will be saved in oci-compose before continuing.": "Ils seront enregistrés dans oci-compose avant de continuer.", + "They will be saved in oci-compose before continuing.": "Elles seront enregistrées dans oci-compose avant de continuer.", "This CIFS share is mounted with restrictive permissions.": "Ce partage CIFS est monté avec des autorisations restrictives.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Ce GPU est considéré comme incompatible avec le relais GPU vers une VM dans ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Ce partage NFS est entièrement restreint : même la racine de l'hôte ne peut pas y écrire.", @@ -7672,7 +7672,7 @@ "active VF(s)": "FV active(s)", "active VFs": "VF actives", "active Virtual Functions. Changing its driver binding would destroy every VF.": "Fonctions virtuelles actives. Changer la liaison de son pilote détruirait chaque VF.", - "added in Proxmox": "ajouté dans Proxmox", + "added in Proxmox": "ajoutée dans Proxmox", "after copy; source tree is incomplete.": "après copie ; l'arborescence des sources est incomplète.", "after update.": "après mise à jour.", "all guests": "tous les invités", @@ -8040,7 +8040,7 @@ "remapped users": "utilisateurs remappés", "remove the (now-empty) directory if possible": "supprimez le répertoire (maintenant vide) si possible", "removed from Proxmox": "supprimé de Proxmox", - "removed in Proxmox": "supprimé dans Proxmox", + "removed in Proxmox": "supprimée dans Proxmox", "removed successfully from Proxmox.": "supprimé avec succès de Proxmox.", "requires running the official installer, which restarts the Monitor service.": "nécessite l'exécution du programme d'installation officiel, qui redémarre le service Monitor.", "requires the package": "nécessite le paquet", diff --git a/lang/it.json b/lang/it.json index 53b3d1b6..1828d013 100644 --- a/lang/it.json +++ b/lang/it.json @@ -2991,7 +2991,7 @@ "Inaccessible device during VM startup": "Dispositivo inaccessibile durante l'avvio della VM", "Inactive": "Inattivo", "Inactive (entry in fstab, not currently mounted)": "Inattivo (presente in fstab, attualmente non montato)", - "Include these changes in oci-compose?": "includere queste modifiche in oci-compose?", + "Include these changes in oci-compose?": "Includere queste modifiche in oci-compose?", "Include these resources in oci-compose?": "Includere queste risorse in oci-compose?", "Includes /etc/network (may drop SSH immediately)": "Include /etc/network (potrebbe interrompere immediatamente la connessione SSH)", "Includes /etc/zfs": "Include /etc/zfs", @@ -6806,7 +6806,7 @@ "These Proxmox resources were added outside ProxMenux:": "Queste risorse Proxmox sono state aggiunte fuori da ProxMenux:", "These VMIDs are not free:": "Questi VMID non sono disponibili:", "These are the changes that will be made": "Verranno apportate queste modifiche", - "These changes were made in Proxmox outside ProxMenux:": "queste modifiche sono state apportate in Proxmox all'esterno di ProxMenux:", + "These changes were made in Proxmox outside ProxMenux:": "Queste modifiche sono state apportate in Proxmox al di fuori di ProxMenux:", "These container mount paths have an extension-like suffix:": "Questi percorsi di mount del container hanno un suffisso simile a un'estensione:", "These containers were restored from a backup and this host has no record of their OCI application:": "Questi container sono stati ripristinati da un backup e questo host non ha il record della loro applicazione OCI:", "These host backups run with this job and expect its storage:": "Questi backup dell’host vengono eseguiti con questo job e richiedono il suo storage:", @@ -6818,7 +6818,7 @@ "These values are asked during the installation:": "Questi valori verranno richiesti durante l’installazione:", "They are described in the image documentation:": "Sono descritti nella documentazione dell'immagine:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Se confermi e la verifica finale riesce, queste risorse verranno registrate in oci-compose prima di proseguire. Le risorse non supportate o modificate non vengono acquisite.", - "They will be saved in oci-compose before continuing.": "verranno salvati in oci-compose prima di continuare.", + "They will be saved in oci-compose before continuing.": "Verranno salvate in oci-compose prima di continuare.", "This CIFS share is mounted with restrictive permissions.": "Questa condivisione CIFS è montata con autorizzazioni restrittive.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Questa GPU è considerata incompatibile con il passthrough GPU a una VM in ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Questa condivisione NFS è completamente limitata: nemmeno l'utente root dell'host può scrivervi.", @@ -7673,7 +7673,7 @@ "active VF(s)": "VF attive", "active VFs": "VF attive", "active Virtual Functions. Changing its driver binding would destroy every VF.": "funzioni virtuali attive. La modifica del collegamento del driver distruggerebbe ogni VF.", - "added in Proxmox": "aggiunto in Proxmox", + "added in Proxmox": "aggiunta in Proxmox", "after copy; source tree is incomplete.": "dopo la copia; l'albero dei sorgenti è incompleto.", "after update.": "dopo l'aggiornamento.", "all guests": "tutti i guest", @@ -8041,7 +8041,7 @@ "remapped users": "utenti rimappati", "remove the (now-empty) directory if possible": "rimuovere la directory (ora vuota) se possibile", "removed from Proxmox": "rimosso da Proxmox", - "removed in Proxmox": "rimosso in Proxmox", + "removed in Proxmox": "rimossa in Proxmox", "removed successfully from Proxmox.": "rimosso con successo da Proxmox.", "requires running the official installer, which restarts the Monitor service.": "richiede l'esecuzione del programma di installazione ufficiale, che riavvia il servizio Monitor.", "requires the package": "richiede il pacchetto", diff --git a/lang/pt.json b/lang/pt.json index 5e33e3a1..41fb2fd7 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -6818,7 +6818,7 @@ "These values are asked during the installation:": "Estes valores são questionados durante a instalação:", "They are described in the image documentation:": "Estão descritos na documentação da imagem:", "They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Eles serão adicionados ao oci-compose antes de continuar. Recursos não suportados ou alterados não são importados.", - "They will be saved in oci-compose before continuing.": "Eles serão salvos em oci-compose antes de continuar.", + "They will be saved in oci-compose before continuing.": "Elas serão salvas no oci-compose antes de continuar.", "This CIFS share is mounted with restrictive permissions.": "Este compartilhamento CIFS é montado com permissões restritivas.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU é considerada incompatível com a passagem de GPU para uma VM no ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Este compartilhamento NFS é totalmente restrito — até mesmo o host root não pode gravar nele.", @@ -7673,7 +7673,7 @@ "active VF(s)": "FV ativa(s)", "active VFs": "FVs ativas", "active Virtual Functions. Changing its driver binding would destroy every VF.": "Funções virtuais ativas. Alterar a ligação do driver destruiria todos os VF.", - "added in Proxmox": "adicionado em Proxmox", + "added in Proxmox": "adicionada no Proxmox", "after copy; source tree is incomplete.": "após cópia; a árvore de origem está incompleta.", "after update.": "após a atualização.", "all guests": "todos os convidados", @@ -8041,7 +8041,7 @@ "remapped users": "usuários remapeados", "remove the (now-empty) directory if possible": "remova o diretório (agora vazio), se possível", "removed from Proxmox": "removido do Proxmox", - "removed in Proxmox": "removido em Proxmox", + "removed in Proxmox": "removida no Proxmox", "removed successfully from Proxmox.": "removido com sucesso do Proxmox.", "requires running the official installer, which restarts the Monitor service.": "requer a execução do instalador oficial, que reinicia o serviço Monitor.", "requires the package": "requer o pacote", @@ -8145,7 +8145,7 @@ "using": "usando", "using direct path (not guaranteed to survive reboots).": "usando caminho direto (não é garantido que sobreviva às reinicializações).", "vCPUs:": "vCPUs:", - "value changed in Proxmox": "valor alterado em Proxmox", + "value changed in Proxmox": "valor alterado no Proxmox", "vfio-pci IDs configured": "IDs vfio-pci configurados", "vfio-pci IDs in /etc/modprobe.d/vfio.conf": "IDs vfio-pci em /etc/modprobe.d/vfio.conf", "video": "vídeo", diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index a2d635ce..cc36b0d0 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -571,9 +571,10 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel target = host_mounts.valid_path(mount['container_path']) if mount['type'] == 'host-bind' and mount['source'] in declared and mount.get('read_only'): continue - if (any(target == p or target.startswith(p.rstrip('/') + '/') - or p.startswith(target.rstrip('/') + '/') for p in paths)): - raise ValueError(translate('Mount paths must not overlap')) + other = next((p for p in paths if target == p or target.startswith(p.rstrip('/') + '/') + or p.startswith(target.rstrip('/') + '/')), None) + if other: + raise ValueError(f"{translate('Mount paths must not overlap')}: {target} - {other}") if mount['type'] == 'managed-volume': if mount.get('backup') is not True or not isinstance(mount.get('size_gb'), int) or mount['size_gb'] < 1: raise ValueError(translate('Managed disks must have backup enabled and a valid size')) diff --git a/oci/tests/test_instance_reconcile.py b/oci/tests/test_instance_reconcile.py index bd9da868..59cd4a3e 100644 --- a/oci/tests/test_instance_reconcile.py +++ b/oci/tests/test_instance_reconcile.py @@ -1,4 +1,5 @@ """Regression tests for explicit adoption of external OCI resources.""" +import copy import json from pathlib import Path import sys @@ -216,5 +217,17 @@ class EnvironmentAdoptionTests(unittest.TestCase): reconcile.propose(record, config) +class OverlapMessageTests(unittest.TestCase): + def test_the_refusal_names_both_paths(self): + from test_modify_review import installation + saved, config = installation() + candidate = copy.deepcopy(saved) + volume = {'type': 'managed-volume', 'source': 'local-lvm', 'size_gb': 1, 'backup': True, 'read_only': False} + candidate['deployment']['mounts'] = [dict(volume, container_path='/source'), + dict(volume, container_path='/source/archivos')] + with self.assertRaisesRegex(ValueError, '/source/archivos - /source$'): + reconcile.transaction.preflight(saved, candidate, config) + + if __name__ == '__main__': unittest.main() From 8757bbeb4fbf49b88498659c0b8af2d34570171a Mon Sep 17 00:00:00 2001 From: MacRimi <123239993+MacRimi@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:05:11 +0200 Subject: [PATCH 40/43] Count merged-usr units once and accept guests without ss or netstat - /lib/systemd/system is skipped when it is the same directory as /usr/lib/systemd/system - A guest without ss or netstat has no listener evidence instead of an incomplete scan - Spanish messages for native discovery --- AppImage/messages/es/common.json | 10 +++++++++ AppImage/scripts/native_app_discovery.py | 5 ++++- .../tests/test_native_app_discovery.py | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index a9754ca6..02a4260b 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1723,6 +1723,16 @@ "searchApplications": "Buscar aplicaciones", "searchingApplications": "Buscando aplicaciones…", "noApplicationsDetected": "No se han detectado aplicaciones.", + "nativeEvidencePort": "Puerto TCP a la escucha", + "nativeEvidenceFile": "Archivo instalado", + "nativeEvidenceProcess": "Ejecutable en marcha", + "nativeEvidenceDirectory": "Directorio de trabajo", + "nativeEvidenceService": "Definición del servicio", + "nativeScanFailed": "La búsqueda de aplicaciones no ha podido terminar. Lo detectado antes puede estar desactualizado; vuelve a intentarlo.", + "nativeScanPartial": "La búsqueda de aplicaciones está incompleta. Revisa lo que se ha encontrado y vuelve a intentarlo.", + "nativeVersionUnknown": "Versión instalada desconocida", + "nativeEvidenceStale": "Lo detectado puede estar desactualizado o incompleto.", + "nativeRegisterOnly": "Confirma el registro en el editor. Una versión conocida se guarda como valor manual; no se configura ningún actualizador.", "noNewApplicationsDetected": "No se han detectado aplicaciones nuevas.", "oneNewApplicationDetected": "Se ha detectado una aplicación nueva.", "newApplicationsDetected": "Se han detectado {count} aplicaciones nuevas.", diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py index ac9daa9e..3ba83ec5 100644 --- a/AppImage/scripts/native_app_discovery.py +++ b/AppImage/scripts/native_app_discovery.py @@ -146,6 +146,8 @@ SERVICE_SCRIPT = r''' command -v awk >/dev/null && command -v head >/dev/null || exit 1 n=0 for dir in /etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d; do + # With a merged /usr, /lib is /usr/lib: the same units must not count twice. + [ "$dir" = /lib/systemd/system ] && [ /lib/systemd/system -ef /usr/lib/systemd/system ] && continue [ ! -d "$dir" ] || [ -r "$dir" ] || exit 1 for f in "$dir"/*; do [ -f "$f" ] || continue @@ -204,7 +206,8 @@ elif command -v netstat >/dev/null; then pid=$7; sub(/\/.*$/,"",pid); if(pid ~ /^[0-9]+$/) printf "L\t%s\t%s\n",pid,port} END {print failed ? "FAILED" : (limited ? "LIMIT" : "END")}' else - exit 1 + # Without ss or netstat there is no listener evidence; a retry would not change it. + printf 'END\n' fi ''' HESTIA_PANEL_SCRIPT = r''' diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index 397bb3fc..dc24424a 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -590,6 +590,28 @@ class DiscoveryFixtures(unittest.TestCase): self.assertNotIn('SECRET', output) self.assertFalse(marker.exists()) + def test_merged_usr_units_are_counted_once(self): + usr = self.root / 'usr-units' + usr.mkdir() + (usr / 'handmade.service').write_text('[Service]\nExecStart=/opt/handmade/server\n') + lib = self.root / 'lib-units' + lib.symlink_to(usr) + check = '[ "$dir" = /lib/systemd/system ] && [ /lib/systemd/system -ef /usr/lib/systemd/system ]' + self.assertIn(check, native.SERVICE_SCRIPT) + script = (native.SERVICE_SCRIPT + .replace('/etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d', f'{usr} {lib}') + .replace(check, f'[ "$dir" = {lib} ] && [ {lib} -ef {usr} ]')) + output = native.bounded_run(['sh', '-c', script], time.monotonic() + 3) + self.assertEqual(output.count('handmade.service'), 1) + + def test_a_guest_without_ss_or_netstat_has_no_listener_evidence(self): + bin_dir = self.root / 'no-tools' + bin_dir.mkdir() + (bin_dir / 'awk').symlink_to('/usr/bin/awk') + output = native.bounded_run(['sh', '-c', 'PATH=' + str(bin_dir) + '\n' + native.PORT_SCRIPT, 'fixture', '256'], + time.monotonic() + 3) + self.assertEqual(native.parse_records(output, 'L'), ([], False, 0)) + def test_actual_process_parser_skips_docker_and_never_reads_environ_cmdline(self): proc = self.root / 'proc' proc.mkdir() From e8b8f95c5b846358ada3ca868d4a43d2ea931534 Mon Sep 17 00:00:00 2001 From: ProxMenuxBot Date: Sun, 18 Oct 2026 10:15:05 +0000 Subject: [PATCH 41/43] chore(i18n): auto-fill missing translations in messages/{locale}/common.json Source: 1cbcdd6 Triggered by: push --- AppImage/messages/de/common.json | 12 +++++++++++- AppImage/messages/fr/common.json | 12 +++++++++++- AppImage/messages/it/common.json | 12 +++++++++++- AppImage/messages/pt/common.json | 12 +++++++++++- AppImage/messages/sv/common.json | 12 +++++++++++- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index eb4fc2ec..6d9f2ee7 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1737,7 +1737,17 @@ "portCategoryCustomPlaceholder": "Geben Sie eine Kategorie ein und drücken Sie Enter (Esc zum Abbrechen)", "portCustomUrlPlaceholder": "Benutzerdefinierte URL (z. B. https://vault.example.com) überschreibt IP:port", "stackMemberTitle": "Teil einer Multi-Container-Anwendung", - "stackMemberBody": "Dieser Container gehört zu einer Anwendung, deren Hauptcontainer {primary} ist. Die Anwendung, ihre Weblinks und ihre Version werden dort angezeigt." + "stackMemberBody": "Dieser Container gehört zu einer Anwendung, deren Hauptcontainer {primary} ist. Die Anwendung, ihre Weblinks und ihre Version werden dort angezeigt.", + "nativeEvidencePort": "Überwachung des TCP-Ports", + "nativeEvidenceFile": "Installierte Datei", + "nativeEvidenceProcess": "Ausführbare Datei ausführen", + "nativeEvidenceDirectory": "Arbeitsverzeichnis", + "nativeEvidenceService": "Dienstdefinition", + "nativeScanFailed": "Die Anwendungssuche konnte nicht abgeschlossen werden.Frühere Beweise könnten veraltet sein;versuchen Sie es erneut.", + "nativeScanPartial": "Die Anwendungssuche ist unvollständig.Überprüfen Sie die verfügbaren Beweise und versuchen Sie es erneut.", + "nativeVersionUnknown": "Installierte Version unbekannt", + "nativeEvidenceStale": "Die Beweise sind möglicherweise veraltet oder unvollständig.", + "nativeRegisterOnly": "Registrierung im Editor bestätigen.Eine bekannte Version ist ein manueller Wert;Es ist kein Updater konfiguriert." }, "statusFilter": { "ariaLabel": "Virtuelle Maschinen und Container filtern", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 5b30c969..475ba188 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1737,7 +1737,17 @@ "portCategoryCustomPlaceholder": "Tapez une catégorie et appuyez sur Entrée (Esc pour annuler)", "portCustomUrlPlaceholder": "URL personnalisée (par exemple https://vault.example.com) — remplace IP:port", "stackMemberTitle": "Fait partie d'une application multi-conteneurs", - "stackMemberBody": "Ce conteneur fait partie d'une application dont le conteneur principal est {primary}. L'application, ses liens web et sa version y sont affichés." + "stackMemberBody": "Ce conteneur fait partie d'une application dont le conteneur principal est {primary}. L'application, ses liens web et sa version y sont affichés.", + "nativeEvidencePort": "Écoute du port TCP", + "nativeEvidenceFile": "Fichier installé", + "nativeEvidenceProcess": "Exécuter un exécutable", + "nativeEvidenceDirectory": "Répertoire de travail", + "nativeEvidenceService": "Définition du service", + "nativeScanFailed": "La recherche d'application n'a pas pu se terminer.Les preuves antérieures peuvent être obsolètes ;essayer à nouveau.", + "nativeScanPartial": "La recherche d’applications est incomplète.Examinez les preuves disponibles et réessayez.", + "nativeVersionUnknown": "version installée inconnue", + "nativeEvidenceStale": "Les preuves peuvent être obsolètes ou incomplètes.", + "nativeRegisterOnly": "Confirmez l'inscription dans l'éditeur.Une version connue est une valeur manuelle ;aucun programme de mise à jour n'est configuré." }, "statusFilter": { "ariaLabel": "Filtrer les machines virtuelles et les conteneurs", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index b595a4cc..542ca677 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1737,7 +1737,17 @@ "portCategoryCustomPlaceholder": "Digita una categoria e premi Enter (Esc per annullare)", "portCustomUrlPlaceholder": "URL personalizzato (ad es. https://vault.example.com) — sostituisce IP:port", "stackMemberTitle": "Parte di un'applicazione multi-container", - "stackMemberBody": "Questo container supporta un’applicazione il cui container principale è {primary}. L’applicazione, i suoi collegamenti web e la sua versione sono mostrati lì." + "stackMemberBody": "Questo container supporta un’applicazione il cui container principale è {primary}. L’applicazione, i suoi collegamenti web e la sua versione sono mostrati lì.", + "nativeEvidencePort": "porta TCP in ascolto", + "nativeEvidenceFile": "file installato", + "nativeEvidenceProcess": "esecuzione dell'eseguibile", + "nativeEvidenceDirectory": "directory di lavoro", + "nativeEvidenceService": "definizione del servizio", + "nativeScanFailed": "impossibile completare la ricerca dell'applicazione.Le prove precedenti potrebbero essere obsolete;riprova.", + "nativeScanPartial": "la ricerca dell'applicazione è incompleta.Esaminare le prove disponibili e riprovare.", + "nativeVersionUnknown": "versione installata sconosciuta", + "nativeEvidenceStale": "le prove potrebbero essere obsolete o incomplete.", + "nativeRegisterOnly": "conferma la registrazione nell'editor.Una versione nota è un valore manuale;nessun programma di aggiornamento è configurato." }, "statusFilter": { "ariaLabel": "Filtra macchine virtuali e container", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index b64fa2d5..3a08fe72 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1737,7 +1737,17 @@ "portCategoryCustomPlaceholder": "Digite uma categoria e pressione Enter (Esc para cancelar)", "portCustomUrlPlaceholder": "URL personalizada (por exemplo, https://vault.example.com) — substitui IP:port", "stackMemberTitle": "Parte de um aplicativo multicontêiner", - "stackMemberBody": "Este contêiner faz parte de um aplicativo cujo contêiner principal é {primary}. O aplicativo, seus links da web e sua versão são mostrados lá." + "stackMemberBody": "Este contêiner faz parte de um aplicativo cujo contêiner principal é {primary}. O aplicativo, seus links da web e sua versão são mostrados lá.", + "nativeEvidencePort": "Porta TCP de escuta", + "nativeEvidenceFile": "arquivo instalado", + "nativeEvidenceProcess": "Executando executável", + "nativeEvidenceDirectory": "diretório de trabalho", + "nativeEvidenceService": "definição de serviço", + "nativeScanFailed": "a pesquisa do aplicativo não pôde ser concluída.As evidências anteriores podem estar desatualizadas;tente novamente.", + "nativeScanPartial": "a pesquisa do aplicativo está incompleta.Revise as evidências disponíveis e tente novamente.", + "nativeVersionUnknown": "Versão instalada desconhecida", + "nativeEvidenceStale": "as evidências podem estar desatualizadas ou incompletas.", + "nativeRegisterOnly": "Confirme o cadastro no editor.Uma versão conhecida é um valor manual;nenhum atualizador está configurado." }, "statusFilter": { "ariaLabel": "Filtrar máquinas virtuais e contêineres", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 7b8cab64..de890ee1 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1737,7 +1737,17 @@ "portCategoryCustomPlaceholder": "Skriv en kategori och tryck på Enter (Esc för att avbryta)", "portCustomUrlPlaceholder": "Anpassad URL (t.ex. https://vault.example.com) - åsidosätter IP:port", "stackMemberTitle": "En del av en multicontainerapplikation", - "stackMemberBody": "Den här behållaren stöder en applikation vars huvudbehållare är {primary}. Applikationen, dess webblänkar och dess version visas där." + "stackMemberBody": "Den här behållaren stöder en applikation vars huvudbehållare är {primary}. Applikationen, dess webblänkar och dess version visas där.", + "nativeEvidencePort": "Lyssnande TCP-port", + "nativeEvidenceFile": "Installerad fil", + "nativeEvidenceProcess": "Kör körbar fil", + "nativeEvidenceDirectory": "Arbetskatalog", + "nativeEvidenceService": "Servicedefinition", + "nativeScanFailed": "Programsökningen kunde inte avslutas.Tidigare bevis kan vara föråldrade;försök igen.", + "nativeScanPartial": "Sökningen är ofullständig.Granska de tillgängliga bevisen och försök igen.", + "nativeVersionUnknown": "Installerad version okänd", + "nativeEvidenceStale": "Bevisen kan vara föråldrade eller ofullständiga.", + "nativeRegisterOnly": "Bekräfta registreringen i editorn.En känd version är ett manuellt värde;ingen uppdatering är konfigurerad." }, "statusFilter": { "ariaLabel": "Filtrera virtuella maskiner och containrar", From f363ef4395d7e1e11b30256639f7713121aebe65 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 18 Oct 2026 12:38:40 +0200 Subject: [PATCH 42/43] Let dismissed remote mount warnings clear the health status --- AppImage/messages/de/common.json | 12 ++-- AppImage/messages/fr/common.json | 14 ++-- AppImage/messages/it/common.json | 20 +++--- AppImage/messages/pt/common.json | 18 ++--- AppImage/messages/sv/common.json | 12 ++-- AppImage/scripts/health_monitor.py | 4 ++ .../tests/test_remote_mount_dismissal.py | 67 +++++++++++++++++++ 7 files changed, 109 insertions(+), 38 deletions(-) create mode 100644 AppImage/scripts/tests/test_remote_mount_dismissal.py diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 6d9f2ee7..4cc5b7e0 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1738,16 +1738,16 @@ "portCustomUrlPlaceholder": "Benutzerdefinierte URL (z. B. https://vault.example.com) überschreibt IP:port", "stackMemberTitle": "Teil einer Multi-Container-Anwendung", "stackMemberBody": "Dieser Container gehört zu einer Anwendung, deren Hauptcontainer {primary} ist. Die Anwendung, ihre Weblinks und ihre Version werden dort angezeigt.", - "nativeEvidencePort": "Überwachung des TCP-Ports", + "nativeEvidencePort": "Lauschender TCP-Port", "nativeEvidenceFile": "Installierte Datei", - "nativeEvidenceProcess": "Ausführbare Datei ausführen", + "nativeEvidenceProcess": "Laufende ausführbare Datei", "nativeEvidenceDirectory": "Arbeitsverzeichnis", "nativeEvidenceService": "Dienstdefinition", - "nativeScanFailed": "Die Anwendungssuche konnte nicht abgeschlossen werden.Frühere Beweise könnten veraltet sein;versuchen Sie es erneut.", - "nativeScanPartial": "Die Anwendungssuche ist unvollständig.Überprüfen Sie die verfügbaren Beweise und versuchen Sie es erneut.", + "nativeScanFailed": "Die Anwendungssuche konnte nicht abgeschlossen werden. Frühere Ergebnisse sind möglicherweise veraltet; versuchen Sie es erneut.", + "nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.", "nativeVersionUnknown": "Installierte Version unbekannt", - "nativeEvidenceStale": "Die Beweise sind möglicherweise veraltet oder unvollständig.", - "nativeRegisterOnly": "Registrierung im Editor bestätigen.Eine bekannte Version ist ein manueller Wert;Es ist kein Updater konfiguriert." + "nativeEvidenceStale": "Die Hinweise sind möglicherweise veraltet oder unvollständig.", + "nativeRegisterOnly": "Bestätigen Sie die Registrierung im Editor. Eine bekannte Version wird als manueller Wert gespeichert; es wird kein Updater eingerichtet." }, "statusFilter": { "ariaLabel": "Virtuelle Maschinen und Container filtern", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 475ba188..7d542fe5 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1738,16 +1738,16 @@ "portCustomUrlPlaceholder": "URL personnalisée (par exemple https://vault.example.com) — remplace IP:port", "stackMemberTitle": "Fait partie d'une application multi-conteneurs", "stackMemberBody": "Ce conteneur fait partie d'une application dont le conteneur principal est {primary}. L'application, ses liens web et sa version y sont affichés.", - "nativeEvidencePort": "Écoute du port TCP", + "nativeEvidencePort": "Port TCP en écoute", "nativeEvidenceFile": "Fichier installé", - "nativeEvidenceProcess": "Exécuter un exécutable", + "nativeEvidenceProcess": "Exécutable en cours d'exécution", "nativeEvidenceDirectory": "Répertoire de travail", "nativeEvidenceService": "Définition du service", - "nativeScanFailed": "La recherche d'application n'a pas pu se terminer.Les preuves antérieures peuvent être obsolètes ;essayer à nouveau.", - "nativeScanPartial": "La recherche d’applications est incomplète.Examinez les preuves disponibles et réessayez.", - "nativeVersionUnknown": "version installée inconnue", - "nativeEvidenceStale": "Les preuves peuvent être obsolètes ou incomplètes.", - "nativeRegisterOnly": "Confirmez l'inscription dans l'éditeur.Une version connue est une valeur manuelle ;aucun programme de mise à jour n'est configuré." + "nativeScanFailed": "La recherche d'applications n'a pas pu se terminer. Les résultats précédents peuvent être obsolètes ; réessayez.", + "nativeScanPartial": "La recherche d'applications est incomplète. Vérifiez les éléments trouvés et réessayez.", + "nativeVersionUnknown": "Version installée inconnue", + "nativeEvidenceStale": "Les éléments trouvés peuvent être obsolètes ou incomplets.", + "nativeRegisterOnly": "Confirmez l'enregistrement dans l'éditeur. Une version connue est enregistrée comme valeur manuelle ; aucun outil de mise à jour n'est configuré." }, "statusFilter": { "ariaLabel": "Filtrer les machines virtuelles et les conteneurs", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 542ca677..0846c5c0 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1738,16 +1738,16 @@ "portCustomUrlPlaceholder": "URL personalizzato (ad es. https://vault.example.com) — sostituisce IP:port", "stackMemberTitle": "Parte di un'applicazione multi-container", "stackMemberBody": "Questo container supporta un’applicazione il cui container principale è {primary}. L’applicazione, i suoi collegamenti web e la sua versione sono mostrati lì.", - "nativeEvidencePort": "porta TCP in ascolto", - "nativeEvidenceFile": "file installato", - "nativeEvidenceProcess": "esecuzione dell'eseguibile", - "nativeEvidenceDirectory": "directory di lavoro", - "nativeEvidenceService": "definizione del servizio", - "nativeScanFailed": "impossibile completare la ricerca dell'applicazione.Le prove precedenti potrebbero essere obsolete;riprova.", - "nativeScanPartial": "la ricerca dell'applicazione è incompleta.Esaminare le prove disponibili e riprovare.", - "nativeVersionUnknown": "versione installata sconosciuta", - "nativeEvidenceStale": "le prove potrebbero essere obsolete o incomplete.", - "nativeRegisterOnly": "conferma la registrazione nell'editor.Una versione nota è un valore manuale;nessun programma di aggiornamento è configurato." + "nativeEvidencePort": "Porta TCP in ascolto", + "nativeEvidenceFile": "File installato", + "nativeEvidenceProcess": "Eseguibile in esecuzione", + "nativeEvidenceDirectory": "Directory di lavoro", + "nativeEvidenceService": "Definizione del servizio", + "nativeScanFailed": "La ricerca delle applicazioni non è stata completata. I risultati precedenti potrebbero non essere aggiornati; riprova.", + "nativeScanPartial": "La ricerca delle applicazioni è incompleta. Controlla gli elementi trovati e riprova.", + "nativeVersionUnknown": "Versione installata sconosciuta", + "nativeEvidenceStale": "Gli elementi trovati potrebbero non essere aggiornati o essere incompleti.", + "nativeRegisterOnly": "Conferma la registrazione nell'editor. Una versione nota viene salvata come valore manuale; non viene configurato alcun aggiornamento automatico." }, "statusFilter": { "ariaLabel": "Filtra macchine virtuali e container", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 3a08fe72..c8fa95c5 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1738,16 +1738,16 @@ "portCustomUrlPlaceholder": "URL personalizada (por exemplo, https://vault.example.com) — substitui IP:port", "stackMemberTitle": "Parte de um aplicativo multicontêiner", "stackMemberBody": "Este contêiner faz parte de um aplicativo cujo contêiner principal é {primary}. O aplicativo, seus links da web e sua versão são mostrados lá.", - "nativeEvidencePort": "Porta TCP de escuta", - "nativeEvidenceFile": "arquivo instalado", - "nativeEvidenceProcess": "Executando executável", - "nativeEvidenceDirectory": "diretório de trabalho", - "nativeEvidenceService": "definição de serviço", - "nativeScanFailed": "a pesquisa do aplicativo não pôde ser concluída.As evidências anteriores podem estar desatualizadas;tente novamente.", - "nativeScanPartial": "a pesquisa do aplicativo está incompleta.Revise as evidências disponíveis e tente novamente.", + "nativeEvidencePort": "Porta TCP em escuta", + "nativeEvidenceFile": "Arquivo instalado", + "nativeEvidenceProcess": "Executável em execução", + "nativeEvidenceDirectory": "Diretório de trabalho", + "nativeEvidenceService": "Definição do serviço", + "nativeScanFailed": "A pesquisa de aplicativos não pôde ser concluída. Os resultados anteriores podem estar desatualizados; tente novamente.", + "nativeScanPartial": "A pesquisa de aplicativos está incompleta. Revise os itens encontrados e tente novamente.", "nativeVersionUnknown": "Versão instalada desconhecida", - "nativeEvidenceStale": "as evidências podem estar desatualizadas ou incompletas.", - "nativeRegisterOnly": "Confirme o cadastro no editor.Uma versão conhecida é um valor manual;nenhum atualizador está configurado." + "nativeEvidenceStale": "Os itens encontrados podem estar desatualizados ou incompletos.", + "nativeRegisterOnly": "Confirme o registro no editor. Uma versão conhecida é salva como valor manual; nenhum atualizador é configurado." }, "statusFilter": { "ariaLabel": "Filtrar máquinas virtuais e contêineres", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index de890ee1..c74cd1c1 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1740,14 +1740,14 @@ "stackMemberBody": "Den här behållaren stöder en applikation vars huvudbehållare är {primary}. Applikationen, dess webblänkar och dess version visas där.", "nativeEvidencePort": "Lyssnande TCP-port", "nativeEvidenceFile": "Installerad fil", - "nativeEvidenceProcess": "Kör körbar fil", + "nativeEvidenceProcess": "Körande programfil", "nativeEvidenceDirectory": "Arbetskatalog", - "nativeEvidenceService": "Servicedefinition", - "nativeScanFailed": "Programsökningen kunde inte avslutas.Tidigare bevis kan vara föråldrade;försök igen.", - "nativeScanPartial": "Sökningen är ofullständig.Granska de tillgängliga bevisen och försök igen.", + "nativeEvidenceService": "Tjänstdefinition", + "nativeScanFailed": "Programsökningen kunde inte slutföras. Tidigare resultat kan vara inaktuella; försök igen.", + "nativeScanPartial": "Programsökningen är ofullständig. Granska det som hittades och försök igen.", "nativeVersionUnknown": "Installerad version okänd", - "nativeEvidenceStale": "Bevisen kan vara föråldrade eller ofullständiga.", - "nativeRegisterOnly": "Bekräfta registreringen i editorn.En känd version är ett manuellt värde;ingen uppdatering är konfigurerad." + "nativeEvidenceStale": "Det som hittades kan vara inaktuellt eller ofullständigt.", + "nativeRegisterOnly": "Bekräfta registreringen i redigeraren. En känd version sparas som ett manuellt värde; ingen uppdatering konfigureras." }, "statusFilter": { "ariaLabel": "Filtrera virtuella maskiner och containrar", diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index 8b6168a9..8f52bed0 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -6076,6 +6076,8 @@ class HealthMonitor: if status == 'stale': checks[check_label] = { 'status': 'CRITICAL', + 'error_key': error_key, + 'dismissable': True, 'detail': f'{fstype} mount stale: {error or "unreachable"}', 'fstype': fstype, 'source': m.get('source', ''), @@ -6105,6 +6107,8 @@ class HealthMonitor: elif status == 'readonly': checks[check_label] = { 'status': 'WARNING', + 'error_key': error_key, + 'dismissable': True, 'detail': f'{fstype} mount unexpectedly read-only', 'fstype': fstype, 'source': m.get('source', ''), diff --git a/AppImage/scripts/tests/test_remote_mount_dismissal.py b/AppImage/scripts/tests/test_remote_mount_dismissal.py new file mode 100644 index 00000000..c66ccf17 --- /dev/null +++ b/AppImage/scripts/tests/test_remote_mount_dismissal.py @@ -0,0 +1,67 @@ +"""Remote mount checks carry the key their dismissal is saved under.""" +import sys +import unittest +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +_health_persistence_module = ModuleType("health_persistence") +_health_persistence_module.health_persistence = SimpleNamespace(cleanup_old_errors=lambda: None) +_health_persistence_module.disk_base_name = lambda name: str(name).replace("/dev/", "") +sys.modules.setdefault("health_persistence", _health_persistence_module) +sys.modules.setdefault("psutil", ModuleType("psutil")) +sys.modules.setdefault("flask_server", SimpleNamespace( + get_proxmox_node_name=lambda: "fixture", + get_cached_pvesh_cluster_resources_vm=lambda: [], + get_cached_vm_disk=lambda _vmid: None, +)) + +import health_monitor # noqa: E402 + + +class _Persistence: + def __init__(self): + self.recorded = [] + + def record_error(self, **kwargs): + self.recorded.append(kwargs) + + def get_active_errors(self, *args, **kwargs): + return [] + + def clear_error(self, key): + pass + + +class RemoteMountDismissal(unittest.TestCase): + def check(self, host, lxc=()): + persistence = _Persistence() + mounts = SimpleNamespace(scan_remote_mounts=lambda force: list(host), + scan_lxc_mounts=lambda force: list(lxc)) + with patch.object(health_monitor, "MOUNT_MONITOR_AVAILABLE", True), \ + patch.object(health_monitor, "mount_monitor", mounts, create=True), \ + patch.object(health_monitor, "health_persistence", persistence): + result = health_monitor.HealthMonitor._check_remote_mounts(SimpleNamespace()) + return result, persistence + + def test_stale_and_read_only_mounts_carry_their_saved_key(self): + result, persistence = self.check( + [{"target": "/mnt/pve/nas", "status": "stale", "fstype": "nfs4"}, + {"target": "/mnt/share", "status": "ok", "fstype": "cifs"}], + [{"target": "/media", "status": "readonly", "fstype": "nfs4", "lxc_id": "120", "lxc_name": "app"}]) + checks = result["checks"] + self.assertEqual(checks["/mnt/pve/nas"]["error_key"], "mount_stale_/mnt/pve/nas") + self.assertEqual(checks["CT 120 (app) /media"]["error_key"], "mount_readonly_ct120:/media") + self.assertTrue(checks["/mnt/pve/nas"]["dismissable"]) + self.assertTrue(checks["CT 120 (app) /media"]["dismissable"]) + self.assertNotIn("error_key", checks["/mnt/share"]) + self.assertEqual({entry["error_key"] for entry in persistence.recorded}, + {"mount_stale_/mnt/pve/nas", "mount_readonly_ct120:/media"}) + + +if __name__ == "__main__": + unittest.main() From f8cabb4700bee2a1630b72f7e642d036342b5557 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 18 Oct 2026 12:58:25 +0200 Subject: [PATCH 43/43] Check translation contracts offline, add Swedish to the installer and enable the website languages - Offline checks for terminal and website catalogs reject translations that change commands, paths, identifiers or placeholders - Terminal and documentation texts that broke those contracts are corrected in every language - Swedish can be chosen in the stable and beta installers - The website routes, language switcher and exported pages cover the eight languages, with the shared website texts in each of them - Website documentation translations are generated only when requested - From PR #475 by @jcastro --- .github/scripts/build_web_docs_i18n.py | 83 +++++- .github/scripts/check_terminal_catalogs.py | 77 ++++++ .github/scripts/check_web_docs_catalogs.py | 75 ++++++ .../scripts/tests/test_build_web_docs_i18n.py | 118 ++++++++ .../test_documentation_review_workflows.py | 30 +++ .../scripts/tests/test_installer_languages.py | 30 +++ .../scripts/tests/test_terminal_catalogs.py | 82 ++++++ .../scripts/tests/test_web_docs_catalogs.py | 94 +++++++ .../scripts/tests/test_web_export_language.py | 41 +++ .github/workflows/build-web-docs-i18n.yml | 11 +- .github/workflows/test-i18n.yml | 10 + install_proxmenux.sh | 5 +- install_proxmenux_beta.sh | 3 +- lang/de.json | 42 +-- lang/es.json | 28 +- lang/fr.json | 50 ++-- lang/pt.json | 44 +-- lang/sk.json | 12 +- lang/sv.json | 36 +-- web/CONTRIBUTING-TRANSLATIONS.md | 22 +- web/app/layout.tsx | 9 +- web/components/language-switcher.tsx | 5 + web/i18n/routing.ts | 2 +- web/messages/de/common.json | 254 ++++++++++++++++++ web/messages/en/common.json | 7 +- web/messages/es/common.json | 7 +- .../docs/backup-restore/creating-backups.json | 2 +- .../es/docs/backup-restore/cross-kernel.json | 4 +- .../backup-restore/destinations/index.json | 2 +- .../docs/backup-restore/destinations/pbs.json | 10 +- .../es/docs/backup-restore/glossary.json | 2 +- .../es/docs/backup-restore/how-it-works.json | 2 +- .../es/docs/backup-restore/restoring.json | 2 +- .../es/docs/hardware/switch-gpu-mode.json | 2 +- web/messages/es/docs/monitor/access-auth.json | 4 +- .../es/docs/monitor/api-reference.json | 4 +- .../docs/monitor/dashboard/vms-lxcs-app.json | 2 +- .../monitor/dashboard/vms-lxcs-updates.json | 8 +- .../es/docs/monitor/integrations.json | 6 +- .../es/docs/monitor/notifications.json | 10 +- .../es/docs/oci-manager/hardware.json | 2 +- .../es/docs/oci-manager/storage-network.json | 2 +- web/messages/es/docs/post-install/system.json | 2 +- .../es/docs/storage-share/host-iscsi.json | 2 +- .../docs/storage-share/lxc-mount-points.json | 2 +- web/messages/fr/common.json | 254 ++++++++++++++++++ web/messages/it/common.json | 254 ++++++++++++++++++ web/messages/pt/common.json | 254 ++++++++++++++++++ web/messages/sk/common.json | 17 +- .../docs/monitor/dashboard/vms-lxcs-app.json | 2 +- .../monitor/dashboard/vms-lxcs-updates.json | 4 +- web/messages/sv/common.json | 254 ++++++++++++++++++ web/package.json | 4 +- web/scripts/localize-export.mjs | 32 +++ 54 files changed, 2149 insertions(+), 173 deletions(-) create mode 100644 .github/scripts/check_terminal_catalogs.py create mode 100644 .github/scripts/check_web_docs_catalogs.py create mode 100644 .github/scripts/tests/test_documentation_review_workflows.py create mode 100644 .github/scripts/tests/test_installer_languages.py create mode 100644 .github/scripts/tests/test_terminal_catalogs.py create mode 100644 .github/scripts/tests/test_web_docs_catalogs.py create mode 100644 .github/scripts/tests/test_web_export_language.py create mode 100644 web/messages/de/common.json create mode 100644 web/messages/fr/common.json create mode 100644 web/messages/it/common.json create mode 100644 web/messages/pt/common.json create mode 100644 web/messages/sv/common.json create mode 100644 web/scripts/localize-export.mjs diff --git a/.github/scripts/build_web_docs_i18n.py b/.github/scripts/build_web_docs_i18n.py index e0567c15..cf77fd5f 100644 --- a/.github/scripts/build_web_docs_i18n.py +++ b/.github/scripts/build_web_docs_i18n.py @@ -73,6 +73,24 @@ TECHNICAL_TERMS = ( "DKMS", "SSH", "API", + "Flask", + "SQLite", + "Prometheus", + "Grafana", + "Uptime Kuma", + "PWA", + "Fail2Ban", + "Tailscale", + "JavaScript", + "TypeScript", + "Next.js", + "Coral", + "rclone", + "journald", + "gasket", + "apex", + "Alpine", + "OpenVINO", ) PROTECTED_PATTERNS = ( @@ -88,9 +106,13 @@ PROTECTED_PATTERNS = ( re.compile(r"(?. + re.compile(r"(?]*>.*?", + r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?", re.IGNORECASE | re.DOTALL, ) @@ -109,16 +131,23 @@ ANY_TAG_RE = re.compile(r"]*>") BARE_SENTINELS = False PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}") NON_TRANSLATABLE_KEYS = { + "accent", + "categoryslug", "command", "code", "href", "icon", + "iconbg", "id", + "menucmd", "path", "route", "slug", "src", "url", + "color", + "tone", + "variant", } SOURCE_STATE_VERSION = 1 @@ -186,12 +215,34 @@ def should_copy(source: str, path: tuple[str | int, ...]) -> bool: if not source.strip() or not re.search(r"[A-Za-z]", source): return True last = str(path[-1]).lower() if path else "" + # These fields describe the surrounding snippet; they are prose, even + # though their names end in "Code". + if last in {"aftercode", "beforecode"}: + return False + # This UI breadcrumb is prose, rather than a filesystem path. + if last == "uipath" and "" in source: + return False + if last == "flow" and source in {"loader", "dedicated"}: + return True + # These exact values drive rendering/navigation; similarly named visible + # labels remain translatable. + if last == "branch" and source in {"main", "develop", "feature/*"}: + return True + if last == "kind" and source in {"internal", "external"}: + return True + if last == "key" and source in {"linux", "nas", "windows"}: + return True if last in NON_TRANSLATABLE_KEYS or any( last.endswith(suffix) for suffix in ("url", "href", "path", "command", "code", "icon") ): return True - if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source): + if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/\S+", source): + return True + # Standalone literal blocks contain examples, not prose. Separators between + # several blocks do not make their command/configuration contents translatable. + without_literals = LITERAL_TAG_RE.sub("", source) + if without_literals != source and not re.search(r"[A-Za-z]", ANY_TAG_RE.sub("", without_literals)): return True if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source): return True @@ -202,6 +253,15 @@ def leaf_token(path: tuple[str | int, ...]) -> str: return json.dumps(path, ensure_ascii=False, separators=(",", ":")) +def literal_values_match(source: Any, target: Any) -> bool: + """Literal commands and rendering metadata must follow the source too.""" + return all( + not should_copy(leaf.source, leaf.path) + or get_at_path(target, leaf.path) == leaf.source + for leaf in iter_leaves(source) + ) + + def source_fingerprints(source: Any) -> dict[str, str]: return { leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20] @@ -240,7 +300,7 @@ def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]: def replace(match: re.Match[str]) -> str: original = match.group(1) - if original.lower() in {"code", "kbd", "pre"}: + if original.lower() in {"code", "codenw", "codexs", "kbd", "pre"}: return match.group(0) key = original.lower() internal = names.get(key) @@ -329,11 +389,14 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]: def restore_text(text: str, mapping: dict[str, str]) -> str: - missing = [token for token in mapping if token not in text] + # A local model may append a digit to a bare sentinel. A substring match + # would silently restore the prefix and leak the extra digit into prose. + patterns = {token: re.compile(re.escape(token) + r"(?![0-9])") for token in mapping} + missing = [token for token, pattern in patterns.items() if not pattern.search(text)] if missing: raise ValueError(f"translation provider changed protected token {missing[0]}") for token, original in mapping.items(): - text = text.replace(token, original) + text = patterns[token].sub(lambda match: original, text) return text @@ -347,6 +410,11 @@ def validate_contract(source: str, target: str) -> None: raise ValueError("literal code changed") if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)): raise ValueError("placeholder contract changed") + # Bare numeric protection markers once leaked a trailing zero just + # outside an otherwise intact code span. Literal/ICU checks cannot see it. + residue = re.compile(r'(?:|)(0+)(?=\s|[;:/<().,-]|$)') + if len(residue.findall(target)) > len(residue.findall(source)): + raise ValueError("unexpected numeric residue next to literal markup") def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]: @@ -822,7 +890,10 @@ def main() -> int: leaves = pending_leaves(source, target, queued_tokens) total_strings += len(iter_leaves(source)) missing_strings += len(leaves) - schema_changed = not schema_matches(source, target) + schema_changed = ( + not schema_matches(source, target) + or not literal_values_match(source, target) + ) if leaves or schema_changed: pending.append( ( diff --git a/.github/scripts/check_terminal_catalogs.py b/.github/scripts/check_terminal_catalogs.py new file mode 100644 index 00000000..ba5795da --- /dev/null +++ b/.github/scripts/check_terminal_catalogs.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Check active terminal translations offline, without generating any text. + +Validates formatting fields and literal commands/identifiers. Missing values +can retain English fallback; --require-complete makes them errors. This does +not establish the accuracy or fluency of the translated prose. +""" +from __future__ import annotations + +import argparse +from pathlib import Path +import re + +from build_translation_cache import ( + DEFAULT_LANGUAGES, STRUCTURAL_LITERAL_RE, extract_catalog_texts, + extract_python_texts, extract_translate_texts, +) +from build_web_docs_i18n import read_json, validate_contract + + +def active_sources(root: Path) -> list[str]: + # Match build-translation-cache.yml, including launchers outside scripts/. + extras = [root / name for name in ('menu', 'install_proxmenux.sh', 'install_proxmenux_beta.sh')] + texts = extract_translate_texts(root / 'scripts', extras) + texts += extract_translate_texts(root / 'oci/remote') + texts += extract_python_texts([root / 'scripts', root / 'oci/src', root / 'oci/remote']) + texts += extract_catalog_texts([root / 'oci/catalog']) + return sorted({text for text in texts if '$' not in text and '`' not in text}) + + +def validate_message(source: str, target: str, *, title_separator: bool = False) -> None: + validate_contract(source, target) + for match in STRUCTURAL_LITERAL_RE.finditer(source): + literal = match.group() + # A page title may replace its decorative separator with a dash. + # In command examples, a pipe remains a shell operator. + if title_separator and literal == '|': + continue + # DNS hostnames are case-insensitive. Paths, field names and command + # names are not, so do not case-fold those to hide a broken example. + is_hostname = re.fullmatch(r'[A-Za-z0-9.-]+\.(?:com|org|net|io)', literal) + if literal not in target and not (is_hostname and literal.casefold() in target.casefold()): + raise ValueError(f'literal command or identifier changed: {literal}') + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, default=Path('.')) + parser.add_argument('--languages', default=','.join(DEFAULT_LANGUAGES)) + parser.add_argument('--require-complete', action='store_true') + args = parser.parse_args() + root = args.root.resolve() + texts = active_sources(root) + errors = 0 + for language in args.languages.split(','): + if language not in DEFAULT_LANGUAGES: + parser.error(f'Unsupported locale: {language}') + catalog = read_json(root / 'lang' / f'{language}.json') or {} + missing = 0 + for source in texts: + target = catalog.get(source) + if not isinstance(target, str) or not target.strip(): + missing += 1 + continue + try: + validate_message(source, target) + except ValueError as error: + print(f'[{language}] {source!r}: {error}') + errors += 1 + print(f'[{language}] active={len(texts)} missing={missing}') + if args.require_complete and missing: + errors += 1 + return 1 if errors else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/check_web_docs_catalogs.py b/.github/scripts/check_web_docs_catalogs.py new file mode 100644 index 00000000..422b39cb --- /dev/null +++ b/.github/scripts/check_web_docs_catalogs.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Check documentation translation structure and literal contracts offline. + +Missing locales/leaves are allowed by default because the website falls back +to English. Use --require-complete when validating a catalog completion PR. +This checks data integrity, not translation fluency or linguistic accuracy. +""" +from __future__ import annotations + +import argparse +from pathlib import Path + +from build_web_docs_i18n import ( + DEFAULT_LANGUAGES, + get_at_path, + iter_leaves, + literal_values_match, + read_json, + schema_matches, + source_files, +) +from check_terminal_catalogs import validate_message + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--messages-dir", type=Path, default=Path("web/messages")) + parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES)) + parser.add_argument("--require-complete", action="store_true") + args = parser.parse_args() + root = args.messages_dir.resolve() + files = source_files(root / "en", ".") + errors = 0 + for language in args.languages.split(","): + language = language.strip() + if language not in DEFAULT_LANGUAGES: + parser.error(f"Unsupported locale: {language}") + missing = checked = present_files = 0 + for file in files: + relative = file.relative_to(root / "en") + source = read_json(file) + target = read_json(root / language / relative) + if target is not None: + present_files += 1 + if args.require_complete and not schema_matches(source, target): + print(f"[{language}] {relative}: source/target schema differs") + errors += 1 + for leaf in iter_leaves(source): + localized = get_at_path(target, leaf.path) + if not isinstance(localized, str) or (leaf.source.strip() and not localized.strip()): + missing += 1 + continue + try: + validate_message( + leaf.source, localized, + title_separator=bool( + leaf.path and leaf.path[0] == "meta" + and leaf.path[-1] in ("title", "ogTitle", "twitterTitle") + ), + ) + except ValueError as exc: + print(f"[{language}] {relative}:{'.'.join(map(str, leaf.path))}: {exc}") + errors += 1 + checked += 1 + if args.require_complete and not literal_values_match(source, target): + print(f"[{language}] {relative}: literal commands or rendering metadata differ") + errors += 1 + print(f"[{language}] files={present_files}/{len(files)} strings={checked} missing={missing}") + if args.require_complete and missing: + errors += 1 + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/tests/test_build_web_docs_i18n.py b/.github/scripts/tests/test_build_web_docs_i18n.py index ea2693ca..691a6639 100644 --- a/.github/scripts/tests/test_build_web_docs_i18n.py +++ b/.github/scripts/tests/test_build_web_docs_i18n.py @@ -60,6 +60,124 @@ class WebDocsI18nTests(unittest.TestCase): target = {"title": "Actualizaciones", "url": "https://example.com"} self.assertEqual(MODULE.pending_leaves(source, target), []) + def test_rendering_metadata_is_copied_but_visible_labels_are_translated(self): + for key, value in (("tone", "amber"), ("variant", "source"), + ("accent", "border-blue-300 bg-blue-50"), + ("iconBg", "bg-blue-100 text-blue-700"), + ("color", "green")): + with self.subTest(key=key): + self.assertTrue(MODULE.should_copy(value, ("steps", 0, key))) + self.assertFalse(MODULE.should_copy(value, ("label",))) + self.assertEqual(MODULE.merge_tree({key: value}, {key: "translated"}, {}), {key: value}) + self.assertFalse(MODULE.should_copy("Translation", ("type",))) + self.assertFalse(MODULE.should_copy("English", ("lang",))) + for value in ("loader", "dedicated"): + self.assertTrue(MODULE.should_copy(value, ("cards", 0, "flow"))) + self.assertFalse(MODULE.should_copy(value, ("label",))) + + def test_literal_only_examples_and_api_routes_are_not_translation_gaps(self): + for source in ("apt update", + "net0 / net1.", + "/api/task-log/<upid>", "/api/vms//config"): + with self.subTest(source=source): + self.assertTrue(MODULE.should_copy(source, ("cell",))) + self.assertFalse(MODULE.should_copy("Run apt update first.", ("body",))) + self.assertFalse(MODULE.should_copy("After running the command, check its output.", ("afterCode",))) + self.assertFalse(MODULE.should_copy("Before running the command, read this.", ("beforeCode",))) + self.assertTrue(MODULE.should_copy("curl http://localhost:8008/api/health", ("curlCode",))) + + def test_inline_identifiers_and_encoded_delimiters_round_trip(self): + source = "Set RS_SKIP_PATHS, call _rs_apply_hot and keep localStorage at <host>." + protected, mapping = MODULE.protect_text(source) + for literal in ("RS_SKIP_PATHS", "_rs_apply_hot", "localStorage", "<", ">"): + self.assertIn(literal, mapping.values()) + self.assertNotIn(literal, protected) + self.assertEqual(MODULE.restore_text(protected, mapping), source) + + def test_custom_code_spans_keep_commands_literal(self): + for tag in ("codeNw", "codeXs"): + source = f"Run <{tag}>pct config 100." + with self.subTest(tag=tag): + protected, mapping = MODULE.protect_rich_tags(source) + self.assertEqual(protected, source) + self.assertEqual(mapping, {}) + with self.assertRaisesRegex(ValueError, "literal code changed"): + MODULE.validate_contract(source, source.replace("config", "configuration")) + with self.assertRaisesRegex(ValueError, "numeric residue"): + MODULE.validate_contract(source, source.replace(f"", f"0")) + self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100", ("example",))) + + def test_navigation_identifiers_are_distinct_from_visible_labels(self): + values = { + "categorySlug": ["system", "network"], + "menuCmd": ["menu"], + "branch": ["main", "develop", "feature/*"], + "kind": ["internal", "external"], + "key": ["nas", "linux", "windows"], + } + for key, items in values.items(): + for value in items: + with self.subTest(key=key, value=value): + self.assertTrue(MODULE.should_copy(value, ("cards", 0, key))) + self.assertFalse(MODULE.should_copy(value, ("label",))) + self.assertFalse(MODULE.should_copy("Translation", ("kind",))) + self.assertFalse(MODULE.should_copy("Storage", ("branch",))) + + def test_ui_breadcrumb_is_prose_while_paths_remain_literal(self): + source = "In the UI: Datacenter → Backup → Retention:" + self.assertFalse(MODULE.should_copy(source, ("retention", "uiPath"))) + self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("uiPath",))) + self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("configPath",))) + + def test_bare_sentinel_with_added_digit_is_rejected(self): + with mock.patch.object(MODULE, "BARE_SENTINELS", True): + protected, mapping = MODULE.protect_text("ProxMenux collects metrics.") + token = next(iter(mapping)) + with self.assertRaisesRegex(ValueError, "changed protected token"): + MODULE.restore_text(protected.replace(token, token + "0"), mapping) + self.assertEqual(MODULE.restore_text(protected, mapping), "ProxMenux collects metrics.") + + def test_numeric_residue_outside_intact_literal_is_rejected(self): + examples = [ + ('Run pct config first.', 'Spustite pct config0 najprv.'), + ('Restart=on-failure restarts the service.', + '0Restart=on-failure reštartuje službu.'), + ] + for source, target in examples: + with self.subTest(source=source): + with self.assertRaisesRegex(ValueError, 'numeric residue'): + MODULE.validate_contract(source, target) + MODULE.validate_contract('The result is echo0.', + 'Výsledok je echo0.') + + def test_product_and_kernel_module_names_are_protected(self): + source = "Flask uses SQLite and journald; rclone works with Coral, gasket, apex and Alpine." + protected, mapping = MODULE.protect_text(source) + for name in ("Flask", "SQLite", "journald", "rclone", "Coral", "gasket", "apex", "Alpine"): + self.assertIn(name, mapping.values()) + self.assertNotIn(name, protected) + self.assertEqual(MODULE.restore_text(protected, mapping), source) + + def test_literal_only_changes_sync_without_retranslating_human_prose(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "messages/en/common.json" + target = root / "messages/de/common.json" + source.parent.mkdir(parents=True) + target.parent.mkdir(parents=True) + source.write_text(json.dumps({"title": "Updates", "code": "tool --version=2", "tone": "blue"})) + target.write_text(json.dumps({"title": "Aktualisierungen", "code": "tool --version=1", "tone": "blau"})) + provider = mock.Mock(side_effect=AssertionError("Existing prose must not be retranslated")) + self.assertEqual(self.run_generator(root, provider), 0) + self.assertEqual(json.loads(target.read_text()), { + "title": "Aktualisierungen", "code": "tool --version=2", "tone": "blue", + }) + provider.assert_not_called() + before = target.stat().st_mtime_ns + self.assertEqual(self.run_generator(root, provider), 0) + self.assertEqual(target.stat().st_mtime_ns, before) + provider.assert_not_called() + def test_changed_source_text_is_pending_even_with_existing_translation(self): source = {"title": "Updated installation guidance"} target = {"title": "Vorherige Installationsanleitung"} diff --git a/.github/scripts/tests/test_documentation_review_workflows.py b/.github/scripts/tests/test_documentation_review_workflows.py new file mode 100644 index 00000000..49d8cd93 --- /dev/null +++ b/.github/scripts/tests/test_documentation_review_workflows.py @@ -0,0 +1,30 @@ +"""Keep machine generation explicit without losing automatic validation.""" +from pathlib import Path +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[3] + + +class DocumentationReviewWorkflowTests(unittest.TestCase): + def workflow(self, name): + return yaml.load( + (ROOT / '.github/workflows' / name).read_text(encoding='utf-8'), + Loader=yaml.BaseLoader, + ) + + def test_generation_requires_explicit_dispatch(self): + triggers = self.workflow('build-web-docs-i18n.yml')['on'] + self.assertEqual(set(triggers), {'workflow_dispatch'}) + + def test_website_changes_keep_automatic_offline_validation(self): + triggers = self.workflow('test-i18n.yml')['on'] + for event in ('pull_request', 'push'): + with self.subTest(event=event): + self.assertIn('web/**', triggers[event]['paths']) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/tests/test_installer_languages.py b/.github/scripts/tests/test_installer_languages.py new file mode 100644 index 00000000..0aa0ccef --- /dev/null +++ b/.github/scripts/tests/test_installer_languages.py @@ -0,0 +1,30 @@ +"""Every shipped terminal language must be selectable during installation.""" +from pathlib import Path +import re +import sys +import unittest + +SCRIPTS = Path(__file__).resolve().parents[1] +ROOT = SCRIPTS.parents[1] +sys.path.insert(0, str(SCRIPTS)) +from build_translation_cache import DEFAULT_LANGUAGES + + +class InstallerLanguageTests(unittest.TestCase): + def test_both_installers_offer_every_shipped_language(self): + expected = {'en', *DEFAULT_LANGUAGES} + for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'): + with self.subTest(installer=name): + source = (ROOT / name).read_text(encoding='utf-8') + function = re.search(r'^select_language\(\) \{(.*?)^\}', source, re.M | re.S) + self.assertIsNotNone(function, 'Missing installer language selector') + offered = re.findall(r'^\s*"([a-z]{2})"\s+"[^"]+"', function.group(1), re.M) + self.assertEqual(set(offered), expected) + self.assertEqual(len(offered), len(expected), 'Duplicate language option') + # English is the source text in the scripts, not a JSON catalog. + for language in set(offered) - {'en'}: + self.assertTrue((ROOT / 'lang' / f'{language}.json').is_file(), language) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/tests/test_terminal_catalogs.py b/.github/scripts/tests/test_terminal_catalogs.py new file mode 100644 index 00000000..f74966cb --- /dev/null +++ b/.github/scripts/tests/test_terminal_catalogs.py @@ -0,0 +1,82 @@ +"""Offline regressions for copyable commands and runtime format fields.""" +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +SCRIPTS = Path(__file__).resolve().parents[1] +ROOT = SCRIPTS.parents[1] +sys.path.insert(0, str(SCRIPTS)) +SPEC = importlib.util.spec_from_file_location('check_terminal_catalogs', SCRIPTS / 'check_terminal_catalogs.py') +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(MODULE) + + +class TerminalCatalogsTests(unittest.TestCase): + def test_extractor_includes_launchers_remote_scripts_and_catalog_text(self): + fixtures = { + 'menu': 'translate "Main launcher"', + 'install_proxmenux.sh': 'translate "Stable installer"', + 'install_proxmenux_beta.sh': 'translate "Beta installer"', + 'scripts/check.sh': 'translate "Host shell script"', + 'scripts/check.py': 'translate("Host Python script")', + 'oci/src/check.py': 'N_("OCI Python script")', + 'oci/remote/check.py': 'translate("Remote Python script")', + 'oci/remote/check.sh': 'translate "Remote shell script"', + 'oci/catalog/app.json': json.dumps({'catalog_ui': {'tagline': {'en_US': 'Application tagline'}}}), + } + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name, content in fixtures.items(): + file = root / name + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(content, encoding='utf-8') + self.assertEqual(set(MODULE.active_sources(root)), { + 'Main launcher', 'Stable installer', 'Beta installer', + 'Host shell script', 'Host Python script', 'OCI Python script', + 'Remote Python script', 'Remote shell script', 'Application tagline', + }) + + def test_runtime_format_field_cannot_be_translated(self): + source = 'Kimai has no default account. Enter the container with: pct enter {main_vmid}' + with self.assertRaisesRegex(ValueError, 'placeholder'): + MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main vmid}') + MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main_vmid}') + + def test_copyable_command_and_environment_variable_are_literal(self): + examples = [ + ('Example: pct config 110 | grep unprivileged', 'Ejemplo: configuración pct 110 | grep sin privilegios'), + ("Use 'pct restore' to recover the disks.", "Verwenden Sie 'pct restart', um die Datenträger wiederherzustellen."), + ('Invalid ALLOWED_HOSTS value', 'Valeur HÔTES INDÉSIRABLES'), + ] + for source, target in examples: + with self.subTest(source=source): + with self.assertRaisesRegex(ValueError, 'literal command or identifier'): + MODULE.validate_message(source, target) + MODULE.validate_message('Example: pct config 110 | grep unprivileged', + 'Ejemplo: pct config 110 | grep unprivileged') + + def test_dns_capitalization_does_not_relax_file_path_case(self): + MODULE.validate_message('Cannot reach download.proxmox.com.', + 'Download.proxmox.com ist nicht erreichbar.') + with self.assertRaisesRegex(ValueError, 'literal command or identifier'): + MODULE.validate_message('Check /etc/pve/storage.cfg', 'Prüfen Sie /etc/PVE/storage.cfg') + + def test_shipped_active_catalogs_keep_literal_contracts(self): + result = subprocess.run( + [sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'), + '--root', str(ROOT), '--require-complete'], + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + for locale in MODULE.DEFAULT_LANGUAGES: + self.assertIn(f'[{locale}] active=', result.stdout) + self.assertNotIn('missing=1', result.stdout) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/tests/test_web_docs_catalogs.py b/.github/scripts/tests/test_web_docs_catalogs.py new file mode 100644 index 00000000..3d851425 --- /dev/null +++ b/.github/scripts/tests/test_web_docs_catalogs.py @@ -0,0 +1,94 @@ +"""Validate complete catalogs without rejecting intentional English fallback.""" +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "check_web_docs_catalogs.py" + + +class WebDocsCatalogTests(unittest.TestCase): + def run_check(self, root, *options): + return subprocess.run( + [sys.executable, "-B", str(SCRIPT), "--messages-dir", str(root), + "--languages", "de", *options], + capture_output=True, text=True, + ) + + def write_catalog(self, root, locale, content): + file = root / locale / "common.json" + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(json.dumps(content), encoding="utf-8") + + def test_partial_catalog_keeps_fallback_but_complete_mode_reports_gap(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.write_catalog(root, "en", {"title": "Updates", "body": "Check the host."}) + self.write_catalog(root, "de", {"title": "Aktualisierungen"}) + partial = self.run_check(root) + self.assertEqual(partial.returncode, 0, partial.stdout + partial.stderr) + self.assertIn("missing=1", partial.stdout) + complete = self.run_check(root, "--require-complete") + self.assertEqual(complete.returncode, 1, complete.stdout + complete.stderr) + self.assertIn("schema differs", complete.stdout) + + def test_translated_code_is_rejected_in_both_modes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.write_catalog(root, "en", {"body": "Run apt update."}) + self.write_catalog(root, "de", {"body": "Führe apt aktualisieren aus."}) + for options in ((), ("--require-complete",)): + with self.subTest(options=options): + result = self.run_check(root, *options) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + + def test_bare_commands_and_operators_are_not_prose(self): + examples = [ + ("Restore with pct restore.", "Mit pct restart wiederherstellen."), + ("pveversion | grep pve-manager", "pveversion grep pve-manager"), + ("cd /opt/my-app && ./update.sh", "cd /opt/meine-app && ./update.sh"), + ] + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for source, target in examples: + with self.subTest(source=source): + self.write_catalog(root, "en", {"body": source}) + self.write_catalog(root, "de", {"body": target}) + result = self.run_check(root) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("literal command or identifier", result.stdout) + + def test_title_separator_can_change_without_relaxing_body_pipes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.write_catalog(root, "en", {"meta": {"title": "Storage | ProxMenux"}}) + self.write_catalog(root, "de", {"meta": {"title": "Speicher — ProxMenux"}}) + result = self.run_check(root, "--require-complete") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.write_catalog(root, "en", {"meta": {"description": "pveversion | grep pve-manager"}}) + self.write_catalog(root, "de", {"meta": {"description": "pveversion grep pve-manager"}}) + result = self.run_check(root) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + + def test_shipped_web_catalogs_keep_literal_contracts(self): + messages = SCRIPT.parents[2] / "web" / "messages" + result = subprocess.run( + [sys.executable, "-B", str(SCRIPT), "--messages-dir", str(messages)], + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_literal_metadata_is_required_but_labels_can_be_localized(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.write_catalog(root, "en", {"label": "Updates", "tone": "blue"}) + self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blau"}) + invalid = self.run_check(root, "--require-complete") + self.assertEqual(invalid.returncode, 1, invalid.stdout + invalid.stderr) + self.assertIn("rendering metadata differ", invalid.stdout) + self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blue"}) + valid = self.run_check(root, "--require-complete") + self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr) diff --git a/.github/scripts/tests/test_web_export_language.py b/.github/scripts/tests/test_web_export_language.py new file mode 100644 index 00000000..143b0ccb --- /dev/null +++ b/.github/scripts/tests/test_web_export_language.py @@ -0,0 +1,41 @@ +"""Exercise the static-export language repair on real HTML files.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_command_descriptions import require_node + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "web/scripts/localize-export.mjs" + + +class WebExportLanguageTests(unittest.TestCase): + def test_locales_nested_pages_preserved_content_and_idempotence(self): + node = require_node() + html = '

    Text

    ' + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + locales = ["en", "es", "de", "fr", "it", "pt", "sk", "sv"] + for locale in locales: + file = root / "out" / locale / "docs/monitor/index.html" + file.parent.mkdir(parents=True) + file.write_text(html) + for relative in ["out/index.html", "out/assets/index.html"]: + file = root / relative + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(html) + result = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("8 localized pages", result.stdout) + for locale in locales: + actual = (root / "out" / locale / "docs/monitor/index.html").read_text() + self.assertEqual(actual, html.replace('&1 1>&2 2>&3) + "sk" "Slovenčina" \ + "sv" "Svenska" 3>&1 1>&2 2>&3) if [ -z "$LANGUAGE" ]; then msg_error "No language selected. Exiting." diff --git a/install_proxmenux_beta.sh b/install_proxmenux_beta.sh index 90022cd2..225b3502 100644 --- a/install_proxmenux_beta.sh +++ b/install_proxmenux_beta.sh @@ -594,7 +594,8 @@ select_language() { "de" "German" \ "it" "Italian" \ "pt" "Portuguese" \ - "sk" "Slovenčina" 3>&1 1>&2 2>&3) + "sk" "Slovenčina" \ + "sv" "Svenska" 3>&1 1>&2 2>&3) if [ -z "$LANGUAGE" ]; then msg_error "No language selected. Exiting." diff --git a/lang/de.json b/lang/de.json index 6b118ceb..1e552659 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1643,8 +1643,8 @@ "Default Proxmox logo applied": "Standardmäßiges Proxmox-Logo angewendet", "Default is none (video-only passthrough). Use SPACE to toggle selections.": "Der Standardwert ist „Keine“ (nur Video-Passthrough). Verwenden Sie die LEERTASTE, um die Auswahl umzuschalten.", "Default journald configuration restored": "Standard-Journald-Konfiguration wiederhergestellt", - "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "Der Standardspeicherort ist /mnt/. Die Freigabe wird hier auf dem Host mit offenen Berechtigungen gemountet, sodass ein unprivilegierter LXC sie binden, mounten und darauf schreiben kann. Für den LXC-Zugriff müssen Sie diesen Pfad mit dem LXC Mount Manager binden.", - "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "Der Standardspeicherort ist /mnt/. Die Freigabe wird hier auf dem Host gemountet. Verwenden Sie diesen Pfad in /etc/fstab. Für den LXC-Zugriff müssen Sie diesen Pfad mit dem LXC Mount Manager binden.", + "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "Der Standardspeicherort ist /mnt/. Die Freigabe wird hier auf dem Host mit offenen Berechtigungen eingehängt, sodass ein unprivilegierter LXC sie per Bind-Mount einbinden und darauf schreiben kann. Verwenden Sie dafür den LXC Mount Manager.", + "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "Der Standardspeicherort ist /mnt/. Die Freigabe wird hier auf dem Host eingehängt. Verwenden Sie diesen Pfad in /etc/fstab. Für den Zugriff aus einem LXC binden Sie ihn mit dem LXC Mount Manager als Bind-Mount ein.", "Default login": "Standard-Login", "Default options": "Standardoptionen", "Default options read/write": "Standardoptionen Lesen/Schreiben", @@ -2035,7 +2035,7 @@ "Enter VM ID:": "Geben Sie die VM-ID ein:", "Enter VM/CT ID:": "Geben Sie die VM/CT-ID ein:", "Enter ZFS pool name for the selected disk:": "Geben Sie den ZFS-Poolnamen für die ausgewählte Festplatte ein:", - "Enter a name for the mount point (used as /mnt/):": "Geben Sie einen Namen für den Mount-Punkt ein (verwendet als /mnt/):", + "Enter a name for the mount point (used as /mnt/):": "Geben Sie einen Namen für den Mountpoint ein (wird als /mnt/ verwendet):", "Enter a name for the new virtual machine:": "Geben Sie einen Namen für die neue virtuelle Maschine ein:", "Enter a non-empty password or access token on a single line.": "Geben Sie in einer einzelnen Zeile ein nicht leeres Passwort oder Zugriffstoken ein.", "Enter a number, or write or paste a command:": "Geben Sie eine Zahl ein oder schreiben oder fügen Sie einen Befehl ein:", @@ -2183,7 +2183,7 @@ "Example: pct stop 110": "Beispiel: pct stop 110", "Example: pct stop 114": "Beispiel: pct stop 114", "Example: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup": "Beispiel: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup", - "Example: pvesm path local-lvm:vm-114-disk-0": "Beispiel: pvesm-Pfad local-lvm:vm-114-disk-0", + "Example: pvesm path local-lvm:vm-114-disk-0": "Beispiel: pvesm path local-lvm:vm-114-disk-0", "Example: sed -i '/^unprivileged: 1/d' /etc/pve/lxc/110.conf": "Beispiel: sed -i '/^unprivileged: 1/d' /etc/pve/lxc/110.conf", "Examples:": "Beispiele:", "Execute destructive rollback?": "Destruktives Rollback ausführen?", @@ -2495,7 +2495,7 @@ "For a consistent export, the VM should be stopped.": "Für einen konsistenten Export sollte die VM gestoppt werden.", "For any question, issue or bug report, please use the official repository": "Für Fragen, Probleme oder Fehlerberichte nutzen Sie bitte das offizielle Repository", "For dedicated GPUs, FLR is required by this policy to reduce VM": "Für dedizierte GPUs ist gemäß dieser Richtlinie FLR erforderlich, um VM zu reduzieren", - "For example: pct enter 101": "Beispiel: pct Geben Sie 101 ein", + "For example: pct enter 101": "Zum Beispiel: pct enter 101", "For guest shares add: --options guest": "Für Gastfreigaben hinzufügen: --options Guest", "For security reasons, the system cannot format it.": "Aus Sicherheitsgründen kann das System es nicht formatieren.", "For unprivileged containers, use instead:": "Verwenden Sie für nichtprivilegierte Container stattdessen:", @@ -2907,8 +2907,8 @@ "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.": "Wenn Benachrichtigungen aktiviert sind (Telegram/Discord/ntfy/...), erhalten Sie die Meldung „Host-Wiederherstellung abgeschlossen“, wenn alle Hintergrundaufgaben abgeschlossen sind.", "If passthrough fails on Windows: install RadeonResetBugFix.": "Wenn Passthrough unter Windows fehlschlägt: Installieren Sie RadeonResetBugFix.", "If path not accessible (ZFS/BTRFS):": "Wenn der Pfad nicht zugänglich ist (ZFS/BTRFS):", - "If pvesm path returned a DEVICE (LVM):": "Wenn der pvesm-Pfad ein GERÄT (LVM) zurückgegeben hat:", - "If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Wenn der pvesm-Pfad ein VERZEICHNIS (ZFS/BTRFS) zurückgegeben hat:", + "If pvesm path returned a DEVICE (LVM):": "Wenn pvesm path ein GERÄT (LVM) zurückgegeben hat:", + "If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Wenn pvesm path ein VERZEICHNIS (ZFS/BTRFS) zurückgegeben hat:", "If repositories error:": "Bei Repository-Fehler:", "If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Wenn der Container bereits geändert wurde, wird sein vorheriges natives Backup wiederhergestellt. Freigegebene Hostverzeichnisse werden nicht zurückgesetzt. Die Festplatten des fehlgeschlagenen Versuchs werden entfernt, wenn die Bereinigung erfolgreich ist.", "If the host freezes, remove hostpci entries from": "Wenn der Host einfriert, entfernen Sie die Hostpci-Einträge aus", @@ -3225,7 +3225,7 @@ "Interrupted operation:": "Unterbrochene operation:", "Interrupted stack operation found": "Unterbrochener Stapel operation gefunden", "Invalid 'proxmox-ve' candidate (not 9.x or none). Please verify your repository configuration and network, then retry.": "Ungültiger „proxmox-ve“-Kandidat (nicht 9.x oder keiner). Bitte überprüfen Sie Ihre Repository-Konfiguration und Ihr Netzwerk und versuchen Sie es dann erneut.", - "Invalid ALLOWED_HOSTS value": "Wert für ungültig ALLOWED HOSTs", + "Invalid ALLOWED_HOSTS value": "Ungültiger Wert für ALLOWED_HOSTS", "Invalid Home Assistant OS check timeout": "Invalid Home Assistant OS Check Timeout", "Invalid ID": "Ungültige ID", "Invalid IPv4 address": "Ungültige IPv4-Adresse", @@ -3460,7 +3460,7 @@ "Keyfile removed.": "Schlüsseldatei entfernt.", "Keyrings method failed; trying apt-key fallback": "Keyrings-Methode fehlgeschlagen; Ich versuche einen Apt-Key-Fallback", "KiCad - A Cross Platform and Open Source Electronics Design Automation Suite.": "KiCad - Eine Cross-Plattform und Open Source Electronics Design Automation Suite.", - "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai hat kein Standardkonto. Geben Sie den Container ein mit: pct enter {main vmid}", + "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai hat kein Standardkonto. Öffnen Sie eine Shell im Container mit: pct enter {main_vmid}", "Kimai is a professional grade time-tracking application, free and open-source.": "Kimai ist eine professionelle Time-Tracking-Anwendung, kostenlos und Open-Source.", "Kometa is a powerful tool designed to give you complete control over your media libraries. With Kometa, you can take your customization to the next level, with granular control over metadata, collections, overlays, and much more.": "Kometa ist ein leistungsstarkes Tool, das Ihnen die vollständige Kontrolle über Ihre Medienbibliotheken gibt. Mit Kometa können Sie Ihre Anpassung auf die nächste Ebene bringen, mit granularer Kontrolle über Metadaten, Sammlungen, Overlays und vielem mehr.", "Kometa reads its configuration from /config/config.yml and the container only ships /config/config.yml.template. Copy the template to config.yml, fill in the required Plex and TMDb connections, then restart the container.": "Kometa liest seine Konfiguration aus /config/config.yml und der Container liefert nur /config/config.yml.template. Kopieren Sie die Vorlage auf config.yml, füllen Sie die required Plex und TMDb-Verbindungen aus und starten Sie dann den Container neu.", @@ -3531,7 +3531,7 @@ "Limiting size and optimizing journald": "Begrenzung der Größe und Optimierung des Journals", "Limiting size and optimizing journald...": "Größe begrenzen und Journal optimieren...", "Limnoria A robust, full-featured, and user/programmer-friendly Python IRC bot, with many existing plugins. Successor of the well-known Supybot.": "Limnoria Ein robuster, voll ausgestatteter und benutzerfreundlicher Python IRC-Bot mit vielen vorhandenen Plugins. Nachfolger des bekannten Supybot.", - "Limnoria joins no IRC network until its configuration file exists. Create it with the setup wizard from the Proxmox host: pct exec -- bash -c 'cd /config && limnoria-wizard'": "Limnoria schließt sich keinem IRC-Netzwerk an, bis seine Konfigurationsdatei existiert. Erstellen Sie es mit dem Setup-Assistenten aus dem Proxmox-Host: pct exec - bash -c 'cd /config & & limnoria-Wizard'", + "Limnoria joins no IRC network until its configuration file exists. Create it with the setup wizard from the Proxmox host: pct exec -- bash -c 'cd /config && limnoria-wizard'": "Limnoria verbindet sich erst mit einem IRC-Netzwerk, wenn die Konfigurationsdatei existiert. Erstellen Sie sie mit dem Einrichtungsassistenten auf dem Proxmox-Host: pct exec -- bash -c 'cd /config && limnoria-wizard'", "Line to paste (single line, including \"command=...\" prefix):": "Einzufügende Zeile (einzelne Zeile, einschließlich „command=…“-Präfix):", "Linux Installation Options": "Linux-Installationsoptionen", "Linux/Mac path:": "Linux/Mac-Pfad:", @@ -4263,7 +4263,7 @@ "No jobs": "Keine Jobs", "No language selected.": "Keine Sprache ausgewählt.", "No local disk storage configured.": "Kein lokaler Festplattenspeicher konfiguriert.", - "No main sources.list present (skipped)": "Keine Hauptquellenliste vorhanden (übersprungen)", + "No main sources.list present (skipped)": "Keine zentrale sources.list vorhanden (übersprungen)", "No matches for:": "Keine Treffer für:", "No matching PVE vzdump jobs were found for the PBS backend.": "Für das PBS-Backend wurden keine passenden PVE-vzdump-Jobs gefunden.", "No matching PVE vzdump jobs were found for the local archive backend.": "Für das lokale Archiv-Backend wurden keine passenden PVE-vzdump-Jobs gefunden.", @@ -6993,7 +6993,7 @@ "Transaction log:": "Transaktionsprotokoll:", "Translation files:": "Übersetzungsdateien:", "Transmission is designed for easy, powerful use. Transmission has the features you want from a BitTorrent client: encryption, a web interface, peer exchange, magnet links, DHT, µTP, UPnP and NAT-PMP port forwarding, webseed support, watch directories, tracker editing, global and per-torrent speed limits, and more.": "Transmission wurde für eine einfache und leistungsstarke Nutzung entwickelt. Transmission verfügt über die Funktionen, die Sie von einem BitTorrent-Client erwarten: Verschlüsselung, eine Web-Schnittstelle, Peer-Exchange, Magnet-Links, DHT, μTP, UPnP und NAT-PMP Port-Weiterleitung, Webseed-Unterstützung, Watch-Verzeichnisse, Tracker-Bearbeitung, globale und per-Torrent-Geschwindigkeitsbeschränkungen und mehr.", - "Tried pvesm path and manual detection methods": "Versuchte den Pvesm-Pfad und manuelle Erkennungsmethoden", + "Tried pvesm path and manual detection methods": "pvesm path und manuelle Erkennungsmethoden wurden versucht", "Trust this certificate and save it for scheduled backups?": "Diesem Zertifikat vertrauen und es für geplante Sicherungen speichern?", "Try Again": "Versuchen Sie es erneut", "Try a different search term.": "Versuchen Sie es mit einem anderen Suchbegriff.", @@ -7249,7 +7249,7 @@ "Usage:": "Verwendung:", "Use \"Check test progress\" to see results.": "Verwenden Sie „Testfortschritt prüfen“, um die Ergebnisse anzuzeigen.", "Use 'Export to file' to save it and inspect manually.": "Verwenden Sie „In Datei exportieren“, um es zu speichern und manuell zu überprüfen.", - "Use 'pct restore' / 'qmrestore' to recover their disks from your VM backups.": "Verwenden Sie „pct restart“ / „qmrestore“, um ihre Festplatten aus Ihren VM-Backups wiederherzustellen.", + "Use 'pct restore' / 'qmrestore' to recover their disks from your VM backups.": "Verwenden Sie 'pct restore' / 'qmrestore', um die Datenträger aus den VM-Sicherungen wiederherzustellen.", "Use Custom backup and uncheck the conflicting path from the list": "Verwenden Sie die benutzerdefinierte Sicherung und deaktivieren Sie den in Konflikt stehenden Pfad aus der Liste", "Use Default Settings?": "Standardeinstellungen verwenden?", "Use Download first if you want to save a copy of the current key. Continue?": "Verwenden Sie zuerst „Herunterladen“, wenn Sie eine Kopie des aktuellen Schlüssels speichern möchten. Weitermachen?", @@ -7994,8 +7994,8 @@ "partition(s). Partition table preserved.": "Partition(en). Partitionstabelle bleibt erhalten.", "pasted Compose file": "Einfügen der Compose-Datei", "paths for next boot (/etc/pve, guests, drivers, ...)": "Pfade für den nächsten Start (/etc/pve, Gäste, Treiber, ...)", - "pct exec authorization failed": "PCT-Exec-Autorisierung fehlgeschlagen", - "pct push failed. Check log:": "PCT-Push fehlgeschlagen. Protokoll prüfen:", + "pct exec authorization failed": "Autorisierung für pct exec fehlgeschlagen", + "pct push failed. Check log:": "pct push fehlgeschlagen. Protokoll prüfen:", "pending (reboot required to enumerate full group)": "ausstehend (Neustart erforderlich, um die vollständige Gruppe aufzulisten)", "phpMyAdmin is installed with arbitrary server connections enabled: the login page has a Server field where the address of the MySQL or MariaDB server is entered, together with its user and password.": "phpMyAdmin wird mit aktivierten beliebigen Serververbindungen installiert: Die Anmeldeseite hat ein Serverfeld, in dem die Adresse des MySQL- oder MariaDB-Servers zusammen mit seinem Benutzer und seinem Passwort eingegeben wird.", "pigz configuration completed": "Pigz-Konfiguration abgeschlossen", @@ -8018,8 +8018,8 @@ "pvesm command not found. This should not happen on Proxmox.": "pvesm-Befehl nicht gefunden. Dies sollte auf Proxmox nicht passieren.", "pvesm not available.": "pvesm nicht verfügbar.", "pvesm not found.": "pvesm nicht gefunden.", - "pvesm path failed, trying manual detection...": "pvesm-Pfad fehlgeschlagen, manuelle Erkennung versucht ...", - "pvesm status failed": "pvesm-Status fehlgeschlagen", + "pvesm path failed, trying manual detection...": "pvesm path fehlgeschlagen, manuelle Erkennung wird versucht...", + "pvesm status failed": "pvesm status fehlgeschlagen", "pyLoad is a Free and Open Source download manager written in Python and designed to be extremely lightweight, easily extensible and fully manageable via web.": "pyLoad ist ein Free- und Open Source-Download-Manager, der in Python geschrieben ist und extrem leicht, leicht erweiterbar und vollständig über das Web zu verwalten ist.", "pyLoad web interface": "pyLoad Web Interface", "qBittorrent WebUI password (user: admin)": "qBittorrent WebUI Passwort (Benutzer: admin)", @@ -8078,8 +8078,8 @@ "skipped (already exist)": "übersprungen (existiert bereits)", "smbclient command is not working properly.": "Der smbclient-Befehl funktioniert nicht ordnungsgemäß.", "smbclient command not found after installation.": "Der Befehl smbclient wurde nach der Installation nicht gefunden.", - "sources.list update skipped (no change)": "Aktualisierung der Quellenliste übersprungen (keine Änderung)", - "sources.list updated to Trixie": "Quellen. Liste auf Trixie aktualisiert", + "sources.list update skipped (no change)": "Aktualisierung von sources.list übersprungen (keine Änderung)", + "sources.list updated to Trixie": "sources.list auf Trixie aktualisiert", "ssh-keygen failed. Cannot create a new SSH key.": "ssh-keygen ist fehlgeschlagen. Es kann kein neuer SSH-Schlüssel erstellt werden.", "stale entry/entries for interfaces no longer present": "veralteter Eintrag/Einträge für Schnittstellen, die nicht mehr vorhanden sind", "standard performance": "Standardleistung", @@ -8098,7 +8098,7 @@ "switch_gpu_mode.sh was not found.": "switch_gpu_mode.sh wurde nicht gefunden.", "sysfs ROM dump failed — trying ACPI VFCT table...": "sysfs-ROM-Dump fehlgeschlagen – Versuch der ACPI-VFCT-Tabelle ...", "syslog-ng allows you to flexibly collect, parse, classify, rewrite and correlate logs from across your infrastructure and store or route them to log analysis tools.": "syslog-ng ermöglicht es Ihnen, Protokolle aus Ihrer gesamten Infrastruktur flexibel zu sammeln, zu analysieren, zu klassifizieren, neu zu schreiben und zu korrelieren und sie zu Protokollanalyse-Tools zu speichern oder zu leiten.", - "systemctl restart networking failed:": "systemctl-Neustart des Netzwerks fehlgeschlagen:", + "systemctl restart networking failed:": "systemctl restart networking fehlgeschlagen:", "systemd OnCalendar expression": "systemd OnCalendar-Ausdruck", "the API key was not generated on the first start": "Der API-Schlüssel wurde beim ersten Start nicht generiert", "the NVIDIA hook of the container is not available:": "der NVIDIA-Hook des Containers ist nicht verfügbar:", @@ -8152,7 +8152,7 @@ "video + recognition": "Video + Erkennung", "video only": "nur Video", "vzdump backup speed optimization completed": "vzdump-Backup-Geschwindigkeitsoptimierung abgeschlossen", - "wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf with the container stopped, and start it again.": "wallabag baut seine Links aus der während der Installation angegebenen Adresse auf. Wenn es nicht mit der Adresse des Containers übereinstimmt, bearbeiten Sie lxc.environment. Laufzeit: SYMFONY ENV DOMAIN NAME in /etc/pve/lxc/.conf mit angehaltenem Container und erneut starten.", + "wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf with the container stopped, and start it again.": "wallabag erzeugt seine Links aus der bei der Installation angegebenen Adresse. Passt diese nicht zur Container-Adresse, stoppen Sie den Container, bearbeiten Sie lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf und starten Sie ihn erneut.", "wallabag listens on port 80 of the container and stores its data in SQLite.": "wallabag hört auf Port 80 des Containers und speichert seine Daten in SQLite.", "wallabag web interface": "wallabag Web-Schnittstelle", "was": "war", diff --git a/lang/es.json b/lang/es.json index b54e843c..0638f139 100644 --- a/lang/es.json +++ b/lang/es.json @@ -320,7 +320,7 @@ "After confirming, you will be asked to choose the NVIDIA driver version to install.": "Después de confirmar, se le pedirá que elija la versión del controlador NVIDIA para instalar.", "After detailed analysis, no changes are needed.": "Después de un análisis detallado, no se necesitan cambios.", "After detailed analysis, no cleanup is needed.": "Después de un análisis detallado, no es necesaria ninguna limpieza.", - "After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "Después de iniciar sesión, ejecute: ip a para obtener la dirección IP.\nLuego, ingrese esa dirección IP en su navegador web de esta manera:\n http://DIRECCIÓN_IP\n\nEsto abrirá el panel de Umbral OS.", + "After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "Después de iniciar sesión, ejecuta ip a para obtener la dirección IP.\nIntroduce esa dirección en el navegador de esta forma:\n http://IP_ADDRESS\n\nSe abrirá el panel de Umbral OS.", "After pasting on the server, ensure the file is chmod 600 and owned by": "Después de pegarlo en el servidor, asegúrese de que el archivo sea chmod 600 y sea propiedad de", "After reboot the system will be fully accessible (SSH, web UI, login), but the following components will be reinstalled in BACKGROUND — until they finish, commands like nvidia-smi may not yet be available:": "Después de reiniciar, el sistema será completamente accesible (SSH, interfaz web e inicio de sesión), pero los siguientes componentes se reinstalarán EN SEGUNDO PLANO; hasta que finalicen, es posible que comandos como nvidia-smi aún no estén disponibles:", "After reboot, these components will reinstall in background:": "Después de reiniciar, estos componentes se reinstalarán en segundo plano:", @@ -1641,8 +1641,8 @@ "Default Proxmox logo applied": "Logotipo predeterminado de Proxmox aplicado", "Default is none (video-only passthrough). Use SPACE to toggle selections.": "Por defecto, ninguno (passthrough solo de vídeo). Use ESPACIO para marcar o desmarcar.", "Default journald configuration restored": "Configuración de diario predeterminada restaurada", - "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "La ubicación predeterminada es /mnt/. El recurso compartido se montará aquí en el host con permisos abiertos para que un LXC sin privilegios pueda vincularlo, montarlo y escribir en él. Para acceder a LXC, vincule esta ruta con LXC Mount Manager.", - "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "La ubicación predeterminada es /mnt/. El recurso compartido se montará aquí en el host. Utilice esta ruta en /etc/fstab. Para acceder a LXC, vincule esta ruta con LXC Mount Manager.", + "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "La ubicación predeterminada es /mnt/. El recurso compartido se montará aquí, en el host, con permisos abiertos para que un LXC sin privilegios pueda montarlo mediante un bind mount y escribir en él. Usa LXC Mount Manager para crear ese montaje en el LXC.", + "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "La ubicación predeterminada es /mnt/. El recurso compartido se montará aquí, en el host. Usa esta ruta en /etc/fstab. Para acceder desde un LXC, crea un bind mount de esta ruta con LXC Mount Manager.", "Default login": "Acceso predeterminado", "Default options": "Opciones predeterminadas", "Default options read/write": "Opciones predeterminadas lectura/escritura", @@ -2033,7 +2033,7 @@ "Enter VM ID:": "Introduzca el ID de la máquina virtual:", "Enter VM/CT ID:": "Introduzca el ID de VM/CT:", "Enter ZFS pool name for the selected disk:": "Ingrese el nombre del grupo ZFS para el disco seleccionado:", - "Enter a name for the mount point (used as /mnt/):": "Ingrese un nombre para el punto de montaje (usado como /mnt/):", + "Enter a name for the mount point (used as /mnt/):": "Introduce un nombre para el punto de montaje (se usará como /mnt/):", "Enter a name for the new virtual machine:": "Ingrese un nombre para la nueva máquina virtual:", "Enter a non-empty password or access token on a single line.": "Escribe una contraseña o un token de acceso, en una sola línea.", "Enter a number, or write or paste a command:": "Ingrese un número, o escriba o pegue un comando:", @@ -2176,11 +2176,11 @@ "Example: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak": "Ejemplo: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak", "Example: daily or Mon..Fri 03:00": "Ejemplo: diario o lunes a viernes 03:00", "Example: mkdir -p /tmp/lxc_convert_114": "Ejemplo: mkdir -p /tmp/lxc_convert_114", - "Example: pct config 110 | grep unprivileged": "Ejemplo: configuración pct 110 | grep sin privilegios", - "Example: pct stop 110": "Ejemplo: parada pct 110", - "Example: pct stop 114": "Ejemplo: parada pct 114", + "Example: pct config 110 | grep unprivileged": "Ejemplo: pct config 110 | grep unprivileged", + "Example: pct stop 110": "Ejemplo: pct stop 110", + "Example: pct stop 114": "Ejemplo: pct stop 114", "Example: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup": "Ejemplo: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup", - "Example: pvesm path local-lvm:vm-114-disk-0": "Ejemplo: ruta pvesm local-lvm:vm-114-disk-0", + "Example: pvesm path local-lvm:vm-114-disk-0": "Ejemplo: pvesm path local-lvm:vm-114-disk-0", "Example: sed -i '/^unprivileged: 1/d' /etc/pve/lxc/110.conf": "Ejemplo: sed -i '/^sin privilegios: 1/d' /etc/pve/lxc/110.conf", "Examples:": "Ejemplos:", "Execute destructive rollback?": "¿Ejecutar el rollback destructivo?", @@ -2492,7 +2492,7 @@ "For a consistent export, the VM should be stopped.": "Para una exportación consistente, se debe detener la VM.", "For any question, issue or bug report, please use the official repository": "Para cualquier pregunta, problema o informe de error, utilice el repositorio oficial", "For dedicated GPUs, FLR is required by this policy to reduce VM": "Para GPU dedicadas, esta política requiere FLR para reducir la VM", - "For example: pct enter 101": "Por ejemplo: pct introduzca 101", + "For example: pct enter 101": "Por ejemplo: pct enter 101", "For guest shares add: --options guest": "Para recursos compartidos de invitado añada: --options guest", "For security reasons, the system cannot format it.": "Por razones de seguridad, el sistema no puede formatearlo.", "For unprivileged containers, use instead:": "Para contenedores sin privilegios, utilice en su lugar:", @@ -5234,7 +5234,7 @@ "Replace": "Reemplazar", "Replace with the actual ID.": "Reemplace con el ID real.", "Replace with your actual container ID": "Reemplace con su ID de contenedor real", - "Replace with the value from step 4": "Reemplace con el valor del paso 4", + "Replace with the value from step 4": "Sustituye por el valor del paso 4", "Replace invalid port(s)": "Reemplazar puerto(s) no válido(s)", "Replace it?": "¿Reemplazarlo?", "Replace paths with your actual values from step 5": "Reemplace las rutas con sus valores reales del paso 5", @@ -7967,7 +7967,7 @@ "partition(s). Partition table preserved.": "partición(es). Se conserva la tabla de particiones.", "pasted Compose file": "archivo Compose pegado", "paths for next boot (/etc/pve, guests, drivers, ...)": "rutas para el próximo arranque (/etc/pve, VM y CT, controladores, ...)", - "pct exec authorization failed": "error en la autorización ejecutiva de PCT", + "pct exec authorization failed": "Ha fallado la autorización de pct exec", "pct push failed. Check log:": "pct push falló. Revise el log:", "pending (reboot required to enumerate full group)": "pendiente (es necesario reiniciar para enumerar el grupo completo)", "phpMyAdmin is installed with arbitrary server connections enabled: the login page has a Server field where the address of the MySQL or MariaDB server is entered, together with its user and password.": "phpMyAdmin se instala con conexiones de servidor arbitrarias habilitadas: la página de inicio de sesión tiene un campo Server donde se introduce la dirección del servidor MySQL o MariaDB, junto con su usuario y contraseña.", @@ -7991,8 +7991,8 @@ "pvesm command not found. This should not happen on Proxmox.": "Comando pvesm no encontrado. Esto no debería suceder en Proxmox.", "pvesm not available.": "pvesm no disponible.", "pvesm not found.": "pvesm no encontrado.", - "pvesm path failed, trying manual detection...": "La ruta pvesm falló, intentando la detección manual...", - "pvesm status failed": "el estado de pvesm falló", + "pvesm path failed, trying manual detection...": "Ha fallado pvesm path; intentando la detección manual...", + "pvesm status failed": "Ha fallado pvesm status", "pyLoad is a Free and Open Source download manager written in Python and designed to be extremely lightweight, easily extensible and fully manageable via web.": "pyLoad es un gestor de descargas libre y de código abierto escrito en Python, diseñado para ser muy ligero, fácil de ampliar y totalmente gestionable por web.", "pyLoad web interface": "Interfaz web de pyLoad", "qBittorrent WebUI password (user: admin)": "contraseña de qBittorrent WebUI (usuario: admin)", @@ -8052,7 +8052,7 @@ "smbclient command is not working properly.": "El comando smbclient no funciona correctamente.", "smbclient command not found after installation.": "El comando smbclient no se encuentra después de la instalación.", "sources.list update skipped (no change)": "Actualización de sources.list omitida (sin cambios)", - "sources.list updated to Trixie": "fuentes.lista actualizada a Trixie", + "sources.list updated to Trixie": "sources.list actualizado a Trixie", "ssh-keygen failed. Cannot create a new SSH key.": "ssh-keygen falló. No se puede crear una nueva clave SSH.", "stale entry/entries for interfaces no longer present": "entrada(s) obsoleta(s) de interfaces que ya no existen", "standard performance": "rendimiento estándar", diff --git a/lang/fr.json b/lang/fr.json index 4a83f430..d9509728 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -301,7 +301,7 @@ "Address of the database server (mysql and pgsql only)": "Adresse du serveur de base de données (mysql et pgsql uniquement)", "Address of the oci-compose file": "Adresse du fichier oci-compose", "Address to reach Pydio Cells (https://domain or https://IP:8080)": "Adresse pour atteindre Pydio Cells (https://domaine ou https://IP:8080)", - "Address used to reach wallabag (http://IP or https://wallabag.example.com)": "Adresse utilisée pour atteindre wallabag (http://IP ou https://wallabag.exemple.com)", + "Address used to reach wallabag (http://IP or https://wallabag.example.com)": "Adresse d’accès à wallabag (http://IP ou https://wallabag.example.com)", "Addresses to update: ipv4, ipv6 or both (uses an external service)": "Adresses à mettre à jour: ipv4, ipv6 ou les deux (utilise un service externe)", "Adguardhome Sync web interface": "Interface web Adguardhome Sync", "Adguardhome-sync is a tool to synchronize AdGuardHome config to replica instances.": "Adguardhome-sync est un outil pour synchroniser la configuration AdGuardHome pour reproduire des instances.", @@ -322,7 +322,7 @@ "After confirming, you will be asked to choose the NVIDIA driver version to install.": "Après confirmation, il vous sera demandé de choisir la version du pilote NVIDIA à installer.", "After detailed analysis, no changes are needed.": "Après une analyse détaillée, aucune modification n’est nécessaire.", "After detailed analysis, no cleanup is needed.": "Après une analyse détaillée, aucun nettoyage n’est nécessaire.", - "After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "Après vous être connecté, exécutez : ip a pour obtenir l'adresse IP.\nEnsuite, saisissez cette adresse IP dans votre navigateur Web comme ceci :\n http://ADRESSE_IP\n\nCela ouvrira le tableau de bord Umbral OS.", + "After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "Après la connexion, exécutez ip a pour obtenir l’adresse IP.\nSaisissez ensuite cette adresse dans votre navigateur sous cette forme :\n http://IP_ADDRESS\n\nLe tableau de bord Umbral OS s’ouvrira.", "After pasting on the server, ensure the file is chmod 600 and owned by": "Après avoir collé sur le serveur, assurez-vous que le fichier est chmod 600 et appartient à", "After reboot the system will be fully accessible (SSH, web UI, login), but the following components will be reinstalled in BACKGROUND — until they finish, commands like nvidia-smi may not yet be available:": "Après le redémarrage, le système sera entièrement accessible (SSH, interface utilisateur Web, connexion), mais les composants suivants seront réinstallés en ARRIÈRE-PLAN — jusqu'à ce qu'ils soient terminés, des commandes comme nvidia-smi peuvent ne pas encore être disponibles :", "After reboot, these components will reinstall in background:": "Après le redémarrage, ces composants seront réinstallés en arrière-plan :", @@ -1643,8 +1643,8 @@ "Default Proxmox logo applied": "Logo Proxmox par défaut appliqué", "Default is none (video-only passthrough). Use SPACE to toggle selections.": "La valeur par défaut est aucune (passthrough vidéo uniquement). Utilisez ESPACE pour basculer les sélections.", "Default journald configuration restored": "Configuration journald par défaut restaurée", - "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "L'emplacement par défaut est /mnt/. Le partage sera monté ici sur l'hôte avec des autorisations ouvertes afin qu'un LXC non privilégié puisse y effectuer un montage en liaison et y écrire. Pour l'accès LXC, montez en liaison ce chemin avec le gestionnaire de montage LXC.", - "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "L'emplacement par défaut est /mnt/. Le partage sera monté ici sur l'hôte. Utilisez ce chemin dans /etc/fstab. Pour l'accès LXC, montez en liaison ce chemin avec le gestionnaire de montage LXC.", + "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "L’emplacement par défaut est /mnt/. Le partage sera monté ici sur l’hôte avec des permissions ouvertes, afin qu’un LXC non privilégié puisse le monter par liaison et y écrire. Utilisez LXC Mount Manager pour créer ce montage dans le LXC.", + "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "L’emplacement par défaut est /mnt/. Le partage sera monté ici sur l’hôte. Utilisez ce chemin dans /etc/fstab. Pour y accéder depuis un LXC, créez un montage lié de ce chemin avec LXC Mount Manager.", "Default login": "Connexion par défaut", "Default options": "Options par défaut", "Default options read/write": "Options par défaut lecture/écriture", @@ -1920,7 +1920,7 @@ "Dry run completed; the container and the mounts were not changed.": "Le parcours à sec est terminé; le conteneur et les supports n'ont pas été changés.", "DuckDNS subdomain without .duckdns.org (comma separated for several)": "DuckDNS sous-domaine sans .duckdns.org (comma séparé pour plusieurs)", "DuckDNS token from your account at duckdns.org": "Jeton DuckDNS de votre compte sur duckdns.org", - "DuckDNS updates the subdomain every 5 minutes. Without UPDATE_IP, DuckDNS itself detects the public IPv4 address of the request.": "DuckDNS met à jour le sous-domaine toutes les 5 minutes. Sans mise à jour, DuckDNS détecte lui-même l'adresse IPv4 publique de la requête.", + "DuckDNS updates the subdomain every 5 minutes. Without UPDATE_IP, DuckDNS itself detects the public IPv4 address of the request.": "DuckDNS met à jour le sous-domaine toutes les 5 minutes. Sans UPDATE_IP, DuckDNS détecte lui-même l’adresse IPv4 publique de la requête.", "DuckStation is a PS1 Emulator aiming for the best accuracy and game support.": "DuckStation est un émulateur PS1 visant le meilleur accuracy et le meilleur support de jeu.", "Duckdns is a free service which will point a DNS (sub domains of duckdns.org) to an IP of your choice. The service is completely free, and doesn't require reactivation or forum posts to maintain its existence.": "Duckdns est un service gratuit qui dirigera un DNS (sous-domaines de duckdns.org) vers une IP de votre choix. Le service est entièrement gratuit, et ne require réactivation ou forum posts pour maintenir son existence.", "Dumping AMD GPU ROM BIOS via sysfs...": "Vidage du BIOS ROM GPU AMD via sysfs...", @@ -2179,11 +2179,11 @@ "Example: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak": "Exemple : cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak", "Example: daily or Mon..Fri 03:00": "Exemple : tous les jours ou du lundi au vendredi à 03h00", "Example: mkdir -p /tmp/lxc_convert_114": "Exemple : mkdir -p /tmp/lxc_convert_114", - "Example: pct config 110 | grep unprivileged": "Exemple : configuration PCT 110 | grep sans privilège", - "Example: pct stop 110": "Exemple : PCT stop 110", - "Example: pct stop 114": "Exemple : PCT arrêt 114", + "Example: pct config 110 | grep unprivileged": "Exemple : pct config 110 | grep unprivileged", + "Example: pct stop 110": "Exemple : pct stop 110", + "Example: pct stop 114": "Exemple : pct stop 114", "Example: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup": "Exemple : pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup", - "Example: pvesm path local-lvm:vm-114-disk-0": "Exemple : chemin pvesm local-lvm:vm-114-disk-0", + "Example: pvesm path local-lvm:vm-114-disk-0": "Exemple : pvesm path local-lvm:vm-114-disk-0", "Example: sed -i '/^unprivileged: 1/d' /etc/pve/lxc/110.conf": "Exemple : sed -i '/^unprivileged : 1/d' /etc/pve/lxc/110.conf", "Examples:": "Exemples :", "Execute destructive rollback?": "Exécuter une restauration destructrice ?", @@ -2495,7 +2495,7 @@ "For a consistent export, the VM should be stopped.": "Pour une exportation cohérente, la VM doit être arrêtée.", "For any question, issue or bug report, please use the official repository": "Pour toute question, problème ou rapport de bug, veuillez utiliser le référentiel officiel", "For dedicated GPUs, FLR is required by this policy to reduce VM": "Pour les GPU dédiés, FLR est requis par cette stratégie pour réduire les VM", - "For example: pct enter 101": "Par exemple : PCT, entrez 101", + "For example: pct enter 101": "Par exemple : pct enter 101", "For guest shares add: --options guest": "Pour les partages invités, ajoutez : --options invité", "For security reasons, the system cannot format it.": "Pour des raisons de sécurité, le système ne peut pas le formater.", "For unprivileged containers, use instead:": "Pour les conteneurs non privilégiés, utilisez plutôt :", @@ -2907,8 +2907,8 @@ "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.": "Si les notifications sont activées (Telegram/Discord/ntfy/...), vous recevrez un message « Restauration de l'hôte terminée » lorsque toutes les tâches en arrière-plan seront terminées.", "If passthrough fails on Windows: install RadeonResetBugFix.": "Si le relais échoue sous Windows : installez RadeonResetBugFix.", "If path not accessible (ZFS/BTRFS):": "Si chemin non accessible (ZFS/BTRFS) :", - "If pvesm path returned a DEVICE (LVM):": "Si le chemin pvesm a renvoyé un DEVICE (LVM) :", - "If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Si le chemin pvesm renvoie un RÉPERTOIRE (ZFS/BTRFS) :", + "If pvesm path returned a DEVICE (LVM):": "Si pvesm path a renvoyé un PÉRIPHÉRIQUE (LVM) :", + "If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Si pvesm path a renvoyé un RÉPERTOIRE (ZFS/BTRFS) :", "If repositories error:": "Si erreur de référentiels :", "If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Si le conteneur a déjà été modifié, sa sauvegarde native précédente est restaurée. Les répertoires hôtes partagés ne sont pas rétablis. Les disques de la tentative ayant échoué sont supprimés une fois le nettoyage réussi.", "If the host freezes, remove hostpci entries from": "Si l'hôte se bloque, supprimez les entrées hostpci de", @@ -3048,7 +3048,7 @@ "Initiator IQN is authorised on the target": "L'IQN initiateur est autorisé sur la cible", "Initiator IQN:": "IQN initiateur :", "Inkscape is professional quality vector graphics software which runs on Linux, Mac OS X and Windows desktop computers.": "Inkscape est un logiciel graphique vectoriel de qualité professionnelle qui fonctionne sur les ordinateurs de bureau Linux, Mac OS X et Windows.", - "Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "À l'intérieur du LXC, créez l'administrateur: console kimai:utilisateur:créez VOTRE USERNAME VOTRE ROLE_SUPER_ADMIN", + "Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "Dans le LXC, créez l’administrateur : console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN", "Inspect disks before any action": "Inspecter les disques avant toute action", "Inspect host device nodes": "Inspecter les nœuds du périphérique hôte", "Inspect passthrough/kernel events": "Inspecter les événements de relais/noyau", @@ -3225,7 +3225,7 @@ "Interrupted operation:": "Interruption du opera:", "Interrupted stack operation found": "Pile interrompue operation trouvée", "Invalid 'proxmox-ve' candidate (not 9.x or none). Please verify your repository configuration and network, then retry.": "Candidat 'proxmox-ve' invalide (pas 9.x ou aucun). Veuillez vérifier la configuration et le réseau de votre référentiel, puis réessayez.", - "Invalid ALLOWED_HOSTS value": "Valeur HÔTES INDÉSIRABLES", + "Invalid ALLOWED_HOSTS value": "Valeur ALLOWED_HOSTS invalide", "Invalid Home Assistant OS check timeout": "Non valide Home Assistant OS check timeout", "Invalid ID": "Pièce d'identité invalide", "Invalid IPv4 address": "Adresse IPv4 non valide", @@ -3460,7 +3460,7 @@ "Keyfile removed.": "fichier clé supprimé.", "Keyrings method failed; trying apt-key fallback": "La méthode des porte-clés a échoué ; essayer la solution de secours apt-key", "KiCad - A Cross Platform and Open Source Electronics Design Automation Suite.": "KiCad - Une plateforme croisée et une suite d'automatisation de conception électronique ouverte.", - "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai n'a pas de compte par défaut. Saisissez le conteneur avec : pct entre {main vmid}", + "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai n’a pas de compte par défaut. Ouvrez un shell dans le conteneur avec : pct enter {main_vmid}", "Kimai is a professional grade time-tracking application, free and open-source.": "Kimai est une application professionnelle de suivi du temps, libre et open-source.", "Kometa is a powerful tool designed to give you complete control over your media libraries. With Kometa, you can take your customization to the next level, with granular control over metadata, collections, overlays, and much more.": "Kometa est un outil puissant conçu pour vous donner un contrôle complet sur vos bibliothèques multimédias. Avec Kometa, vous pouvez porter votre personnalisation au niveau suivant, avec un contrôle granulaire sur les métadonnées, les collections, les superpositions et bien plus encore.", "Kometa reads its configuration from /config/config.yml and the container only ships /config/config.yml.template. Copy the template to config.yml, fill in the required Plex and TMDb connections, then restart the container.": "Kometa lit sa configuration à partir de /config/config.yml et le conteneur ne livre que /config/config.yml.template. Copiez le modèle pour config.yml, remplissez les connexions required Plex et TMDb, puis redémarrez le conteneur.", @@ -3512,7 +3512,7 @@ "Leave empty to keep every backup (not recommended).": "Laissez vide pour conserver chaque sauvegarde (non recommandé).", "Leave empty to use the retention of the storage.": "Laisser vide pour utiliser la rétention du stockage.", "Legacy PVE 8 .list files commented or not present": "Fichiers .list hérités PVE 8 commentés ou non présents", - "Legacy ceph.list commented or not present": "Ceph.list hérité commenté ou non présent", + "Legacy ceph.list commented or not present": "Ancien fichier ceph.list commenté ou absent", "Legacy gasket-dkms cleanup could not be verified as complete.": "Impossible de confirmer que le nettoyage de l'ancien paquet gasket-dkms est terminé.", "Legacy gasket-dkms detected": "Ancien paquet gasket-dkms détecté", "Legacy network tools (e.g., ifconfig)": "Outils réseau hérités (par exemple, ifconfig)", @@ -4263,7 +4263,7 @@ "No jobs": "Aucun emploi", "No language selected.": "Aucune langue sélectionnée.", "No local disk storage configured.": "Aucun stockage sur disque local configuré.", - "No main sources.list present (skipped)": "Aucune liste de sources principale présente (ignorée)", + "No main sources.list present (skipped)": "Aucun fichier sources.list principal présent (ignoré)", "No matches for:": "Aucune correspondance pour :", "No matching PVE vzdump jobs were found for the PBS backend.": "Aucune tâche vzdump PVE correspondante n'a été trouvée pour le backend PBS.", "No matching PVE vzdump jobs were found for the local archive backend.": "Aucune tâche vzdump PVE correspondante n'a été trouvée pour le backend d'archive local.", @@ -6992,7 +6992,7 @@ "Transaction log:": "Registre des transactions & #160;:", "Translation files:": "Fichiers de traduction :", "Transmission is designed for easy, powerful use. Transmission has the features you want from a BitTorrent client: encryption, a web interface, peer exchange, magnet links, DHT, µTP, UPnP and NAT-PMP port forwarding, webseed support, watch directories, tracker editing, global and per-torrent speed limits, and more.": "Transmission est conçu pour une utilisation facile et puissante. Transmission possède les fonctionnalités que vous souhaitez d'un client BitTorrent : cryptage, interface web, échange entre pairs, liens magnétiques, DHT, μTP, UPnP et le transfert de port NAT-PMP, support de webseed, répertoires de veille, édition de tracker, limites de vitesse globales et per-torrent, et plus encore.", - "Tried pvesm path and manual detection methods": "Chemin pvesm essayé et méthodes de détection manuelle", + "Tried pvesm path and manual detection methods": "pvesm path et les méthodes de détection manuelle ont été essayés", "Trust this certificate and save it for scheduled backups?": "Faire confiance à ce certificat et l'enregistrer pour les sauvegardes planifiées ?", "Try Again": "Essayer à nouveau", "Try a different search term.": "Essayez un autre terme de recherche.", @@ -7248,7 +7248,7 @@ "Usage:": "Utilisation :", "Use \"Check test progress\" to see results.": "Utilisez « Vérifier la progression du test » pour voir les résultats.", "Use 'Export to file' to save it and inspect manually.": "Utilisez « Exporter vers un fichier » pour l'enregistrer et l'inspecter manuellement.", - "Use 'pct restore' / 'qmrestore' to recover their disks from your VM backups.": "Utilisez « PCT Restore » / « qmrestore » pour récupérer leurs disques à partir des sauvegardes de votre VM.", + "Use 'pct restore' / 'qmrestore' to recover their disks from your VM backups.": "Utilisez 'pct restore' / 'qmrestore' pour récupérer leurs disques depuis les sauvegardes des VM.", "Use Custom backup and uncheck the conflicting path from the list": "Utilisez la sauvegarde personnalisée et décochez le chemin en conflit dans la liste", "Use Default Settings?": "Utiliser les paramètres par défaut ?", "Use Download first if you want to save a copy of the current key. Continue?": "utilisez d'abord Télécharger si vous souhaitez enregistrer une copie de la clé actuelle. Continuer?", @@ -7993,8 +7993,8 @@ "partition(s). Partition table preserved.": "partition(s). Table de partition conservée.", "pasted Compose file": "fichier Compose collé", "paths for next boot (/etc/pve, guests, drivers, ...)": "chemins pour le prochain démarrage (/etc/pve, invités, pilotes, ...)", - "pct exec authorization failed": "échec de l'autorisation d'exécution PCT", - "pct push failed. Check log:": "La poussée PCT a échoué. Journal de vérification :", + "pct exec authorization failed": "Échec de l’autorisation pour pct exec", + "pct push failed. Check log:": "Échec de pct push. Consultez le journal :", "pending (reboot required to enumerate full group)": "en attente (redémarrage requis pour énumérer le groupe complet)", "phpMyAdmin is installed with arbitrary server connections enabled: the login page has a Server field where the address of the MySQL or MariaDB server is entered, together with its user and password.": "phpMyAdmin est installé avec des connexions de serveur arbitraires activées : la page de connexion a un champ Serveur où l'adresse du serveur MySQL ou MariaDB est saisie, ainsi que son utilisateur et son mot de passe.", "pigz configuration completed": "configuration pigz terminée", @@ -8017,8 +8017,8 @@ "pvesm command not found. This should not happen on Proxmox.": "Commande pvesm introuvable. Cela ne devrait pas arriver sur Proxmox.", "pvesm not available.": "pvesm non disponible.", "pvesm not found.": "pvesm introuvable.", - "pvesm path failed, trying manual detection...": "Échec du chemin pvesm, tentative de détection manuelle...", - "pvesm status failed": "le statut pvesm a échoué", + "pvesm path failed, trying manual detection...": "Échec de pvesm path, tentative de détection manuelle...", + "pvesm status failed": "Échec de pvesm status", "pyLoad is a Free and Open Source download manager written in Python and designed to be extremely lightweight, easily extensible and fully manageable via web.": "pyLoad est un gestionnaire de téléchargement gratuit et Open Source écrit en Python et conçu pour être extrêmement léger, facilement extensible et entièrement gérable via le web.", "pyLoad web interface": "PyLoad interface web", "qBittorrent WebUI password (user: admin)": "qBittorrent Mot de passe WebUI (utilisateur: admin)", @@ -8097,7 +8097,7 @@ "switch_gpu_mode.sh was not found.": "switch_gpu_mode.sh n'a pas été trouvé.", "sysfs ROM dump failed — trying ACPI VFCT table...": "Échec du vidage de la ROM sysfs - tentative de la table ACPI VFCT...", "syslog-ng allows you to flexibly collect, parse, classify, rewrite and correlate logs from across your infrastructure and store or route them to log analysis tools.": "syslog-ng vous permet de collecter, d'analyser, de classer, de réécrire et de corréler les logs de toute votre infrastructure et de les stocker ou de les acheminer vers des outils d'analyse de log.", - "systemctl restart networking failed:": "échec du redémarrage du réseau systemctl :", + "systemctl restart networking failed:": "Échec de systemctl restart networking :", "systemd OnCalendar expression": "expression systemd OnCalendar", "the API key was not generated on the first start": "la clé API n'a pas été générée au premier démarrage", "the NVIDIA hook of the container is not available:": "le hook NVIDIA du conteneur n'est pas disponible :", @@ -8151,7 +8151,7 @@ "video + recognition": "vidéo + reconnaissance", "video only": "vidéo uniquement", "vzdump backup speed optimization completed": "Optimisation de la vitesse de sauvegarde de vzdump terminée", - "wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf with the container stopped, and start it again.": "wallabag construit ses liens à partir de l'adresse donnée pendant l'installation. S'il ne correspond pas à l'adresse du conteneur, modifier lxc.environnement. temps d'exécution: SYMFONY ENV DOMAIN NAME dans /etc/pve/lxc/.conf avec le conteneur arrêté, et recommencer.", + "wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf with the container stopped, and start it again.": "wallabag construit ses liens avec l’adresse indiquée à l’installation. Si elle ne correspond pas à celle du conteneur, arrêtez-le, modifiez lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME dans /etc/pve/lxc/.conf, puis redémarrez-le.", "wallabag listens on port 80 of the container and stores its data in SQLite.": "wallabag écoute sur le port 80 du conteneur et stocke ses données dans SQLite.", "wallabag web interface": "Interface web wallabag", "was": "était", diff --git a/lang/pt.json b/lang/pt.json index 41fb2fd7..ade30ee9 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -138,7 +138,7 @@ "ALL DATA ON": "TODOS OS DADOS EM", "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!": "TODOS OS DADOS NESTE DISCO SERÃO PERMANENTEMENTE PERDIDOS!", "ALL Utilities": "TODOS os utilitários", - "ALLOWED_HOSTS cannot contain line breaks": "Allowed hosts não pode conter quebras de linha", + "ALLOWED_HOSTS cannot contain line breaks": "ALLOWED_HOSTS não pode conter quebras de linha", "AList initial login": "Login inicial do AList", "AMD (ROCm)": "AMD (ROCm)", "AMD (VA-API and ROCm detection)": "AMD (detecção VA-API e ROCm)", @@ -1643,8 +1643,8 @@ "Default Proxmox logo applied": "Logotipo padrão do Proxmox aplicado", "Default is none (video-only passthrough). Use SPACE to toggle selections.": "O padrão é nenhum (passagem somente de vídeo). Use ESPAÇO para alternar as seleções.", "Default journald configuration restored": "Configuração padrão do diário restaurada", - "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "O local padrão é /mnt/. O compartilhamento será montado aqui no host com permissões abertas para que um LXC sem privilégios possa montar e gravar nele. Para acesso LXC, monte este caminho com o LXC Mount Manager.", - "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "O local padrão é /mnt/. O compartilhamento será montado aqui no host. Use este caminho em /etc/fstab. Para acesso LXC, monte este caminho com o LXC Mount Manager.", + "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "A localização predefinida é /mnt/. A partilha será montada aqui no host com permissões abertas, para que um LXC sem privilégios a possa montar por bind mount e nela escrever. Utilize o LXC Mount Manager para criar essa montagem no LXC.", + "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "A localização predefinida é /mnt/. A partilha será montada aqui no host. Utilize este caminho em /etc/fstab. Para aceder a partir de um LXC, crie um bind mount deste caminho com o LXC Mount Manager.", "Default login": "Utilizador por omissão", "Default options": "Opções padrão", "Default options read/write": "Opções padrão leitura/gravação", @@ -2035,7 +2035,7 @@ "Enter VM ID:": "Insira o ID da VM:", "Enter VM/CT ID:": "Insira o ID da VM/CT:", "Enter ZFS pool name for the selected disk:": "Insira o nome do pool ZFS para o disco selecionado:", - "Enter a name for the mount point (used as /mnt/):": "Insira um nome para o ponto de montagem (usado como /mnt/):", + "Enter a name for the mount point (used as /mnt/):": "Introduza um nome para o ponto de montagem (será usado como /mnt/):", "Enter a name for the new virtual machine:": "Insira um nome para a nova máquina virtual:", "Enter a non-empty password or access token on a single line.": "Insira uma senha ou token de acesso não vazio em uma única linha.", "Enter a number, or write or paste a command:": "Digite um número ou escreva ou cole um comando:", @@ -2179,11 +2179,11 @@ "Example: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak": "Exemplo: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak", "Example: daily or Mon..Fri 03:00": "Exemplo: diariamente ou Seg..Sex 03:00", "Example: mkdir -p /tmp/lxc_convert_114": "Exemplo: mkdir -p /tmp/lxc_convert_114", - "Example: pct config 110 | grep unprivileged": "Exemplo: configuração pct 110 | grep sem privilégios", + "Example: pct config 110 | grep unprivileged": "Exemplo: pct config 110 | grep unprivileged", "Example: pct stop 110": "Exemplo: pct stop 110", - "Example: pct stop 114": "Exemplo: parada pct 114", + "Example: pct stop 114": "Exemplo: pct stop 114", "Example: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup": "Exemplo: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup", - "Example: pvesm path local-lvm:vm-114-disk-0": "Exemplo: caminho pvesm local-lvm:vm-114-disk-0", + "Example: pvesm path local-lvm:vm-114-disk-0": "Exemplo: pvesm path local-lvm:vm-114-disk-0", "Example: sed -i '/^unprivileged: 1/d' /etc/pve/lxc/110.conf": "Exemplo: sed -i '/^unprivileged: 1/d' /etc/pve/lxc/110.conf", "Examples:": "Exemplos:", "Execute destructive rollback?": "Executar reversão destrutiva?", @@ -2495,7 +2495,7 @@ "For a consistent export, the VM should be stopped.": "Para uma exportação consistente, a VM deve ser interrompida.", "For any question, issue or bug report, please use the official repository": "Para qualquer dúvida, problema ou relatório de bug, use o repositório oficial", "For dedicated GPUs, FLR is required by this policy to reduce VM": "Para GPUs dedicadas, o FLR é exigido por esta política para reduzir VM", - "For example: pct enter 101": "Por exemplo: pct digite 101", + "For example: pct enter 101": "Por exemplo: pct enter 101", "For guest shares add: --options guest": "Para compartilhamentos de convidados, adicione: --options guest", "For security reasons, the system cannot format it.": "Por motivos de segurança, o sistema não pode formatá-lo.", "For unprivileged containers, use instead:": "Para contêineres sem privilégios, use:", @@ -2907,8 +2907,8 @@ "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.": "Se as notificações estiverem habilitadas (Telegram/Discord/ntfy/...), você receberá uma mensagem \"Restauração do host concluída\" quando todas as tarefas em segundo plano forem concluídas.", "If passthrough fails on Windows: install RadeonResetBugFix.": "Se a passagem falhar no Windows: instale RadeonResetBugFix.", "If path not accessible (ZFS/BTRFS):": "Se o caminho não estiver acessível (ZFS/BTRFS):", - "If pvesm path returned a DEVICE (LVM):": "Se o caminho pvesm retornou um DEVICE (LVM):", - "If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Se o caminho pvesm retornou um DIRETÓRIO (ZFS/BTRFS):", + "If pvesm path returned a DEVICE (LVM):": "Se pvesm path devolveu um DISPOSITIVO (LVM):", + "If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Se pvesm path devolveu um DIRETÓRIO (ZFS/BTRFS):", "If repositories error:": "Se houver erro nos repositórios:", "If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Se o contêiner já tiver sido alterado, seu backup nativo anterior será restaurado. Os diretórios de host compartilhados não são revertidos. Os discos da tentativa fracassada serão removidos quando a limpeza for bem-sucedida.", "If the host freezes, remove hostpci entries from": "Se o host congelar, remova as entradas hostpci de", @@ -3048,7 +3048,7 @@ "Initiator IQN is authorised on the target": "O iniciador IQN está autorizado no alvo", "Initiator IQN:": "IQN do iniciador:", "Inkscape is professional quality vector graphics software which runs on Linux, Mac OS X and Windows desktop computers.": "Inkscape é um software gráfico vetorial de qualidade profissional que funciona em computadores desktop Linux, Mac OS X e Windows.", - "Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "Dentro do LXC, crie o administrador: console kimai:user:create Your USERNAME Your EMAIL ROLE_SUPER_ADMIN", + "Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "Dentro do LXC, crie o administrador: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN", "Inspect disks before any action": "Inspecione os discos antes de qualquer ação", "Inspect host device nodes": "Inspecione os nós do dispositivo host", "Inspect passthrough/kernel events": "Inspecionar eventos de passagem/kernel", @@ -3225,7 +3225,7 @@ "Interrupted operation:": "operation interrompida:", "Interrupted stack operation found": "Pilha interrompida operation encontrada", "Invalid 'proxmox-ve' candidate (not 9.x or none). Please verify your repository configuration and network, then retry.": "Candidato 'proxmox-ve' inválido (não 9.x ou nenhum). Verifique a configuração e a rede do seu repositório e tente novamente.", - "Invalid ALLOWED_HOSTS value": "Valor Allowed hosts inválido", + "Invalid ALLOWED_HOSTS value": "Valor de ALLOWED_HOSTS inválido", "Invalid Home Assistant OS check timeout": "Tempo de verificação do sistema operacional Home Assistant inválido", "Invalid ID": "ID inválido", "Invalid IPv4 address": "Endereço IPv4 inválido", @@ -3460,7 +3460,7 @@ "Keyfile removed.": "arquivo-chave removido.", "Keyrings method failed; trying apt-key fallback": "O método dos chaveiros falhou; tentando substituto do apt-key", "KiCad - A Cross Platform and Open Source Electronics Design Automation Suite.": "KiCad - Uma plataforma cruzada e Open Source Electronics Design Automation Suite.", - "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai não tem conta padrão. Digite o recipiente com: pct enter {main vmid}", + "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "O Kimai não tem uma conta predefinida. Aceda ao contentor com: pct enter {main_vmid}", "Kimai is a professional grade time-tracking application, free and open-source.": "Kimai é um aplicativo de rastreamento de tempo profissional, gratuito e de código aberto.", "Kometa is a powerful tool designed to give you complete control over your media libraries. With Kometa, you can take your customization to the next level, with granular control over metadata, collections, overlays, and much more.": "Kometa é uma ferramenta poderosa projetada para lhe dar controle completo sobre suas bibliotecas de mídia. Com o Kometa, você pode levar sua personalização para o próximo nível, com controle granular sobre metadados, coleções, sobreposições e muito mais.", "Kometa reads its configuration from /config/config.yml and the container only ships /config/config.yml.template. Copy the template to config.yml, fill in the required Plex and TMDb connections, then restart the container.": "Kometa lê a sua configuração a partir de /config/config.yml e o recipiente apenas navios /config/config.yml.template. Copie o modelo para config.yml, preencha o requi vermelho Plex e conexões TMDb, em seguida, reinicie o recipiente.", @@ -3512,7 +3512,7 @@ "Leave empty to keep every backup (not recommended).": "Deixe em branco para manter todos os backups (não recomendado).", "Leave empty to use the retention of the storage.": "Deixe em branco para usar a retenção do armazenamento.", "Legacy PVE 8 .list files commented or not present": "Arquivos .list PVE 8 legados comentados ou não presentes", - "Legacy ceph.list commented or not present": "Ceph.list legado comentado ou não presente", + "Legacy ceph.list commented or not present": "ceph.list antigo comentado ou inexistente", "Legacy gasket-dkms cleanup could not be verified as complete.": "Não foi possível confirmar a conclusão da limpeza do pacote gasket-dkms legado.", "Legacy gasket-dkms detected": "gasket-dkms legado detetado", "Legacy network tools (e.g., ifconfig)": "Ferramentas de rede legadas (por exemplo, ifconfig)", @@ -4263,7 +4263,7 @@ "No jobs": "Sem empregos", "No language selected.": "Nenhum idioma selecionado.", "No local disk storage configured.": "Nenhum armazenamento em disco local configurado.", - "No main sources.list present (skipped)": "Nenhuma fonte principal.list presente (ignorada)", + "No main sources.list present (skipped)": "Não existe um sources.list principal (ignorado)", "No matches for:": "Nenhuma correspondência para:", "No matching PVE vzdump jobs were found for the PBS backend.": "Nenhum trabalho PVE vzdump correspondente foi encontrado para o backend do PBS.", "No matching PVE vzdump jobs were found for the local archive backend.": "Nenhum trabalho PVE vzdump correspondente foi encontrado para o back-end do arquivo local.", @@ -6993,7 +6993,7 @@ "Transaction log:": "Registo de transacções:", "Translation files:": "Arquivos de tradução:", "Transmission is designed for easy, powerful use. Transmission has the features you want from a BitTorrent client: encryption, a web interface, peer exchange, magnet links, DHT, µTP, UPnP and NAT-PMP port forwarding, webseed support, watch directories, tracker editing, global and per-torrent speed limits, and more.": "Transmission é projetado para uso fácil e poderoso. Transmission tem os recursos que você deseja de um cliente BitTorrent: criptografia, uma interface web, troca de pares, links magnéticos, DHT, μTP, encaminhamento de portas UPnP e NAT-PMP, suporte a webseed, diretórios de relógio, edição de rastreadores, limites de velocidade globais e per-torrent, e muito mais.", - "Tried pvesm path and manual detection methods": "Tentei caminho pvesm e métodos de detecção manual", + "Tried pvesm path and manual detection methods": "Foram tentados pvesm path e os métodos de deteção manual", "Trust this certificate and save it for scheduled backups?": "Confiar neste certificado e salvá-lo para backups agendados?", "Try Again": "Tente novamente", "Try a different search term.": "Experimente um termo de pesquisa diferente.", @@ -7213,7 +7213,7 @@ "Updates file is empty or unreadable.": "O arquivo de atualizações está vazio ou ilegível.", "Updating": "Actualização", "Updating APT package lists...": "Atualizando listas de pacotes APT...", - "Updating Debian Bookworm → Trixie in sources.list...": "Atualizando Debian Bookworm → Trixie em fontes.list...", + "Updating Debian Bookworm → Trixie in sources.list...": "A atualizar Debian Bookworm → Trixie em sources.list...", "Updating Figurine binary...": "Atualizando o binário da estatueta...", "Updating Lynis to the latest version...": "Atualizando Lynis para a versão mais recente...", "Updating PVE application manager...": "Atualizando o gerenciador de aplicativos PVE...", @@ -8018,8 +8018,8 @@ "pvesm command not found. This should not happen on Proxmox.": "Comando pvesm não encontrado. Isso não deveria acontecer no Proxmox.", "pvesm not available.": "pvesm não disponível.", "pvesm not found.": "pvesm não encontrado.", - "pvesm path failed, trying manual detection...": "caminho pvesm falhou, tentando detecção manual...", - "pvesm status failed": "status pvesm falhou", + "pvesm path failed, trying manual detection...": "O pvesm path falhou; a tentar a deteção manual...", + "pvesm status failed": "O pvesm status falhou", "pyLoad is a Free and Open Source download manager written in Python and designed to be extremely lightweight, easily extensible and fully manageable via web.": "pyLoad é um gerenciador de download livre e Open Source escrito em Python e projetado para ser extremamente leve, facilmente extensível e totalmente gerenciável via web.", "pyLoad web interface": "interface web pyLoad", "qBittorrent WebUI password (user: admin)": "Senha do WebUI do qBittorrent (utilizador: administrador)", @@ -8079,7 +8079,7 @@ "smbclient command is not working properly.": "O comando smbclient não está funcionando corretamente.", "smbclient command not found after installation.": "Comando smbclient não encontrado após a instalação.", "sources.list update skipped (no change)": "Atualização de sources.list ignorada (sem alteração)", - "sources.list updated to Trixie": "fontes.list atualizado para Trixie", + "sources.list updated to Trixie": "sources.list atualizado para Trixie", "ssh-keygen failed. Cannot create a new SSH key.": "ssh-keygen falhou. Não é possível criar uma nova chave SSH.", "stale entry/entries for interfaces no longer present": "entradas/entradas obsoletas para interfaces não estão mais presentes", "standard performance": "desempenho padrão", @@ -8098,7 +8098,7 @@ "switch_gpu_mode.sh was not found.": "switch_gpu_mode.sh não foi encontrado.", "sysfs ROM dump failed — trying ACPI VFCT table...": "Falha no despejo de ROM do sysfs - tentando tabela ACPI VFCT...", "syslog-ng allows you to flexibly collect, parse, classify, rewrite and correlate logs from across your infrastructure and store or route them to log analysis tools.": "o syslog-ng permite coletar, analisar, classificar, reescrever e correlacionar logs de toda sua infraestrutura e armazená-los ou roteá-los para ferramentas de análise de log.", - "systemctl restart networking failed:": "falha na reinicialização da rede do systemctl:", + "systemctl restart networking failed:": "O systemctl restart networking falhou:", "systemd OnCalendar expression": "Expressão Systemd OnCalendar", "the API key was not generated on the first start": "a tecla API não foi gerada no primeiro início", "the NVIDIA hook of the container is not available:": "o hook NVIDIA do contêiner não está disponível:", @@ -8152,7 +8152,7 @@ "video + recognition": "vídeo + reconhecimento", "video only": "apenas vídeo", "vzdump backup speed optimization completed": "otimização da velocidade de backup do vzdump concluída", - "wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf with the container stopped, and start it again.": "wallabag constrói seus links a partir do endereço fornecido durante a instalação. Se não corresponder ao endereço do recipiente, edite lxc.environment. tempo de execução: SYMFONY ENV DOMAIN NAME em /etc/pve/lxc/.conf com o recipiente parado, e inicie-o novamente.", + "wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf with the container stopped, and start it again.": "O wallabag gera as ligações com o endereço indicado na instalação. Se não corresponder ao do contentor, pare-o, edite lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME em /etc/pve/lxc/.conf e volte a iniciá-lo.", "wallabag listens on port 80 of the container and stores its data in SQLite.": "wallabag escuta na porta 80 do recipiente e armazena seus dados em SQLite.", "wallabag web interface": "Interface Web wallabag", "was": "era", diff --git a/lang/sk.json b/lang/sk.json index 63a88682..becda058 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -138,7 +138,7 @@ "ALL DATA ON": "VŠETKY DÁTA NA", "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!": "VŠETKY DÁTA NA TOMTO DISKU BUDÚ NATRVALO STRATENÉ!", "ALL Utilities": "VŠETKY nástroje", - "ALLOWED_HOSTS cannot contain line breaks": "UDELENÉ HOSTS nemôžu obsahovať prestávky na čiare", + "ALLOWED_HOSTS cannot contain line breaks": "ALLOWED_HOSTS nesmie obsahovať zalomenia riadkov", "AList initial login": "AList začiatočné prihlásenie", "AMD (ROCm)": "AMD (ROCm)", "AMD (VA-API and ROCm detection)": "AMD (detekcia VA-API a ROCm)", @@ -322,7 +322,7 @@ "After confirming, you will be asked to choose the NVIDIA driver version to install.": "Po potvrdení si vyberiete verziu ovládača NVIDIA, ktorú chcete nainštalovať.", "After detailed analysis, no changes are needed.": "Po podrobnej analýze nie sú potrebné žiadne zmeny.", "After detailed analysis, no cleanup is needed.": "Po podrobnej analýze nie je potrebné žiadne čistenie.", - "After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "Po prihlásení spustite príkaz: ip a, aby ste zistili IP adresu.\nPotom túto IP adresu zadajte do prehliadača takto:\n http://IP_ADRESA\n\nOtvorí sa tým webové rozhranie Umbral OS.", + "After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "Po prihlásení zistite IP adresu príkazom ip a.\nPotom ju zadajte do prehliadača v tomto tvare:\n http://IP_ADDRESS\n\nOtvorí sa panel Umbral OS.", "After pasting on the server, ensure the file is chmod 600 and owned by": "Po vložení na serveri overte, že súbor má chmod 600 a vlastní ho", "After reboot the system will be fully accessible (SSH, web UI, login), but the following components will be reinstalled in BACKGROUND — until they finish, commands like nvidia-smi may not yet be available:": "Po reštarte bude systém plne dostupný (SSH, webové rozhranie, prihlásenie), ale tieto súčasti sa budú doinštalovávať NA POZADÍ - kým neskončia, príkazy ako nvidia-smi ešte nemusia byť dostupné:", "After reboot, these components will reinstall in background:": "Po reštarte sa tieto súčasti doinštalujú na pozadí:", @@ -3048,7 +3048,7 @@ "Initiator IQN is authorised on the target": "IQN iniciátora je na targete povolené", "Initiator IQN:": "IQN iniciátora:", "Inkscape is professional quality vector graphics software which runs on Linux, Mac OS X and Windows desktop computers.": "Inkscape je profesionálne kvalitný vektorový grafický softvér, ktorý beží na počítačoch Linux, Mac OS X a Windows.", - "Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "Vnútri LXC, vytvoriť administrátor: konzola kimai:používateľ:vytvorte VAŠE VYUŽITE VAŠE EMAIL ROLE_SUPER_ADMIN", + "Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "V LXC vytvorte správcu príkazom: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN", "Inspect disks before any action": "Skontrolovať disky pred akoukoľvek akciou", "Inspect host device nodes": "Skontrolovať device nodes na hostovi", "Inspect passthrough/kernel events": "Skontrolovať passthrough/kernel udalosti", @@ -3225,7 +3225,7 @@ "Interrupted operation:": "Prerušená operation:", "Interrupted stack operation found": "Prerušený zásobník operation nájdený", "Invalid 'proxmox-ve' candidate (not 9.x or none). Please verify your repository configuration and network, then retry.": "Neplatný kandidát 'proxmox-ve' (nie je 9.x alebo chýba). Skontrolujte nastavenie repozitárov a sieť, potom skúste znova.", - "Invalid ALLOWED_HOSTS value": "Neplatná hodnota UDELENÁ HOSTS", + "Invalid ALLOWED_HOSTS value": "Neplatná hodnota ALLOWED_HOSTS", "Invalid Home Assistant OS check timeout": "Neplatný čas odhlásenia Home Assistant OS", "Invalid ID": "Neplatné ID", "Invalid IPv4 address": "Neplatná adresa IPv4", @@ -3460,7 +3460,7 @@ "Keyfile removed.": "Keyfile odstránený.", "Keyrings method failed; trying apt-key fallback": "Metóda keyrings zlyhala; skúšam náhradný apt-key postup", "KiCad - A Cross Platform and Open Source Electronics Design Automation Suite.": "KiCad - Cross Platform a Open Source Electronics Design Automation Suite.", - "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai nemá predvolený účet. Zadajte kontajner s: pct zadajte {main vmid}", + "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai nemá predvolený účet. Do kontajnera vstúpite príkazom: pct enter {main_vmid}", "Kimai is a professional grade time-tracking application, free and open-source.": "Kimai je profesionálna časová aplikácia, zadarmo a open-source.", "Kometa is a powerful tool designed to give you complete control over your media libraries. With Kometa, you can take your customization to the next level, with granular control over metadata, collections, overlays, and much more.": "Kometa je výkonný nástroj navrhnutý tak, aby vám plnú kontrolu nad vaše mediálne knižnice. S Kometa, môžete vziať svoje prispôsobenie na ďalšiu úroveň, s granulovanou kontrolou nad metaúdaje, zbierky, prekrývanie, a oveľa viac.", "Kometa reads its configuration from /config/config.yml and the container only ships /config/config.yml.template. Copy the template to config.yml, fill in the required Plex and TMDb connections, then restart the container.": "Kometa číta svoju konfiguráciu z /config/config.yml a kontajner iba lode /config/config.yml.templát. Skopírujte šablónu pre config.yml, vyplňte required Plex a TMDb pripojenia, potom reštartujte kontajner.", @@ -8153,7 +8153,7 @@ "video + recognition": "video + rozpoznávanie", "video only": "iba video", "vzdump backup speed optimization completed": "Optimalizácia rýchlosti záloh vzdump je dokončená", - "wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf with the container stopped, and start it again.": "wallabag buduje svoje odkazy z adresy uvedenej počas inštalácie. Ak nezodpovedá adrese kontajnera, upravte prostredie lxc. čas: SYMFONY ENV DOMAIN NAME in /etc/pve/lxc/ .conf s kontajnerom zastavil, a začať znova.", + "wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf with the container stopped, and start it again.": "wallabag vytvára odkazy z adresy zadanej pri inštalácii. Ak sa nezhoduje s adresou kontajnera, zastavte ho, upravte lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME v /etc/pve/lxc/.conf a znova ho spustite.", "wallabag listens on port 80 of the container and stores its data in SQLite.": "wallabag počúva port 80 kontajnera a ukladá svoje údaje v SQLite.", "wallabag web interface": "Webové rozhranie wallabag", "was": "bolo", diff --git a/lang/sv.json b/lang/sv.json index c06a631e..663e5e28 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -322,7 +322,7 @@ "After confirming, you will be asked to choose the NVIDIA driver version to install.": "Efter bekräftelse kommer du att bli ombedd att välja NVIDIA-drivrutinsversionen att installera.", "After detailed analysis, no changes are needed.": "Efter detaljerad analys behövs inga ändringar.", "After detailed analysis, no cleanup is needed.": "Efter detaljerad analys behövs ingen rengöring.", - "After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "När du har loggat in, kör: ip a för att få IP-adressen.\nAnge sedan den IP-adressen i din webbläsare så här:\n http://IP_ADRESS\n\nDetta öppnar Umbral OS-instrumentpanelen.", + "After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "Kör ip a efter inloggningen för att ta reda på IP-adressen.\nAnge sedan adressen i webbläsaren så här:\n http://IP_ADDRESS\n\nDå öppnas instrumentpanelen för Umbral OS.", "After pasting on the server, ensure the file is chmod 600 and owned by": "Efter att ha klistrat in på servern, se till att filen är chmod 600 och ägs av", "After reboot the system will be fully accessible (SSH, web UI, login), but the following components will be reinstalled in BACKGROUND — until they finish, commands like nvidia-smi may not yet be available:": "Efter omstart kommer systemet att vara fullt tillgängligt (SSH, webbgränssnitt, inloggning), men följande komponenter kommer att installeras om i BAKGRUND - tills de är klara kanske kommandon som nvidia-smi inte är tillgängliga ännu:", "After reboot, these components will reinstall in background:": "Efter omstart kommer dessa komponenter att installeras om i bakgrunden:", @@ -1643,8 +1643,8 @@ "Default Proxmox logo applied": "Standard Proxmox-logotyp tillämpas", "Default is none (video-only passthrough). Use SPACE to toggle selections.": "Standard är ingen (endast videogenomströmning). Använd MELLANSLAG för att växla mellan val.", "Default journald configuration restored": "Standard journalkonfiguration återställd", - "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "Standardplatsen är /mnt/. Delingen kommer att monteras här på värden med öppna behörigheter så att en oprivilegierad LXC kan bind-mounta och skriva till den. För LXC-åtkomst, bind-montera den här sökvägen med LXC Mount Manager.", - "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "Standardplatsen är /mnt/. Delen kommer att monteras här på värden. Använd denna sökväg i /etc/fstab. För LXC-åtkomst, bind-montera den här sökvägen med LXC Mount Manager.", + "Default location is /mnt/. The share will be mounted here on the host with open permissions so an unprivileged LXC can bind-mount and write to it. For LXC access, bind-mount this path with the LXC Mount Manager.": "Standardplatsen är /mnt/. Den delade resursen monteras här på värden med öppna behörigheter, så att en oprivilegierad LXC-container kan bind-montera den och skriva till den. Använd LXC Mount Manager för att skapa monteringen i LXC-containern.", + "Default location is /mnt/. The share will be mounted here on the host. Use this path in /etc/fstab. For LXC access, bind-mount this path with the LXC Mount Manager.": "Standardplatsen är /mnt/. Den delade resursen monteras här på värden. Använd sökvägen i /etc/fstab. För åtkomst från en LXC-container, skapa en bind-montering av sökvägen med LXC Mount Manager.", "Default login": "Standard inloggning", "Default options": "Standardalternativ", "Default options read/write": "Standardalternativ läs/skriv", @@ -2035,7 +2035,7 @@ "Enter VM ID:": "Ange VM ID:", "Enter VM/CT ID:": "Ange VM/CT ID:", "Enter ZFS pool name for the selected disk:": "Ange ZFS-poolnamn för den valda disken:", - "Enter a name for the mount point (used as /mnt/):": "Ange ett namn för monteringspunkten (används som /mnt/):", + "Enter a name for the mount point (used as /mnt/):": "Ange ett namn för monteringspunkten (används som /mnt/):", "Enter a name for the new virtual machine:": "Ange ett namn för den nya virtuella maskinen:", "Enter a non-empty password or access token on a single line.": "Ange ett icke-tomt lösenord eller åtkomsttoken på en enda rad.", "Enter a number, or write or paste a command:": "Ange ett nummer, eller skriv eller klistra in ett kommando:", @@ -2183,7 +2183,7 @@ "Example: pct stop 110": "Exempel: pct stop 110", "Example: pct stop 114": "Exempel: pct stop 114", "Example: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,backup": "Exempel: pvesm add nfs nfs-nas --server 192.168.1.50 --export /volume1/proxmox --content import,säkerhetskopia", - "Example: pvesm path local-lvm:vm-114-disk-0": "Exempel: pvesm sökväg local-lvm:vm-114-disk-0", + "Example: pvesm path local-lvm:vm-114-disk-0": "Exempel: pvesm path local-lvm:vm-114-disk-0", "Example: sed -i '/^unprivileged: 1/d' /etc/pve/lxc/110.conf": "Exempel: sed -i '/^unprivileged: 1/d' /etc/pve/lxc/110.conf", "Examples:": "Exempel:", "Execute destructive rollback?": "Utföra destruktiv återställning?", @@ -2495,7 +2495,7 @@ "For a consistent export, the VM should be stopped.": "För en konsekvent export bör den virtuella datorn stoppas.", "For any question, issue or bug report, please use the official repository": "För alla frågor, problem eller felrapporter, använd det officiella arkivet", "For dedicated GPUs, FLR is required by this policy to reduce VM": "För dedikerade GPU:er krävs FLR enligt denna policy för att minska virtuella datorer", - "For example: pct enter 101": "Till exempel: pct ange 101", + "For example: pct enter 101": "Till exempel: pct enter 101", "For guest shares add: --options guest": "För gästandelar lägg till: --options gäst", "For security reasons, the system cannot format it.": "Av säkerhetsskäl kan systemet inte formatera det.", "For unprivileged containers, use instead:": "För oprivilegierade behållare, använd istället:", @@ -2773,7 +2773,7 @@ "Host backup attached to PVE job": "Värdsäkerhetskopia kopplad till PVE-jobb", "Host data is not restored by the backup; confirm it with --acknowledge-external-data": "Värddata återställs inte av säkerhetskopieringen; bekräfta den med --acknowledge-external-data", "Host device for /dev/kvm": "Värd enhet för /dev/kvm", - "Host device for /dev/net/tun": "Värd enhet för /dev/nett/tun", + "Host device for /dev/net/tun": "Enhet på värden för /dev/net/tun", "Host device for /dev/ttyUSB0": "Värd enhet för /dev/ttyUSB0", "Host device for /dev/video10": "Värd enhet för /dev/video10", "Host device for /dev/video11": "Värd enhet för /dev/video11", @@ -2907,8 +2907,8 @@ "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.": "Om aviseringar är aktiverade (Telegram/Discord/ntfy/...), kommer du att få meddelandet \"Värdåterställning avslutad\" när alla bakgrundsuppgifter är klara.", "If passthrough fails on Windows: install RadeonResetBugFix.": "Om passthrough misslyckas på Windows: installera RadeonResetBugFix.", "If path not accessible (ZFS/BTRFS):": "Om sökväg inte är tillgänglig (ZFS/BTRFS):", - "If pvesm path returned a DEVICE (LVM):": "Om pvesm-sökvägen returnerade en DEVICE (LVM):", - "If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Om pvesm-sökvägen returnerade en DIRECTORY (ZFS/BTRFS):", + "If pvesm path returned a DEVICE (LVM):": "Om pvesm path returnerade en ENHET (LVM):", + "If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Om pvesm path returnerade en KATALOG (ZFS/BTRFS):", "If repositories error:": "Om förrådsfel:", "If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Om behållaren redan hade ändrats, återställs dess tidigare inbyggda säkerhetskopia. Delade värdkataloger återställs inte. Diskarna för det misslyckade försöket tas bort när rensningen lyckas.", "If the host freezes, remove hostpci entries from": "Om värden fryser, ta bort hostpci-poster från", @@ -3048,7 +3048,7 @@ "Initiator IQN is authorised on the target": "Initiator IQN är auktoriserad på målet", "Initiator IQN:": "Initiativtagare IQN:", "Inkscape is professional quality vector graphics software which runs on Linux, Mac OS X and Windows desktop computers.": "Inkscape är professionell kvalitet vektor grafik programvara som körs på Linux, Mac OS X och Windows stationära datorer.", - "Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "Inuti LXC, skapa administratören: konsol kimai:user: skapa ditt USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN", + "Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "Skapa administratören inne i LXC-containern: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN", "Inspect disks before any action": "Inspektera diskar innan någon åtgärd", "Inspect host device nodes": "Inspektera värdenhetsnoder", "Inspect passthrough/kernel events": "Inspektera passthrough/kärnhändelser", @@ -3460,7 +3460,7 @@ "Keyfile removed.": "Nyckelfilen har tagits bort.", "Keyrings method failed; trying apt-key fallback": "Nyckelringsmetoden misslyckades; försöker apt-key fallback", "KiCad - A Cross Platform and Open Source Electronics Design Automation Suite.": "KiCad - En Cross Platform och Open Source Electronics Design Automation Suite.", - "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai har inget standardkonto. Ange behållaren med: pct enter {main vmid}", + "Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai har inget standardkonto. Öppna ett skal i containern med: pct enter {main_vmid}", "Kimai is a professional grade time-tracking application, free and open-source.": "Kimai är en professionell tidsspårningsapplikation, fri och öppen källkod.", "Kometa is a powerful tool designed to give you complete control over your media libraries. With Kometa, you can take your customization to the next level, with granular control over metadata, collections, overlays, and much more.": "Kometa är ett kraftfullt verktyg för att ge dig fullständig kontroll över dina mediebibliotek. Med Kometa kan du ta din anpassning till nästa nivå, med granulär kontroll över metadata, samlingar, överlagringar och mycket mer.", "Kometa reads its configuration from /config/config.yml and the container only ships /config/config.yml.template. Copy the template to config.yml, fill in the required Plex and TMDb connections, then restart the container.": "Kometa läser sin konfiguration från /config/config.yml och behållaren endast fartyg /config/config.yml.template. Kopiera mallen till config.yml, fyll i required Plex och TMDb-anslutningar och starta sedan om behållaren.", @@ -3531,7 +3531,7 @@ "Limiting size and optimizing journald": "Begränsning av storlek och optimering av journal", "Limiting size and optimizing journald...": "Begränsar storlek och optimerar journal...", "Limnoria A robust, full-featured, and user/programmer-friendly Python IRC bot, with many existing plugins. Successor of the well-known Supybot.": "Limnoria En robust, fullfjädrad och användarvänlig Python IRC bot, med många befintliga plugins. Framgång för den välkända Supybot.", - "Limnoria joins no IRC network until its configuration file exists. Create it with the setup wizard from the Proxmox host: pct exec -- bash -c 'cd /config && limnoria-wizard'": "Limnoria går med i inget IRC-nätverk tills dess konfigurationsfil finns. Skapa den med installationsguiden från Proxmox-värden: pct exec - bash -c \"cd /config & & & & limnoria-wizard\"", + "Limnoria joins no IRC network until its configuration file exists. Create it with the setup wizard from the Proxmox host: pct exec -- bash -c 'cd /config && limnoria-wizard'": "Limnoria ansluter inte till något IRC-nätverk förrän konfigurationsfilen finns. Skapa den med installationsguiden från Proxmox-värden: pct exec -- bash -c 'cd /config && limnoria-wizard'", "Line to paste (single line, including \"command=...\" prefix):": "Rad att klistra in (en rad, inklusive \"command=...\" prefix):", "Linux Installation Options": "Linux installationsalternativ", "Linux/Mac path:": "Linux/Mac sökväg:", @@ -4600,7 +4600,7 @@ "Partition created:": "Partition skapad:", "Partition table wiped": "Skiljebord torkat", "Pass /dev/kvm to the LXC": "Pass /dev/kvm till LXC", - "Pass /dev/net/tun to the LXC": "Pass /dev/netto/tun till LXC", + "Pass /dev/net/tun to the LXC": "Ge LXC-containern åtkomst till /dev/net/tun", "Pass /dev/ttyUSB0 to the LXC": "Pass /dev/ttyUSB0 till LXC", "Pass /dev/video10 to the LXC": "Pass /dev/video10 till LXC", "Pass /dev/video11 to the LXC": "Pass /dev/video11 till LXC", @@ -5250,7 +5250,7 @@ "Replace": "Ersätta", "Replace with the actual ID.": "Ersätt med det faktiska ID:t.", "Replace with your actual container ID": "Ersätt med ditt faktiska container-ID", - "Replace with the value from step 4": "Ersätt med värdet från steg 4", + "Replace with the value from step 4": "Ersätt med värdet från steg 4", "Replace invalid port(s)": "Ersätt ogiltiga port(ar)", "Replace it?": "Byt ut den?", "Replace paths with your actual values from step 5": "Ersätt sökvägar med dina faktiska värden från steg 5", @@ -6993,7 +6993,7 @@ "Transaction log:": "Transaktionslogg:", "Translation files:": "Översättningsfiler:", "Transmission is designed for easy, powerful use. Transmission has the features you want from a BitTorrent client: encryption, a web interface, peer exchange, magnet links, DHT, µTP, UPnP and NAT-PMP port forwarding, webseed support, watch directories, tracker editing, global and per-torrent speed limits, and more.": "Transmission är utformad för enkel, kraftfull användning. Transmission har de funktioner du vill ha från en BitTorrent-klient: kryptering, ett webbgränssnitt, peer-utbyte, magnetlänkar, DHT, μTP, UPnP och NAT-PMP-port vidarebefordran, webseed support, watch kataloger, tracker redigering, globala och per-torrent hastighet gränser, och mer.", - "Tried pvesm path and manual detection methods": "Testade pvesm-väg och manuella detekteringsmetoder", + "Tried pvesm path and manual detection methods": "Har provat pvesm path och metoder för manuell identifiering", "Trust this certificate and save it for scheduled backups?": "Lita på det här certifikatet och spara det för schemalagda säkerhetskopieringar?", "Try Again": "Försök igen", "Try a different search term.": "Prova en annan sökterm.", @@ -8018,8 +8018,8 @@ "pvesm command not found. This should not happen on Proxmox.": "pvesm-kommandot hittades inte. Detta bör inte hända på Proxmox.", "pvesm not available.": "pvesm inte tillgänglig.", "pvesm not found.": "pvesm hittades inte.", - "pvesm path failed, trying manual detection...": "pvesm sökväg misslyckades, försöker manuell upptäckt...", - "pvesm status failed": "pvesm-status misslyckades", + "pvesm path failed, trying manual detection...": "pvesm path misslyckades, försöker identifiera manuellt...", + "pvesm status failed": "pvesm status misslyckades", "pyLoad is a Free and Open Source download manager written in Python and designed to be extremely lightweight, easily extensible and fully manageable via web.": "pyLoad är en gratis och öppen källkod nedladdningshanterare skriven i Python och utformad för att vara extremt lätt, lättföränderlig och fullt hanterbar via webben.", "pyLoad web interface": "PyLoad webbgränssnitt", "qBittorrent WebUI password (user: admin)": "qBittorrent WebUI lösenord (användare: admin)", @@ -8098,7 +8098,7 @@ "switch_gpu_mode.sh was not found.": "switch_gpu_mode.sh hittades inte.", "sysfs ROM dump failed — trying ACPI VFCT table...": "sysfs ROM-dump misslyckades — försöker ACPI VFCT-tabell...", "syslog-ng allows you to flexibly collect, parse, classify, rewrite and correlate logs from across your infrastructure and store or route them to log analysis tools.": "syslog-ng låter dig flexibelt samla in, analysera, klassificera, skriva och korrelera loggar från hela din infrastruktur och lagra eller dirigera dem för att logga analysverktyg.", - "systemctl restart networking failed:": "systemctl omstart av nätverk misslyckades:", + "systemctl restart networking failed:": "systemctl restart networking misslyckades:", "systemd OnCalendar expression": "systemd OnCalendar-uttryck", "the API key was not generated on the first start": "API-nyckeln genererades inte i första början", "the NVIDIA hook of the container is not available:": "NVIDIA-hooken för behållaren är inte tillgänglig:", diff --git a/web/CONTRIBUTING-TRANSLATIONS.md b/web/CONTRIBUTING-TRANSLATIONS.md index ce9250cb..bd668edc 100644 --- a/web/CONTRIBUTING-TRANSLATIONS.md +++ b/web/CONTRIBUTING-TRANSLATIONS.md @@ -5,7 +5,12 @@ serves every published page under locale-prefixed URLs: - `/en/` — English, the source of truth - `/es/` — Spanish +- `/de/` — German +- `/fr/` — French +- `/it/` — Italian +- `/pt/` — Portuguese - `/sk/` — Slovak, with English fallback where needed +- `/sv/` — Swedish We use [`next-intl`](https://next-intl.dev) for the i18n plumbing. Anyone can translate the docs without writing TypeScript: most of the work is @@ -148,6 +153,18 @@ Example: ### 4. Test locally +From the repository root, check the available catalogs without using a +translation provider: + +```bash +python3 .github/scripts/check_web_docs_catalogs.py +``` + +For a full locale-completion change, add `--require-complete` to also require +every English file and leaf. This validates schema, placeholders, rich-text +tags, literal code and rendering metadata; it does not replace linguistic +review. Partial translations can continue to use the English fallback. + ```bash cd web npm run dev @@ -311,7 +328,10 @@ registers a renderer for them. If you want to add a language that isn't in the project yet: 1. Create `messages//common.json` and the page catalogs. The automated - builder can provide the initial baseline. + builder can provide the initial baseline when explicitly dispatched. It + does not run on merge: changing English and its translations together must + not trigger machine retranslation of the reviewed text. Offline validation + still runs automatically for website changes. 2. Review the shared navigation and a representative set of documentation pages with a native speaker. 3. Add the locale code to `routing.ts` only when the locale is ready to be diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 22dcf74a..05cf6b82 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -8,13 +8,14 @@ const inter = Inter({ subsets: ["latin"] }) * Minimal root layout — the localized layout under [locale]/ does the * real work (provider, navbar, etc.) but cannot own / tags * because Next.js requires those at the root level. Lang is set to the - * default locale here and refined client-side from inside the locale - * layout so the page still renders correctly with JS disabled (defaults - * to English) and switches to the active locale once hydrated. + * default locale here; scripts/localize-export.mjs sets the exported + * document's language before search indexing, and LocaleHtmlSync keeps + * it in sync during client navigation. The root-only hydration suppression + * allows that intentional lang attribute change in the static export. */ export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/web/components/language-switcher.tsx b/web/components/language-switcher.tsx index aa084783..cede72da 100644 --- a/web/components/language-switcher.tsx +++ b/web/components/language-switcher.tsx @@ -29,7 +29,12 @@ export function LanguageSwitcher() { const labels: Record = { en: t("en"), es: t("es"), + de: t("de"), + fr: t("fr"), + it: t("it"), + pt: t("pt"), sk: t("sk"), + sv: t("sv"), } return ( diff --git a/web/i18n/routing.ts b/web/i18n/routing.ts index 41c0cfdc..ffc11c7f 100644 --- a/web/i18n/routing.ts +++ b/web/i18n/routing.ts @@ -19,7 +19,7 @@ import { defineRouting } from "next-intl/routing" * `CONTRIBUTING-TRANSLATIONS.md`. */ export const routing = defineRouting({ - locales: ["en", "es", "sk"], + locales: ["en", "es", "de", "fr", "it", "pt", "sk", "sv"], defaultLocale: "en", localePrefix: "always", }) diff --git a/web/messages/de/common.json b/web/messages/de/common.json new file mode 100644 index 00000000..c3aefb73 --- /dev/null +++ b/web/messages/de/common.json @@ -0,0 +1,254 @@ +{ + "site": { + "name": "ProxMenux", + "tagline": "Interaktives Menü und Web-Dashboard für Proxmox VE" + }, + "nav": { + "home": "Startseite", + "docs": "Dokumentation", + "documentation": "Dokumentation", + "guides": "Anleitungen", + "changelog": "Änderungsprotokoll", + "github": "GitHub", + "rss": "RSS", + "rssTitle": "RSS-Feed", + "menuOpen": "Menü öffnen" + }, + "footer": { + "sponsorHeading": "Projekt unterstützen", + "sponsorBody": "Wenn Sie das Projekt unterstützen möchten.", + "sponsorAlt": "Auf Ko-fi unterstützen", + "connectHeading": "Community", + "connectBody": "Beteiligen Sie sich an den Community-Diskussionen auf GitHub, um Hilfe zu erhalten, Ideen auszutauschen und zum Projekt beizutragen. Jede Idee ist willkommen!", + "joinDiscussion": "An der Diskussion teilnehmen", + "copyrightPrefix": "ProxMenux, ein gemeinschaftliches Open-Source-Projekt von", + "copyrightSuffix": "." + }, + "language": { + "switcher": "Sprache", + "en": "English", + "es": "Español", + "sk": "Slovenčina", + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "pt": "Português", + "sv": "Svenska" + }, + "resources": { + "documentation": { + "title": "Dokumentation", + "description": "Systembeschreibung und Benutzeranleitungen" + }, + "changelog": { + "title": "Änderungsprotokoll", + "description": "Informationen zu den neuesten Aktualisierungen" + }, + "guides": { + "title": "Anleitungen", + "description": "Schrittweise Anleitungen für häufige Aufgaben" + }, + "github": { + "title": "GitHub-Repository", + "description": "Sehen Sie sich den Quellcode an." + } + }, + "docSidebar": { + "documentation": "Dokumentation", + "items": { + "introduction": "Einführung", + "installation": "Installation", + "proxmenuxMonitor": "ProxMenux Monitor", + "monitorOverview": "Übersicht", + "architecture": "Architektur", + "accessAuth": "Zugriff und Authentifizierung", + "dashboard": "Dashboard", + "dashboardSystemOverview": "Registerkarte Systemübersicht", + "dashboardApps": "Registerkarte Apps", + "dashboardStorage": "Registerkarte Speicher", + "dashboardNetwork": "Registerkarte Netzwerk", + "dashboardVmsLxcs": "Registerkarte VMs und LXCs", + "dashboardVmsLxcsApp": "App", + "dashboardVmsLxcsUpdates": "Aktualisierungen", + "dashboardHardware": "Registerkarte Hardware", + "dashboardSystemLogs": "Registerkarte Systemprotokolle", + "dashboardTerminal": "Registerkarte Terminal", + "dashboardSecurity": "Registerkarte Sicherheit", + "dashboardSettings": "Registerkarte Einstellungen", + "healthMonitor": "Zustandsüberwachung", + "auditReport": "Prüfung und Berichte", + "auditReportOverview": "Übersicht", + "auditReportAssessment": "Bewertung und Inventar", + "auditReportPolicy": "Richtlinie", + "auditReportChanges": "Änderungen", + "auditReportReports": "Berichte und Vergleiche", + "auditReportScope": "Umfang und Garantien", + "notifications": "Benachrichtigungen", + "aiAssistant": "KI-Assistent", + "apiReference": "API-Referenz", + "integrations": "Integrationen", + "ociManager": "OCI manager Apps", + "ociOverview": "Übersicht", + "ociArchitecture": "So wird ein OCI-Image umgesetzt", + "ociCustomImage": "Image außerhalb des Katalogs", + "ociStorageNetwork": "Daten, Pfade und Netzwerk", + "ociHardware": "Geräte und Beschleunigung", + "ociStacks": "Anwendungen mit mehreren Containern", + "ociLifecycle": "Installieren, aktualisieren und ändern", + "ociMonitor": "In ProxMenux Monitor", + "proxmenuxScripts": "ProxMenux Scripts", + "postInstallScript": "Skript zur Nachinstallation", + "postInstallOverview": "Übersicht", + "postInstallAutomated": "Automatisch", + "postInstallCustomizable": "Anpassbar", + "postInstallBasicSettings": "Grundeinstellungen", + "postInstallSystem": "System", + "postInstallVirtualization": "Virtualisierung", + "postInstallNetwork": "Netzwerk", + "postInstallStorage": "Speicher", + "postInstallSecurity": "Sicherheit", + "postInstallCustomization": "Anpassung", + "postInstallMonitoring": "Überwachung", + "postInstallPerformance": "Leistung", + "postInstallOptional": "Optional", + "postInstallUpdates": "Verfügbare Aktualisierungen installieren", + "postInstallUninstall": "Optimierungen entfernen", + "gpusCoralTpu": "GPUs und Coral TPU", + "nvidiaHost": "NVIDIA-Treiber auf dem Host installieren", + "coralHost": "Coral TPU auf dem Host installieren", + "addGpuLxc": "GPU für einen LXC freigeben", + "addCoralLxc": "Coral TPU für einen LXC freigeben", + "addGpuVm": "GPU einer VM zuweisen (Passthrough)", + "switchGpuMode": "GPU-Modus wechseln (VM ↔ LXC)", + "createVm": "VM erstellen", + "createVmOverview": "Übersicht", + "createVmSystemNas": "NAS-Systeme", + "createVmSynology": "Synology-VM", + "createVmNasOthers": "Weitere NAS-Systeme", + "createVmSystemWindows": "Windows-Systeme", + "createVmSystemLinux": "Linux-Systeme", + "diskManager": "Datenträgerverwaltung", + "diskManagerOverview": "Übersicht", + "diskImportVm": "Datenträger in eine VM importieren", + "diskImportImageVm": "Datenträgerabbild in eine VM importieren", + "diskAddController": "Controller oder NVMe-Gerät zu einer VM hinzufügen", + "diskImportLxc": "Datenträger in einen LXC importieren", + "diskFormat": "Physischen Datenträger formatieren oder löschen", + "diskSmart": "SMART-Zustand und Datenträgertests", + "storageShareManager": "Speicher und Freigaben verwalten", + "storageShareOverview": "Übersicht", + "hostStorage": "Speicher in den Host einbinden", + "hostNfs": "NFS-Freigabe als Proxmox-Speicher hinzufügen", + "hostSamba": "Samba-Freigabe als Proxmox-Speicher hinzufügen", + "hostIscsi": "iSCSI-Ziel als Proxmox-Speicher hinzufügen", + "hostLocalDisk": "Lokalen Datenträger als Proxmox-Speicher hinzufügen", + "hostLocalShared": "Freigegebenes Verzeichnis auf dem Host anlegen", + "lxcMountPoints": "LXC-Mountpoints (Host ↔ CT)", + "lxcNetworkSharing": "Netzwerkfreigaben in LXC", + "lxcNfsClient": "NFS-Client in LXC", + "lxcSambaClient": "Samba-Client in LXC", + "lxcNfsServer": "NFS-Server in LXC", + "lxcSambaServer": "Samba-Server in LXC", + "network": "Netzwerk", + "networkOverview": "Übersicht", + "networkDiagnostics": "Diagnose", + "networkMonitoring": "Werkzeuge zur Echtzeitüberwachung", + "networkBridge": "Netzwerk-Bridges prüfen und reparieren", + "networkConfig": "Konfiguration prüfen und bereinigen", + "networkPersistent": "Dauerhafte Schnittstellennamen", + "networkBackup": "Schnittstellen sichern und neu starten", + "security": "Sicherheit", + "securityOverview": "Übersicht", + "securityFail2ban": "Fail2Ban", + "securityLynis": "Lynis", + "utilities": "Dienstprogramme", + "utilsOverview": "Übersicht", + "utilsUupDump": "ISO-Erstellung mit UUP Dump", + "utilsSystemUtils": "Systemwerkzeuge installieren", + "utilsSystemUpdate": "Proxmox-System aktualisieren", + "utilsUpgradePve": "Von PVE 8 auf PVE 9 aktualisieren", + "utilsExportVm": "VM als OVA/OVF exportieren", + "utilsImportVm": "VM aus OVA/OVF importieren", + "settingsProxmenux": "ProxMenux-Einstellungen", + "settingsOverview": "Übersicht", + "settingsMonitor": "ProxMenux Monitor", + "settingsBeta": "Release-Kanal wechseln", + "settingsVersion": "Versionsinformationen anzeigen", + "settingsUninstall": "ProxMenux deinstallieren", + "commandsReference": "Befehlsreferenz", + "commandsOverview": "Übersicht", + "commandsSystem": "Nützliche Systembefehle", + "commandsVmCt": "VMs und CTs verwalten", + "commandsStorage": "Speicher und Datenträger", + "commandsNetwork": "Netzwerkbefehle", + "commandsUpdates": "Aktualisierungen und Pakete", + "commandsGpu": "GPU-Passthrough", + "commandsZfs": "ZFS verwalten", + "commandsBackup": "Sichern und Wiederherstellen", + "commandsTools": "Systemwerkzeuge für die Befehlszeile", + "glossary": "Glossar", + "about": "Über das Projekt", + "aboutOverview": "Übersicht", + "aboutFaq": "FAQ", + "aboutContributors": "Mitwirkende", + "aboutContributing": "Mitwirken", + "aboutCodeOfConduct": "Verhaltenskodex", + "externalRepositories": "Externe Repositorys", + "backupRestore": "Sichern und Wiederherstellen", + "backupRestoreOverview": "Übersicht", + "backupRestoreHowItWorks": "Funktionsweise", + "backupRestoreDestinations": "Ziele", + "backupRestoreDestOverview": "Übersicht", + "backupRestoreDestLocal": "Lokales Archiv", + "backupRestoreDestPbs": "Proxmox Backup Server", + "backupRestoreDestBorg": "Borg", + "backupRestoreCreating": "Sicherungen erstellen", + "backupRestoreJobs": "Geplante Aufträge", + "backupRestoreRestoring": "Wiederherstellen", + "backupRestoreCrossKernel": "Wiederherstellung mit anderer Kernel-Version", + "backupRestoreGlossary": "Glossar" + } + }, + "hero": { + "title": "ProxMenux", + "tagline": "Ein interaktives Menü und Web-Dashboard für Proxmox VE", + "description": "ProxMenux verbindet ein interaktives Menü mit einem Web-Dashboard für Proxmox VE. Führen Sie Befehle, Skripte und geführte Assistenten über das Terminalmenü aus oder verfolgen Sie Host-Zustand, Messwerte und Benachrichtigungen im Browser.", + "installButton": "Jetzt installieren", + "whatIsButton": "Was ist ProxMenux?", + "logoAlt": "ProxMenux-Logo" + }, + "docNav": { + "previous": "Zurück", + "next": "Weiter" + }, + "tocPanel": { + "onThisPage": "Auf dieser Seite" + }, + "supportProject": { + "heading": "Unterstützen Sie das Projekt!", + "body": "Wenn Sie ProxMenux hilfreich finden, geben Sie dem Projekt eine ⭐ auf GitHub, damit auch andere darauf aufmerksam werden.", + "button": "Stern auf GitHub vergeben" + }, + "rssLink": { + "heading": "Bleiben Sie auf dem Laufenden!", + "body": "Abonnieren Sie unseren RSS-Feed, um über neue Änderungen informiert zu werden.", + "copy": "Kopieren", + "copied": "Kopiert!", + "copyTitle": "RSS-URL kopieren", + "openFeed": "RSS-Feed öffnen", + "openTitle": "RSS-Feed öffnen" + }, + "changelog": { + "pageTitle": "Änderungsprotokoll", + "meta": { + "title": "ProxMenux-Änderungsprotokoll", + "titleSuffix": "Versionshinweise", + "latest": "Aktuell", + "mostRecent": "Neueste Version", + "description": "Versionshinweise und Aktualisierungsverlauf für ProxMenux, das menügesteuerte Skript zur Verwaltung von Proxmox VE.", + "descriptionTail": "Neue Funktionen, Fehlerbehebungen, Sicherheitsupdates, Verbesserungen an ProxMenux Monitor, Kompatibilität mit KI-Anbietern, zusätzliche Absicherung beim GPU-Passthrough und mehr.", + "ogDescription": "Versionshinweise für ProxMenux.", + "ogTail": "Abonnieren Sie den RSS-Feed oder besuchen Sie diese Seite erneut, um neue Versionen zu finden." + } + } +} diff --git a/web/messages/en/common.json b/web/messages/en/common.json index 8e5f794b..2c37c948 100644 --- a/web/messages/en/common.json +++ b/web/messages/en/common.json @@ -28,7 +28,12 @@ "switcher": "Language", "en": "English", "es": "Español", - "sk": "Slovenčina" + "sk": "Slovenčina", + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "pt": "Português", + "sv": "Svenska" }, "resources": { "documentation": { diff --git a/web/messages/es/common.json b/web/messages/es/common.json index 3ba17487..477d303b 100644 --- a/web/messages/es/common.json +++ b/web/messages/es/common.json @@ -28,7 +28,12 @@ "switcher": "Idioma", "en": "English", "es": "Español", - "sk": "Slovenčina" + "sk": "Slovenčina", + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "pt": "Português", + "sv": "Svenska" }, "resources": { "documentation": { diff --git a/web/messages/es/docs/backup-restore/creating-backups.json b/web/messages/es/docs/backup-restore/creating-backups.json index f4b2188f..8bca0e53 100644 --- a/web/messages/es/docs/backup-restore/creating-backups.json +++ b/web/messages/es/docs/backup-restore/creating-backups.json @@ -157,7 +157,7 @@ "Filesystems runtime del kernel y sistema — /proc, /sys, /dev y /run son pseudo-filesystems que produce el kernel y udev; no se persisten en ningún sitio.", "Binarios de paquetes bajo /usr/bin, /usr/lib, /lib, /sbin — los reinstala el APT del destino a partir de packages.manual.list.", "/var/log/, /var/tmp/, /var/cache/ — estado runtime por host, no se restaura.", - "/home/USUARIO — no está en el perfil por defecto. Añadirlo como ruta custom cuando un sistema tenga directorios home de usuario que deban sobrevivir a una restauración." + "/home/USER — no está en el perfil por defecto. Añadirlo como ruta custom cuando un sistema tenga directorios home de usuario que deban sobrevivir a una restauración." ], "customPathsTitle": "Cómo se tratan las rutas custom", "customPathsBody": "Una ruta custom añadida en línea en modo Custom o persistida en backup-extra-paths.txt pasa por el mismo pipeline de rsync que las rutas del perfil por defecto. Se aplican las exclusiones globales. Si la ruta custom está bajo /root/ o /usr/local/share/proxmenux/, siguen aplicando las exclusiones específicas de arriba. Cada ruta archivada — default o custom — queda registrada en metadata/paths_archived.txt. Las rutas que no existen en el origen se registran en metadata/missing_paths.txt sin detener la copia." diff --git a/web/messages/es/docs/backup-restore/cross-kernel.json b/web/messages/es/docs/backup-restore/cross-kernel.json index 407a7f53..ced544ff 100644 --- a/web/messages/es/docs/backup-restore/cross-kernel.json +++ b/web/messages/es/docs/backup-restore/cross-kernel.json @@ -14,7 +14,7 @@ }, "intro": { "title": "Cada salto de kernel se maneja de forma distinta", - "body": "Cuando la restauración detecta que la copia y el destino tienen versiones mayores distintas de kernel, el flujo se ramifica en función de la dirección del salto — si la copia es más antigua o más reciente que el destino — porque los dos casos tienen modos de fallo opuestos. Las copias más recientes que el destino se restauran limpiamente tal cual (verificado empíricamente en múltiples pruebas de banco). Las copias más antiguas que el destino necesitan un filtro para evitar romper el arranque del destino con configuración escrita para un kernel que desde entonces ha cambiado." + "body": "El kernel de un host Proxmox se identifica mediante una versión como 6.17.13-2-pve. Los dos primeros números (6.17 en este ejemplo) definen la versión principal: cuando cambian, algunos archivos de configuración del kernel anterior podrían no ser válidos para el nuevo. Si la restauración detecta versiones principales distintas en la copia y en el destino, el proceso toma un camino u otro según la dirección del salto: si el kernel de la copia es anterior o posterior al del destino. Los dos casos presentan riesgos opuestos. Las copias con un kernel más reciente que el del destino se restauran tal cual, sin problemas según las pruebas realizadas con varios saltos de kernel. Las copias con un kernel anterior al del destino necesitan un filtro para evitar que una configuración del kernel antiguo impida arrancar el sistema de destino." }, "directionCheck": { "heading": "La comprobación de dirección", @@ -78,7 +78,7 @@ "intro": "Para desarrolladores que quieran trazar o extender el comportamiento cross-kernel:", "rows": [ { "component": "Detección de dirección", "location": "hb_compat_check en lib_host_backup_common.sh. Fija HB_COMPAT_KERNEL_DIRECTION." }, - { "component": "Lista de rutas del subconjunto seguro", "location": "hb_unsafe_paths_cross_version en lib_host_backup_common.sh. Emite líneas path\\tmotivo usadas tanto por el filtro CLI como por el endpoint Web /api/host-backups/restore/prepare." }, + { "component": "Lista de rutas del subconjunto seguro", "location": "hb_unsafe_paths_cross_version en lib_host_backup_common.sh. Emite líneas path\\treason usadas tanto por el filtro CLI como por el endpoint Web /api/host-backups/restore/prepare." }, { "component": "Fases de hidratación", "location": "_rs_hyd_grub, _rs_hyd_kernel_cmdline, _rs_hyd_modules, _rs_hyd_files en backup_host.sh. Orquestados por _rs_apply_bk_older_hydration." }, { "component": "Forzado de reflow post-arranque", "location": "apply_pending_restore.sh lee HB_HYDRATION_APPLIED de plan.env y fuerza NEEDS_INITRAMFS=1/NEEDS_GRUB=1." }, { "component": "Preview Web", "location": "/api/host-backups/restore/prepare en flask_server.py. Fuentea la librería helper, ejecuta _rs_apply_bk_older_hydration en modo plan, devuelve las acciones al modal Web." } diff --git a/web/messages/es/docs/backup-restore/destinations/index.json b/web/messages/es/docs/backup-restore/destinations/index.json index 1965649f..efb1e349 100644 --- a/web/messages/es/docs/backup-restore/destinations/index.json +++ b/web/messages/es/docs/backup-restore/destinations/index.json @@ -76,7 +76,7 @@ }, "sameArchive": { "heading": "El contenido interno de la copia es el mismo en cualquier destino", - "body": "Con independencia de dónde se guarde la copia, dentro siempre están los mismos tres bloques descritos en Cómo funciona: el sistema de ficheros (rootfs/), los metadatos (metadata/) y el manifiesto (manifest.json). Al extraer una copia local .tar.zst, restaurar un archivo .pxar desde PBS o descomprimir un archivo Borg, el árbol de directorios resultante es idéntico en los tres casos. Esto significa que el proceso de restauración lee siempre los mismos ficheros y no le importa desde qué destino provenga la copia." + "body": "Los tres destinos contienen la misma estructura rootfs/ + metadata/ + manifest.json descrita en Cómo funciona. Extraer un .tar.zst de un archivo local, restaurar un .pxar desde PBS o extraer un archivo de Borg con borg extract produce el mismo árbol de directorios. El proceso de restauración (_rs_check_layout, _rs_apply, _rs_prepare_pending_restore) lee los mismos tres componentes sin necesitar saber de qué destino proceden." }, "extractStandalone": { "heading": "Extraer una copia fuera de ProxMenux", diff --git a/web/messages/es/docs/backup-restore/destinations/pbs.json b/web/messages/es/docs/backup-restore/destinations/pbs.json index 917cc76d..354ad987 100644 --- a/web/messages/es/docs/backup-restore/destinations/pbs.json +++ b/web/messages/es/docs/backup-restore/destinations/pbs.json @@ -23,7 +23,7 @@ }, "repoSelection": { "heading": "Selección del repositorio", - "intro": "ProxMenux descubre repositorios PBS desde dos fuentes en cada copia. El usuario elige uno desde un menú unificado; esa elección determina, para esa ejecución concreta, contra qué servidor y datastore se envía la copia, con qué contraseña se autentica y con qué huella (fingerprint) valida el certificado.", + "intro": "ProxMenux descubre los repositorios de PBS a partir de dos fuentes en cada copia de seguridad. El usuario elige uno en un menú unificado; esa elección determina los valores de HB_PBS_REPOSITORY, HB_PBS_SECRET y HB_PBS_FINGERPRINT para esa ejecución.", "sourceRows": [ { "source": "storage.cfg de Proxmox (auto-descubierto)", @@ -46,7 +46,7 @@ "backupIdTitle": "Nombrado del backup ID", "backupIdBody": "El backup ID por defecto es hostcfg-HOSTNAME. Se pide al usuario que lo confirme o edite antes de la subida; cualquier carácter fuera de [A-Za-z0-9_-] se elimina y los guiones finales se recortan. Reutilizar el mismo ID entre ejecuciones es intencional — PBS trata el ID como un grupo, y cada copia posterior aparece como un nuevo backup dentro de ese grupo, compartiendo dedup con las ejecuciones previas.", "pxarTitle": "Qué se incluye dentro del archivo .pxar", - "pxarBody": "Al construir el archivo .pxar se empaqueta el directorio de trabajo completo: el sistema de ficheros del host (rootfs/), los metadatos (metadata/) y el manifiesto (manifest.json). Al restaurar, ProxMenux compara la información del manifiesto con la del host destino para detectar si son equivalentes, si cambia el hardware o si se trata de un equipo distinto, y ajustar el proceso en consecuencia. Las copias antiguas —hechas con versiones anteriores de ProxMenux que empaquetaban solo el sistema de ficheros— siguen restaurándose sin problema: el flujo de restauración detecta ese formato heredado y lo reorganiza automáticamente." + "pxarBody": "El origen del .pxar es todo el directorio staging_root, con rootfs/, metadata/ y manifest.json juntos. Las versiones anteriores utilizaban $staging_root/rootfs como origen; esto dejaba metadata/ fuera del archivo y la comprobación de compatibilidad de la restauración no tenía datos que leer, por lo que mostraba advertencias de restauración entre hosts incluso al restaurar en el mismo host. Las copias antiguas creadas solo a partir de rootfs siguen restaurándose correctamente mediante la rama del caso 3 de _rs_check_layout, que vuelve a colocar el árbol plano etc/var/root/usr dentro de una estructura rootfs/." }, "encryption": { "heading": "Cifrado del lado del cliente", @@ -61,9 +61,9 @@ "modesSharedTitle": "Clave compartida (importar la misma en cada host)", "modesSharedBody": "Se genera una única clave y se importa en todos los hosts mediante la opción Usar una clave existente. La gestión es más sencilla: hay un solo secreto que proteger, y cualquier host puede leer las copias de cualquier otro (útil para verificación centralizada, ejercicios de restauración cruzada o consolidación). A cambio, si la clave compartida se filtra queda expuesto todo el conjunto de hosts a la vez. Recomendado para laboratorios personales y entornos donde todos los hosts pertenecen al mismo responsable y comparten el mismo nivel de confianza.", "recoveryTitle": "¿Subir la clave a PBS? — el usuario decide", - "recoveryBody": "Justo después de elegir la clave, ProxMenux pregunta si se quiere subir una copia de esa clave a PBS como red de seguridad, con dos opciones. No (por defecto) mantiene la clave solo en el host; ProxMenux no toca PBS con nada relacionado con la recuperación y el usuario gestiona por su cuenta la copia externa (desde el Monitor, la acción Descargar en la fila del destino PBS la exporta en el acto). Sí pide una passphrase de recuperación —dos veces, para comprobar que coincidan—; con esa passphrase, la clave se envuelve localmente con AES-256-CBC y PBKDF2 (600 000 iteraciones, sal aleatoria) en un fichero pbs-key.recovery.enc. A partir de ese momento, cada copia cifrada sube también ese fichero envuelto a PBS. Ni la passphrase ni la clave en claro salen del host. La respuesta se puede cambiar después desde el Monitor en cualquier momento; el interruptor está inline en la fila del destino PBS y el botón que aparece se adapta al estado (empezar a subir, dejar de subir, o cambiar la passphrase).", + "recoveryBody": "Justo después de elegir la clave, ProxMenux hace una única pregunta explícita — ¿Subir clave a PBS? — con dos respuestas. No se sube nada a PBS hasta que el usuario responde Sí. La opción por defecto es No, mantener solo local: la clave se queda solo en su ruta local, ProxMenux no toca PBS con nada relacionado con la recuperación y el usuario gestiona por su cuenta la copia externa (desde el Monitor, el botón Descargar archivo clave de la fila del destino PBS la exporta en el acto). Al elegir Sí, subir se pide una passphrase de recuperación —dos veces, para comprobar que coincidan— y ProxMenux envuelve la clave con esa passphrase usando AES-256-CBC y PBKDF2 (600 000 iteraciones, sal aleatoria) en un fichero pbs-key.recovery.enc. A partir de ese momento, cada backup cifrado sube también ese fichero envuelto a PBS como un grupo de backup asociado. Ni la passphrase ni la clave en claro salen del host. La respuesta se puede cambiar después desde el Monitor en cualquier momento: el interruptor de la fila del destino PBS cambia entre Sí y No y se aplica al momento (empezar a subir, dejar de subir o cambiar la passphrase, según el estado).", "blobUploadTitle": "Grupo emparejado en PBS", - "blobUploadBody1": "Cuando el usuario ha respondido Sí, ProxMenux sube el sobre de recuperación a PBS después de cada copia cifrada como un segundo grupo emparejado, con el mismo nombre que el del host pero terminado en -keyrecovery. Los dos grupos aparecen juntos en el listado del datastore: uno con las copias del host, otro con los sobres. El sobre nunca sale del host en claro: se cifra en el host con AES-256-CBC y PBKDF2 antes de enviar un solo byte (openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt). PBS solo recibe el sobre ya cifrado.", + "blobUploadBody1": "Cuando la custodia está configurada como Sí, subir, ProxMenux sube el archivo de recuperación protegido a PBS después de cada copia cifrada. Lo guarda en un segundo grupo de copia asociado, con el mismo nombre que el grupo del host y el sufijo -keyrecovery. Los dos grupos aparecen juntos en la lista del almacén de datos: uno para las copias del host y otro para los archivos de recuperación. El archivo de recuperación nunca sale del host en texto plano: se cifra en el propio host con AES-256-CBC y PBKDF2 antes de enviar ningún dato (openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt). PBS solo recibe el archivo ya cifrado.", "envelopeSecurityTitle": "¿No es un riesgo subir el archivo keyrecovery a PBS?", "envelopeSecurityBody": "El sobre viaja y se guarda cifrado en todo momento. La passphrase de recuperación no sale nunca del host: el cifrado ocurre en local antes de la subida y PBS solo recibe el resultado ya cifrado. Un administrador de PBS —o cualquiera con acceso al datastore— puede descargar el sobre, pero sin la passphrase no puede leer la clave que contiene: son solo bytes cifrados. Para reconstruir la clave hacen falta las dos cosas a la vez, el sobre y la passphrase, y solo el usuario tiene ambas.", "blobUploadConstraintTitle": "Por qué dos grupos y no uno solo cifrado", @@ -73,7 +73,7 @@ "recoverTitle": "Recuperación en un equipo recién instalado", "recoverBody": "Cuando la clave no está en su sitio —tras reinstalar el host desde cero o después de eliminarla a propósito— el flujo de restauración prueba tres fuentes en orden y se queda con la primera que produce una clave usable. Primero, si el repositorio PBS elegido tiene una clave gestionada por PVE en /etc/pve/priv/storage/<NAME>.enc, se copia sola a la ruta canónica. Segundo, si en PBS hay un grupo -keyrecovery, se descarga la copia más reciente y se pide la passphrase de recuperación para abrirla. Tercero, si ninguna de las dos anteriores funciona o el usuario las descarta, el diálogo pide la ruta absoluta donde ha dejado la clave en este host, y se copia a la ruta canónica. El Monitor expone el mismo flujo dentro del modal de la copia. Si al Restaurar, Descargar o Ver contenido falta la clave, aparece un panel ámbar arriba del modal con las mismas tres vías de importación. Si la clave está instalada pero no es la correcta para esa copia, PBS devuelve un error de tipo wrong key — manifest's key XX does not match provided key YY y el modal muestra un panel estructurado con el fingerprint que PBS espera (el que quedó grabado en el manifest de la copia), para que el usuario sepa qué clave concreta necesita importar.", "monitorManagementTitle": "Gestionar la clave desde el Monitor", - "monitorManagementBody": "En la página de configuración de copias, la fila de cada destino PBS incluye los controles de la clave. Tres acciones directas: Descargar exporta la clave actual al navegador. Subir reemplaza la clave instalada por otra que aporta el usuario (la anterior solo se sobrescribe cuando la nueva ha llegado correctamente a su sitio). Eliminar retira la clave del host. Justo al lado está el interruptor del sobre de recuperación: un Sí/No para subir o no la clave a PBS, un campo de passphrase que aparece al elegir Sí, y un botón que se adapta al estado actual (empezar a subir, dejar de subir, o cambiar la passphrase). Los controles del Monitor y el diálogo del shell hacen lo mismo por dentro, así que el resultado en disco y en PBS es idéntico se use la que se use." + "monitorManagementBody": "El Monitor muestra los controles del archivo de clave en cada fila de destino PBS de la página de configuración de copias. Hay tres acciones: Descargar envía el archivo de clave actual al navegador del usuario; Subir sustituye el archivo de clave instalado por uno proporcionado por el usuario (el anterior solo se sobrescribe cuando el nuevo se ha guardado correctamente en la ruta canónica); y Eliminar borra el archivo de clave del host. Junto a estas acciones hay un único control de custodia para la recuperación: las opciones Sí y No de ¿Subir la clave a PBS?, un campo para la frase de contraseña al elegir Sí y un botón contextual para aplicar el cambio, con el texto Iniciar subida, Detener subida o Actualizar frase de contraseña según el estado actual. Todas las acciones utilizan los mismos endpoints de Flask que el menú de terminal, por lo que el resultado en disco y el estado en PBS son idénticos desde ambas interfaces." }, "restoreAccess": { "heading": "Recuperación del lado de la restauración", diff --git a/web/messages/es/docs/backup-restore/glossary.json b/web/messages/es/docs/backup-restore/glossary.json index c96e29a7..a4f35780 100644 --- a/web/messages/es/docs/backup-restore/glossary.json +++ b/web/messages/es/docs/backup-restore/glossary.json @@ -112,7 +112,7 @@ { "id": "repositorio-pbs", "term": "Repositorio PBS", - "def": "Combinación de servidor Proxmox Backup Server + datastore + usuario al que se suben las copias. Se identifica con el formato usuario@realm@host:datastore. Un mismo servidor PBS puede alojar varios repositorios." + "def": "Combinación de servidor Proxmox Backup Server + datastore + usuario al que se suben las copias. Se identifica con el formato user@realm@host:datastore. Un mismo servidor PBS puede alojar varios repositorios." }, { "id": "datastore", diff --git a/web/messages/es/docs/backup-restore/how-it-works.json b/web/messages/es/docs/backup-restore/how-it-works.json index 0c5ec8c7..51c91934 100644 --- a/web/messages/es/docs/backup-restore/how-it-works.json +++ b/web/messages/es/docs/backup-restore/how-it-works.json @@ -121,7 +121,7 @@ } ], "schemaTitle": "Validación por esquema", - "schemaBody": "ProxMenux incluye una plantilla que describe qué campos debe contener el manifiesto y qué forma tiene cada uno. Cuando se genera una copia se puede comprobar automáticamente que el manifiesto respeta esa plantilla, de modo que si un colector produjera un JSON malformado o con un campo mal escrito se detectaría en el momento. Es una comprobación destinada al desarrollo del propio ProxMenux: si el sistema no la tiene instalada, la copia sigue funcionando con normalidad y el manifiesto se genera igual." + "schemaBody": "El manifiesto se valida con scripts/backup_restore/schema/manifest.schema.json. Ejecutar build_manifest.sh --validate activa una validación JSON Schema en Python (requiere python3 y jsonschema). Si el módulo no está instalado, se omite la comprobación sin mostrar ningún aviso: esta validación es principalmente una ayuda para el desarrollo, no una dependencia necesaria durante la ejecución." }, "applications": { "heading": "El inventario de aplicaciones", diff --git a/web/messages/es/docs/backup-restore/restoring.json b/web/messages/es/docs/backup-restore/restoring.json index 44689048..732fa278 100644 --- a/web/messages/es/docs/backup-restore/restoring.json +++ b/web/messages/es/docs/backup-restore/restoring.json @@ -26,7 +26,7 @@ }, { "action": "Descargar", - "detail": "Exporta el archivo como un fichero portable .tar.zst (tar comprimido con zstd). Se puede extraer en cualquier sistema Linux con tar --zstd -xf FICHERO.tar.zst, o desde macOS/Windows con herramientas que soporten zstd (7-Zip, PeaZip, Keka…). El árbol extraído contiene manifest.json, metadata/ y rootfs/, exactamente el mismo layout que consume la restauración. Es útil para inspección offline o para restaurar en un host que no tiene acceso al destino original (PBS, Borg)." + "detail": "Exporta el archivo como un fichero portable .tar.zst (tar comprimido con zstd). Se puede extraer en cualquier sistema Linux con tar --zstd -xf FILE.tar.zst, o desde macOS/Windows con herramientas que soporten zstd (7-Zip, PeaZip, Keka…). El árbol extraído contiene manifest.json, metadata/ y rootfs/, exactamente el mismo layout que consume la restauración. Es útil para inspección offline o para restaurar en un host que no tiene acceso al destino original (PBS, Borg)." }, { "action": "Restaurar", diff --git a/web/messages/es/docs/hardware/switch-gpu-mode.json b/web/messages/es/docs/hardware/switch-gpu-mode.json index b3aabdcf..bd4b1841 100644 --- a/web/messages/es/docs/hardware/switch-gpu-mode.json +++ b/web/messages/es/docs/hardware/switch-gpu-mode.json @@ -110,7 +110,7 @@ }, "apply": { "title": "Aplicar cambios del host + workloads", - "body": "Una vez confirmas, el script escribe los cambios del lado del host — vfio.conf, blacklist, módulos y (para NVIDIA) la regla udev por BDF en /etc/udev/rules.d/10-proxmenux-vfio-bind.rules más el estado BDF en /etc/proxmenux/vfio-bind.bdfs. También aplica la política de conflicto elegida a cada VM/LXC afectada. Si la config del host realmente cambió, ejecuta update-initramfs -u -k all — si no, salta ese paso." + "body": "Tras confirmar, el script escribe los cambios en el host: vfio.conf, la lista de módulos bloqueados, los módulos y, para NVIDIA, la regla udev por BDF en /etc/udev/rules.d/10-proxmenux-vfio-bind.rules y el estado BDF en /etc/proxmenux/vfio-bind.bdfs. También aplica la política de conflictos elegida a cada VM/LXC afectado. Si la configuración del host ha cambiado, ejecuta update-initramfs -u -k all y después proxmox-boot-tool refresh; si no ha cambiado, omite ambos comandos." }, "reboot": { "title": "Reiniciar", diff --git a/web/messages/es/docs/monitor/access-auth.json b/web/messages/es/docs/monitor/access-auth.json index 82207bf9..1600caa8 100644 --- a/web/messages/es/docs/monitor/access-auth.json +++ b/web/messages/es/docs/monitor/access-auth.json @@ -257,7 +257,7 @@ "Hecho. El 2FA está ahora activo. La próxima vez que te logues, el panel pide primero la contraseña; una vez aceptada pide el código actual de 6 dígitos." ], "setupStep4Sub": [ - "Camino fácil: en la app, pulsa Añadir cuenta → Escanear código QR, apunta la cámara al QR en la pantalla. La app nombra la entrada automáticamente (algo como ProxMenux Monitor (tu-usuario)) y empieza a mostrar un código de 6 dígitos que se refresca cada 30 segundos.", + "Camino fácil: en la app, pulsa Añadir cuenta → Escanear código QR, apunta la cámara al QR en la pantalla. La app nombra la entrada automáticamente (algo como ProxMenux Monitor (your-username)) y empieza a mostrar un código de 6 dígitos que se refresca cada 30 segundos.", "Plan B manual (cuando escanear no es posible — p. ej. configurando en el mismo móvil con el que abriste el panel): pulsa Añadir cuenta → Introducir clave de setup. Escribe cualquier nombre (p. ej. Proxmox Monitor), pega la cadena Base32 del diálogo, deja Tipo como Basado en tiempo, guarda." ], "testTitle": "Prueba antes de hacer logout", @@ -319,7 +319,7 @@ "heading": "Secure Gateway (Tailscale)", "intro": "Los reverse proxies son la respuesta clásica a \"llegar al panel desde fuera\" pero requieren un dominio público, certificado y un puerto abierto en el borde. Secure Gateway es la alternativa cero-puertos que viene dentro del propio Monitor — una app desplegable preconstruida que levanta un LXC Alpine ejecutando Tailscale como subnet router. Una vez unido a tu tailnet, cada dispositivo en él puede llegar al Monitor en la propia IP LAN del host — desde un portátil de vacaciones, un móvil con 5G u otro nodo — sin exponer TCP 8008 a internet.", "calloutTitle": "Por qué esto es cómodo", - "calloutBody": "La URL se mantiene igual que en la LAN — http://<ip-lan-proxmox>:8008 funciona donde Tailscale funcione. Sin certificados, sin DNS, sin port forwarding. El propio Monitor ve la petición viniendo de una IP de tailnet (típicamente 100.x.y.z), así que el log de auth y el hook de Fail2Ban siguen funcionando como en la LAN.", + "calloutBody": "La URL se mantiene igual que en la LAN — http://<proxmox-lan-ip>:8008 funciona donde Tailscale funcione. Sin certificados, sin DNS, sin port forwarding. El propio Monitor ve la petición viniendo de una IP de tailnet (típicamente 100.x.y.z), así que el log de auth y el hook de Fail2Ban siguen funcionando como en la LAN.", "deployBody": "El flujo de despliegue es una sola pantalla — elige el storage LXC del host, pega un auth-key de Tailscale (generado en login.tailscale.com/admin/settings/keys), elige qué subnets anunciar, pulsa Deploy. El LXC tarda ~30 segundos en arrancar y se registra en el tailnet automáticamente.", "outro": "El despliegue paso a paso, la configuración de subnet-routes, las ACLs de Tailscale y el modo Exit Node se documentan por separado en Dashboard → Security → Secure Gateway — ahí vive el wizard de despliegue en la UI del panel. Esta página solo cubre el patrón de acceso." }, diff --git a/web/messages/es/docs/monitor/api-reference.json b/web/messages/es/docs/monitor/api-reference.json index b2906347..11ffc9eb 100644 --- a/web/messages/es/docs/monitor/api-reference.json +++ b/web/messages/es/docs/monitor/api-reference.json @@ -55,7 +55,7 @@ "heading": "Convenciones", "items": [ "Todas las peticiones y respuestas son JSON salvo que se indique explícitamente (la descarga de logs es texto plano, /api/prometheus es text/plain en formato OpenMetrics, el log de tareas es texto plano).", - "Los endpoints que modifican estado con éxito devuelven '{'\"success\": true, ...'}'. Las respuestas de error usan un estado HTTP no-2xx con '{'\"success\": false, \"error\": \"'<'motivo'>'\"'}'.", + "Los endpoints que modifican estado con éxito devuelven '{'\"success\": true, ...'}'. Las respuestas de error usan un estado HTTP no-2xx con '{'\"success\": false, \"error\": \"'<'reason'>'\"'}'.", "Los endpoints de listado aceptan limit, offset, since opcionales y filtros específicos por categoría vía query string.", "Los campos de tiempo se devuelven como segundos epoch Unix o ISO-8601 con zona horaria explícita, nunca como cadenas de locale." ] @@ -174,7 +174,7 @@ "syncVsAsyncBody": "Las acciones a nivel de sistema (power del host, PVE update, self-update de ProxMenux) pueden tardar minutos — corren en su propia unit systemd transitoria y exponen un endpoint .../status más un DELETE para cancelar. Las acciones VM / LXC (start, stop, shutdown, reboot, backup) son rápidas y usan el estilo fire-and-return que la UI del Monitor ya expone: el POST devuelve el resultado directo. Ambos estilos comparten el mismo modelo de autenticación (JWT o token de API de larga duración).", "curlTitle": "Ejemplo con curl", "curlBody": "Disparar una actualización de PVE y hacer polling hasta que termine — exactamente la misma secuencia que ejecutaría una automatización de Home Assistant o un playbook de Ansible:", - "curlCode": "TOKEN=\n\ncurl -sSf -X POST \\\n -H \"Authorization: Bearer $TOKEN\" \\\n https://:8008/api/system/pve-update/run\n\nwhile true; do\n state=$(curl -sSf -H \"Authorization: Bearer $TOKEN\" \\\n https://:8008/api/system/pve-update/status | jq -r .state)\n [ \"$state\" != \"running\" ] && break\n sleep 30\ndone\necho \"estado final: $state\"", + "curlCode": "TOKEN=\n\ncurl -sSf -X POST \\\n -H \"Authorization: Bearer $TOKEN\" \\\n https://:8008/api/system/pve-update/run\n\nwhile true; do\n state=$(curl -sSf -H \"Authorization: Bearer $TOKEN\" \\\n https://:8008/api/system/pve-update/status | jq -r .state)\n [ \"$state\" != \"running\" ] && break\n sleep 30\ndone\necho \"final state: $state\"", "haTitle": "Snippet de integración con Home Assistant", "haBody": "Un sensor rest hace polling del estado y un rest_command dispara la actualización. Conecta ambos a un botón del dashboard y a las automatizaciones que quieras:", "haCode": "# configuration.yaml\nsensor:\n - platform: rest\n resource: https://pve.local:8008/api/system/pve-update/status\n name: pve_update_state\n value_template: \"{{ value_json.state }}\"\n scan_interval: 60\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\nrest_command:\n pve_update:\n url: https://pve.local:8008/api/system/pve-update/run\n method: POST\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Encender VM 100 (funciona igual para LXC — mismo endpoint)\n vm_100_start:\n url: https://pve.local:8008/api/vms/100/control\n method: POST\n content_type: 'application/json'\n payload: '{\"action\": \"start\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"\n\n # Backup de la VM 100 al storage 'pbs-main'\n vm_100_backup:\n url: https://pve.local:8008/api/vms/100/backup\n method: POST\n content_type: 'application/json'\n payload: '{\"storage\": \"pbs-main\", \"mode\": \"snapshot\", \"compress\": \"zstd\"}'\n headers:\n Authorization: \"Bearer !secret proxmenux_token\"" diff --git a/web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json b/web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json index 08895a8f..1cafcd5b 100644 --- a/web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json +++ b/web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json @@ -101,7 +101,7 @@ ], "sourcesHeading": "Fuentes para la versión disponible", "sources": [ - "Repositorio de GitHub: último lanzamiento o etiqueta de un repositorio público propietario/nombre.", + "Repositorio de GitHub: último lanzamiento o etiqueta de un repositorio público owner/name.", "HTTP JSON: un punto final público y una ruta como data.version o releases[0].tag_name.", "Docker Hub: etiquetas versionadas filtradas con una expresión regular. La vista previa muestra etiquetas reales coincidentes antes de guardar.", "Las etiquetas móviles como latest, stable o lts no contienen una versión. Esas imágenes se siguen por digest desde las actualizaciones de imágenes Docker." diff --git a/web/messages/es/docs/monitor/dashboard/vms-lxcs-updates.json b/web/messages/es/docs/monitor/dashboard/vms-lxcs-updates.json index 6bd3f77d..0fb53ddb 100644 --- a/web/messages/es/docs/monitor/dashboard/vms-lxcs-updates.json +++ b/web/messages/es/docs/monitor/dashboard/vms-lxcs-updates.json @@ -110,8 +110,8 @@ "No incluyas pct exec; ProxMenux ya entra en el contenedor y ejecuta el comando como root." ], "exampleLead": "Ejemplo de procedimiento completo dentro del contenedor:", - "example": "cd /opt/mi-app && ./update.sh", - "callout": "Un comando de versión como miapp --version solo lee una versión; no instala nada. Los comandos se ejecutan con privilegios administrativos y deben revisarse con el mismo cuidado que un comando de shell como root." + "example": "cd /opt/my-app && ./update.sh", + "callout": "Un comando de versión como myapp --version solo lee una versión; no instala nada. Los comandos se ejecutan con privilegios administrativos y deben revisarse con el mismo cuidado que un comando de shell como root." }, "bulk": { "heading": "Actualización en bloque", @@ -142,7 +142,7 @@ "La espera tras una versión solo se aplica a las apps seleccionadas con seguimiento. Las apps sin seguimiento ejecutan su actualizador cuando vence la programación.", "El estado de la última ejecución diferencia entre éxito, finalización parcial, error, retención de seguridad y ausencia de elementos pendientes. Después de la primera ejecución programada, Actualizaciones → Actualizaciones programadas → Ver log abre la salida completa capturada del actualizador y de los scripts secundarios que haya ejecutado.", "Después de cada ejecución, ProxMenux comprueba el marcador estándar de Debian que indica si es necesario reiniciar. Cuando hace falta, la pestaña Actualizaciones y la notificación de finalización lo indican; el aviso se elimina cuando ese LXC se inicia o reinicia.", - "En el host Proxmox, los logs de las ejecuciones programadas se guardan en /usr/local/share/proxmenux/logs/lxc-updates/ con el nombre <VMID>-scheduled-<id-de-ejecución>.log. Se conservan los diez últimos logs de cada LXC y los archivos más antiguos se eliminan automáticamente.", + "En el host Proxmox, los logs de las ejecuciones programadas se guardan en /usr/local/share/proxmenux/logs/lxc-updates/ con el nombre <VMID>-scheduled-<run-id>.log. Se conservan los diez últimos logs de cada LXC y los archivos más antiguos se eliminan automáticamente.", "Este historial corresponde a las actualizaciones programadas. Una actualización manual muestra su salida en tiempo real en la ventana de ejecución del Monitor y no crea un log de ejecución programada en ese directorio.", "Las programaciones externas detectadas de Proxmox VE Helper-Scripts se muestran aparte para hacer visible cualquier automatización coincidente." ], @@ -186,7 +186,7 @@ }, { "problem": "Una actualización programada indica que es necesario reiniciar", - "resolution": "Abre Ver log para revisar la ejecución completada y reinicia ese LXC. ProxMenux elimina el aviso mediante el evento de ciclo de vida existente cuando el contenedor vuelve a arrancar." + "resolution": "Abre Ver registro para revisar la ejecución completada y después reinicia ese LXC. ProxMenux borra el aviso cuando el evento de ciclo de vida detecta el nuevo arranque." } ] }, diff --git a/web/messages/es/docs/monitor/integrations.json b/web/messages/es/docs/monitor/integrations.json index 36896b10..13c8d5de 100644 --- a/web/messages/es/docs/monitor/integrations.json +++ b/web/messages/es/docs/monitor/integrations.json @@ -37,7 +37,7 @@ "headingHref": "https://gethomepage.dev", "intro": "Homepage es un panel de aplicaciones totalmente estático y personalizable. ProxMenux Monitor se enchufa vía el widget customapi integrado — pega una entrada de servicio en services.yaml, reinicia Homepage y la tarjeta aparece con números en vivo.", "iconCalloutTitle": "El logo oficial de ProxMenux está en dashboardicons.com", - "iconCalloutBody": "Las guías de abajo usan icon: proxmenux.png. Homepage resuelve automáticamente los nombres de archivo simples contra dashboardicons.com — una librería de iconos curada para paneles autoalojados. La entrada de ProxMenux vive en dashboardicons.com/icons/external/proxmenux y Homepage la descarga en el primer render. La misma búsqueda funciona para miles de otras herramientas (Telegram, Discord, Grafana, Tailscale, etc.) — basta con escribir icon: <nombre>.png en cualquier entrada de servicio.", + "iconCalloutBody": "Las guías de abajo usan icon: proxmenux.png. Homepage resuelve automáticamente los nombres de archivo simples contra dashboardicons.com — una librería de iconos curada para paneles autoalojados. La entrada de ProxMenux vive en dashboardicons.com/icons/external/proxmenux y Homepage la descarga en el primer render. La misma búsqueda funciona para miles de otras herramientas (Telegram, Discord, Grafana, Tailscale, etc.) — basta con escribir icon: <name>.png en cualquier entrada de servicio.", "imageAlt": "Panel Homepage mostrando tres tarjetas de ProxMenux Monitor (EDGE, VOID, DREAM) con uptime, CPU, RAM y temperatura para cada host Proxmox", "imageCaption": "Tres instancias de ProxMenux Monitor renderizadas como tarjetas Homepage — uptime, CPU, RAM y temperatura de CPU leídas en vivo desde /api/system en cada host cada 10 s.", "basicTitle": "Widget básico — sin autenticación", @@ -94,7 +94,7 @@ "twoColTipBody": "Reemplaza cualquier par de tarjetas (p. ej. Sistema + VMs, o Almacenamiento + Red) con un único horizontal-stack envolviendo ambas, para que siempre se rendericen lado a lado independientemente del ancho de pantalla:", "twoColTipOutro": "En móvil la fila se mantiene comprimida; el auto layout de HA (sin horizontal-stack) refluye mejor a anchos estrechos.", "step6Title": "6 · Automatizaciones", - "step6Body": "Tres automatizaciones que cubren los escenarios reactivos más comunes — reemplaza notify.mobile_app_<tu_movil> por el servicio notify que uses:", + "step6Body": "Tres automatizaciones que cubren los escenarios reactivos más comunes — reemplaza notify.mobile_app_<your_phone> por el servicio notify que uses:", "logoTitle": "Sobre el logo de ProxMenux", "logoBody": "La tarjeta picture-entity en la parte superior del YAML Lovelace descarga el logo oficial de ProxMenux desde dashboardicons.com — una librería de iconos gratuita curada para paneles autoalojados. La entrada de ProxMenux vive en dashboardicons.com/icons/external/proxmenux. Home Assistant descarga el SVG sobre HTTPS en el primer render y lo cachea.", "logoBrokenTitle": "Si la tarjeta del logo muestra una imagen rota", @@ -120,7 +120,7 @@ "tokenTipTitle": "Token vía archivo o env, no inline", "tokenTipBody": "Para despliegues en producción evita poner el token inline. Prometheus soporta credentials_file: /etc/prometheus/secrets/proxmenux.token como alternativa — guarda el token en un archivo 0600 y deja que Prometheus lo lea.", "step2Title": "2 · Verifica el scrape con un par de queries", - "step2Body": "Antes de configurar Grafana, confirma que Prometheus realmente tiene los datos. Abre la propia UI de Prometheus en http://<host-prometheus>:9090, pulsa Query y ejecuta cualquiera de estas — deberías obtener números en vivo de tu host Proxmox:", + "step2Body": "Antes de configurar Grafana, confirma que Prometheus realmente tiene los datos. Abre la propia UI de Prometheus en http://<prometheus-host>:9090, pulsa Query y ejecuta cualquiera de estas — deberías obtener números en vivo de tu host Proxmox:", "headerQuery": "Query", "headerConfirms": "Qué confirma", "verifyRows": [ diff --git a/web/messages/es/docs/monitor/notifications.json b/web/messages/es/docs/monitor/notifications.json index 85089fa0..6a0cf1f3 100644 --- a/web/messages/es/docs/monitor/notifications.json +++ b/web/messages/es/docs/monitor/notifications.json @@ -44,7 +44,7 @@ "Arranca el hilo de fondo de despacho. El hilo hace polling de la cola de eventos y camina cada evento por la pipeline diagramada arriba." ], "activeAlt": "Tarjeta de notificaciones activada con pestañas de canales, nombre visible y opciones avanzadas de IA", - "activeCaption": "Estado activo: pestañas Telegram, Gotify, Discord, Email, Pushover y Apprise, nombre visible, categorías por canal y opciones avanzadas de IA." + "activeCaption": "Estado activo: pestañas de canales en la parte superior (Telegram / Gotify / Discord / correo electrónico / Pushover / Apprise), campo Nombre para mostrar, lista de categorías de cada canal y sección desplegable Avanzado: mejora con IA." }, "sources": { "heading": "Fuentes de eventos", @@ -119,7 +119,7 @@ "groupItems": [ "Añade el bot al grupo como miembro (y hazlo admin si el grupo lo requiere para publicar).", "Envía cualquier mensaje en el grupo.", - "Abre https://api.telegram.org/bot<TU_TOKEN>/getUpdates en un navegador. Busca chat.id en la respuesta JSON — para grupos es un número negativo, para supergrupos empieza con -100.", + "Abre https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates en un navegador. Busca chat.id en la respuesta JSON — para grupos es un número negativo, para supergrupos empieza con -100.", "Para supergrupos con Topics activado, anota también el message_thread_id del tema al que quieres apuntar — eso va en el campo opcional Topic ID." ], "groupAlt": "Formulario de canal Telegram con Bot Token (enmascarado), Chat ID negativo prefijado con -100 indicando un supergrupo, y Topic ID 3 configurado para entregar a un tema específico", @@ -203,7 +203,7 @@ "stepsTitle": "Pasos", "steps": [ "Elige el servicio destino en la wiki de Apprise y copia la plantilla de URL para él. Cada página de servicio muestra el esquema exacto a usar (ntfy://, matrix://, pover://, slack://…) más cualquier token, canal u hostname requerido.", - "Rellena los placeholders con tus propias credenciales. Por ejemplo, un topic de ntfy.sh tiene aspecto de ntfy://ntfy.sh/mi-topic; una URL de Pushover tiene aspecto de pover://user@token; una URL de Matrix tiene aspecto de matrix://user:pass@host:port/#room.", + "Sustituye los marcadores por tus credenciales. Ejemplos: ntfy.sh ntfy://ntfy.sh/my-topic; Pushover pover://user@token; Matrix matrix://user:pass@host:port/#room.", "Pega la URL final en el campo Apprise URL en la pestaña Apprise del panel Notifications y guarda.", "Pulsa Send Test para verificar que la URL es alcanzable y las credenciales se aceptan." ], @@ -295,7 +295,7 @@ "rows": [ { "stage": "1. Deduplicación por huella", - "what": "Cada evento genera una huella (event_type + campos clave de data). Las huellas idénticas dentro de una ventana corta se consideran duplicados del primero.", + "what": "Cada evento genera una huella (event_type + key fields from data). Las huellas idénticas dentro de una ventana corta se consideran duplicados del primero.", "tunable": "No — lógica interna del dispatcher." }, { @@ -339,7 +339,7 @@ "Llamadores remotos. Cinco capas se apilan encima del rate-limiting: un shared secret en la cabecera X-Webhook-Secret, un timestamp de frescura en X-ProxMenux-Timestamp (rechazado si se desvía más allá de la ventana configurada), una búsqueda de caché de replay y una allowlist de IP opcional. El shared secret vive en la tabla de ajustes SQLite del Monitor — no en /etc/pve/priv/notifications.cfg — y se genera en el primer setup. Este camino existe para integraciones personalizadas que hacen POST desde fuera del host; el destino configurado por PVE nunca lo ejercita." ], "practiceTitle": "En la práctica", - "practiceBody": "El setup de PVE escribe el destino como http://127.0.0.1:8008, así que las notificaciones emitidas por PVE siempre pasan por el camino loopback con seguridad solo rate-limit. El camino de llamador remoto con shared secret es opt-in para integraciones personalizadas — apunta un servicio externo a https://<host-monitor>:<port>/api/notifications/webhook y suministra la cabecera X-Webhook-Secret para usarlo.", + "practiceBody": "El setup de PVE escribe el destino como http://127.0.0.1:8008, así que las notificaciones emitidas por PVE siempre pasan por el camino loopback con seguridad solo rate-limit. El camino de llamador remoto con shared secret es opt-in para integraciones personalizadas — apunta un servicio externo a https://<monitor-host>:<port>/api/notifications/webhook y suministra la cabecera X-Webhook-Secret para usarlo.", "actionsIntro": "El Monitor gestiona este destino a través de tres acciones en la pestaña Settings:", "actionsItems": [ "Setup — corre automáticamente cuando activas Notifications. Crea la entrada en /etc/pve/notifications.cfg. El archivo solo se escribe cuando su contenido tiene que cambiar, y se guarda un backup del anterior en /usr/local/share/proxmenux/backups/pve-notifications/ (los tres más recientes). No se escribe nada mientras /etc/pve no está montado: un Monitor que arranca durante un reinicio de pve-cluster registra la entrada cuando vuelve.", diff --git a/web/messages/es/docs/oci-manager/hardware.json b/web/messages/es/docs/oci-manager/hardware.json index b4f24154..92d0bf14 100644 --- a/web/messages/es/docs/oci-manager/hardware.json +++ b/web/messages/es/docs/oci-manager/hardware.json @@ -59,7 +59,7 @@ "table": { "headers": ["Tipo", "Identificación", "Validación"], "rows": [ - ["Intel/AMD DRM", "/dev/dri/renderD* y /sys/class/drm/NODO/device/vendor", "Un dispositivo de caracteres con fabricante 0x8086 (Intel) o 0x1002 (AMD)"], + ["Intel/AMD DRM", "/dev/dri/renderD* y /sys/class/drm/NODE/device/vendor", "Un dispositivo de caracteres con fabricante 0x8086 (Intel) o 0x1002 (AMD)"], ["OpenCL AMD", "El render node, más /dev/kfd cuando el perfil lo necesita", "Existencia, tipo, fabricante, permisos y compatibilidad declarada"], ["AMD ROCm", "El nodo de render, /dev/kfd y la generación que declara el driver de cómputo en /sys/class/kfd/kfd/topology/nodes/*/properties", "Una generación para la que la imagen ROCm de la aplicación trae kernels, las GPU RDNA2 que incluye y las posteriores, se ofrece tal cual. Las demás GPU de esas familias, como las Radeon 680M y 780M, se ofrecen como experimentales y nunca son la opción propuesta: ROCm no las soporta oficialmente, la aplicación las presenta como la generación de su familia (HSA_OVERRIDE_GFX_VERSION) y el instalador pide confirmación antes de descargar la imagen, que es más grande. Una GPU anterior, como los gráficos integrados de un Ryzen 5000U, no se ofrece e Immich deja el reconocimiento en la CPU"], ["NVIDIA", "nvidia-smi y nvidia-container-cli", "GPU, UUID, bus PCI, versión del driver, Toolkit, nodos /dev/nvidia*, binarios y librerías"], diff --git a/web/messages/es/docs/oci-manager/storage-network.json b/web/messages/es/docs/oci-manager/storage-network.json index 08d67c4e..00849e0c 100644 --- a/web/messages/es/docs/oci-manager/storage-network.json +++ b/web/messages/es/docs/oci-manager/storage-network.json @@ -112,7 +112,7 @@ "items": [ { "title": "Contenedor y remoto", "body": "El VMID del contenedor Rclone, el nombre exacto del remoto y, opcionalmente, una ruta dentro de él." }, { "title": "Nombre y caché", "body": "El nombre del montaje y el modo de caché VFS: off, minimal, writes o full (por defecto)." }, - { "title": "Vistas publicadas", "body": "Una raíz común, /mnt/oci-shared por defecto, contiene una vista de lectura y escritura en /mnt/oci-shared/remotes/NOMBRE y una de solo lectura en /mnt/oci-shared/remotes-ro/NOMBRE." }, + { "title": "Vistas publicadas", "body": "Una raíz común, /mnt/oci-shared de forma predeterminada, contiene una vista de lectura y escritura en /mnt/oci-shared/remotes/NAME y otra de solo lectura en /mnt/oci-shared/remotes-ro/NAME." }, { "title": "Activación", "body": "Tras una confirmación, el CT se detiene, se configuran su orden de arranque y un hookscript de Proxmox VE, y se vuelve a iniciar. La operación espera hasta que las dos vistas están montadas en el host." } ] } diff --git a/web/messages/es/docs/post-install/system.json b/web/messages/es/docs/post-install/system.json index 0861d3b7..1abad55d 100644 --- a/web/messages/es/docs/post-install/system.json +++ b/web/messages/es/docs/post-install/system.json @@ -20,7 +20,7 @@ "SystemMaxUse=64M / RuntimeMaxUse=60M — caps duros sobre el uso de disco/memoria del journal.", "Compress=yes, Seal=no — comprimir logs, saltar el forward-secure sealing (ahorra CPU).", "MaxLevelStore=info — almacenar info y superiores (requerido para el visor de logs del ProxMenux Monitor y para que Fail2Ban detecte fallos de autenticación SSH/Proxmox desde el journal).", - "Rate-limits: 1000 eventos / 30 s para prevenir flooding de logs.", + "Límite de frecuencia: 1000 events / 30 s para evitar una avalancha de registros.", "ForwardToSyslog=no, ForwardToWall=no — no duplicar mensajes a syslog ni broadcastear a consolas." ], "tipTitle": "Por qué importa MaxLevelStore=info", diff --git a/web/messages/es/docs/storage-share/host-iscsi.json b/web/messages/es/docs/storage-share/host-iscsi.json index 04be8a5f..e77d54ca 100644 --- a/web/messages/es/docs/storage-share/host-iscsi.json +++ b/web/messages/es/docs/storage-share/host-iscsi.json @@ -56,7 +56,7 @@ "Entrada del portal — escribe la IP o hostname del servidor del target iSCSI. Se asume el puerto 3260 a menos que escribas host:port.", "Descubrimiento de targets — ProxMenux llama a iscsiadm --mode discovery --type sendtargets --portal <ip:port>. El servidor responde con cada IQN que permite ver a este initiator. Si el descubrimiento falla, el script muestra el error exacto de iscsiadm y lista las causas comunes (IP incorrecta, firewall, IQN del initiator no autorizado).", "Selección del target — si hay un solo target se selecciona automáticamente; con varios targets se muestra un menú.", - "Storage ID — por defecto es iscsi-<último segmento del IQN, máx. 20 caracteres>. Solo letras, dígitos, - y _.", + "Storage ID — por defecto es iscsi-<last IQN segment, 20 chars max>. Solo letras, dígitos, - y _.", "Content type — a diferencia de NFS / Samba, el contenido iSCSI está fijado a images. iSCSI expone dispositivos de bloque raw, no un sistema de archivos, así que Proxmox solo te deja alojar discos de VM ahí." ], "authTitle": "Autorizar el initiator de Proxmox en el target", diff --git a/web/messages/es/docs/storage-share/lxc-mount-points.json b/web/messages/es/docs/storage-share/lxc-mount-points.json index d89b7a54..fa80e077 100644 --- a/web/messages/es/docs/storage-share/lxc-mount-points.json +++ b/web/messages/es/docs/storage-share/lxc-mount-points.json @@ -129,7 +129,7 @@ }, "troubleshoot": { "heading": "Solución de problemas", - "noMountTitle": "El montaje aparece en la config del pct pero no dentro del contenedor", + "noMountTitle": "El montaje aparece en pct config, pero no dentro del contenedor", "noMountBody": "Los bind mounts solo se adjuntan cuando el contenedor arranca. Si añadiste el montaje mientras el CT estaba corriendo, reinícialo (pct reboot <ctid>). Si ya está reiniciado y sigue faltando, comprueba pct config <ctid> para confirmar que la entrada existe y que la ruta del host realmente contiene un directorio.", "noWriteTitle": "El contenedor puede ver el directorio pero no puede escribir", "noWriteBody": "Casi siempre un problema de permisos. Para contenedores unprivileged, ejecuta de nuevo el fix de permisos del script — las ACLs del directorio del host pueden haber sido reseteadas por otra herramienta (rsync, restore desde backup, chmod manual). Para CIFS, revisa las opciones de montaje en el host: findmnt /mnt/pve/cifs-…, busca valores uid= / gid= / file_mode=.", diff --git a/web/messages/fr/common.json b/web/messages/fr/common.json new file mode 100644 index 00000000..76c712d2 --- /dev/null +++ b/web/messages/fr/common.json @@ -0,0 +1,254 @@ +{ + "site": { + "name": "ProxMenux", + "tagline": "Menu interactif et tableau de bord web pour Proxmox VE" + }, + "nav": { + "home": "Accueil", + "docs": "Documentation", + "documentation": "Documentation", + "guides": "Guides", + "changelog": "Journal des modifications", + "github": "GitHub", + "rss": "RSS", + "rssTitle": "Flux RSS", + "menuOpen": "Ouvrir le menu" + }, + "footer": { + "sponsorHeading": "Soutenir le projet", + "sponsorBody": "Si vous souhaitez soutenir le projet.", + "sponsorAlt": "Soutenez-moi sur Ko-fi", + "connectHeading": "Communauté", + "connectBody": "Rejoignez les discussions de la communauté sur GitHub pour obtenir de l’aide, partager vos idées et contribuer au projet. Toutes les idées sont les bienvenues !", + "joinDiscussion": "Participer à la discussion", + "copyrightPrefix": "ProxMenux, un projet collaboratif et open source créé par", + "copyrightSuffix": "." + }, + "language": { + "switcher": "Langue", + "en": "English", + "es": "Español", + "sk": "Slovenčina", + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "pt": "Português", + "sv": "Svenska" + }, + "resources": { + "documentation": { + "title": "Documentation", + "description": "Présentation du système et guides d’utilisation" + }, + "changelog": { + "title": "Journal des modifications", + "description": "Informations sur les dernières mises à jour" + }, + "guides": { + "title": "Guides", + "description": "Tutoriels et guides pas à pas pour les tâches courantes" + }, + "github": { + "title": "Dépôt GitHub", + "description": "Consultez le code source." + } + }, + "docSidebar": { + "documentation": "Documentation", + "items": { + "introduction": "Introduction", + "installation": "Installation", + "proxmenuxMonitor": "ProxMenux Monitor", + "monitorOverview": "Vue d’ensemble", + "architecture": "Architecture", + "accessAuth": "Accès et authentification", + "dashboard": "Tableau de bord", + "dashboardSystemOverview": "Onglet Vue d’ensemble du système", + "dashboardApps": "Onglet Applications", + "dashboardStorage": "Onglet Stockage", + "dashboardNetwork": "Onglet Réseau", + "dashboardVmsLxcs": "Onglet VM et LXC", + "dashboardVmsLxcsApp": "Application", + "dashboardVmsLxcsUpdates": "Mises à jour", + "dashboardHardware": "Onglet Matériel", + "dashboardSystemLogs": "Onglet Journaux système", + "dashboardTerminal": "Onglet Terminal", + "dashboardSecurity": "Onglet Sécurité", + "dashboardSettings": "Onglet Paramètres", + "healthMonitor": "Surveillance de l’état du système", + "auditReport": "Audit et rapports", + "auditReportOverview": "Vue d’ensemble", + "auditReportAssessment": "Évaluation et inventaire", + "auditReportPolicy": "Politique", + "auditReportChanges": "Modifications", + "auditReportReports": "Rapports et comparaisons", + "auditReportScope": "Périmètre et garanties", + "notifications": "Notifications", + "aiAssistant": "Assistant IA", + "apiReference": "Référence de l’API", + "integrations": "Intégrations", + "ociManager": "OCI manager Apps", + "ociOverview": "Vue d’ensemble", + "ociArchitecture": "Comment une image OCI est adaptée", + "ociCustomImage": "Image absente du catalogue", + "ociStorageNetwork": "Données, chemins et réseau", + "ociHardware": "Périphériques et accélération", + "ociStacks": "Applications à plusieurs conteneurs", + "ociLifecycle": "Installer, mettre à jour et modifier", + "ociMonitor": "Dans ProxMenux Monitor", + "proxmenuxScripts": "ProxMenux Scripts", + "postInstallScript": "Script de post-installation", + "postInstallOverview": "Vue d’ensemble", + "postInstallAutomated": "Automatique", + "postInstallCustomizable": "Personnalisable", + "postInstallBasicSettings": "Paramètres de base", + "postInstallSystem": "Système", + "postInstallVirtualization": "Virtualisation", + "postInstallNetwork": "Réseau", + "postInstallStorage": "Stockage", + "postInstallSecurity": "Sécurité", + "postInstallCustomization": "Personnalisation", + "postInstallMonitoring": "Surveillance", + "postInstallPerformance": "Performances", + "postInstallOptional": "Facultatif", + "postInstallUpdates": "Appliquer les mises à jour disponibles", + "postInstallUninstall": "Supprimer les optimisations", + "gpusCoralTpu": "GPU et Coral TPU", + "nvidiaHost": "Installer les pilotes NVIDIA sur l’hôte", + "coralHost": "Installer Coral TPU sur l’hôte", + "addGpuLxc": "Donner accès à un GPU dans un LXC", + "addCoralLxc": "Donner accès à Coral TPU dans un LXC", + "addGpuVm": "Attribuer un GPU à une VM (passthrough)", + "switchGpuMode": "Changer le mode du GPU (VM ↔ LXC)", + "createVm": "Créer une VM", + "createVmOverview": "Vue d’ensemble", + "createVmSystemNas": "Systèmes NAS", + "createVmSynology": "VM Synology", + "createVmNasOthers": "Autres systèmes NAS", + "createVmSystemWindows": "Systèmes Windows", + "createVmSystemLinux": "Systèmes Linux", + "diskManager": "Gestionnaire de disques", + "diskManagerOverview": "Vue d’ensemble", + "diskImportVm": "Importer un disque dans une VM", + "diskImportImageVm": "Importer une image disque dans une VM", + "diskAddController": "Ajouter un contrôleur ou un périphérique NVMe à une VM", + "diskImportLxc": "Importer un disque dans un LXC", + "diskFormat": "Formater ou effacer un disque physique", + "diskSmart": "État SMART et tests du disque", + "storageShareManager": "Gestion du stockage et des partages", + "storageShareOverview": "Vue d’ensemble", + "hostStorage": "Intégration du stockage à l’hôte", + "hostNfs": "Ajouter un partage NFS comme stockage Proxmox", + "hostSamba": "Ajouter un partage Samba comme stockage Proxmox", + "hostIscsi": "Ajouter une cible iSCSI comme stockage Proxmox", + "hostLocalDisk": "Ajouter un disque local comme stockage Proxmox", + "hostLocalShared": "Créer un répertoire partagé sur l’hôte", + "lxcMountPoints": "Points de montage LXC (hôte ↔ CT)", + "lxcNetworkSharing": "Partages réseau dans LXC", + "lxcNfsClient": "Client NFS dans LXC", + "lxcSambaClient": "Client Samba dans LXC", + "lxcNfsServer": "Serveur NFS dans LXC", + "lxcSambaServer": "Serveur Samba dans LXC", + "network": "Réseau", + "networkOverview": "Vue d’ensemble", + "networkDiagnostics": "Diagnostic", + "networkMonitoring": "Outils de surveillance en temps réel", + "networkBridge": "Analyse et réparation des ponts réseau", + "networkConfig": "Analyse et nettoyage de la configuration", + "networkPersistent": "Noms persistants des interfaces", + "networkBackup": "Sauvegarde et redémarrage des interfaces", + "security": "Sécurité", + "securityOverview": "Vue d’ensemble", + "securityFail2ban": "Fail2Ban", + "securityLynis": "Lynis", + "utilities": "Utilitaires", + "utilsOverview": "Vue d’ensemble", + "utilsUupDump": "Création d’ISO avec UUP Dump", + "utilsSystemUtils": "Installation d’utilitaires système", + "utilsSystemUpdate": "Mise à jour du système Proxmox", + "utilsUpgradePve": "Passer de PVE 8 à PVE 9", + "utilsExportVm": "Exporter une VM au format OVA/OVF", + "utilsImportVm": "Importer une VM depuis OVA/OVF", + "settingsProxmenux": "Paramètres de ProxMenux", + "settingsOverview": "Vue d’ensemble", + "settingsMonitor": "ProxMenux Monitor", + "settingsBeta": "Changer de canal de publication", + "settingsVersion": "Afficher les informations de version", + "settingsUninstall": "Désinstaller ProxMenux", + "commandsReference": "Référence des commandes", + "commandsOverview": "Vue d’ensemble", + "commandsSystem": "Commandes système utiles", + "commandsVmCt": "Gestion des VM et CT", + "commandsStorage": "Stockage et disques", + "commandsNetwork": "Commandes réseau", + "commandsUpdates": "Mises à jour et paquets", + "commandsGpu": "Passthrough GPU", + "commandsZfs": "Gestion de ZFS", + "commandsBackup": "Sauvegarde et restauration", + "commandsTools": "Outils système en ligne de commande", + "glossary": "Glossaire", + "about": "À propos", + "aboutOverview": "Vue d’ensemble", + "aboutFaq": "FAQ", + "aboutContributors": "Contributeurs", + "aboutContributing": "Contribuer", + "aboutCodeOfConduct": "Code de conduite", + "externalRepositories": "Dépôts externes", + "backupRestore": "Sauvegarde et restauration", + "backupRestoreOverview": "Vue d’ensemble", + "backupRestoreHowItWorks": "Fonctionnement", + "backupRestoreDestinations": "Destinations", + "backupRestoreDestOverview": "Vue d’ensemble", + "backupRestoreDestLocal": "Archive locale", + "backupRestoreDestPbs": "Proxmox Backup Server", + "backupRestoreDestBorg": "Borg", + "backupRestoreCreating": "Créer des sauvegardes", + "backupRestoreJobs": "Tâches planifiées", + "backupRestoreRestoring": "Restaurer", + "backupRestoreCrossKernel": "Restauration entre versions du noyau", + "backupRestoreGlossary": "Glossaire" + } + }, + "hero": { + "title": "ProxMenux", + "tagline": "Un menu interactif et un tableau de bord web pour Proxmox VE", + "description": "ProxMenux réunit un menu interactif et un tableau de bord web pour Proxmox VE. Exécutez des commandes, des scripts et des assistants depuis le terminal, ou consultez l’état de l’hôte, ses métriques et ses notifications dans un navigateur.", + "installButton": "Installer maintenant", + "whatIsButton": "Qu’est-ce que ProxMenux ?", + "logoAlt": "Logo ProxMenux" + }, + "docNav": { + "previous": "Précédent", + "next": "Suivant" + }, + "tocPanel": { + "onThisPage": "Sur cette page" + }, + "supportProject": { + "heading": "Soutenez le projet !", + "body": "Si ProxMenux vous est utile, donnez-lui une ⭐ sur GitHub pour aider d’autres personnes à le découvrir.", + "button": "Ajouter une étoile sur GitHub" + }, + "rssLink": { + "heading": "Suivez les nouveautés !", + "body": "Abonnez-vous à notre flux RSS pour être informé des nouveautés.", + "copy": "Copier", + "copied": "Copié !", + "copyTitle": "Copier l’URL du flux RSS", + "openFeed": "Ouvrir le flux RSS", + "openTitle": "Ouvrir le flux RSS" + }, + "changelog": { + "pageTitle": "Journal des modifications", + "meta": { + "title": "Journal des modifications de ProxMenux", + "titleSuffix": "Notes de version", + "latest": "Dernière version", + "mostRecent": "Version la plus récente", + "description": "Notes de version et historique des mises à jour de ProxMenux, le script à menus pour administrer Proxmox VE.", + "descriptionTail": "Nouvelles fonctionnalités, corrections de bugs, mises à jour de sécurité, améliorations de ProxMenux Monitor, compatibilité avec les fournisseurs d’IA, sécurisation du passthrough GPU et bien plus.", + "ogDescription": "Notes de version de ProxMenux.", + "ogTail": "Abonnez-vous au flux RSS ou revenez consulter les nouvelles versions." + } + } +} diff --git a/web/messages/it/common.json b/web/messages/it/common.json new file mode 100644 index 00000000..659c356d --- /dev/null +++ b/web/messages/it/common.json @@ -0,0 +1,254 @@ +{ + "site": { + "name": "ProxMenux", + "tagline": "Menu interattivo e dashboard web per Proxmox VE" + }, + "nav": { + "home": "Home", + "docs": "Documentazione", + "documentation": "Documentazione", + "guides": "Guide", + "changelog": "Registro delle modifiche", + "github": "GitHub", + "rss": "RSS", + "rssTitle": "Feed RSS", + "menuOpen": "Apri menu" + }, + "footer": { + "sponsorHeading": "Sostieni il progetto", + "sponsorBody": "Se desideri sostenere il progetto.", + "sponsorAlt": "Sostienimi su Ko-fi", + "connectHeading": "Community", + "connectBody": "Partecipa alle discussioni della community su GitHub per ricevere aiuto, condividere idee e contribuire al progetto. Ogni idea è benvenuta!", + "joinDiscussion": "Partecipa alla discussione", + "copyrightPrefix": "ProxMenux, un progetto collaborativo e open source di", + "copyrightSuffix": "." + }, + "language": { + "switcher": "Lingua", + "en": "English", + "es": "Español", + "sk": "Slovenčina", + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "pt": "Português", + "sv": "Svenska" + }, + "resources": { + "documentation": { + "title": "Documentazione", + "description": "Descrizione del sistema e guide per l’utente" + }, + "changelog": { + "title": "Registro delle modifiche", + "description": "Informazioni sugli ultimi aggiornamenti" + }, + "guides": { + "title": "Guide", + "description": "Tutorial e guide passo passo per le attività più comuni" + }, + "github": { + "title": "Repository GitHub", + "description": "Esplora il codice sorgente." + } + }, + "docSidebar": { + "documentation": "Documentazione", + "items": { + "introduction": "Introduzione", + "installation": "Installazione", + "proxmenuxMonitor": "ProxMenux Monitor", + "monitorOverview": "Panoramica", + "architecture": "Architettura", + "accessAuth": "Accesso e autenticazione", + "dashboard": "Dashboard", + "dashboardSystemOverview": "Scheda Panoramica del sistema", + "dashboardApps": "Scheda App", + "dashboardStorage": "Scheda Archiviazione", + "dashboardNetwork": "Scheda Rete", + "dashboardVmsLxcs": "Scheda VM e LXC", + "dashboardVmsLxcsApp": "App", + "dashboardVmsLxcsUpdates": "Aggiornamenti", + "dashboardHardware": "Scheda Hardware", + "dashboardSystemLogs": "Scheda Log di sistema", + "dashboardTerminal": "Scheda Terminale", + "dashboardSecurity": "Scheda Sicurezza", + "dashboardSettings": "Scheda Impostazioni", + "healthMonitor": "Monitoraggio dello stato", + "auditReport": "Verifica e report", + "auditReportOverview": "Panoramica", + "auditReportAssessment": "Valutazione e inventario", + "auditReportPolicy": "Criteri", + "auditReportChanges": "Modifiche", + "auditReportReports": "Report e confronti", + "auditReportScope": "Ambito e garanzie", + "notifications": "Notifiche", + "aiAssistant": "Assistente IA", + "apiReference": "Riferimento API", + "integrations": "Integrazioni", + "ociManager": "OCI manager Apps", + "ociOverview": "Panoramica", + "ociArchitecture": "Come viene adattata un’immagine OCI", + "ociCustomImage": "Immagine non presente nel catalogo", + "ociStorageNetwork": "Dati, percorsi e rete", + "ociHardware": "Dispositivi e accelerazione", + "ociStacks": "Applicazioni con più container", + "ociLifecycle": "Installazione, aggiornamento e modifica", + "ociMonitor": "In ProxMenux Monitor", + "proxmenuxScripts": "ProxMenux Scripts", + "postInstallScript": "Script di post-installazione", + "postInstallOverview": "Panoramica", + "postInstallAutomated": "Automatico", + "postInstallCustomizable": "Personalizzabile", + "postInstallBasicSettings": "Impostazioni di base", + "postInstallSystem": "Sistema", + "postInstallVirtualization": "Virtualizzazione", + "postInstallNetwork": "Rete", + "postInstallStorage": "Archiviazione", + "postInstallSecurity": "Sicurezza", + "postInstallCustomization": "Personalizzazione", + "postInstallMonitoring": "Monitoraggio", + "postInstallPerformance": "Prestazioni", + "postInstallOptional": "Facoltativo", + "postInstallUpdates": "Applica gli aggiornamenti disponibili", + "postInstallUninstall": "Rimuovi le ottimizzazioni", + "gpusCoralTpu": "GPU e Coral TPU", + "nvidiaHost": "Installa i driver NVIDIA sull’host", + "coralHost": "Installa Coral TPU sull’host", + "addGpuLxc": "Abilita una GPU in un LXC", + "addCoralLxc": "Abilita Coral TPU in un LXC", + "addGpuVm": "Assegna una GPU a una VM (passthrough)", + "switchGpuMode": "Cambia modalità GPU (VM ↔ LXC)", + "createVm": "Crea una VM", + "createVmOverview": "Panoramica", + "createVmSystemNas": "Sistemi NAS", + "createVmSynology": "VM Synology", + "createVmNasOthers": "Altri sistemi NAS", + "createVmSystemWindows": "Sistemi Windows", + "createVmSystemLinux": "Sistemi Linux", + "diskManager": "Gestione dischi", + "diskManagerOverview": "Panoramica", + "diskImportVm": "Importa un disco in una VM", + "diskImportImageVm": "Importa un’immagine disco in una VM", + "diskAddController": "Aggiungi un controller o un dispositivo NVMe a una VM", + "diskImportLxc": "Importa un disco in un LXC", + "diskFormat": "Formatta o cancella un disco fisico", + "diskSmart": "Stato SMART e test del disco", + "storageShareManager": "Gestione archiviazione e condivisioni", + "storageShareOverview": "Panoramica", + "hostStorage": "Integrazione dell’archiviazione nell’host", + "hostNfs": "Aggiungi una condivisione NFS come storage Proxmox", + "hostSamba": "Aggiungi una condivisione Samba come storage Proxmox", + "hostIscsi": "Aggiungi una destinazione iSCSI come storage Proxmox", + "hostLocalDisk": "Aggiungi un disco locale come storage Proxmox", + "hostLocalShared": "Crea una directory condivisa sull’host", + "lxcMountPoints": "Punti di montaggio LXC (host ↔ CT)", + "lxcNetworkSharing": "Condivisioni di rete in LXC", + "lxcNfsClient": "Client NFS in LXC", + "lxcSambaClient": "Client Samba in LXC", + "lxcNfsServer": "Server NFS in LXC", + "lxcSambaServer": "Server Samba in LXC", + "network": "Rete", + "networkOverview": "Panoramica", + "networkDiagnostics": "Diagnostica", + "networkMonitoring": "Strumenti di monitoraggio in tempo reale", + "networkBridge": "Analisi e riparazione dei bridge di rete", + "networkConfig": "Analisi e pulizia della configurazione", + "networkPersistent": "Nomi persistenti delle interfacce", + "networkBackup": "Backup e riavvio delle interfacce", + "security": "Sicurezza", + "securityOverview": "Panoramica", + "securityFail2ban": "Fail2Ban", + "securityLynis": "Lynis", + "utilities": "Utilità", + "utilsOverview": "Panoramica", + "utilsUupDump": "Creazione di ISO con UUP Dump", + "utilsSystemUtils": "Installazione di utilità di sistema", + "utilsSystemUpdate": "Aggiornamento del sistema Proxmox", + "utilsUpgradePve": "Passaggio da PVE 8 a PVE 9", + "utilsExportVm": "Esporta una VM in OVA/OVF", + "utilsImportVm": "Importa una VM da OVA/OVF", + "settingsProxmenux": "Impostazioni di ProxMenux", + "settingsOverview": "Panoramica", + "settingsMonitor": "ProxMenux Monitor", + "settingsBeta": "Cambia canale di rilascio", + "settingsVersion": "Mostra informazioni sulla versione", + "settingsUninstall": "Disinstalla ProxMenux", + "commandsReference": "Riferimento dei comandi", + "commandsOverview": "Panoramica", + "commandsSystem": "Comandi di sistema utili", + "commandsVmCt": "Gestione di VM e CT", + "commandsStorage": "Archiviazione e dischi", + "commandsNetwork": "Comandi di rete", + "commandsUpdates": "Aggiornamenti e pacchetti", + "commandsGpu": "Passthrough GPU", + "commandsZfs": "Gestione di ZFS", + "commandsBackup": "Backup e ripristino", + "commandsTools": "Strumenti di sistema da riga di comando", + "glossary": "Glossario", + "about": "Informazioni sul progetto", + "aboutOverview": "Panoramica", + "aboutFaq": "FAQ", + "aboutContributors": "Collaboratori", + "aboutContributing": "Come contribuire", + "aboutCodeOfConduct": "Codice di condotta", + "externalRepositories": "Repository esterni", + "backupRestore": "Backup e ripristino", + "backupRestoreOverview": "Panoramica", + "backupRestoreHowItWorks": "Come funziona", + "backupRestoreDestinations": "Destinazioni", + "backupRestoreDestOverview": "Panoramica", + "backupRestoreDestLocal": "Archivio locale", + "backupRestoreDestPbs": "Proxmox Backup Server", + "backupRestoreDestBorg": "Borg", + "backupRestoreCreating": "Creare backup", + "backupRestoreJobs": "Attività pianificate", + "backupRestoreRestoring": "Ripristino", + "backupRestoreCrossKernel": "Ripristino tra versioni del kernel", + "backupRestoreGlossary": "Glossario" + } + }, + "hero": { + "title": "ProxMenux", + "tagline": "Un menu interattivo e una dashboard web per Proxmox VE", + "description": "ProxMenux unisce un menu interattivo e una dashboard web per Proxmox VE. Esegui comandi, script e procedure guidate dal terminale oppure controlla lo stato dell’host, le metriche e le notifiche dal browser.", + "installButton": "Installa ora", + "whatIsButton": "Che cos’è ProxMenux?", + "logoAlt": "Logo di ProxMenux" + }, + "docNav": { + "previous": "Precedente", + "next": "Successivo" + }, + "tocPanel": { + "onThisPage": "In questa pagina" + }, + "supportProject": { + "heading": "Sostieni il progetto!", + "body": "Se ProxMenux ti è utile, lascia una ⭐ su GitHub per aiutare altre persone a scoprirlo.", + "button": "Lascia una stella su GitHub" + }, + "rssLink": { + "heading": "Resta aggiornato!", + "body": "Iscriviti al feed RSS per ricevere le novità.", + "copy": "Copia", + "copied": "Copiato!", + "copyTitle": "Copia l’URL del feed RSS", + "openFeed": "Apri il feed RSS", + "openTitle": "Apri il feed RSS" + }, + "changelog": { + "pageTitle": "Registro delle modifiche", + "meta": { + "title": "Registro delle modifiche di ProxMenux", + "titleSuffix": "Note di rilascio", + "latest": "Ultima versione", + "mostRecent": "Versione più recente", + "description": "Note di rilascio e cronologia degli aggiornamenti di ProxMenux, lo script a menu per gestire Proxmox VE.", + "descriptionTail": "Nuove funzionalità, correzioni di bug, aggiornamenti di sicurezza, miglioramenti di ProxMenux Monitor, compatibilità con i fornitori IA, maggiore sicurezza del passthrough GPU e altro.", + "ogDescription": "Note di rilascio di ProxMenux.", + "ogTail": "Iscriviti al feed RSS o torna su questa pagina per le nuove versioni." + } + } +} diff --git a/web/messages/pt/common.json b/web/messages/pt/common.json new file mode 100644 index 00000000..a7cb872c --- /dev/null +++ b/web/messages/pt/common.json @@ -0,0 +1,254 @@ +{ + "site": { + "name": "ProxMenux", + "tagline": "Menu interativo e painel web para o Proxmox VE" + }, + "nav": { + "home": "Início", + "docs": "Documentação", + "documentation": "Documentação", + "guides": "Guias", + "changelog": "Histórico de alterações", + "github": "GitHub", + "rss": "RSS", + "rssTitle": "Feed RSS", + "menuOpen": "Abrir menu" + }, + "footer": { + "sponsorHeading": "Apoiar o projeto", + "sponsorBody": "Se quiser apoiar o projeto.", + "sponsorAlt": "Apoie-me no Ko-fi", + "connectHeading": "Comunidade", + "connectBody": "Participe nas discussões da comunidade no GitHub para obter ajuda, partilhar ideias e contribuir para o projeto. Todas as ideias são bem-vindas!", + "joinDiscussion": "Participar na discussão", + "copyrightPrefix": "ProxMenux, um projeto colaborativo e de código aberto criado por", + "copyrightSuffix": "." + }, + "language": { + "switcher": "Idioma", + "en": "English", + "es": "Español", + "sk": "Slovenčina", + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "pt": "Português", + "sv": "Svenska" + }, + "resources": { + "documentation": { + "title": "Documentação", + "description": "Descrição do sistema e guias de utilização" + }, + "changelog": { + "title": "Histórico de alterações", + "description": "Informações sobre as últimas atualizações" + }, + "guides": { + "title": "Guias", + "description": "Tutoriais e guias passo a passo para tarefas comuns" + }, + "github": { + "title": "Repositório do GitHub", + "description": "Explore o código-fonte." + } + }, + "docSidebar": { + "documentation": "Documentação", + "items": { + "introduction": "Introdução", + "installation": "Instalação", + "proxmenuxMonitor": "ProxMenux Monitor", + "monitorOverview": "Visão geral", + "architecture": "Arquitetura", + "accessAuth": "Acesso e autenticação", + "dashboard": "Painel", + "dashboardSystemOverview": "Separador Visão geral do sistema", + "dashboardApps": "Separador Aplicações", + "dashboardStorage": "Separador Armazenamento", + "dashboardNetwork": "Separador Rede", + "dashboardVmsLxcs": "Separador VM e LXC", + "dashboardVmsLxcsApp": "Aplicação", + "dashboardVmsLxcsUpdates": "Atualizações", + "dashboardHardware": "Separador Hardware", + "dashboardSystemLogs": "Separador Registos do sistema", + "dashboardTerminal": "Separador Terminal", + "dashboardSecurity": "Separador Segurança", + "dashboardSettings": "Separador Definições", + "healthMonitor": "Monitor de estado", + "auditReport": "Auditoria e relatórios", + "auditReportOverview": "Visão geral", + "auditReportAssessment": "Avaliação e inventário", + "auditReportPolicy": "Política", + "auditReportChanges": "Alterações", + "auditReportReports": "Relatórios e comparações", + "auditReportScope": "Âmbito e garantias", + "notifications": "Notificações", + "aiAssistant": "Assistente de IA", + "apiReference": "Referência da API", + "integrations": "Integrações", + "ociManager": "OCI manager Apps", + "ociOverview": "Visão geral", + "ociArchitecture": "Como é adaptada uma imagem OCI", + "ociCustomImage": "Imagem fora do catálogo", + "ociStorageNetwork": "Dados, caminhos e rede", + "ociHardware": "Dispositivos e aceleração", + "ociStacks": "Aplicações com vários contentores", + "ociLifecycle": "Instalar, atualizar e modificar", + "ociMonitor": "No ProxMenux Monitor", + "proxmenuxScripts": "ProxMenux Scripts", + "postInstallScript": "Script de pós-instalação", + "postInstallOverview": "Visão geral", + "postInstallAutomated": "Automático", + "postInstallCustomizable": "Personalizável", + "postInstallBasicSettings": "Definições básicas", + "postInstallSystem": "Sistema", + "postInstallVirtualization": "Virtualização", + "postInstallNetwork": "Rede", + "postInstallStorage": "Armazenamento", + "postInstallSecurity": "Segurança", + "postInstallCustomization": "Personalização", + "postInstallMonitoring": "Monitorização", + "postInstallPerformance": "Desempenho", + "postInstallOptional": "Opcional", + "postInstallUpdates": "Aplicar as atualizações disponíveis", + "postInstallUninstall": "Remover otimizações", + "gpusCoralTpu": "GPU e Coral TPU", + "nvidiaHost": "Instalar controladores NVIDIA no host", + "coralHost": "Instalar Coral TPU no host", + "addGpuLxc": "Disponibilizar uma GPU num LXC", + "addCoralLxc": "Disponibilizar Coral TPU num LXC", + "addGpuVm": "Atribuir uma GPU a uma VM (passthrough)", + "switchGpuMode": "Alterar o modo da GPU (VM ↔ LXC)", + "createVm": "Criar uma VM", + "createVmOverview": "Visão geral", + "createVmSystemNas": "Sistemas NAS", + "createVmSynology": "VM Synology", + "createVmNasOthers": "Outros sistemas NAS", + "createVmSystemWindows": "Sistemas Windows", + "createVmSystemLinux": "Sistemas Linux", + "diskManager": "Gestor de discos", + "diskManagerOverview": "Visão geral", + "diskImportVm": "Importar um disco para uma VM", + "diskImportImageVm": "Importar uma imagem de disco para uma VM", + "diskAddController": "Adicionar um controlador ou dispositivo NVMe a uma VM", + "diskImportLxc": "Importar um disco para um LXC", + "diskFormat": "Formatar ou apagar um disco físico", + "diskSmart": "Estado SMART e testes do disco", + "storageShareManager": "Gestor de armazenamento e partilhas", + "storageShareOverview": "Visão geral", + "hostStorage": "Integração de armazenamento no host", + "hostNfs": "Adicionar uma partilha NFS como armazenamento do Proxmox", + "hostSamba": "Adicionar uma partilha Samba como armazenamento do Proxmox", + "hostIscsi": "Adicionar um destino iSCSI como armazenamento do Proxmox", + "hostLocalDisk": "Adicionar um disco local como armazenamento do Proxmox", + "hostLocalShared": "Criar um diretório partilhado no host", + "lxcMountPoints": "Pontos de montagem LXC (host ↔ CT)", + "lxcNetworkSharing": "Partilhas de rede em LXC", + "lxcNfsClient": "Cliente NFS em LXC", + "lxcSambaClient": "Cliente Samba em LXC", + "lxcNfsServer": "Servidor NFS em LXC", + "lxcSambaServer": "Servidor Samba em LXC", + "network": "Rede", + "networkOverview": "Visão geral", + "networkDiagnostics": "Diagnóstico", + "networkMonitoring": "Ferramentas de monitorização em tempo real", + "networkBridge": "Análise e reparação de bridges de rede", + "networkConfig": "Análise e limpeza da configuração", + "networkPersistent": "Nomes persistentes das interfaces", + "networkBackup": "Cópia de segurança e reinício das interfaces", + "security": "Segurança", + "securityOverview": "Visão geral", + "securityFail2ban": "Fail2Ban", + "securityLynis": "Lynis", + "utilities": "Utilitários", + "utilsOverview": "Visão geral", + "utilsUupDump": "Criação de ISO com UUP Dump", + "utilsSystemUtils": "Instalação de utilitários do sistema", + "utilsSystemUpdate": "Atualização do sistema Proxmox", + "utilsUpgradePve": "Atualizar do PVE 8 para o PVE 9", + "utilsExportVm": "Exportar uma VM para OVA/OVF", + "utilsImportVm": "Importar uma VM a partir de OVA/OVF", + "settingsProxmenux": "Definições do ProxMenux", + "settingsOverview": "Visão geral", + "settingsMonitor": "ProxMenux Monitor", + "settingsBeta": "Alterar o canal de versões", + "settingsVersion": "Mostrar informações da versão", + "settingsUninstall": "Desinstalar o ProxMenux", + "commandsReference": "Referência de comandos", + "commandsOverview": "Visão geral", + "commandsSystem": "Comandos úteis do sistema", + "commandsVmCt": "Gestão de VM e CT", + "commandsStorage": "Armazenamento e discos", + "commandsNetwork": "Comandos de rede", + "commandsUpdates": "Atualizações e pacotes", + "commandsGpu": "Passthrough de GPU", + "commandsZfs": "Gestão do ZFS", + "commandsBackup": "Cópia de segurança e restauro", + "commandsTools": "Ferramentas de sistema para a linha de comandos", + "glossary": "Glossário", + "about": "Sobre o projeto", + "aboutOverview": "Visão geral", + "aboutFaq": "FAQ", + "aboutContributors": "Colaboradores", + "aboutContributing": "Como contribuir", + "aboutCodeOfConduct": "Código de conduta", + "externalRepositories": "Repositórios externos", + "backupRestore": "Cópia de segurança e restauro", + "backupRestoreOverview": "Visão geral", + "backupRestoreHowItWorks": "Como funciona", + "backupRestoreDestinations": "Destinos", + "backupRestoreDestOverview": "Visão geral", + "backupRestoreDestLocal": "Arquivo local", + "backupRestoreDestPbs": "Proxmox Backup Server", + "backupRestoreDestBorg": "Borg", + "backupRestoreCreating": "Criar cópias de segurança", + "backupRestoreJobs": "Tarefas agendadas", + "backupRestoreRestoring": "Restaurar", + "backupRestoreCrossKernel": "Restauro entre versões do kernel", + "backupRestoreGlossary": "Glossário" + } + }, + "hero": { + "title": "ProxMenux", + "tagline": "Um menu interativo e um painel web para o Proxmox VE", + "description": "O ProxMenux reúne um menu interativo e um painel web para o Proxmox VE. Execute comandos, scripts e assistentes a partir do terminal ou consulte o estado do host, as métricas e as notificações no navegador.", + "installButton": "Instalar agora", + "whatIsButton": "O que é o ProxMenux?", + "logoAlt": "Logótipo do ProxMenux" + }, + "docNav": { + "previous": "Anterior", + "next": "Seguinte" + }, + "tocPanel": { + "onThisPage": "Nesta página" + }, + "supportProject": { + "heading": "Apoie o projeto!", + "body": "Se o ProxMenux lhe é útil, dê-lhe uma ⭐ no GitHub para ajudar outras pessoas a descobri-lo.", + "button": "Dar uma estrela no GitHub" + }, + "rssLink": { + "heading": "Acompanhe as novidades!", + "body": "Subscreva o feed RSS para receber as novidades.", + "copy": "Copiar", + "copied": "Copiado!", + "copyTitle": "Copiar o URL do feed RSS", + "openFeed": "Abrir o feed RSS", + "openTitle": "Abrir o feed RSS" + }, + "changelog": { + "pageTitle": "Histórico de alterações", + "meta": { + "title": "Histórico de alterações do ProxMenux", + "titleSuffix": "Notas da versão", + "latest": "Última versão", + "mostRecent": "Versão mais recente", + "description": "Notas de versão e histórico de atualizações do ProxMenux, o script com menus para gerir o Proxmox VE.", + "descriptionTail": "Novas funcionalidades, correções de erros, atualizações de segurança, melhorias do ProxMenux Monitor, compatibilidade com fornecedores de IA, reforço de segurança do passthrough de GPU e muito mais.", + "ogDescription": "Notas de versão do ProxMenux.", + "ogTail": "Subscreva o feed RSS ou volte a esta página para consultar novas versões." + } + } +} diff --git a/web/messages/sk/common.json b/web/messages/sk/common.json index 1c34e6b1..b95d8c96 100644 --- a/web/messages/sk/common.json +++ b/web/messages/sk/common.json @@ -28,7 +28,12 @@ "switcher": "Jazyk", "en": "English", "es": "Español", - "sk": "Slovenčina" + "sk": "Slovenčina", + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "pt": "Português", + "sv": "Svenska" }, "resources": { "documentation": { @@ -193,7 +198,15 @@ "backupRestoreJobs": "Plánované úlohy", "backupRestoreRestoring": "Obnova", "backupRestoreCrossKernel": "Obnova medzi kernelmi", - "backupRestoreGlossary": "Slovník pojmov" + "backupRestoreGlossary": "Slovník pojmov", + "dashboardApps": "Karta Aplikácie", + "auditReport": "Audit a správy", + "auditReportOverview": "Prehľad", + "auditReportAssessment": "Hodnotenie a inventár", + "auditReportPolicy": "Pravidlá", + "auditReportChanges": "Zmeny", + "auditReportReports": "Správy a porovnania", + "auditReportScope": "Rozsah a záruky" } }, "hero": { diff --git a/web/messages/sk/docs/monitor/dashboard/vms-lxcs-app.json b/web/messages/sk/docs/monitor/dashboard/vms-lxcs-app.json index 8abc8a2b..551b00fc 100644 --- a/web/messages/sk/docs/monitor/dashboard/vms-lxcs-app.json +++ b/web/messages/sk/docs/monitor/dashboard/vms-lxcs-app.json @@ -98,7 +98,7 @@ ], "sourcesHeading": "Zdroje dostupnej verzie", "sources": [ - "GitHub repozitár: najnovšie vydanie alebo značka verejného repozitára vlastník/názov.", + "GitHub repozitár: najnovšie vydanie alebo značka verejného repozitára owner/name.", "HTTP JSON: verejný koncový bod a cesta ako data.version alebo releases[0].tag_name.", "Docker Hub: verzované značky filtrované regulárnym výrazom. Živý náhľad pred uložením ukáže skutočné zodpovedajúce značky.", "Pohyblivé značky ako latest, stable alebo lts neobsahujú verziu. Tieto obrazy sledujte podľa digestu v aktualizáciách Docker obrazov." diff --git a/web/messages/sk/docs/monitor/dashboard/vms-lxcs-updates.json b/web/messages/sk/docs/monitor/dashboard/vms-lxcs-updates.json index 7f4438f6..52331ccf 100644 --- a/web/messages/sk/docs/monitor/dashboard/vms-lxcs-updates.json +++ b/web/messages/sk/docs/monitor/dashboard/vms-lxcs-updates.json @@ -110,7 +110,7 @@ "Nepridávajte pct exec; ProxMenux už vstupuje do kontajnera a príkaz spúšťa ako root." ], "exampleLead": "Príklad úplného postupu v kontajneri:", - "example": "cd /opt/moja-aplikacia && ./update.sh", + "example": "cd /opt/my-app && ./update.sh", "callout": "Príkaz ako myapp --version iba číta verziu a nič neaktualizuje. Príkazy sa spúšťajú s oprávneniami správcu a musia sa kontrolovať ako príkazy shellu root." }, "bulk": { @@ -140,7 +140,7 @@ "items": [ "Vyberte predvoľbu alebo cron výraz a potom presné ciele: balíky OS, jednotlivé aplikácie, Docker Engine, samostatné jednotky Dockeru alebo skupiny služieb Compose.", "Oneskorenie po vydaní platí iba pre vybrané aplikácie so sledovaním verzie. Aplikácie bez sledovania spustia aktualizátor pri každom termíne.", - "Stav posledného behu rozlišuje úspech, čiastočné dokončenie, zlyhanie, bezpečnostné pozdržanie a stav bez čakajúcich aktualizácií.", + "Stav posledného behu rozlišuje úspech, čiastočné dokončenie, zlyhanie, bezpečnostné pozastavenie a žiadne čakajúce aktualizácie. Po prvom plánovanom behu otvorí Aktualizácie → Plánované aktualizácie → Zobraziť záznam celý výstup aktualizátora a podradených skriptov.", "Zistené externé plány Proxmox VE Helper-Scripts sa zobrazia samostatne, aby bola viditeľná prekrývajúca sa automatizácia." ], "callout": "Pred zapnutím plánu otestujte každú vybranú metódu manuálne. Plánovaný príkaz nemôže odpovedať na interaktívne otázky." diff --git a/web/messages/sv/common.json b/web/messages/sv/common.json new file mode 100644 index 00000000..658933e3 --- /dev/null +++ b/web/messages/sv/common.json @@ -0,0 +1,254 @@ +{ + "site": { + "name": "ProxMenux", + "tagline": "Interaktiv meny och webbaserad instrumentpanel för Proxmox VE" + }, + "nav": { + "home": "Start", + "docs": "Dokumentation", + "documentation": "Dokumentation", + "guides": "Guider", + "changelog": "Ändringslogg", + "github": "GitHub", + "rss": "RSS", + "rssTitle": "RSS-flöde", + "menuOpen": "Öppna meny" + }, + "footer": { + "sponsorHeading": "Stöd projektet", + "sponsorBody": "Om du vill stödja projektet.", + "sponsorAlt": "Stöd mig på Ko-fi", + "connectHeading": "Gemenskap", + "connectBody": "Delta i diskussionerna på GitHub för att få hjälp, dela idéer och bidra till projektet. Alla idéer är välkomna!", + "joinDiscussion": "Delta i diskussionen", + "copyrightPrefix": "ProxMenux, ett samarbetsprojekt med öppen källkod av", + "copyrightSuffix": "." + }, + "language": { + "switcher": "Språk", + "en": "English", + "es": "Español", + "sk": "Slovenčina", + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "pt": "Português", + "sv": "Svenska" + }, + "resources": { + "documentation": { + "title": "Dokumentation", + "description": "Systembeskrivning och användarguider" + }, + "changelog": { + "title": "Ändringslogg", + "description": "Information om de senaste uppdateringarna" + }, + "guides": { + "title": "Guider", + "description": "Stegvisa instruktioner och guider för vanliga uppgifter" + }, + "github": { + "title": "GitHub-arkiv", + "description": "Utforska källkoden." + } + }, + "docSidebar": { + "documentation": "Dokumentation", + "items": { + "introduction": "Introduktion", + "installation": "Installation", + "proxmenuxMonitor": "ProxMenux Monitor", + "monitorOverview": "Översikt", + "architecture": "Arkitektur", + "accessAuth": "Åtkomst och autentisering", + "dashboard": "Instrumentpanel", + "dashboardSystemOverview": "Fliken Systemöversikt", + "dashboardApps": "Fliken Program", + "dashboardStorage": "Fliken Lagring", + "dashboardNetwork": "Fliken Nätverk", + "dashboardVmsLxcs": "Fliken VM och LXC", + "dashboardVmsLxcsApp": "Program", + "dashboardVmsLxcsUpdates": "Uppdateringar", + "dashboardHardware": "Fliken Maskinvara", + "dashboardSystemLogs": "Fliken Systemloggar", + "dashboardTerminal": "Fliken Terminal", + "dashboardSecurity": "Fliken Säkerhet", + "dashboardSettings": "Fliken Inställningar", + "healthMonitor": "Systemövervakning", + "auditReport": "Granskning och rapporter", + "auditReportOverview": "Översikt", + "auditReportAssessment": "Bedömning och inventering", + "auditReportPolicy": "Policy", + "auditReportChanges": "Ändringar", + "auditReportReports": "Rapporter och jämförelser", + "auditReportScope": "Omfattning och garantier", + "notifications": "Aviseringar", + "aiAssistant": "AI-assistent", + "apiReference": "API-referens", + "integrations": "Integrationer", + "ociManager": "OCI manager Apps", + "ociOverview": "Översikt", + "ociArchitecture": "Så anpassas en OCI-avbildning", + "ociCustomImage": "Avbildning utanför katalogen", + "ociStorageNetwork": "Data, sökvägar och nätverk", + "ociHardware": "Enheter och acceleration", + "ociStacks": "Program med flera containrar", + "ociLifecycle": "Installera, uppdatera och ändra", + "ociMonitor": "I ProxMenux Monitor", + "proxmenuxScripts": "ProxMenux Scripts", + "postInstallScript": "Skript efter installationen", + "postInstallOverview": "Översikt", + "postInstallAutomated": "Automatiskt", + "postInstallCustomizable": "Anpassningsbart", + "postInstallBasicSettings": "Grundinställningar", + "postInstallSystem": "System", + "postInstallVirtualization": "Virtualisering", + "postInstallNetwork": "Nätverk", + "postInstallStorage": "Lagring", + "postInstallSecurity": "Säkerhet", + "postInstallCustomization": "Anpassning", + "postInstallMonitoring": "Övervakning", + "postInstallPerformance": "Prestanda", + "postInstallOptional": "Valfritt", + "postInstallUpdates": "Installera tillgängliga uppdateringar", + "postInstallUninstall": "Ta bort optimeringar", + "gpusCoralTpu": "GPU och Coral TPU", + "nvidiaHost": "Installera NVIDIA-drivrutiner på värden", + "coralHost": "Installera Coral TPU på värden", + "addGpuLxc": "Gör en GPU tillgänglig i en LXC", + "addCoralLxc": "Gör Coral TPU tillgänglig i en LXC", + "addGpuVm": "Tilldela en GPU till en VM (passthrough)", + "switchGpuMode": "Byt GPU-läge (VM ↔ LXC)", + "createVm": "Skapa en VM", + "createVmOverview": "Översikt", + "createVmSystemNas": "NAS-system", + "createVmSynology": "Synology-VM", + "createVmNasOthers": "Andra NAS-system", + "createVmSystemWindows": "Windows-system", + "createVmSystemLinux": "Linux-system", + "diskManager": "Diskhantering", + "diskManagerOverview": "Översikt", + "diskImportVm": "Importera en disk till en VM", + "diskImportImageVm": "Importera en diskavbildning till en VM", + "diskAddController": "Lägg till en styrenhet eller NVMe-enhet i en VM", + "diskImportLxc": "Importera en disk till en LXC", + "diskFormat": "Formatera eller radera en fysisk disk", + "diskSmart": "SMART-status och disktester", + "storageShareManager": "Hantering av lagring och delningar", + "storageShareOverview": "Översikt", + "hostStorage": "Anslut lagring till värden", + "hostNfs": "Lägg till en NFS-delning som Proxmox-lagring", + "hostSamba": "Lägg till en Samba-delning som Proxmox-lagring", + "hostIscsi": "Lägg till ett iSCSI-mål som Proxmox-lagring", + "hostLocalDisk": "Lägg till en lokal disk som Proxmox-lagring", + "hostLocalShared": "Skapa en delad katalog på värden", + "lxcMountPoints": "LXC-monteringspunkter (värd ↔ CT)", + "lxcNetworkSharing": "Nätverksdelning i LXC", + "lxcNfsClient": "NFS-klient i LXC", + "lxcSambaClient": "Samba-klient i LXC", + "lxcNfsServer": "NFS-server i LXC", + "lxcSambaServer": "Samba-server i LXC", + "network": "Nätverk", + "networkOverview": "Översikt", + "networkDiagnostics": "Diagnostik", + "networkMonitoring": "Verktyg för övervakning i realtid", + "networkBridge": "Analysera och reparera nätverksbryggor", + "networkConfig": "Analysera och rensa konfigurationen", + "networkPersistent": "Beständiga gränssnittsnamn", + "networkBackup": "Säkerhetskopiera och starta om gränssnitt", + "security": "Säkerhet", + "securityOverview": "Översikt", + "securityFail2ban": "Fail2Ban", + "securityLynis": "Lynis", + "utilities": "Verktyg", + "utilsOverview": "Översikt", + "utilsUupDump": "Skapa ISO med UUP Dump", + "utilsSystemUtils": "Installera systemverktyg", + "utilsSystemUpdate": "Uppdatera Proxmox-systemet", + "utilsUpgradePve": "Uppgradera från PVE 8 till PVE 9", + "utilsExportVm": "Exportera en VM till OVA/OVF", + "utilsImportVm": "Importera en VM från OVA/OVF", + "settingsProxmenux": "ProxMenux-inställningar", + "settingsOverview": "Översikt", + "settingsMonitor": "ProxMenux Monitor", + "settingsBeta": "Byt versionskanal", + "settingsVersion": "Visa versionsinformation", + "settingsUninstall": "Avinstallera ProxMenux", + "commandsReference": "Kommandoreferens", + "commandsOverview": "Översikt", + "commandsSystem": "Användbara systemkommandon", + "commandsVmCt": "Hantera VM och CT", + "commandsStorage": "Lagring och diskar", + "commandsNetwork": "Nätverkskommandon", + "commandsUpdates": "Uppdateringar och paket", + "commandsGpu": "GPU-passthrough", + "commandsZfs": "Hantera ZFS", + "commandsBackup": "Säkerhetskopiering och återställning", + "commandsTools": "Systemverktyg för kommandoraden", + "glossary": "Ordlista", + "about": "Om projektet", + "aboutOverview": "Översikt", + "aboutFaq": "FAQ", + "aboutContributors": "Medverkande", + "aboutContributing": "Bidra", + "aboutCodeOfConduct": "Uppförandekod", + "externalRepositories": "Externa arkiv", + "backupRestore": "Säkerhetskopiering och återställning", + "backupRestoreOverview": "Översikt", + "backupRestoreHowItWorks": "Så fungerar det", + "backupRestoreDestinations": "Mål", + "backupRestoreDestOverview": "Översikt", + "backupRestoreDestLocal": "Lokalt arkiv", + "backupRestoreDestPbs": "Proxmox Backup Server", + "backupRestoreDestBorg": "Borg", + "backupRestoreCreating": "Skapa säkerhetskopior", + "backupRestoreJobs": "Schemalagda jobb", + "backupRestoreRestoring": "Återställning", + "backupRestoreCrossKernel": "Återställning mellan kärnversioner", + "backupRestoreGlossary": "Ordlista" + } + }, + "hero": { + "title": "ProxMenux", + "tagline": "En interaktiv meny och webbaserad instrumentpanel för Proxmox VE", + "description": "ProxMenux kombinerar en interaktiv meny med en webbaserad instrumentpanel för Proxmox VE. Kör kommandon, skript och guidade steg från terminalmenyn, eller följ värdens status, mätvärden och aviseringar i webbläsaren.", + "installButton": "Installera nu", + "whatIsButton": "Vad är ProxMenux?", + "logoAlt": "ProxMenux-logotyp" + }, + "docNav": { + "previous": "Föregående", + "next": "Nästa" + }, + "tocPanel": { + "onThisPage": "På den här sidan" + }, + "supportProject": { + "heading": "Stöd projektet!", + "body": "Om du har nytta av ProxMenux kan du ge projektet en ⭐ på GitHub så att fler upptäcker det.", + "button": "Ge en stjärna på GitHub" + }, + "rssLink": { + "heading": "Följ uppdateringarna!", + "body": "Prenumerera på vårt RSS-flöde för att få information om nyheter.", + "copy": "Kopiera", + "copied": "Kopierat!", + "copyTitle": "Kopiera RSS-adressen", + "openFeed": "Öppna RSS-flödet", + "openTitle": "Öppna RSS-flödet" + }, + "changelog": { + "pageTitle": "Ändringslogg", + "meta": { + "title": "Ändringslogg för ProxMenux", + "titleSuffix": "Versionsinformation", + "latest": "Senaste", + "mostRecent": "Senaste versionen", + "description": "Versionsinformation och uppdateringshistorik för ProxMenux, det menystyrda skriptet för administration av Proxmox VE.", + "descriptionTail": "Nya funktioner, felrättningar, säkerhetsuppdateringar, förbättringar i ProxMenux Monitor, stöd för AI-leverantörer, säkrare GPU-passthrough och mer.", + "ogDescription": "Versionsinformation för ProxMenux.", + "ogTail": "Prenumerera via RSS eller besök sidan igen för att se nya versioner." + } + } +} diff --git a/web/package.json b/web/package.json index ca3302cd..3996563d 100644 --- a/web/package.json +++ b/web/package.json @@ -4,10 +4,10 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build && pagefind --site out --output-path public/pagefind && cp -r public/pagefind out/pagefind", + "build": "next build && node scripts/localize-export.mjs && pagefind --site out --output-path public/pagefind && cp -r public/pagefind out/pagefind", "start": "next start", "lint": "next lint", - "index": "pagefind --site out --output-path public/pagefind && cp -r public/pagefind out/pagefind" + "index": "node scripts/localize-export.mjs && pagefind --site out --output-path public/pagefind && cp -r public/pagefind out/pagefind" }, "dependencies": { "@hookform/resolvers": "^3.9.1", diff --git a/web/scripts/localize-export.mjs b/web/scripts/localize-export.mjs new file mode 100644 index 00000000..89e7f221 --- /dev/null +++ b/web/scripts/localize-export.mjs @@ -0,0 +1,32 @@ +// Next's shared root layout cannot read its nested [locale] parameter. +// Set the static document language before Pagefind indexes the export, so +// search and no-JavaScript readers see the same locale as the hydrated app. +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +async function htmlFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const files = await Promise.all(entries.map(entry => { + const path = join(directory, entry.name) + return entry.isDirectory() ? htmlFiles(path) : entry.isFile() && entry.name.endsWith('.html') ? [path] : [] + })) + return files.flat() +} + +let count = 0 +for (const entry of await readdir('out', { withFileTypes: true })) { + if (!entry.isDirectory() || !/^[a-z]{2}(?:-[a-z0-9]+)?$/.test(entry.name)) continue + const locale = entry.name + for (const file of await htmlFiles(join('out', locale))) { + const original = await readFile(file, 'utf8') + const updated = original.replace(/]*)>/i, (tag, attributes) => { + const next = /\blang\s*=\s*["'][^"']*["']/i.test(attributes) + ? attributes.replace(/\blang\s*=\s*["'][^"']*["']/i, `lang="${locale}"`) + : `${attributes} lang="${locale}"` + return `` + }) + if (updated !== original) await writeFile(file, updated, 'utf8') + count += 1 + } +} +console.log(`Static document language verified for ${count} localized pages.`)