mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-05 04:57:18 +00:00
Adds the OCI manager: an engine that turns a Docker Compose file into an LXC definition, a catalog of 365 applications drawn from LinuxServer.io and other container image sources, and a per-instance registry recording what each container was built from. Reachable from the main menu. Catalog text is translated like every other string in the project: the taglines go through translate() and land in lang/*.json, so the entries read in all eight languages instead of only English. Translation cache builder: - a failed translation leaves the key absent rather than writing English, which previously made the string count as translated forever - a result identical to a 3+ word source is rejected, catching a provider that silently returns the text it was given - strings that are nothing but glossary terms keep their source spelling instead of being discarded as failures - no backoff between attempts when the provider is deterministic - application names are protected so "HAOS One" survives translation - argos joins the provider list, and the workflow reads the OCI sources Audit & Report: - findings that moved in the wrong direction between runs are reported alongside the ones that improved - an accepted risk can carry a review date and is flagged when it falls due - backup checks explain in plain language what they looked at and what to do next Monitor: - disks can be excluded from periodic reads, and an idle disk says so instead of showing a stale temperature - per-disk identity survives a controller or enclosure change - scheduled Borg backups resolve their SSH key from the repository entry - PVE upgrades log the package list and the resulting dpkg changes The web build no longer copies scripts/ into public/: the documentation links to GitHub, so nothing read that folder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
126 lines
5.4 KiB
Python
126 lines
5.4 KiB
Python
"""IPv4 address of the containers on their access bridge: DHCP or static."""
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from . import host
|
|
from .i18n import translate
|
|
from .ui import UserCancelled
|
|
|
|
DHCP = "dhcp"
|
|
STATIC = "static"
|
|
|
|
|
|
def usable(address: ipaddress.IPv4Address, interface: ipaddress.IPv4Interface) -> bool:
|
|
network = interface.network
|
|
return (address.version == 4 and not address.is_multicast and not address.is_unspecified
|
|
and not address.is_loopback and address in network
|
|
and (network.prefixlen >= 31
|
|
or address not in (network.network_address, network.broadcast_address)))
|
|
|
|
|
|
def addresses_in_use() -> set[str]:
|
|
"""IPv4 addresses assigned to guests of the cluster and to the bridges of this node."""
|
|
used: set[str] = set()
|
|
for pattern in ("*/lxc/*.conf", "*/qemu-server/*.conf"):
|
|
for path in Path("/etc/pve/nodes").glob(pattern):
|
|
try:
|
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
except OSError:
|
|
continue
|
|
used.update(re.findall(r"(?:^|[,\s])ip=(\d+\.\d+\.\d+\.\d+)/", text, re.MULTILINE))
|
|
for row in host.bridges(include_private=True):
|
|
if row.get("cidr"):
|
|
used.add(row["cidr"].split("/", 1)[0])
|
|
return used
|
|
|
|
|
|
def _bridge(bridge: str) -> dict:
|
|
return next((row for row in host.bridges() if row.get("iface") == bridge), {})
|
|
|
|
|
|
def _example(bridge: str, taken: set[str]) -> str:
|
|
"""An address of the bridge subnet that no guest uses, to show the format."""
|
|
try:
|
|
network = ipaddress.ip_interface(_bridge(bridge).get("cidr") or "").network
|
|
except ValueError:
|
|
network = None
|
|
if network is None or network.version != 4 or network.prefixlen > 24:
|
|
return "192.168.1.100/24"
|
|
address = next((a for a in (network.network_address + n for n in range(100, 250))
|
|
if str(a) not in taken), network.network_address + 100)
|
|
return f"{address}/{network.prefixlen}"
|
|
|
|
|
|
def _static_address(ui, bridge: str, label: str, default: str, taken: set[str]) -> ipaddress.IPv4Interface:
|
|
text = (f"{translate('Static IPv4 address for')} {label}" if label
|
|
else translate("Static IPv4 address"))
|
|
example = _example(bridge, taken)
|
|
text = f"{text} ({translate('with prefix, e.g.')} {example})"
|
|
while True:
|
|
value = ui.ask(text, default).strip()
|
|
try:
|
|
interface = ipaddress.ip_interface(value) if "/" in value else None
|
|
except ValueError:
|
|
interface = None
|
|
if interface is None or interface.version != 4 or not usable(interface.ip, interface):
|
|
ui.message(f"{translate('Enter a usable IPv4 address with its prefix, for example')} {example}")
|
|
continue
|
|
if str(interface.ip) in taken:
|
|
ui.message(f"{translate('The address is already assigned on this host or cluster:')} {interface.ip}")
|
|
continue
|
|
return interface
|
|
|
|
|
|
def _gateway(ui, bridge: str, interfaces: list[ipaddress.IPv4Interface], default: str | None) -> str | None:
|
|
default = default or _bridge(bridge).get("gateway") or ""
|
|
try:
|
|
if default and not all(usable(ipaddress.ip_address(default), i) for i in interfaces):
|
|
default = ""
|
|
except ValueError:
|
|
default = ""
|
|
while True:
|
|
value = ui.ask(translate("IPv4 gateway (empty = no outbound route)"), default, required=False).strip()
|
|
if not value:
|
|
return None
|
|
try:
|
|
gateway = ipaddress.ip_address(value)
|
|
except ValueError:
|
|
gateway = None
|
|
if gateway and all(usable(gateway, i) and gateway != i.ip for i in interfaces):
|
|
return str(gateway)
|
|
ui.message(translate("The gateway must be another usable address in the same subnet."))
|
|
|
|
|
|
def ask_addresses(ui, bridge: str, labels: list[str], current: dict[str, str] | None = None,
|
|
current_gateway: str | None = None) -> tuple[dict[str, str], str | None]:
|
|
"""One DHCP or static choice for the containers named in `labels` on
|
|
`bridge`; static addresses share one gateway."""
|
|
current = current or {}
|
|
static = any(value and value != DHCP for value in current.values())
|
|
title = translate("IPv4 address of the container") if len(labels) == 1 else translate("IPv4 address of the containers")
|
|
mode = ui.choose(title, [(DHCP, translate("DHCP (automatic)")), (STATIC, translate("Static IP"))],
|
|
STATIC if static else DHCP)
|
|
if mode is None:
|
|
raise UserCancelled(title)
|
|
if mode == DHCP:
|
|
return {label: DHCP for label in labels}, None
|
|
taken = addresses_in_use()
|
|
interfaces: dict[str, ipaddress.IPv4Interface] = {}
|
|
for label in labels:
|
|
own = current.get(label) if current.get(label) != DHCP else None
|
|
if own:
|
|
taken.discard(own.split("/", 1)[0])
|
|
interfaces[label] = _static_address(ui, bridge, label if len(labels) > 1 else "", own or "", taken)
|
|
taken.add(str(interfaces[label].ip))
|
|
gateway = _gateway(ui, bridge, list(interfaces.values()), current_gateway)
|
|
return {label: str(interface) for label, interface in interfaces.items()}, gateway
|
|
|
|
|
|
def ask_ipv4(ui, bridge: str, current: str | None = None,
|
|
current_gateway: str | None = None) -> tuple[str, str | None]:
|
|
addresses, gateway = ask_addresses(ui, bridge, [""], {"": current} if current else None, current_gateway)
|
|
return addresses[""], gateway
|