mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge remote-tracking branch 'origin/develop' into HEAD
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
|
||||
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
|
||||
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
|
||||
# Identifiers and encoded delimiters remain literal even outside <code>.
|
||||
re.compile(r"(?<![A-Za-z0-9_])_?[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+(?![A-Za-z0-9_])"),
|
||||
re.compile(r"\b[A-Za-z0-9]*(?:[a-z][A-Z])[A-Za-z0-9]*\b"),
|
||||
re.compile(r"&(?:[A-Za-z]+|#[0-9]+|#x[0-9A-Fa-f]+);"),
|
||||
)
|
||||
LITERAL_TAG_RE = re.compile(
|
||||
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
@@ -109,16 +131,23 @@ ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
|
||||
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 "<strong>" 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'(?:</(?:code|codeNw|codeXs|pre|kbd)>|<strong>)(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(
|
||||
(
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ("<code>apt update</code>",
|
||||
"<code>net0</code> / <code>net1</code>.",
|
||||
"/api/task-log/<upid>", "/api/vms/<vmid>/config"):
|
||||
with self.subTest(source=source):
|
||||
self.assertTrue(MODULE.should_copy(source, ("cell",)))
|
||||
self.assertFalse(MODULE.should_copy("Run <code>apt update</code> 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</{tag}>."
|
||||
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"</{tag}>", f"</{tag}>0"))
|
||||
self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100</{tag}>", ("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: <strong>Datacenter → Backup → Retention</strong>:"
|
||||
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 <code>pct config</code> first.', 'Spustite <code>pct config</code>0 najprv.'),
|
||||
('<strong><code>Restart=on-failure</code></strong> restarts the service.',
|
||||
'<strong>0<code>Restart=on-failure</code></strong> 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 <code>echo</code>0.',
|
||||
'Výsledok je <code>echo</code>0.')
|
||||
|
||||
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"}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Keep the canonical conduct policy and its English website source in sync."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DocumentationMarkdownTests(unittest.TestCase):
|
||||
def test_english_conduct_body_matches_canonical_policy(self):
|
||||
source = json.loads((ROOT / 'web/messages/en/docs/about/code-of-conduct.json').read_text(encoding='utf-8'))
|
||||
self.assertEqual(source['contentMarkdown'], (ROOT / 'CODE_OF_CONDUCT.md').read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,7 +130,9 @@ 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),
|
||||
'carry_records': lambda project, **options: {},
|
||||
'_work_backup_ready': lambda project, ui, vmid: True,
|
||||
|
||||
@@ -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()
|
||||
@@ -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 <code>apt update</code>."})
|
||||
self.write_catalog(root, "de", {"body": "Führe <code>apt aktualisieren</code> 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)
|
||||
@@ -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 = '<!doctype html><html lang="en" class="dark"><body><h1>Text</h1><script>const example=\'<html lang="en">\';</script></body></html>'
|
||||
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('<html lang="en"', f'<html lang="{locale}"', 1))
|
||||
for relative in ["out/index.html", "out/assets/index.html"]:
|
||||
self.assertEqual((root / relative).read_text(), html)
|
||||
tracked = root / "out/de/docs/monitor/index.html"
|
||||
before = tracked.stat().st_mtime_ns
|
||||
second = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
self.assertEqual(tracked.stat().st_mtime_ns, before)
|
||||
@@ -1,14 +1,9 @@
|
||||
name: Build web documentation translations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- 'web/messages/en/*.json'
|
||||
- 'web/messages/en/**/*.json'
|
||||
- '.github/scripts/build_web_docs_i18n.py'
|
||||
- '.github/scripts/build_translation_cache.py'
|
||||
- '.github/workflows/build-web-docs-i18n.yml'
|
||||
# Source and locale corrections are reviewed together. An automatic run
|
||||
# would treat the changed English fingerprints as stale translations and
|
||||
# overwrite those reviewed locale strings with provider output.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
languages:
|
||||
|
||||
@@ -28,7 +28,55 @@ 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
|
||||
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 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 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'
|
||||
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
|
||||
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
|
||||
- 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_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
|
||||
- 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
|
||||
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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<HealthDetails | null>(null)
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
@@ -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 */}
|
||||
<div className="space-y-2">
|
||||
{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
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{categoryLabel(key)}</p>
|
||||
{hasChecks && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
({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
|
||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? categoryLabel(catMeta.key) : cs.label}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||
{durationLabel}
|
||||
|
||||
@@ -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,37 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
const [loading, setLoading] = useState(!seed)
|
||||
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
|
||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
||||
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
|
||||
const nativeRequest = useRef(0)
|
||||
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
|
||||
const request = ++nativeRequest.current
|
||||
setNativeDiscovery(null)
|
||||
setEditing(null)
|
||||
setDetectionNotice(null)
|
||||
setSearchingApplications(false)
|
||||
setSaving(false)
|
||||
if (managed) return
|
||||
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(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])
|
||||
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 +559,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 +572,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 +656,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 +692,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 +718,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 +789,32 @@ 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)
|
||||
setError(null)
|
||||
try {
|
||||
let native = await fetchApi<NativeDiscovery>(`/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<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
|
||||
if (!isCurrent()) return
|
||||
setNativeDiscovery(native)
|
||||
setSuggestions(result)
|
||||
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
|
||||
|
||||
@@ -765,11 +822,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 +845,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 +864,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 +893,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 +924,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 +1009,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 +1026,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 +1042,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 +1050,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1283,7 +1353,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"
|
||||
@@ -2452,6 +2522,24 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
||||
: t("vmLxc.appEditor.detectedInContainer")}
|
||||
</div>
|
||||
{d.source === "native_metadata" && (
|
||||
<div className="text-xs text-muted-foreground mt-1 break-all">
|
||||
<div>{d.installed_version
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
|
||||
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
|
||||
{(nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
|
||||
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
|
||||
)}
|
||||
{d.evidence?.map((e, index) => <div key={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}
|
||||
</div>)}
|
||||
<div>{t("vmLxc.appEditor.nativeRegisterOnly")}</div>
|
||||
</div>
|
||||
)}
|
||||
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
||||
<div className="text-[10px] text-amber-400/90 mt-0.5">
|
||||
{t("vmLxc.appEditor.legacyDetectorUsed")}
|
||||
|
||||
@@ -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<MonitorLinks>(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" ? <DatabaseBackup className={className} /> : <Server className={className} />
|
||||
}
|
||||
|
||||
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<MonitorLinks>("/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 <div onClick={event => event.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className={`gap-2 border-border bg-transparent hover:bg-secondary ${compact ? "h-8 px-2.5 text-xs" : ""}`}>
|
||||
<Server className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
|
||||
<span className="max-w-[12rem] truncate">{t("status.node", { node: nodeName })}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t("monitorLinks.thisNode")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem disabled className="gap-2 opacity-100">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<ProductIcon product={data?.self.product || ""} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{nodeName}</span>
|
||||
</DropdownMenuItem>
|
||||
{origin && !originKnown && <DropdownMenuItem className="gap-2" onSelect={() => open(withOrigin(origin.url), false)}>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("monitorLinks.backTo", { name: origin.name })}</span>
|
||||
</DropdownMenuItem>}
|
||||
{isLoading && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{error && <p className="px-2 py-1.5 text-xs text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data && data.links.length === 0 && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data && GROUPS.map(group => {
|
||||
const rows = data.links.filter(link => link.group === group)
|
||||
if (rows.length === 0) return null
|
||||
return <div key={group}>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t(`monitorLinks.groups.${group}`)}</DropdownMenuLabel>
|
||||
{rows.map(link => {
|
||||
const target = destination(link)
|
||||
return <DropdownMenuItem key={link.key} disabled={!target} className="gap-2" onSelect={() => go(link, false)}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${link.status === "online" ? "bg-green-500" : "bg-gray-500"}`} />
|
||||
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
||||
{target?.native
|
||||
? <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsInterface")}</span>
|
||||
: link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
|
||||
{target && <button type="button" aria-label={t("monitorLinks.openNewTab")} title={t("monitorLinks.openNewTab")}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={event => { event.preventDefault(); event.stopPropagation(); go(link, true) }}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>}
|
||||
</DropdownMenuItem>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2" onSelect={() => setManageOpen(true)}>
|
||||
<Plus className="h-4 w-4" />{t("monitorLinks.manage")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={setOfferOrigin}>
|
||||
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.cameFromTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.cameFromBody", { name: origin.name })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="break-all rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-sm">{origin.url}</p>
|
||||
{addError && <p role="alert" className="text-sm text-red-500">{addError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" disabled={adding} onClick={() => setOfferOrigin(false)}>{t("monitorLinks.notNow")}</Button>
|
||||
<Button disabled={adding} onClick={addOrigin}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const t = useT()
|
||||
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
|
||||
const [addresses, setAddresses] = useState<Record<string, string>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
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 <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.dialogDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
|
||||
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-url">{t("monitorLinks.address")}</Label>
|
||||
<Input id="monitor-link-url" value={form.url} disabled={busy} placeholder="https://192.168.1.20:8008"
|
||||
onChange={event => setForm({ ...form, url: event.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-product">{t("monitorLinks.type")}</Label>
|
||||
<Select value={form.product} disabled={busy} onValueChange={product => setForm({ ...form, product })}>
|
||||
<SelectTrigger id="monitor-link-product" className="w-full sm:w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pve">Proxmox VE</SelectItem>
|
||||
<SelectItem value="pbs">Proxmox Backup Server</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
|
||||
await send("/api/monitor-links", "POST", form)
|
||||
setForm({ name: "", url: "", product: "pve" })
|
||||
})}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||
</div>
|
||||
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data?.links.map(link => {
|
||||
const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "")
|
||||
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${link.hidden ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProductIcon product={link.product} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{link.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")}</span>
|
||||
{!link.manual && <label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{t("monitorLinks.visible")}
|
||||
<Switch checked={!link.hidden} disabled={busy} className="data-[state=checked]:bg-blue-600"
|
||||
onCheckedChange={checked => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { hidden: !checked }))} />
|
||||
</label>}
|
||||
{link.manual && <Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.remove")}
|
||||
onClick={() => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "DELETE"))}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={address} disabled={busy} aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${link.url ? `: ${link.url}` : ""}`}
|
||||
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||
<Button variant="outline" size="sm" className="h-10" disabled={busy || addresses[link.key] === undefined}
|
||||
onClick={() => run(async () => {
|
||||
await send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { url: addresses[link.key] })
|
||||
setAddresses(({ [link.key]: _, ...rest }) => rest)
|
||||
})}>{t("monitorLinks.save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
@@ -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<NotificationConfig>(DEFAULT_CONFIG)
|
||||
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
||||
@@ -703,7 +707,7 @@ export function NotificationSettings() {
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.notificationCategories")}</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{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
|
||||
|
||||
@@ -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<Vitals>("/api/system", url => fetchApi<Vitals>(url), { refreshInterval: 10000 })
|
||||
return <div className="container mx-auto max-w-7xl space-y-6 px-4 py-6">
|
||||
<header className="flex flex-wrap items-center gap-3 border-b pb-4">
|
||||
<div className="mr-auto"><h1 className="text-2xl font-bold">ProxMenux · PBS</h1><p className="text-sm text-muted-foreground">{data?.hostname || t("pbs.loading")}</p></div>
|
||||
<ThemeToggle />
|
||||
<Button variant="outline" onClick={() => { localStorage.removeItem("proxmenux-auth-token"); window.location.reload() }}>{t("pbs.logout")}</Button>
|
||||
</header>
|
||||
<nav aria-label={t("pbs.navigation")} className="flex flex-wrap gap-2">
|
||||
{(["overview", "logs", "terminal", "security"] as const).map(key =>
|
||||
<Button key={key} variant={tab === key ? "default" : "outline"} onClick={() => setTab(key)}>{t(`pbs.${key}`)}</Button>)}
|
||||
</nav>
|
||||
{tab === "overview" && <>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[[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]) =>
|
||||
<Card key={label}><CardContent className="p-4"><p className="text-xs text-muted-foreground">{label}</p><p className="mt-1 text-lg font-semibold">{value}</p></CardContent></Card>)}
|
||||
</div>
|
||||
<PBSPanel />
|
||||
</>}
|
||||
{tab === "logs" && <SystemLogs />}
|
||||
{tab === "terminal" && <TerminalPanel />}
|
||||
{tab === "security" && <Security hostType="pbs" />}
|
||||
</div>
|
||||
}
|
||||
@@ -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 <div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-500">{store.error || t("pbs.capacityUnavailable")}</p>
|
||||
</div>
|
||||
}
|
||||
const tone = usageTone(percent)
|
||||
return <div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className={`text-xs font-semibold tabular-nums ${tone.text}`}>{percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={percent} className={`${compact ? "h-1.5" : "h-2"} ${tone.bar}`} />
|
||||
{!compact && <div className="flex justify-between text-xs text-muted-foreground tabular-nums">
|
||||
<span>{gib(store.used!)} / {gib(store.total!)}</span>
|
||||
<span>{t("pbs.free")}: <span className="font-semibold text-green-500">{gib(store.avail ?? store.total! - store.used!)}</span></span>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
// 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 <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<h3 className="font-semibold text-base md:text-lg truncate">{store.name}</h3>
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">{type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge className={store.error ? "bg-red-500/10 text-red-500 border-red-500/20" : "bg-green-500/10 text-green-500 border-green-500/20"}>
|
||||
{store.error ? t("storage.status.error") : t("storage.status.active")}
|
||||
</Badge>
|
||||
{percent != null && <span className="text-sm font-medium">{percent.toFixed(2)}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
{percent != null && tone ? (
|
||||
<div className="space-y-2">
|
||||
<Progress value={percent} className={`h-2 ${tone.bar}`} />
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.total")}</p>
|
||||
<p className="font-medium">{gib(store.total!)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.used")}</p>
|
||||
<p className={`font-medium ${tone.used}`}>{gib(store.used!)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.available")}</p>
|
||||
<p className="font-medium text-green-400">{gib(store.avail ?? store.total! - store.used!)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
|
||||
)}
|
||||
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
|
||||
{fullDate !== undefined && <span className="flex items-center">
|
||||
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}</span>
|
||||
</span>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
datastores: Datastore[] | null; tasks: Task[] | null;
|
||||
datastores: PBSDatastore[] | null; tasks: Task[] | null;
|
||||
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
|
||||
}
|
||||
|
||||
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<string, typeof Activity> = {
|
||||
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<Connections>(
|
||||
"/api/pbs/servers", url => fetchApi<Connections>(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 <div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-xl font-semibold">{t("pbs.title")}</h2>
|
||||
<select aria-label={t("pbs.server")} className={`${selectClass} sm:ml-auto`} value={selected}
|
||||
onChange={event => setSelected(event.target.value)}>
|
||||
{!selected && <option value="">{t("pbs.chooseServer")}</option>}
|
||||
{connections?.local_available && <option value="local">{t("pbs.local")}</option>}
|
||||
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
|
||||
</select>
|
||||
<Button variant="outline" disabled={isLoading || !selected} onClick={() => mutate()}>{t("pbs.refresh")}</Button>
|
||||
<Button variant="outline" onClick={() => setManage(!manage)}>{t("pbs.manage")}</Button>
|
||||
</div>
|
||||
return <div className="space-y-4 md:space-y-6">
|
||||
{!localOnly && <div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="flex items-center text-2xl font-semibold text-foreground"><Server className="mr-2 h-5 w-5" />{t("pbs.title")}</h2>
|
||||
<Select value={selected || undefined} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.local")}</SelectItem>}
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setManage(!manage)}>
|
||||
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
|
||||
</Button>
|
||||
</div>}
|
||||
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
|
||||
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
|
||||
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
|
||||
{manage && <Card>
|
||||
{manage && !localOnly && <Card>
|
||||
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p>
|
||||
<select aria-label={t("pbs.editConnection")} className={`${selectClass} w-full`} value={form.id}
|
||||
onChange={event => {
|
||||
const row = connections?.servers.find(item => item.id === event.target.value)
|
||||
setForm(row ? { ...row, token_secret: "" } : emptyForm)
|
||||
}}>
|
||||
<option value="">{t("pbs.add")}</option>
|
||||
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
|
||||
</select>
|
||||
<Select value={form.id || NEW_CONNECTION} onValueChange={value => {
|
||||
const row = connections?.servers.find(item => item.id === value)
|
||||
setForm(row ? { ...row, token_secret: "" } : emptyForm)
|
||||
}}>
|
||||
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NEW_CONNECTION}>{t("pbs.add")}</SelectItem>
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
|
||||
<div key={field} className="space-y-2">
|
||||
@@ -118,32 +170,78 @@ export function PBSPanel() {
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{data && <>
|
||||
<p className="text-sm text-muted-foreground">{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</p>
|
||||
{Object.keys(data.services).length > 0 && <div className="flex flex-wrap gap-3 text-sm">
|
||||
{Object.entries(data.services).map(([service, state]) => <span key={service} className={state === "active" ? "text-green-500" : "text-red-500"}>{service}: {state}</span>)}
|
||||
</div>}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</span>
|
||||
{Object.entries(data.services).map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : badgeTone.error}>{service}: {state}</Badge>)}
|
||||
</div>
|
||||
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
<Card><CardHeader><CardTitle>{t("pbs.datastores")}</CardTitle></CardHeader><CardContent>
|
||||
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
|
||||
{data.datastores && data.datastores.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.name")}</th><th>{t("pbs.total")}</th><th>{t("pbs.used")}</th><th>{t("pbs.free")}</th></tr></thead>
|
||||
<tbody>{data.datastores.map(row => <tr key={row.name} className="border-b"><td className="p-2 font-medium">{row.name}{row.error && <p className="font-normal text-xs text-amber-500">{row.error}</p>}</td><td>{capacity(row.total)}</td><td>{capacity(row.used)}</td><td>{capacity(row.avail)}</td></tr>)}</tbody>
|
||||
</table></div>}
|
||||
</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle>{t("pbs.tasks")}</CardTitle></CardHeader><CardContent>
|
||||
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
|
||||
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.task")}</th><th>{t("pbs.started")}</th><th>{t("pbs.status")}</th></tr></thead>
|
||||
<tbody>{data.tasks.map(row => <tr key={row.upid} className="border-b"><td className="p-2">{row.worker_type} {row.worker_id}</td><td>{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td><td>{row.status || (row.endtime ? "—" : t("pbs.running"))}</td></tr>)}</tbody>
|
||||
</table></div>}
|
||||
</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle>{t("pbs.jobs")}</CardTitle></CardHeader><CardContent>
|
||||
<div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.job")}</th><th>{t("pbs.datastores")}</th><th>{t("pbs.schedule")}</th><th>{t("pbs.status")}</th></tr></thead>
|
||||
<tbody>{(["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row =>
|
||||
<tr key={`${kind}-${row.id}`} className="border-b"><td className="p-2">{t(`pbs.${kind}`)} · {row.id}</td><td>{row.store || "—"}</td><td>{row.schedule || "—"}</td><td>{row["last-run-state"] || "—"}</td></tr>))}</tbody>
|
||||
</table></div>
|
||||
</CardContent></Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
|
||||
{data.datastores && data.datastores.length > 0 && <div className="space-y-4">
|
||||
{data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)}
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Activity className="mr-2 h-5 w-5" />{t("pbs.tasks")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
|
||||
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.task")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.started")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.duration")}</th>
|
||||
<th className="py-2 font-medium">{t("pbs.status")}</th>
|
||||
</tr></thead>
|
||||
<tbody>{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 <tr key={row.upid} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-foreground">{row.worker_type}</span>
|
||||
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
|
||||
</span></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatDuration(end - row.starttime) : "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={`${badgeTone[state]} max-w-[16rem] truncate`} title={row.status}>
|
||||
{state === "running" ? t("pbs.running") : row.status || "—"}
|
||||
</Badge></td>
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><ListChecks className="mr-2 h-5 w-5" />{t("pbs.jobs")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row })))
|
||||
if (jobs.length === 0) return <p className="text-sm text-muted-foreground">{t("pbs.noJobs")}</p>
|
||||
return <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.job")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.datastores")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.schedule")}</th>
|
||||
<th className="py-2 font-medium">{t("pbs.status")}</th>
|
||||
</tr></thead>
|
||||
<tbody>{jobs.map(({ kind, row }) => <tr key={`${kind}-${row.id}`} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
|
||||
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={badgeTone[jobState(row["last-run-state"])]}>{row["last-run-state"] || "—"}</Badge></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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 <MonitorDashboard product="pve" />
|
||||
}
|
||||
|
||||
function PBSDashboard() {
|
||||
return <MonitorDashboard product="pbs" />
|
||||
}
|
||||
|
||||
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<SystemStatus>({
|
||||
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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<OnboardingCarousel />
|
||||
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
|
||||
{/* Both describe the PVE Monitor. */}
|
||||
{!isPbs && <OnboardingCarousel />}
|
||||
{!isPbs && <ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />}
|
||||
|
||||
{!isServerConnected && (
|
||||
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
|
||||
@@ -473,22 +495,17 @@ function PVEDashboard() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t(isPbs ? "app.descriptionPbs" : "app.description")}</p>
|
||||
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Server className="h-3 w-3" />
|
||||
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span className="truncate">{t("status.uptime", { uptime: displayUptime })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden lg:flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MonitorSwitcher nodeName={displayServerName} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
@@ -589,9 +606,7 @@ function PVEDashboard() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t("status.uptime", { uptime: displayUptime })}
|
||||
</span>
|
||||
<MonitorSwitcher nodeName={displayServerName} compact />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -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() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
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 (
|
||||
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
|
||||
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-4" : "grid-cols-7"} bg-card border border-border`}>
|
||||
{tabOrder.map(renderTop)}
|
||||
</TabsList>
|
||||
)
|
||||
@@ -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() {
|
||||
<React.Fragment key="admin">
|
||||
{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"))}
|
||||
</React.Fragment>
|
||||
@@ -820,9 +837,16 @@ function PVEDashboard() {
|
||||
5 s / 59 s refresh intervals keep the data fresh in the
|
||||
background — reopening the tab is instant. */}
|
||||
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
|
||||
<SystemOverview key={`overview-${componentKey}`} />
|
||||
<SystemOverview key={`overview-${componentKey}`} product={product} />
|
||||
</TabsContent>
|
||||
|
||||
{isPbs && (
|
||||
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
|
||||
<PBSPanel key={`datastores-${componentKey}`} localOnly />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{!isPbs && <>
|
||||
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
|
||||
<AppsDashboard key={`apps-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -848,11 +872,13 @@ function PVEDashboard() {
|
||||
<TabsContent value="hardware" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Hardware key={`hardware-${componentKey}`} />
|
||||
</TabsContent>
|
||||
</>}
|
||||
|
||||
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
|
||||
<SystemLogs key={`logs-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
{!isPbs && <>
|
||||
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
|
||||
<AuditReport key={`audit-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -860,13 +886,14 @@ function PVEDashboard() {
|
||||
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||
<BackupTab key={`backup-${componentKey}`} />
|
||||
</TabsContent>
|
||||
</>}
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Security key={`security-${componentKey}`} />
|
||||
<Security key={`security-${componentKey}`} hostType={product} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Profile tab — not surfaced in the top tabs nav. The only
|
||||
|
||||
@@ -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() {
|
||||
</Card>
|
||||
|
||||
{/* Navigation Tab Order — user-orderable top-level tabs */}
|
||||
<NavTabOrderCard />
|
||||
{!isPbs && <NavTabOrderCard />}
|
||||
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
@@ -1280,7 +1282,7 @@ export function Settings() {
|
||||
|
||||
{/* Per-category rows */}
|
||||
<div className="divide-y divide-border/50">
|
||||
{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 (
|
||||
<div key={cat.key}>
|
||||
@@ -1496,6 +1499,7 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!isPbs && <>
|
||||
{/* Remote Storage Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1678,6 +1682,7 @@ export function Settings() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Network Interface Exclusions */}
|
||||
<Card>
|
||||
@@ -1875,6 +1880,7 @@ export function Settings() {
|
||||
is re-enabled). */}
|
||||
<LxcUpdateDetection />
|
||||
|
||||
{!isPbs && <>
|
||||
{/* GitHub API — optional authentication for app upstream checks. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1981,15 +1987,16 @@ export function Settings() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Notification Settings */}
|
||||
<NotificationSettings />
|
||||
{/* Notification Settings (PVE only for now) */}
|
||||
{!isPbs && <NotificationSettings />}
|
||||
|
||||
{/* 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 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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<SystemData | null>(null)
|
||||
const [vmData, setVmData] = useState<VMData[]>([])
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(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() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
||||
{isPbs ? (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("pbs.datastores")}</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!datastores && !pbsError ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : !datastores ? (
|
||||
<p className="text-sm text-amber-500">{t("pbs.loadFailed")}</p>
|
||||
) : datastores.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-4xl font-bold leading-none text-foreground">{datastores.length}</span>
|
||||
{datastores.some(store => store.error) ? (
|
||||
<Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
||||
{t("pbs.datastoresUnavailable", { count: datastores.filter(store => store.error).length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{t("pbs.datastoresOnline")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{datastores.slice(0, 2).map(store => <DatastoreUsageRow key={store.name} store={store} compact />)}
|
||||
{datastores.length > 2 && (
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.moreDatastores", { count: datastores.length - 2 })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
|
||||
@@ -565,6 +615,7 @@ export function SystemOverview() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
|
||||
@@ -708,7 +759,16 @@ export function SystemOverview() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
{isPbs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
|
||||
{!datastores ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
|
||||
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
|
||||
</div>
|
||||
) : vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -744,7 +804,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localStorage && (
|
||||
{!isPbs && localStorage && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -899,7 +959,7 @@ export function SystemOverview() {
|
||||
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
|
||||
<span className="text-muted-foreground">{t(isPbs ? "overview.pbsVersion" : "overview.proxmoxVersion")}</span>
|
||||
<span className="text-foreground">{systemData.proxmox_version || t("app.notAvailable")}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -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 ? <Clock className={iconSize} /> : <ArrowUpCircle className={iconSize} />}
|
||||
{stale ? t("vmLxc.updates.checkStale") : uc.count}
|
||||
<ArrowUpCircle className={iconSize} />
|
||||
{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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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...",
|
||||
@@ -1735,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": "Lauschender TCP-Port",
|
||||
"nativeEvidenceFile": "Installierte Datei",
|
||||
"nativeEvidenceProcess": "Laufende ausführbare Datei",
|
||||
"nativeEvidenceDirectory": "Arbeitsverzeichnis",
|
||||
"nativeEvidenceService": "Dienstdefinition",
|
||||
"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 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",
|
||||
@@ -1831,7 +1843,8 @@
|
||||
"logs": "Systemprotokolle",
|
||||
"updates": "Systemaktualisierungen",
|
||||
"security": "Sicherheit & Zertifikate",
|
||||
"temperature": "Temperatur"
|
||||
"temperature": "Temperatur",
|
||||
"pbsServices": "PBS Services"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -4824,7 +4837,8 @@
|
||||
"services": "PVE-Dienste",
|
||||
"logs": "Systemprotokolle",
|
||||
"updates": "Systemaktualisierungen",
|
||||
"security": "Sicherheit & Zertifikate"
|
||||
"security": "Sicherheit & Zertifikate",
|
||||
"pbsServices": "PBS-Dienste"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "CPU-Auslastung",
|
||||
@@ -7320,6 +7334,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 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 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 Backup Server 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"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
@@ -1713,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.",
|
||||
@@ -1881,6 +1901,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",
|
||||
@@ -2652,7 +2673,7 @@
|
||||
"expires": "Vence",
|
||||
"selfSignedWarning": "Los certificados autofirmados pueden mostrar una advertencia en el navegador. Esto es normal para las configuraciones locales de Proxmox.",
|
||||
"configuring": "Configurando...",
|
||||
"useProxmoxCertificate": "Utilice el certificado Proxmox",
|
||||
"useProxmoxCertificate": "Usar certificado de Proxmox",
|
||||
"noProxmoxCertificate": "No se detectó ningún certificado Proxmox.",
|
||||
"useCustomCertificate": "Usar certificado personalizado",
|
||||
"customPaths": "Rutas de certificados personalizadas",
|
||||
@@ -4874,7 +4895,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 +7343,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 Proxmox Backup Server"
|
||||
},
|
||||
"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...",
|
||||
@@ -1735,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": "Port TCP en écoute",
|
||||
"nativeEvidenceFile": "Fichier installé",
|
||||
"nativeEvidenceProcess": "Exécutable en cours d'exécution",
|
||||
"nativeEvidenceDirectory": "Répertoire de travail",
|
||||
"nativeEvidenceService": "Définition du service",
|
||||
"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",
|
||||
@@ -1831,7 +1843,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 +4837,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 +7334,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": "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 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 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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 di Proxmox Backup Server"
|
||||
},
|
||||
"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...",
|
||||
@@ -1735,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": "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",
|
||||
@@ -1831,7 +1843,8 @@
|
||||
"logs": "Log di sistema",
|
||||
"updates": "Aggiornamenti di sistema",
|
||||
"security": "Sicurezza e certificati",
|
||||
"temperature": "Temperatura"
|
||||
"temperature": "Temperatura",
|
||||
"pbsServices": "Servizi PBS"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -4824,7 +4837,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 +7334,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": "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.",
|
||||
"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 Proxmox Backup Server 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 Proxmox Backup Server"
|
||||
},
|
||||
"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...",
|
||||
@@ -1735,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 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": "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",
|
||||
@@ -1831,7 +1843,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 +4837,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 +7334,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": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Este nó",
|
||||
"checking": "Verificando nós…",
|
||||
"loadFailed": "não foi possível carregar a lista de nós.",
|
||||
"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 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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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á...",
|
||||
@@ -1682,6 +1684,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.",
|
||||
@@ -1830,7 +1842,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 +4836,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 +7333,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": "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.",
|
||||
"noResponse": "Žiadna odpoveď",
|
||||
"pbsInterface": "rozhranie PBS",
|
||||
"openNewTab": "Otvoriť na novej karte",
|
||||
"manage": "Pridať alebo spravovať uzly…",
|
||||
"dialogTitle": "Prepojené uzly",
|
||||
"dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
@@ -1735,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örande programfil",
|
||||
"nativeEvidenceDirectory": "Arbetskatalog",
|
||||
"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": "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",
|
||||
@@ -1831,7 +1843,8 @@
|
||||
"logs": "Systemloggar",
|
||||
"updates": "Systemuppdateringar",
|
||||
"security": "Säkerhet och certifikat",
|
||||
"temperature": "Temperatur"
|
||||
"temperature": "Temperatur",
|
||||
"pbsServices": "PBS Services"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -4824,7 +4837,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 +7334,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 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 Server identifieras automatiskt. Varje Monitor 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -163,6 +164,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"
|
||||
|
||||
@@ -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/<path:key>", 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/<path:key>", 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
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
@@ -2276,6 +2278,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':
|
||||
@@ -12214,6 +12218,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 +12252,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 +12346,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:
|
||||
@@ -14090,6 +14102,20 @@ def api_vm_apps_suggestions(vmid):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/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/<int:vmid>/apps/adguard-setup', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_apps_adguard_setup(vmid):
|
||||
@@ -23339,8 +23365,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 +23385,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
|
||||
|
||||
@@ -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', ''),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,750 @@
|
||||
#!/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
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import signal
|
||||
import stat
|
||||
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 = {}
|
||||
_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')
|
||||
_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',
|
||||
'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',
|
||||
# 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)?|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',
|
||||
'watch', 'watchdog', 'worker',
|
||||
}
|
||||
_HESTIA_NGINX = '/usr/local/hestia/nginx/sbin/hestia-nginx'
|
||||
_HESTIA_ROOT = '/usr/local/hestia'
|
||||
_HESTIA_PANEL_PORT = 8083
|
||||
|
||||
|
||||
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
|
||||
# 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
|
||||
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
|
||||
# Without ss or netstat there is no listener evidence; a retry would not change it.
|
||||
printf 'END\n'
|
||||
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'
|
||||
if [ -f "$2/package.json" ]; then
|
||||
[ -r "$2/package.json" ] || exit 1
|
||||
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):
|
||||
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 (raw.startswith('php') and raw.endswith('-fpm'))
|
||||
or stem.startswith((
|
||||
'systemd-', 'dbus-', 'getty', 'containerd-', 'apt-', 'dpkg-', 'e2scrub',
|
||||
'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 _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
|
||||
if path.startswith(('/opt/', '/srv/', '/usr/local/')):
|
||||
return True
|
||||
# Native apps commonly keep their runtime below /home/<service-user>/<app>.
|
||||
# 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 _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'):
|
||||
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 _helper(service)
|
||||
or not _path(exe)):
|
||||
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. 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)
|
||||
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):
|
||||
continue
|
||||
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
|
||||
# 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':
|
||||
_, 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')
|
||||
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
|
||||
|
||||
|
||||
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.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': [_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}
|
||||
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:
|
||||
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:
|
||||
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')
|
||||
|
||||
|
||||
@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:
|
||||
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))
|
||||
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 (
|
||||
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 _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)
|
||||
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})
|
||||
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'])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if len(items) >= MAX_CANDIDATES:
|
||||
issues.append('candidate_limit')
|
||||
break
|
||||
try:
|
||||
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'])
|
||||
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:
|
||||
_generations[int(vmid)] = _generations.get(int(vmid), 0) + 1
|
||||
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:
|
||||
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())
|
||||
# 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:
|
||||
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}
|
||||
@@ -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()
|
||||
|
||||
@@ -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"<html>")
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,785 @@
|
||||
"""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='', 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'
|
||||
|
||||
|
||||
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()
|
||||
native._generations.clear()
|
||||
self.calls = []
|
||||
|
||||
def runner(self, services=None, processes='END\n', footprint='FILE\n', manifest=None,
|
||||
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)
|
||||
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.HESTIA_PANEL_SCRIPT:
|
||||
return hestia_panels
|
||||
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')
|
||||
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_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_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'),
|
||||
('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'),
|
||||
('miminails-watch', '/usr/local/bin/miminails-watch', '/usr/local/bin', ''),
|
||||
]
|
||||
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_hestia_panel_is_recognized_only_from_its_exact_runtime_and_port(self):
|
||||
hestia = '/usr/local/hestia/nginx/sbin/hestia-nginx'
|
||||
panel = f'H\t391\t{hestia}\t/\t8083\nEND\n'
|
||||
result = native.scan(101, self.runner(
|
||||
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.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'))
|
||||
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'),
|
||||
('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.MANIFEST_FOOTPRINT_SCRIPT in call]
|
||||
self.assertEqual(len(footprint_calls), 1)
|
||||
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):
|
||||
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:
|
||||
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_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()
|
||||
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_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()
|
||||
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_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())
|
||||
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()
|
||||
@@ -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()
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
# • whiptail (interactive terminal menus)
|
||||
# • curl (downloads and connectivity checks)
|
||||
# • jq (JSON parsing)
|
||||
# • Python 3 + venv (for translation support)
|
||||
# - Installs pre-generated JSON language catalogs (no translation runtime).
|
||||
# - Creates the ProxMenux base directories and configuration files:
|
||||
# • $BASE_DIR/config.json
|
||||
# • $BASE_DIR/cache.json
|
||||
@@ -396,7 +396,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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
+71
-21
@@ -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",
|
||||
@@ -443,10 +446,12 @@
|
||||
"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...",
|
||||
"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 ...",
|
||||
@@ -538,6 +543,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:",
|
||||
@@ -749,6 +755,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 +890,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:",
|
||||
@@ -1520,6 +1528,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:",
|
||||
@@ -1634,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/<name>. 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/<Name>. 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/<name>. 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/<Name>. 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/<name>. 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/<name>. 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/<name>. 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/<name>. 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",
|
||||
@@ -1685,6 +1694,7 @@
|
||||
"Detected Coral hardware:": "Erkannte Coral-Hardware:",
|
||||
"Detected GPU(s):": "Erkannte GPU(s):",
|
||||
"Detected Intel CPU": "Intel-CPU 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",
|
||||
@@ -1766,6 +1776,9 @@
|
||||
"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 NFS exports on a server": "NFS-Exporte eines Servers ermitteln",
|
||||
"Discover targets on portal:": "Entdecken Sie Ziele auf dem Portal:",
|
||||
"Discovering iSCSI targets...": "iSCSI-Ziele werden ermittelt...",
|
||||
"Discovery successful": "Entdeckung erfolgreich",
|
||||
@@ -1845,6 +1858,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?",
|
||||
@@ -2022,7 +2036,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/<name>):": "Geben Sie einen Namen für den Mount-Punkt ein (verwendet als /mnt/<Name>):",
|
||||
"Enter a name for the mount point (used as /mnt/<name>):": "Geben Sie einen Namen für den Mountpoint ein (wird als /mnt/<name> 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:",
|
||||
@@ -2170,7 +2184,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?",
|
||||
@@ -2270,6 +2284,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.",
|
||||
@@ -2481,7 +2496,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:",
|
||||
@@ -2893,8 +2908,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",
|
||||
@@ -2977,6 +2992,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",
|
||||
@@ -3210,9 +3226,11 @@
|
||||
"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",
|
||||
"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:",
|
||||
@@ -3245,6 +3263,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",
|
||||
@@ -3308,6 +3327,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",
|
||||
@@ -3326,6 +3346,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",
|
||||
@@ -3440,7 +3461,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.",
|
||||
@@ -3511,7 +3532,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 <CTID> -- 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 <CTID> - 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 <CTID> -- 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 <CTID> -- 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:",
|
||||
@@ -4157,6 +4178,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.",
|
||||
@@ -4242,7 +4264,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.",
|
||||
@@ -4261,6 +4283,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 +4425,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.",
|
||||
@@ -4874,7 +4898,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 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": "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",
|
||||
@@ -4887,6 +4915,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:",
|
||||
@@ -5234,6 +5263,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:",
|
||||
@@ -5328,6 +5358,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 +5818,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",
|
||||
@@ -5977,6 +6009,7 @@
|
||||
"Start the VM to begin Windows installation from the mounted ISO.": "Starten Sie die VM, um die Windows-Installation von der gemounteten ISO aus zu starten.",
|
||||
"Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.": "Die Anwendungen starten, sobald sie registriert sind? Antworten Sie mit „Nein“, wenn die ursprünglichen Container noch auf einem anderen Host laufen: Beide würden die gleichen Adressen verwenden.",
|
||||
"Start the converted container:": "Starten Sie den konvertierten Container:",
|
||||
"Start the interactive Proxmox package upgrade": "Interaktive Paketaktualisierung von Proxmox starten",
|
||||
"Start the main system upgrade:": "Starten Sie das Hauptsystem-Upgrade:",
|
||||
"Start the stack with Proxmox": "Starten Sie den Stack mit Proxmox",
|
||||
"Start uploading to PBS — sets a recovery passphrase": "Hochladen auf PBS starten – legt eine Wiederherstellungspassphrase fest",
|
||||
@@ -6326,6 +6359,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",
|
||||
@@ -6445,6 +6479,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",
|
||||
@@ -6636,6 +6671,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.",
|
||||
@@ -6681,6 +6717,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.",
|
||||
@@ -6703,6 +6740,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",
|
||||
@@ -6770,6 +6808,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:",
|
||||
@@ -6777,12 +6816,15 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"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:",
|
||||
@@ -6866,6 +6908,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 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.",
|
||||
@@ -6952,7 +6995,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.",
|
||||
@@ -6997,6 +7040,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",
|
||||
@@ -7112,6 +7156,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:",
|
||||
@@ -7192,6 +7237,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?",
|
||||
@@ -7205,7 +7251,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?",
|
||||
@@ -7629,6 +7675,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",
|
||||
@@ -7949,8 +7996,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",
|
||||
@@ -7973,8 +8020,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)",
|
||||
@@ -7996,6 +8043,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",
|
||||
@@ -8032,8 +8080,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",
|
||||
@@ -8052,7 +8100,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:",
|
||||
@@ -8099,18 +8147,20 @@
|
||||
"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",
|
||||
"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/<CTID>.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/<CTID>.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/<CTID>.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/<CTID>.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",
|
||||
"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",
|
||||
|
||||
+75
-25
@@ -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",
|
||||
@@ -317,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:",
|
||||
@@ -440,10 +443,12 @@
|
||||
"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...",
|
||||
"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...",
|
||||
@@ -536,6 +541,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:",
|
||||
@@ -748,6 +754,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)",
|
||||
@@ -779,7 +786,7 @@
|
||||
"Certificate provider: empty for Let's Encrypt, zerossl for ZeroSSL": "Proveedor de certificados: vacío para Let's Encrypt, zerossl para ZeroSSL",
|
||||
"Change GPU acceleration?": "¿Cambiar la aceleración de GPU?",
|
||||
"Change Language": "Cambiar idioma",
|
||||
"Change Release Channel": "Cambiar canal de release",
|
||||
"Change Release Channel": "Cambiar canal de versiones",
|
||||
"Change it after the first login.": "Cámbialo después del primer inicio de sesión.",
|
||||
"Change or add an environment variable?": "¿Cambiar o añadir una variable de entorno?",
|
||||
"Change the access network?": "¿Cambiar la red de acceso?",
|
||||
@@ -791,7 +798,7 @@
|
||||
"Changes have been applied to the configuration file.": "Se han aplicado cambios al archivo de configuración.",
|
||||
"Changes prepared": "Cambios preparados",
|
||||
"Changes will apply after reboot.": "Los cambios se aplicarán después del reinicio.",
|
||||
"Changing Release Channel": "Cambio de canal de release",
|
||||
"Changing Release Channel": "Cambio de canal de versiones",
|
||||
"Changing between a privileged and an unprivileged container, or the host monitor mode, requires installing the application again": "Cambiar entre contenedor privilegiado y sin privilegios, o el modo de monitor del host, requiere instalar la aplicación de nuevo",
|
||||
"Changing the machine type on an existing installed VM is not safe: it changes the chipset and PCI slot layout, which typically prevents the guest OS from booting.": "Cambiar el tipo de máquina de una VM ya instalada no es seguro: cambia el chipset y la disposición de las ranuras PCI, lo que normalmente impide que arranque el sistema operativo de la VM.",
|
||||
"Changing the rootfs or its storage requires a separate migration": "Cambiar los rootfs o su almacenamiento requiere una migración separada",
|
||||
@@ -882,6 +889,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:",
|
||||
@@ -911,7 +919,7 @@
|
||||
"Choose storage for the downloaded image.": "Elige el almacenamiento para la imagen descargada.",
|
||||
"Choose the driver version for Coral M.2:": "Elige la versión del controlador para Coral M.2:",
|
||||
"Choose the filesystem for the new partition:": "Elige el sistema de archivos para la nueva partición:",
|
||||
"Choose the release channel to use:": "Elige el canal de release que quieres usar:",
|
||||
"Choose the release channel to use:": "Elige el canal de versiones que quieres usar:",
|
||||
"Choose the storage for the EFI disk (4MB):": "Elija el almacenamiento para el disco EFI (4 MB):",
|
||||
"Choose the storage for the TPM disk (4MB):": "Elija el almacenamiento para el disco TPM (4 MB):",
|
||||
"Choose the storage for the loader disk:": "Elija el almacenamiento para el disco del cargador:",
|
||||
@@ -1518,6 +1526,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:",
|
||||
@@ -1632,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/<name>. 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/<nombre>. 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/<name>. 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/<nombre>. 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/<name>. 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/<name>. 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/<name>. 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/<name>. 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",
|
||||
@@ -1683,6 +1692,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",
|
||||
@@ -1764,6 +1774,9 @@
|
||||
"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 NFS exports on a server": "Descubrir los exports NFS de un servidor",
|
||||
"Discover targets on portal:": "Detectar destinos en el portal:",
|
||||
"Discovering iSCSI targets...": "Descubriendo destinos iSCSI...",
|
||||
"Discovery successful": "Detección correcta",
|
||||
@@ -1843,6 +1856,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?",
|
||||
@@ -2020,7 +2034,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/<name>):": "Ingrese un nombre para el punto de montaje (usado como /mnt/<nombre>):",
|
||||
"Enter a name for the mount point (used as /mnt/<name>):": "Introduce un nombre para el punto de montaje (se usará como /mnt/<name>):",
|
||||
"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:",
|
||||
@@ -2163,11 +2177,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?",
|
||||
@@ -2267,6 +2281,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.",
|
||||
@@ -2478,7 +2493,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:",
|
||||
@@ -2974,6 +2989,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",
|
||||
@@ -3210,6 +3226,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:",
|
||||
@@ -3242,6 +3260,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",
|
||||
@@ -3305,6 +3324,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",
|
||||
@@ -3323,6 +3343,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",
|
||||
@@ -4152,6 +4173,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.",
|
||||
@@ -4256,6 +4278,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 +4417,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.",
|
||||
@@ -4525,7 +4549,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",
|
||||
@@ -4865,7 +4889,11 @@
|
||||
"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": "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",
|
||||
"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",
|
||||
@@ -4878,6 +4906,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:",
|
||||
@@ -5073,10 +5102,10 @@
|
||||
"Reinstalling": "Reinstalando",
|
||||
"Reinstalling core Proxmox packages...": "Reinstalando los paquetes principales de Proxmox...",
|
||||
"Relative CPU priority (cpuunits)": "Prioridad relativa de la CPU (cpuunits)",
|
||||
"Release Channel": "Canal de release",
|
||||
"Release channel set to Beta.": "Canal de release establecido en Beta.",
|
||||
"Release channel set to Stable.": "Canal de release establecido en Estable.",
|
||||
"Release channel:": "Canal de release:",
|
||||
"Release Channel": "Canal de versiones",
|
||||
"Release channel set to Beta.": "Canal de versiones establecido en Beta.",
|
||||
"Release channel set to Stable.": "Canal de versiones establecido en Estable.",
|
||||
"Release channel:": "Canal de versiones:",
|
||||
"Reload Proxmox firewall rules": "Recargar las reglas del cortafuegos de Proxmox",
|
||||
"Reload network configuration (ifupdown2)": "Recargar la configuración de red (ifupdown2)",
|
||||
"Remapped Users:": "Usuarios reasignados:",
|
||||
@@ -5206,7 +5235,7 @@
|
||||
"Replace": "Reemplazar",
|
||||
"Replace <container-id> with the actual ID.": "Reemplace <container-id> con el ID real.",
|
||||
"Replace <container-id> with your actual container ID": "Reemplace <container-id> con su ID de contenedor real",
|
||||
"Replace <storage-identifier> with the value from step 4": "Reemplace <identificador-de-almacenamiento> con el valor del paso 4",
|
||||
"Replace <storage-identifier> with the value from step 4": "Sustituye <storage-identifier> 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",
|
||||
@@ -5218,6 +5247,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:",
|
||||
@@ -5312,6 +5342,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",
|
||||
@@ -5354,7 +5385,7 @@
|
||||
"Run now": "Ejecutar ahora",
|
||||
"Run security audit now": "Ejecute la auditoría de seguridad ahora",
|
||||
"Run the following inside the VM:": "Ejecute lo siguiente dentro de la VM:",
|
||||
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Ejecute el cambio de canal de release desde una sesión SSH o la consola del host Proxmox con:",
|
||||
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Ejecuta el cambio de canal de versiones desde una sesión SSH o la consola del host Proxmox con:",
|
||||
"Run the update from an SSH session or the Proxmox host console with:": "Ejecute la actualización desde una sesión SSH o la consola del host Proxmox con:",
|
||||
"Run this operation as root.": "Ejecuta esta operación como root.",
|
||||
"Run upgrade checklist script:": "Ejecute el script de la lista de verificación de actualización:",
|
||||
@@ -5771,6 +5802,7 @@
|
||||
"ShareCoveX panel": "Panel de ShareCoveX",
|
||||
"Shared Directory Ready:": "Directorio compartido listo:",
|
||||
"Shared Group": "Grupo compartido",
|
||||
"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",
|
||||
@@ -5962,6 +5994,7 @@
|
||||
"Start the VM to begin Windows installation from the mounted ISO.": "Inicie la VM para comenzar la instalación de Windows desde la ISO montada.",
|
||||
"Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.": "¿Iniciar las aplicaciones una vez registradas? Responde No si los contenedores originales siguen en marcha en otro host: los dos usarían las mismas direcciones.",
|
||||
"Start the converted container:": "Inicie el contenedor convertido:",
|
||||
"Start the interactive Proxmox package upgrade": "Iniciar la actualización interactiva de paquetes de Proxmox",
|
||||
"Start the main system upgrade:": "Inicie la actualización principal del sistema:",
|
||||
"Start the stack with Proxmox": "Iniciar la pila con Proxmox",
|
||||
"Start uploading to PBS — sets a recovery passphrase": "Empezar a subir a PBS — establece una frase de contraseña de recuperación",
|
||||
@@ -6309,6 +6342,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",
|
||||
@@ -6427,6 +6461,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",
|
||||
@@ -6612,6 +6647,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.",
|
||||
@@ -6657,6 +6693,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.",
|
||||
@@ -6679,6 +6716,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",
|
||||
@@ -6746,18 +6784,22 @@
|
||||
"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:",
|
||||
"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.",
|
||||
"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.",
|
||||
"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:",
|
||||
@@ -6833,7 +6875,7 @@
|
||||
"This process changes file ownership inside the container": "Este proceso cambia la propiedad del archivo dentro del contenedor",
|
||||
"This profile only supports directory bind mounts": "Este perfil solo admite bind mounts de directorio",
|
||||
"This profile requires a privileged LXC, which reduces isolation from the host.": "Este perfil requiere un LXC privilegiado, lo que reduce el aislamiento respecto al host.",
|
||||
"This release channel is already active.": "Este canal de release ya está activo.",
|
||||
"This release channel is already active.": "Este canal de versiones ya está activo.",
|
||||
"This removes the 'unprivileged: 1' line from the config": "Esto elimina la línea 'sin privilegios: 1' de la configuración",
|
||||
"This removes the storage from Proxmox. The iSCSI target is not affected.": "Esto elimina el almacenamiento de Proxmox. El destino iSCSI no se ve afectado.",
|
||||
"This replaces the currently installed keyfile.": "Esto reemplaza el archivo de claves instalado actualmente.",
|
||||
@@ -6841,6 +6883,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.",
|
||||
@@ -6972,6 +7015,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",
|
||||
@@ -7087,6 +7131,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:",
|
||||
@@ -7158,13 +7203,14 @@
|
||||
"Updating kernel panic configuration...": "Actualizando la configuración de pánico del kernel...",
|
||||
"Updating package lists...": "Actualizando listas de paquetes...",
|
||||
"Updating pve-enterprise.list (if present) to Trixie...": "Actualizando pve-enterprise.list (si está presente) a Trixie...",
|
||||
"Updating release channel configuration...": "Actualizando la configuración del canal de release...",
|
||||
"Updating release channel configuration...": "Actualizando la configuración del canal de versiones...",
|
||||
"Updating the copy of the record...": "Actualizando la copia del registro...",
|
||||
"Upgrade Ceph to compatible version": "Actualice Ceph a una versión compatible",
|
||||
"Upgrade PVE 8 to PVE 9": "Actualizar PVE 8 a PVE 9",
|
||||
"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?",
|
||||
@@ -7603,6 +7649,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",
|
||||
@@ -7922,7 +7969,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.",
|
||||
@@ -7946,8 +7993,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)",
|
||||
@@ -7969,6 +8016,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",
|
||||
@@ -8006,7 +8054,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",
|
||||
@@ -8072,6 +8120,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",
|
||||
@@ -8084,6 +8133,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",
|
||||
|
||||
+75
-25
@@ -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é",
|
||||
@@ -298,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.",
|
||||
@@ -319,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 :",
|
||||
@@ -443,10 +446,12 @@
|
||||
"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...",
|
||||
"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...",
|
||||
@@ -538,6 +543,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 :",
|
||||
@@ -749,6 +755,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 +890,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 :",
|
||||
@@ -1520,6 +1528,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 :",
|
||||
@@ -1634,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/<name>. 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/<nom>. 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/<name>. 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/<nom>. 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/<name>. 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/<name>. 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/<name>. 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/<name>. 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",
|
||||
@@ -1685,6 +1694,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": "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",
|
||||
@@ -1766,6 +1776,9 @@
|
||||
"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 NFS exports on a server": "Découvrir les exports NFS d’un serveur",
|
||||
"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",
|
||||
@@ -1845,6 +1858,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 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 ?",
|
||||
@@ -1907,7 +1921,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...",
|
||||
@@ -2166,11 +2180,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 ?",
|
||||
@@ -2270,6 +2284,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 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.",
|
||||
@@ -2481,7 +2496,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 :",
|
||||
@@ -2893,8 +2908,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",
|
||||
@@ -2977,6 +2992,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",
|
||||
@@ -3033,7 +3049,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",
|
||||
@@ -3210,9 +3226,11 @@
|
||||
"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",
|
||||
"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:",
|
||||
@@ -3245,6 +3263,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",
|
||||
@@ -3308,6 +3327,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",
|
||||
@@ -3326,6 +3346,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",
|
||||
@@ -3440,7 +3461,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.",
|
||||
@@ -3492,7 +3513,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)",
|
||||
@@ -4157,6 +4178,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é.",
|
||||
@@ -4242,7 +4264,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.",
|
||||
@@ -4261,6 +4283,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 +4425,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.",
|
||||
@@ -4874,7 +4898,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": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Destinations Proxmox Backup Server (PBS)",
|
||||
"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",
|
||||
@@ -4887,6 +4915,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 :",
|
||||
@@ -5234,6 +5263,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:",
|
||||
@@ -5328,6 +5358,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 +5818,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",
|
||||
@@ -5977,6 +6009,7 @@
|
||||
"Start the VM to begin Windows installation from the mounted ISO.": "Démarrez la VM pour commencer l'installation de Windows à partir de l'ISO montée.",
|
||||
"Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.": "Lancer les applications une fois enregistrées ? Répondez Non si les conteneurs d'origine s'exécutent toujours sur un autre hôte : les deux utiliseraient les mêmes adresses.",
|
||||
"Start the converted container:": "Démarrez le conteneur converti :",
|
||||
"Start the interactive Proxmox package upgrade": "Lancer la mise à niveau interactive des paquets Proxmox",
|
||||
"Start the main system upgrade:": "Démarrez la mise à niveau principale du système :",
|
||||
"Start the stack with Proxmox": "Démarrer la pile avec Proxmox",
|
||||
"Start uploading to PBS — sets a recovery passphrase": "Commencer le téléchargement sur PBS – définit une phrase secrète de récupération",
|
||||
@@ -6325,6 +6358,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",
|
||||
@@ -6444,6 +6478,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",
|
||||
@@ -6635,6 +6670,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é.",
|
||||
@@ -6680,6 +6716,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.",
|
||||
@@ -6702,6 +6739,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é",
|
||||
@@ -6769,6 +6807,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 :",
|
||||
@@ -6776,12 +6815,15 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"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 :",
|
||||
@@ -6865,6 +6907,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 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é.",
|
||||
@@ -6951,7 +6994,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.",
|
||||
@@ -6996,6 +7039,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",
|
||||
@@ -7111,6 +7155,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 :",
|
||||
@@ -7191,6 +7236,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 ?",
|
||||
@@ -7204,7 +7250,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?",
|
||||
@@ -7628,6 +7674,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é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",
|
||||
@@ -7948,8 +7995,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",
|
||||
@@ -7972,8 +8019,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)",
|
||||
@@ -7995,6 +8042,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é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",
|
||||
@@ -8051,7 +8099,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 :",
|
||||
@@ -8098,18 +8146,20 @@
|
||||
"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",
|
||||
"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/<CTID>.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/<CTID>.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/<CTID>.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/<CTID>.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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
@@ -443,10 +446,12 @@
|
||||
"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...",
|
||||
"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...",
|
||||
@@ -538,6 +543,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:",
|
||||
@@ -749,6 +755,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 +890,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:",
|
||||
@@ -1520,6 +1528,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 +1694,7 @@
|
||||
"Detected Coral hardware:": "Hardware Coral rilevato:",
|
||||
"Detected GPU(s):": "GPU rilevate:",
|
||||
"Detected Intel CPU": "Rilevata CPU Intel",
|
||||
"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",
|
||||
@@ -1766,6 +1776,9 @@
|
||||
"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 NFS exports on a server": "Individuare le esportazioni NFS di un server",
|
||||
"Discover targets on portal:": "Scopri target sul portale:",
|
||||
"Discovering iSCSI targets...": "Individuazione dei target iSCSI...",
|
||||
"Discovery successful": "Scoperta riuscita",
|
||||
@@ -1845,6 +1858,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 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?",
|
||||
@@ -2270,6 +2284,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": "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.",
|
||||
@@ -2977,6 +2992,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",
|
||||
@@ -3213,6 +3229,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:",
|
||||
@@ -3245,6 +3263,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",
|
||||
@@ -3308,6 +3327,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",
|
||||
@@ -3326,6 +3346,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",
|
||||
@@ -4157,6 +4178,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.",
|
||||
@@ -4261,6 +4283,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 +4425,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.",
|
||||
@@ -4874,7 +4898,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": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Destinazioni Proxmox Backup Server (PBS)",
|
||||
"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",
|
||||
@@ -4887,6 +4915,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:",
|
||||
@@ -5234,6 +5263,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.": "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:",
|
||||
@@ -5328,6 +5358,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 +5818,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",
|
||||
@@ -5977,6 +6009,7 @@
|
||||
"Start the VM to begin Windows installation from the mounted ISO.": "Avvia la VM per iniziare l’installazione di Windows dalla ISO montata.",
|
||||
"Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.": "Avviare le applicazioni dopo la registrazione? Rispondi No se i container originali sono ancora in esecuzione su un altro host: entrambi userebbero gli stessi indirizzi.",
|
||||
"Start the converted container:": "Avvia il container convertito:",
|
||||
"Start the interactive Proxmox package upgrade": "Avviare l’aggiornamento interattivo dei pacchetti Proxmox",
|
||||
"Start the main system upgrade:": "Avvia l’aggiornamento principale del sistema:",
|
||||
"Start the stack with Proxmox": "Avvia lo stack con Proxmox",
|
||||
"Start uploading to PBS — sets a recovery passphrase": "Avvia il caricamento su PBS — viene impostata una passphrase di ripristino",
|
||||
@@ -6326,6 +6359,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",
|
||||
@@ -6445,6 +6479,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",
|
||||
@@ -6636,6 +6671,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.",
|
||||
@@ -6681,6 +6717,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.",
|
||||
@@ -6703,6 +6740,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",
|
||||
@@ -6770,6 +6808,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 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:",
|
||||
@@ -6777,12 +6816,15 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"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:",
|
||||
@@ -6866,6 +6908,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 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.",
|
||||
@@ -6997,6 +7040,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",
|
||||
@@ -7112,6 +7156,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:",
|
||||
@@ -7192,6 +7237,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?",
|
||||
@@ -7629,6 +7675,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": "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",
|
||||
@@ -7996,6 +8043,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": "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",
|
||||
@@ -8099,6 +8147,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",
|
||||
@@ -8111,6 +8160,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",
|
||||
|
||||
+72
-22
@@ -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",
|
||||
@@ -136,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)",
|
||||
@@ -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",
|
||||
@@ -443,10 +446,12 @@
|
||||
"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...",
|
||||
"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...",
|
||||
@@ -538,6 +543,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:",
|
||||
@@ -749,6 +755,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 +890,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:",
|
||||
@@ -1520,6 +1528,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:",
|
||||
@@ -1634,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/<name>. 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/<nome>. 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/<name>. 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/<nome>. 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/<name>. 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/<name>. 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/<name>. 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/<name>. 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",
|
||||
@@ -1685,6 +1694,7 @@
|
||||
"Detected Coral hardware:": "Hardware Coral detectado:",
|
||||
"Detected GPU(s):": "GPU(s) detectada(s):",
|
||||
"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": "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",
|
||||
@@ -1766,6 +1776,9 @@
|
||||
"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 NFS exports on a server": "Descobrir as exportações NFS de um servidor",
|
||||
"Discover targets on portal:": "Descubra alvos no portal:",
|
||||
"Discovering iSCSI targets...": "Descobrindo alvos iSCSI...",
|
||||
"Discovery successful": "Descoberta bem-sucedida",
|
||||
@@ -1845,6 +1858,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?": "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?",
|
||||
@@ -2022,7 +2036,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/<name>):": "Insira um nome para o ponto de montagem (usado como /mnt/<nome>):",
|
||||
"Enter a name for the mount point (used as /mnt/<name>):": "Introduza um nome para o ponto de montagem (será usado como /mnt/<name>):",
|
||||
"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:",
|
||||
@@ -2166,11 +2180,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?",
|
||||
@@ -2270,6 +2284,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 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.",
|
||||
@@ -2481,7 +2496,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:",
|
||||
@@ -2893,8 +2908,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",
|
||||
@@ -2977,6 +2992,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",
|
||||
@@ -3033,7 +3049,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",
|
||||
@@ -3210,9 +3226,11 @@
|
||||
"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",
|
||||
"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:",
|
||||
@@ -3245,6 +3263,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",
|
||||
@@ -3308,6 +3327,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",
|
||||
@@ -3326,6 +3346,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",
|
||||
@@ -3440,7 +3461,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.",
|
||||
@@ -3492,7 +3513,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)",
|
||||
@@ -4157,6 +4178,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.",
|
||||
@@ -4242,7 +4264,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.",
|
||||
@@ -4261,6 +4283,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 +4425,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.",
|
||||
@@ -4874,7 +4898,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": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Destinos Proxmox Backup Server (PBS)",
|
||||
"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",
|
||||
@@ -4887,6 +4915,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:",
|
||||
@@ -5234,6 +5263,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:",
|
||||
@@ -5328,6 +5358,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 +5818,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",
|
||||
@@ -5977,6 +6009,7 @@
|
||||
"Start the VM to begin Windows installation from the mounted ISO.": "Inicie a VM para iniciar a instalação do Windows a partir do ISO montado.",
|
||||
"Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.": "Iniciar os aplicativos assim que estiverem registrados? Responda Não se os contêineres originais ainda estiverem em execução em outro host: ambos usariam os mesmos endereços.",
|
||||
"Start the converted container:": "Inicie o contêiner convertido:",
|
||||
"Start the interactive Proxmox package upgrade": "Iniciar a atualização interativa de pacotes do Proxmox",
|
||||
"Start the main system upgrade:": "Inicie a atualização principal do sistema:",
|
||||
"Start the stack with Proxmox": "Iniciar a pilha com Proxmox",
|
||||
"Start uploading to PBS — sets a recovery passphrase": "comece a enviar para o PBS – define uma senha de recuperação",
|
||||
@@ -6326,6 +6359,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",
|
||||
@@ -6445,6 +6479,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",
|
||||
@@ -6636,6 +6671,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.",
|
||||
@@ -6681,6 +6717,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.",
|
||||
@@ -6703,6 +6740,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",
|
||||
@@ -6770,6 +6808,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:",
|
||||
@@ -6777,12 +6816,15 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"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á:",
|
||||
@@ -6866,6 +6908,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 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.",
|
||||
@@ -6952,7 +6995,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.",
|
||||
@@ -6997,6 +7040,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",
|
||||
@@ -7112,6 +7156,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:",
|
||||
@@ -7170,7 +7215,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...",
|
||||
@@ -7192,6 +7237,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?",
|
||||
@@ -7629,6 +7675,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": "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",
|
||||
@@ -7973,8 +8020,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)",
|
||||
@@ -7996,6 +8043,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": "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",
|
||||
@@ -8033,7 +8081,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",
|
||||
@@ -8052,7 +8100,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:",
|
||||
@@ -8099,18 +8147,20 @@
|
||||
"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 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",
|
||||
"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/<CTID>.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/<CTID>.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/<CTID>.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/<CTID>.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",
|
||||
"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",
|
||||
|
||||
+56
-6
@@ -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á",
|
||||
@@ -136,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)",
|
||||
@@ -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é",
|
||||
@@ -319,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í:",
|
||||
@@ -443,10 +446,12 @@
|
||||
"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...",
|
||||
"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...",
|
||||
@@ -538,6 +543,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:",
|
||||
@@ -749,6 +755,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 +890,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:",
|
||||
@@ -1520,6 +1528,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 +1694,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ý 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",
|
||||
@@ -1766,6 +1776,9 @@
|
||||
"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 NFS exports on a server": "Zistiť NFS exporty servera",
|
||||
"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é",
|
||||
@@ -1845,6 +1858,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 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?",
|
||||
@@ -2270,6 +2284,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": "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ť.",
|
||||
@@ -2977,6 +2992,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",
|
||||
@@ -3033,7 +3049,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",
|
||||
@@ -3210,9 +3226,11 @@
|
||||
"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",
|
||||
"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:",
|
||||
@@ -3245,6 +3263,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",
|
||||
@@ -3308,6 +3327,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",
|
||||
@@ -3326,6 +3346,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",
|
||||
@@ -3440,7 +3461,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.",
|
||||
@@ -4157,6 +4178,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.",
|
||||
@@ -4261,6 +4283,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 +4427,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.",
|
||||
@@ -4876,7 +4900,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": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Ciele Proxmox Backup Servera (PBS)",
|
||||
"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",
|
||||
@@ -4889,6 +4917,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:",
|
||||
@@ -5236,6 +5265,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:",
|
||||
@@ -5330,6 +5360,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 +5820,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",
|
||||
@@ -5979,6 +6011,7 @@
|
||||
"Start the VM to begin Windows installation from the mounted ISO.": "Spustite VM a začnite inštaláciu Windowsu z pripojeného ISO.",
|
||||
"Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.": "Spustiť aplikácie po ich zaregistrovaní? Odpoveď Nie, ak pôvodné kontajnery stále bežia na inom hostiteľovi: oba by používali rovnaké adresy.",
|
||||
"Start the converted container:": "Spustiť prevedený kontajner:",
|
||||
"Start the interactive Proxmox package upgrade": "Spustiť interaktívnu aktualizáciu balíkov Proxmoxu",
|
||||
"Start the main system upgrade:": "Spustite hlavnú aktualizáciu systému:",
|
||||
"Start the stack with Proxmox": "Spustiť zásobník s Proxmox",
|
||||
"Start uploading to PBS — sets a recovery passphrase": "Spustiť nahrávanie do PBS - nastaví obnovovaciu frázu",
|
||||
@@ -6327,6 +6360,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",
|
||||
@@ -6446,6 +6480,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",
|
||||
@@ -6637,6 +6672,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.",
|
||||
@@ -6682,6 +6718,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.",
|
||||
@@ -6704,6 +6741,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",
|
||||
@@ -6771,6 +6809,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:",
|
||||
@@ -6778,12 +6817,15 @@
|
||||
"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ú.",
|
||||
"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ť.",
|
||||
"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á:",
|
||||
@@ -6867,6 +6909,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áš 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.",
|
||||
@@ -6998,6 +7041,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",
|
||||
@@ -7113,6 +7157,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:",
|
||||
@@ -7193,6 +7238,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?",
|
||||
@@ -7630,6 +7676,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",
|
||||
@@ -7997,6 +8044,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",
|
||||
@@ -8100,18 +8148,20 @@
|
||||
"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",
|
||||
"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/<CTID>.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/ <CTID>.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/<CTID>.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/<CTID>.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",
|
||||
"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",
|
||||
|
||||
+68
-18
@@ -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",
|
||||
@@ -319,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:",
|
||||
@@ -443,10 +446,12 @@
|
||||
"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...",
|
||||
"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...",
|
||||
@@ -538,6 +543,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:",
|
||||
@@ -749,6 +755,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 +890,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:",
|
||||
@@ -1520,6 +1528,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:",
|
||||
@@ -1634,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/<name>. 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/<namn>. 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/<name>. 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/<namn>. 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/<name>. 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/<name>. 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/<name>. 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/<name>. 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",
|
||||
@@ -1685,6 +1694,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",
|
||||
@@ -1766,6 +1776,9 @@
|
||||
"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 NFS exports on a server": "Hitta NFS-exporter på en server",
|
||||
"Discover targets on portal:": "Upptäck mål på portalen:",
|
||||
"Discovering iSCSI targets...": "Upptäcker iSCSI-mål...",
|
||||
"Discovery successful": "Upptäckten lyckades",
|
||||
@@ -1845,6 +1858,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?",
|
||||
@@ -2022,7 +2036,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/<name>):": "Ange ett namn för monteringspunkten (används som /mnt/<namn>):",
|
||||
"Enter a name for the mount point (used as /mnt/<name>):": "Ange ett namn för monteringspunkten (används som /mnt/<name>):",
|
||||
"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:",
|
||||
@@ -2170,7 +2184,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?",
|
||||
@@ -2270,6 +2284,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.",
|
||||
@@ -2481,7 +2496,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:",
|
||||
@@ -2759,7 +2774,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",
|
||||
@@ -2893,8 +2908,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",
|
||||
@@ -2977,6 +2992,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",
|
||||
@@ -3033,7 +3049,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",
|
||||
@@ -3213,6 +3229,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:",
|
||||
@@ -3245,6 +3263,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",
|
||||
@@ -3308,6 +3327,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",
|
||||
@@ -3326,6 +3346,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",
|
||||
@@ -3440,7 +3461,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.",
|
||||
@@ -3511,7 +3532,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 <CTID> -- 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 <CTID> - 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 <CTID> -- 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 <CTID> -- 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:",
|
||||
@@ -4157,6 +4178,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.",
|
||||
@@ -4261,6 +4283,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 +4425,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.",
|
||||
@@ -4577,7 +4601,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",
|
||||
@@ -4874,7 +4898,11 @@
|
||||
"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:",
|
||||
"Proxmox System Update": "Proxmox systemuppdatering",
|
||||
@@ -4887,6 +4915,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:",
|
||||
@@ -5222,7 +5251,7 @@
|
||||
"Replace": "Ersätta",
|
||||
"Replace <container-id> with the actual ID.": "Ersätt <container-id> med det faktiska ID:t.",
|
||||
"Replace <container-id> with your actual container ID": "Ersätt <container-id> med ditt faktiska container-ID",
|
||||
"Replace <storage-identifier> with the value from step 4": "Ersätt <lagringsidentifierare> med värdet från steg 4",
|
||||
"Replace <storage-identifier> with the value from step 4": "Ersätt <storage-identifier> 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",
|
||||
@@ -5234,6 +5263,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:",
|
||||
@@ -5328,6 +5358,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 +5818,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",
|
||||
@@ -5977,6 +6009,7 @@
|
||||
"Start the VM to begin Windows installation from the mounted ISO.": "Starta den virtuella datorn för att påbörja Windows-installationen från den monterade ISO.",
|
||||
"Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.": "Starta applikationerna när de är registrerade? Svara Nej om de ursprungliga behållarna fortfarande körs på en annan värd: båda skulle använda samma adresser.",
|
||||
"Start the converted container:": "Starta den konverterade behållaren:",
|
||||
"Start the interactive Proxmox package upgrade": "Starta den interaktiva paketuppgraderingen av Proxmox",
|
||||
"Start the main system upgrade:": "Starta huvudsystemuppgraderingen:",
|
||||
"Start the stack with Proxmox": "Börja stapeln med Proxmox",
|
||||
"Start uploading to PBS — sets a recovery passphrase": "Börja ladda upp till PBS — ställer in en återställningslösenfras",
|
||||
@@ -6326,6 +6359,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",
|
||||
@@ -6445,6 +6479,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",
|
||||
@@ -6636,6 +6671,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.",
|
||||
@@ -6681,6 +6717,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.",
|
||||
@@ -6703,6 +6740,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",
|
||||
@@ -6770,6 +6808,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:",
|
||||
@@ -6777,12 +6816,15 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"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 +6908,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.",
|
||||
@@ -6952,7 +6995,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.",
|
||||
@@ -6997,6 +7040,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",
|
||||
@@ -7112,6 +7156,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:",
|
||||
@@ -7192,6 +7237,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?",
|
||||
@@ -7629,6 +7675,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",
|
||||
@@ -7973,8 +8020,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)",
|
||||
@@ -7996,6 +8043,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",
|
||||
@@ -8052,7 +8100,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:",
|
||||
@@ -8099,6 +8147,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",
|
||||
@@ -8111,6 +8160,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",
|
||||
|
||||
+5
-1
@@ -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** | 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,6 +159,10 @@ 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. 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. 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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() {
|
||||
@@ -1025,15 +1030,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")
|
||||
}
|
||||
|
||||
@@ -1091,7 +1099,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")"
|
||||
@@ -1230,6 +1238,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
|
||||
@@ -2253,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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -352,6 +382,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 +413,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}
|
||||
|
||||
@@ -415,10 +466,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')}
|
||||
@@ -453,7 +503,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 +512,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 +541,17 @@ 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()
|
||||
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 \
|
||||
@@ -511,9 +571,10 @@ def preflight(record, candidate, config, coordinated=None):
|
||||
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'))
|
||||
@@ -532,7 +593,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'))
|
||||
@@ -547,7 +609,9 @@ def preflight(record, candidate, config, coordinated=None):
|
||||
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():
|
||||
@@ -734,11 +798,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:
|
||||
@@ -1041,6 +1104,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'],
|
||||
@@ -1051,6 +1115,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 +1125,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'])
|
||||
@@ -1114,6 +1182,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'))
|
||||
@@ -1123,8 +1192,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)
|
||||
@@ -1139,9 +1208,13 @@ 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)
|
||||
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']))
|
||||
@@ -1273,6 +1346,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:
|
||||
@@ -1335,8 +1411,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'}
|
||||
@@ -1364,6 +1441,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""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 $/; <STDIN> });
|
||||
$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
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
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):
|
||||
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'}
|
||||
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
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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."""
|
||||
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']
|
||||
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'))
|
||||
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()})
|
||||
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
|
||||
@@ -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 <mounted rootfs>/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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
@@ -168,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'
|
||||
@@ -179,11 +201,42 @@ 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)
|
||||
resource_changes = None
|
||||
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
|
||||
# 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)):
|
||||
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)
|
||||
# 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
|
||||
archive, digest = resolve_archive(desired, config, current, lambda: work_backup.locate(
|
||||
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid))
|
||||
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:
|
||||
msg_ok(translate('The image is already up to date; nothing was changed.'))
|
||||
return
|
||||
@@ -207,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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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})
|
||||
@@ -132,7 +151,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 +159,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 +256,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 +286,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
|
||||
@@ -313,7 +347,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
|
||||
@@ -397,23 +431,22 @@ 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']):
|
||||
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'))
|
||||
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)
|
||||
@@ -423,29 +456,16 @@ 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):
|
||||
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
|
||||
@@ -453,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')),
|
||||
@@ -495,7 +524,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 '')
|
||||
|
||||
@@ -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,143 @@ 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}")),
|
||||
# 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')))
|
||||
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):
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""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_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)
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Regression tests for explicit adoption of external OCI resources."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -155,5 +156,78 @@ 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)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,338 @@
|
||||
"""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_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'))
|
||||
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, reject_recreation=False):
|
||||
saved = record()
|
||||
candidate = copy.deepcopy(saved)
|
||||
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()), \
|
||||
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', 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, \
|
||||
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()
|
||||
resolve.assert_not_called()
|
||||
native.assert_called_once()
|
||||
image.assert_not_called()
|
||||
|
||||
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])
|
||||
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()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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,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
|
||||
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):
|
||||
# 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):
|
||||
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()
|
||||
@@ -0,0 +1,223 @@
|
||||
"""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_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))
|
||||
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, 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.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), \
|
||||
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, ui or 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])
|
||||
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,262 @@
|
||||
"""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_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',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()), \
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user