ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes

OCI manager Apps
- App tab: containers installed from an OCI image are identified from their
  installation record; the application and image versions are shown and an
  update is detected by image digest; repository link; Refresh data.
- Updates tab for OCI containers: Update and Recreate run the same flow as the
  OCI menu in the Monitor terminal; the pre-update backup can be kept in a
  backup storage; scheduled image updates with an optional minimum age.
- Logs tab: console output of the application, kept on the host
  (lxc.console.logfile + logrotate) and followed live.
- The Proxmox console opens a shell (cmode: shell) when the image has one.
- A damaged image download is fetched again before failing.
- Multi-container applications open at their LAN address; volume mount
  points on block storage report their usage.

Monitor
- Proxmox notifications are delivered to a loopback-only HTTP listener when
  HTTPS is enabled, so they no longer fail certificate verification.
- Log persistence counts recurring patterns only; an ended burst is not
  reported as persistent and its warning clears on its own (#386).
- Proxmox notification config backups are deduplicated and capped at three.
- The update icon on the Apps page opens the container on its Updates tab.
- Version 1.2.6.2-beta and its release notes in every Monitor language.

Docs
- OCI manager Apps and Audit & Report rebuilt as per-page message files,
  with a new page for OCI containers in the Monitor.
- Seven pages fixed where rich-text tags were missing from t.rich.

Translations
- Spanish fixes across the OCI engine, the Monitor and the TUI menus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-25 21:51:12 +02:00
co-authored by Claude Opus 5.5
parent 386d33df6e
commit 4437a671d2
524 changed files with 14459 additions and 3841 deletions
+393 -7
View File
@@ -29,6 +29,7 @@ import datetime
import copy
import concurrent.futures
import hashlib
import ipaddress
import json
import os
import re
@@ -36,6 +37,7 @@ import signal
import shlex
import socket
import subprocess
import sys
import threading
import time
import urllib.error
@@ -62,7 +64,7 @@ _UPSTREAM_CACHE_TTL_SEC = 24 * 3600
_VALID_METHODS = ("dpkg", "apk", "file", "binary",
"python_dist", "docker_label", "docker_exec",
"command", "manual")
"command", "manual", "oci_image")
_DETECTOR_FIELDS = (
"package", "file_path", "file_regex", "binary_path", "binary_args",
"python_path", "distribution", "container_name", "label",
@@ -93,7 +95,7 @@ _VALID_UPSTREAM_TYPES = ("github", "http_json", "docker_hub")
# exposes and the freeform "custom" text field.
_VALID_SCHEDULE_TARGETS = ("os", "app", "both")
_SCHEDULE_TARGET_ID_RE = re.compile(
r"^(?:os|apps|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-(?:compose|container):[A-Za-z0-9][A-Za-z0-9_.-]{0,127}|docker-unit:[a-f0-9]{20})$"
r"^(?:os|apps|oci_image|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-(?:compose|container):[A-Za-z0-9][A-Za-z0-9_.-]{0,127}|docker-unit:[a-f0-9]{20})$"
)
_BULK_TARGET_ID_RE = re.compile(
r"^(?:os|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-unit:[a-f0-9]{20})$"
@@ -886,6 +888,10 @@ def validate_schedule(payload: Any) -> tuple[bool, Any]:
"backup_storage": backup_storage,
"restart": restart,
"release_delay_days": release_delay_days,
# Host directories of an OCI container are not reverted by its
# backup; a scheduled image update runs only when this was
# confirmed when the schedule was saved.
"acknowledge_external_data": bool(payload.get("acknowledge_external_data")),
}
# Preserve `last_run_at` / `last_run_status` when the caller sent
# them (typical when the scheduler writes back after firing);
@@ -951,7 +957,12 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
if method:
conf["installed_via"] = method
if method in ("dpkg", "apk"):
if method == "oci_image":
# Everything it needs is in the installation record ProxMenux wrote:
# the image, the digest and the registry. There is no field to fill
# and no upstream to configure.
pass
elif method in ("dpkg", "apk"):
pkg = (payload.get("package") or "").strip()
if not pkg or not _PACKAGE_RE.match(pkg):
return _err("package is required (letters/digits/._+@:/ up to 127 chars)")
@@ -1345,6 +1356,15 @@ def detect_installed_version(vmid, config: dict) -> tuple[Optional[str], Optiona
# tag_regex is reused (backward-compat with older hints).
pattern = config.get("installed_regex") or config.get("tag_regex") or r"(\d+[.\d]+)"
if method == "oci_image":
result = _oci_image_versions(vmid, with_latest=False)
if result.get("error"):
return None, result["error"]
# An image that states no application version is still an image with
# a build date and a digest, which is what its updates are decided on.
return (result.get("installed_version")
or _oci_image_label(None, result.get("image_created"), result.get("installed_digest"))), None
if method == "dpkg":
rc, out, err = _pct_exec(vmid, ["dpkg-query", "-W", "-f=${Version}", config["package"]])
if rc != 0:
@@ -3767,9 +3787,14 @@ def partition_scheduled_release_targets(
# A delegated app has no updater of its own and never resolves a
# release date, so including it would hold the whole schedule back
# waiting for a date that will never arrive.
# An application ProxMenux installed from an OCI image is updated by
# replacing its image through the OCI engine's own transaction, not by
# a helper or a command inside the guest, so a scripted plan has
# nothing it could run for it.
if (not app_id or app.get("managed_oci_app_id")
or app.get("helper_slug") == "docker"
or app.get("update_via") == "docker"):
or app.get("update_via") == "docker"
or app.get("installed_via") == "oci_image"):
continue
if not select_all_apps and app_id not in selected_app_ids:
continue
@@ -3962,13 +3987,20 @@ def _app_update_notification_payload(vmid, app: dict) -> Optional[dict]:
return None
state = app.get("state") or {}
latest = state.get("latest_version")
installed = state.get("installed_version")
if app.get("installed_via") == "oci_image" and state.get("latest_digest"):
# What is published is an image. Naming it by version, build date and
# digest keeps two rebuilds of the same version from being taken for
# one notification, and tells the reader what actually changed.
latest = _oci_image_label(latest, state.get("latest_image_created"), state.get("latest_digest"))
installed = _oci_image_label(installed, state.get("image_created"), state.get("installed_digest"))
if not state.get("update_available") or not latest:
return None
return {
"vmid": int(vmid),
"ct_name": app.get("name") or f"CT-{vmid}",
"app_name": app.get("name") or "app",
"installed": state.get("installed_version") or "unknown",
"installed": installed or "unknown",
"latest": latest,
"app_id": str(app.get("id") or ""),
}
@@ -4291,6 +4323,28 @@ def check_app(
except (ValueError, TypeError):
pass
if app.get("installed_via") == "oci_image":
result = _oci_image_versions(vmid, known=state)
app["state"] = {
"installed_version": result.get("installed_version"),
"latest_version": result.get("latest_version"),
"latest_published_at": None,
"update_available": result.get("update_available"),
"error": result.get("error"),
"checked_at": _now_iso(),
"installed_digest": result.get("installed_digest"),
"latest_digest": result.get("latest_digest"),
"image_created": result.get("image_created"),
"latest_image_created": result.get("latest_image_created"),
"image_reference": result.get("image_reference"),
"image_repository": result.get("image_repository"),
}
sidecar["updated_at"] = _now_iso()
_write_sidecar(vmid, sidecar)
if notify and app["state"]["update_available"] and app["state"]["latest_version"]:
_fire_update_notification(vmid, app)
return sidecar
installed, inst_err, _healed = _detect_with_alt_healing(vmid, app)
# Trigger the upstream fetch when ANY upstream source is
# configured. The dispatcher inside `fetch_latest_upstream`
@@ -4511,6 +4565,12 @@ def _summarise_app(app: dict) -> dict:
# match this registered app against the CT's helper_slug and
# display its installed/upstream versions.
"helper_slug": app.get("helper_slug") or "",
# OCI image identity, for the Updates tab of an OCI container.
"image_reference": state.get("image_reference"),
"image_created": state.get("image_created"),
"installed_digest": state.get("installed_digest"),
"latest_image_created": state.get("latest_image_created"),
"latest_digest": state.get("latest_digest"),
}
@@ -5238,6 +5298,276 @@ def _helper_slug_meta(vmid) -> Optional[dict]:
return None
_OCI_INSTANCE_ROOT = "/usr/local/share/proxmenux/oci/instances"
_OCI_CATALOG_INDEX = "/usr/local/share/proxmenux/oci/engine/catalog/index.json"
_oci_catalog_cache: tuple[float, dict] | None = None
def _oci_catalog_icons() -> dict:
"""Current icon per catalog application, keyed by template id.
The installation record keeps a copy of the catalog entry as it stood on
the day of the install, which freezes the icon along with everything else.
Icons get corrected — most of the catalog used to point at a URL that
answered 404 — so the panel reads the catalog and keeps the record as the
fallback for an application the catalog no longer lists.
"""
global _oci_catalog_cache
now = time.time()
if _oci_catalog_cache and now - _oci_catalog_cache[0] < 600:
return _oci_catalog_cache[1]
icons: dict = {}
try:
with open(_OCI_CATALOG_INDEX, encoding="utf-8") as handle:
for item in (json.load(handle) or {}).get("applications", []):
icon = (item or {}).get("icon")
if not isinstance(icon, str) or not icon.startswith("http"):
continue
# An installation records the template id; the catalog is keyed
# by the application id and carries both.
for key in (item.get("template_id"), item.get("id")):
if key:
icons.setdefault(key, icon)
except (OSError, ValueError, TypeError):
pass
_oci_catalog_cache = (now, icons)
return icons
def _oci_localised(value) -> str:
"""A catalog_ui text field, which is either a string or a locale map."""
if isinstance(value, str):
return value.strip()
if isinstance(value, dict):
for key in ("en_US", "en", *sorted(value)):
text = value.get(key)
if isinstance(text, str) and text.strip():
return text.strip()
return ""
def _oci_name_from_image(reference: str | None) -> str:
"""A presentable name for an image that carries no catalog entry.
A stack member records the image it runs and the role it plays, not a
title: `nextcloud:latest` as the `application` of a Nextcloud stack.
"""
repository = str(reference or "").split("@", 1)[0]
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip()
if not basename:
return ""
return " ".join(word.capitalize() for word in re.split(r"[-_.]+", basename) if word)
def _oci_instance_meta(vmid) -> Optional[dict]:
"""What ProxMenux itself recorded when it installed this container.
The sibling of `_helper_slug_meta`, and stronger evidence: a helper slug
is a hint read back out of the guest, while this is the contract the
installer wrote. It needs no `pct exec`, so it also answers for a stopped
container, and it cannot mistake an incidental binary for the application
— CT 152 runs Chromium and happens to have Docker inside, which the
runtime probe reported as the only candidate.
"""
try:
with open(f"{_OCI_INSTANCE_ROOT}/{int(vmid)}/oci-compose.json", encoding="utf-8") as handle:
record = json.load(handle)
except (OSError, ValueError, TypeError):
return None
if not isinstance(record, dict) or record.get("status") not in ("installed", "assembling"):
return None
template = record.get("template") or {}
contract = template.get("container_contract") or {}
image = contract.get("image") or {}
observed_image = (record.get("observed") or {}).get("image") or {}
# A stack member carries only its own image contract; the presentation
# belongs to the stack it is part of, which records its title, site,
# category and the endpoint the stack is reached on.
stack = record.get("stack") if isinstance(record.get("stack"), dict) else {}
stack_template = stack.get("template") if isinstance(stack.get("template"), dict) else {}
role = str((record.get("stack_member") or {}).get("name") or "").strip()
member = record.get("stack_member") or {}
is_primary = not stack_template or member.get("primary_vmid") in (None, record.get("vmid"))
ui = template.get("catalog_ui") or stack_template.get("catalog_ui") or {}
if not template.get("catalog_ui") and stack_template and is_primary:
# Only the member the stack is reached on inherits the stack endpoint.
# The cache and the database of a Nextcloud stack do not answer on its
# web port, and offering it would register a service that is not there.
template = {**stack_template, "id": template.get("id") or stack_template.get("id")}
elif not template.get("catalog_ui") and stack_template:
ui = {key: value for key, value in ui.items()
if key in ("category", "category_label")}
# `container_contract.ports` lists every port the image exposes; the
# endpoint is the one the application is actually reached on, with its
# scheme. Chromium exposes 3000 and 3001 and serves on 3001 over https.
endpoint = next((e for e in (template.get("first_run") or {}).get("endpoints") or []
if isinstance(e, dict) and e.get("port")), None) or ui.get("launch") or {}
ports = []
for entry in contract.get("ports") or []:
try:
port = int((entry or {}).get("container_port"))
except (TypeError, ValueError):
continue
if 1 <= port <= 65535 and port not in ports:
ports.append(port)
template_id = str(template.get("id") or "").strip()
catalog_icons = _oci_catalog_icons()
logo = catalog_icons.get(template_id) or ""
if not logo:
# A stack or a one-off image has no catalog entry of its own, but the
# image it runs usually does: the Nextcloud stack wears Nextcloud's.
repository = str(image.get("reference") or "").split("@", 1)[0]
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
logo = catalog_icons.get(basename) or ""
if not logo:
logo = ui.get("icon") if isinstance(ui.get("icon"), str) else ""
website = ui.get("website") if isinstance(ui.get("website"), str) else ""
return {
"template_id": template_id or None,
"name": (_oci_localised(ui.get("title"))
or _oci_name_from_image(image.get("reference"))
or str(template.get("id") or "").strip() or None),
"role": role or None,
"logo": logo if logo.startswith(("http://", "https://")) else "",
"website": website if website.startswith(("http://", "https://")) else "",
"category": str(ui.get("category") or "").strip() or None,
"category_label": str(ui.get("category_label") or "").strip() or None,
"image_reference": str(image.get("reference") or "").strip() or None,
# The repository of the image — its GitHub project, or its page on the
# registry for an official image — which is where its updates come from.
"repository": (str(ui.get("repository") or "").strip()
or str((template.get("source") or {}).get("repository") or "").strip() or None),
"endpoint_port": endpoint.get("port") if isinstance(endpoint.get("port"), int) else None,
"endpoint_scheme": str(endpoint.get("scheme") or "").strip().lower() or None,
"endpoint_path": str(endpoint.get("path") or "").strip() or None,
"ports": ports,
# The exact image this container was created from. Its digest is what
# an update is decided on; the version label is only for reading.
"installed_digest": str(observed_image.get("manifest_digest") or "").strip() or None,
"architecture": str(observed_image.get("architecture") or "").strip() or None,
}
def oci_adguard_setup_available(vmid) -> bool:
"""Probe only this OCI application's setup endpoint, without caching it."""
meta = _oci_instance_meta(vmid)
if not meta or meta.get('template_id') != 'image-adguard-home':
return False
try:
result = subprocess.run(['lxc-info', '-n', str(int(vmid)), '-iH'],
capture_output=True, text=True, timeout=3, check=True)
ip = next(str(ipaddress.IPv4Address(value.strip()))
for value in result.stdout.splitlines()
if value.strip() and ipaddress.ip_address(value.strip()).version == 4)
with socket.create_connection((ip, 3000), timeout=1) as connection:
connection.settimeout(1)
connection.sendall(b'GET / HTTP/1.0\r\nHost: localhost\r\n\r\n')
return connection.recv(32).startswith(b'HTTP/')
except (OSError, ValueError, StopIteration, subprocess.SubprocessError):
return False
_OCI_REMOTE_DIR = "/usr/local/share/proxmenux/oci/engine/remote"
def _oci_state_module():
"""The OCI engine's own reader of image versions.
Reused rather than copied: it resolves the platform manifest, reads the
version the image states in its environment before the label it may
have inherited from its base, and it is the same code the engine
installs and updates with, so the panel and the updater cannot disagree
about what an image is.
"""
if _OCI_REMOTE_DIR not in sys.path:
sys.path.insert(0, _OCI_REMOTE_DIR)
import oci_installation_state
return oci_installation_state
def _oci_repository(reference: str) -> str:
repository = reference.split("@", 1)[0]
if ":" in repository.rsplit("/", 1)[-1]:
repository = repository.rsplit(":", 1)[0]
return repository
def _oci_resolve(module, reference: str, architecture: str) -> dict:
# One retry: an anonymous registry answers the occasional request with an
# error that the next one does not repeat.
try:
return module.resolve_candidate(reference, architecture)
except RuntimeError:
time.sleep(2)
return module.resolve_candidate(reference, architecture)
def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool = True) -> dict:
"""Installed and published version of a container ProxMenux installed.
Nothing is inferred. The installation record names the image and the
exact digest the container was created from; the registry says which
digest the same tag points at today. An update is available when those
two differ — the version strings only say which one it is, and they are
not compared, because a rebuild can keep its number and a build id such
as ``b1ee1dc8-ls55`` has no order to compare.
The installed version is read by digest, which never changes, so a
previous answer for the same digest is reused instead of asked again.
"""
meta = _oci_instance_meta(vmid)
if not meta or not meta.get("image_reference") or not meta.get("installed_digest"):
return {"error": "no OCI installation record for this container"}
reference = meta["image_reference"]
architecture = meta.get("architecture") or "amd64"
installed_digest = meta["installed_digest"]
result: dict = {"installed_digest": installed_digest, "image_reference": reference,
"image_repository": meta.get("repository")}
try:
module = _oci_state_module()
except Exception as exc:
return {**result, "error": f"OCI engine unavailable: {exc}"}
known = known or {}
if (known.get("installed_digest") == installed_digest
and (known.get("installed_version") or known.get("image_created"))):
result["installed_version"] = known.get("installed_version")
result["image_created"] = known.get("image_created")
else:
try:
installed = _oci_resolve(
module, f"{_oci_repository(reference)}@{installed_digest}", architecture)
result["installed_version"] = installed.get("version")
result["image_created"] = installed.get("created")
except Exception as exc:
return {**result, "error": f"could not read the installed image: {exc}"}
if not with_latest:
return result
try:
latest = _oci_resolve(module, reference, architecture)
except Exception as exc:
return {**result, "error": f"could not read {reference} from its registry: {exc}"}
latest_digest = latest.get("manifest_digest")
# The image decides. An application whose version did not move can still
# have a new image — a rebuild on a patched base — and that is an update
# for a container whose application only changes when its image does.
replaced = bool(latest_digest) and latest_digest != installed_digest
result.update(latest_digest=latest_digest, latest_version=latest.get("version"),
latest_image_created=latest.get("created"), update_available=replaced)
return result
def _oci_image_label(version: Optional[str], created: Optional[str], digest: Optional[str]) -> str:
"""One image, as a line a reader can compare: version, build date, digest."""
parts = [version] if version else []
if created:
parts.append(str(created)[:10])
if digest:
parts.append(str(digest).split(":", 1)[-1][:8])
return " · ".join(parts) or "unknown"
def _catalog_lookup(slug: str) -> Optional[dict]:
"""Fetch the community-scripts catalog entry for a slug.
Returns {name, updateable, default_port, logo} or None.
@@ -5292,6 +5622,7 @@ def get_suggestions(vmid, force: bool = False) -> dict:
if p in _KNOWN_WEB_PORTS:
web_hint = "/"
break
oci_meta = _oci_instance_meta(vmid)
meta = _helper_slug_meta(vmid) or {}
slug = meta.get("slug")
# Suppress base-OS helper slugs from the suggestion pipeline.
@@ -5318,7 +5649,10 @@ def get_suggestions(vmid, force: bool = False) -> dict:
detector.get("installed_via") in ("docker_label", "docker_exec")
for detector in primary_matches
)
if "docker" in detected_map and primary_is_docker_workload:
# An OCI container installed by ProxMenux is the application named in its
# own record. Promoting a probed binary over that would offer Docker for a
# Chromium container just because the image ships a docker client.
if "docker" in detected_map and primary_is_docker_workload and not oci_meta:
slug = "docker"
meta = {"slug": "docker", "name": "Docker"}
# Tracking hint pipeline: catalog + curated hints merged.
@@ -5560,6 +5894,47 @@ def get_suggestions(vmid, force: bool = False) -> dict:
"tracking_suggestion": det_tracking,
})
if oci_meta:
# The record states what this container runs, so a probe finding is
# noise: CT 152 runs Chromium and ships a docker client, and offering
# to register Docker there invites the user to track the wrong thing.
extras = []
# Recorded facts replace every probed guess: the name, the logo, the
# site and the endpoint the application is served on. The version is
# not filled here — the image has no detector among the guest-side
# methods — so the entry registers without version tracking until the
# OCI detector exists.
name_sug = oci_meta["name"] or name_sug
logo_url = oci_meta["logo"] or logo_url
category_suggestion = oci_meta["category_label"] or suggest_category_for(slug)
if oci_meta["endpoint_port"]:
default_ports = [oci_meta["endpoint_port"]]
ports = [oci_meta["endpoint_port"]] + [p for p in (oci_meta["ports"] or ports)
if p != oci_meta["endpoint_port"]]
elif oci_meta["ports"]:
default_ports = list(oci_meta["ports"])
web_hint = oci_meta["endpoint_path"] or web_hint
if oci_meta["template_id"] == "image-adguard-home":
# The first-run endpoint disappears once setup switches to :80.
default_ports = [80]
ports = [80, 3000]
# Version tracking comes with the registration. Its updates are
# decided by the image, which always has a build date and a digest,
# so it applies even to an image that states no application version.
versions = _oci_image_versions(vmid, with_latest=False)
if not versions.get("error"):
tracking = {
"installed_via": "oci_image",
"detected_version": versions.get("installed_version") or _oci_image_label(
None, versions.get("image_created"), versions.get("installed_digest")),
"detector_verified": True,
"detector_source": "oci_instance_record",
"logo": logo_url or None,
"website": oci_meta["website"] or None,
}
else:
category_suggestion = suggest_category_for(slug)
return {
"name_suggestion": name_sug,
"helper_slug": slug,
@@ -5568,9 +5943,20 @@ def get_suggestions(vmid, force: bool = False) -> dict:
"tracking_suggestion": tracking,
"default_ports": default_ports,
"logo_url": logo_url or None,
# Identity of a ProxMenux OCI install: the scheme the endpoint is
# served on, the image it was created from, and the upstream source.
"oci_instance": {
"template_id": oci_meta["template_id"],
"image_reference": oci_meta["image_reference"],
"repository": oci_meta["repository"],
"scheme": oci_meta["endpoint_scheme"],
"port": oci_meta["endpoint_port"],
"path": oci_meta["endpoint_path"],
"website": oci_meta["website"],
} if oci_meta else None,
# Categoría preset for the primary detection — same lookup as
# get_catalog_entry so the Register button pre-selects it.
"category": suggest_category_for(slug),
"category": category_suggestion,
"extras": extras,
"docker_workloads": sorted(docker_workloads, key=lambda item: item["name"].lower()),
"docker_web_links": docker_web_links,