Pre-release 1.2.7: OCI fixes, 12 new catalog applications and docs updates

Last changes before version 1.2.7.

OCI manager Apps
- Multi-container applications start while one of their containers is
  being backed up: the start hook waits until vzdump releases it and does
  not start it twice. The DHCP client of a dependency no longer keeps the
  lock of the hook, so later starts do not hang, and the watchdog waits
  while a dependency is locked. Installing or updating ProxMenux replaces
  the published copies of the hook already in place.
- Images built from scratch with a numeric user, such as ddns-updater,
  install: the container is created from a copy of the image that adds
  the /etc/passwd and /etc/group Proxmox VE needs, and the application
  runs with the user the image declares. The downloaded image stays
  unchanged. An image with a named user and no user database stops
  before anything is created, with a message that says why.

Catalog
- Twelve new applications: MDBListarr, CrossWatch, Free Games Claimer
  Remaster, Cloudreve, Poznote and AceStream HTTP Proxy, suggested in
  discussion #440, and the Dockur images of Chrony, Dnsmasq, Tor,
  Stunnel, strfry and Statping. The catalog lists 347 applications.
- Their prompts, labels and notes are translated into the seven
  languages of the menus.

Documentation
- OCI manager: architecture, lifecycle and stacks pages revised, with
  larger diagrams and new screenshots.
- Contributors: the page now thanks those developing, reviewing,
  translating and testing ProxMenux today and those who helped in its
  first steps, with new avatars.

Tests for the stack start hook, the watchdog, the user database check
of OCI images and the Tor installation summary.
This commit is contained in:
MacRimi
2026-10-25 13:17:54 +01:00
parent 2f48689087
commit fc41b04e87
103 changed files with 12799 additions and 603 deletions
+157
View File
@@ -3,12 +3,17 @@
from __future__ import annotations
import hashlib
import io
import json
import os
import shlex
import shutil
import stat
import subprocess
import sys
import tarfile
import threading
from pathlib import Path
from typing import Any
@@ -59,6 +64,141 @@ def image_entrypoint(archive_path: Path) -> list[str]:
return entrypoint
def _layer_entries(archive: tarfile.TarFile, descriptor: dict[str, Any]):
"""Names in one image layer, decompressed as its media type says."""
member = _member(archive, _blob_name(str(descriptor["digest"])))
source = archive.extractfile(member)
if source is None:
raise RuntimeResolutionError(f"{translate('Cannot read')} {member.name}")
media = str(descriptor.get("mediaType", ""))
if "zstd" in media:
process = subprocess.Popen(["zstd", "-dc"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
feeder = threading.Thread(target=lambda: (shutil.copyfileobj(source, process.stdin), process.stdin.close()),
daemon=True)
feeder.start()
try:
with tarfile.open(fileobj=process.stdout, mode="r|") as layer:
for item in layer:
yield item
finally:
process.stdout.close()
process.wait()
feeder.join()
return
with tarfile.open(fileobj=source, mode="r|gz" if "gzip" in media else "r|") as layer:
for item in layer:
yield item
def missing_user_databases(archive_path: Path) -> list[str]:
"""Files Proxmox needs to resolve the image User, absent from its rootfs.
Proxmox VE resolves a User through /etc/passwd and /etc/group inside the
image, even a numeric one, and refuses to create the container without
them. An image built from scratch often has neither."""
with tarfile.open(archive_path, mode="r:*") as archive:
index = _json_member(archive, "index.json")
manifests = index.get("manifests") or []
if len(manifests) != 1:
raise RuntimeResolutionError(translate("The OCI archive does not contain exactly one manifest"))
manifest = _json_member(archive, _blob_name(str(manifests[0]["digest"])))
config = _json_member(archive, _blob_name(str(manifest["config"]["digest"])))
if not (config.get("config") or {}).get("User"):
return []
state = {"etc/passwd": None, "etc/group": None}
# The topmost layer that names a path decides whether it exists.
for descriptor in reversed(manifest.get("layers") or []):
pending = [path for path, found in state.items() if found is None]
if not pending:
break
names = {item.name.removeprefix("./").strip("/") for item in _layer_entries(archive, descriptor)}
for path in pending:
folder, _, leaf = path.rpartition("/")
if path in names:
state[path] = True
elif (f"{folder}/.wh.{leaf}" in names or f"{folder}/.wh..wh..opq" in names
or f".wh.{folder}" in names):
state[path] = False
return ["/" + path for path, found in state.items() if not found]
USER_DATABASES = {
"etc/passwd": b"root:x:0:0:root:/root:/sbin/nologin\n",
"etc/group": b"root:x:0:\n",
}
def _tar_entry(name: str, kind: bytes, mode: int, size: int = 0) -> tarfile.TarInfo:
entry = tarfile.TarInfo(name)
entry.type, entry.mode, entry.size = kind, mode, size
entry.uid = entry.gid = entry.mtime = 0
entry.uname = entry.gname = "root"
return entry
def add_user_databases(source: Path, destination: Path) -> list[str]:
"""Copy of an image with a top layer that adds the /etc/passwd and
/etc/group it lacks, holding only root.
Only a numeric User is resolved this way: Proxmox VE then applies the
UID and GID as given, as Docker does for a user absent from the image."""
missing = missing_user_databases(source)
if not missing:
return []
with tarfile.open(source, mode="r:*") as archive:
index = _json_member(archive, "index.json")
manifest_descriptor = (index.get("manifests") or [{}])[0]
manifest = _json_member(archive, _blob_name(str(manifest_descriptor["digest"])))
config = _json_member(archive, _blob_name(str(manifest["config"]["digest"])))
user = str((config.get("config") or {}).get("User", ""))
if not all(part.isdigit() for part in user.split(":", 1)):
raise RuntimeResolutionError(translate("The image user is not numeric:") + " " + user)
layer = io.BytesIO()
with tarfile.open(fileobj=layer, mode="w", format=tarfile.PAX_FORMAT) as files:
files.addfile(_tar_entry("etc", tarfile.DIRTYPE, 0o755))
for path in missing:
body = USER_DATABASES[path.lstrip("/")]
files.addfile(_tar_entry(path.lstrip("/"), tarfile.REGTYPE, 0o644, len(body)), io.BytesIO(body))
blobs: dict[str, bytes] = {}
def blob(data: bytes) -> dict[str, Any]:
digest = "sha256:" + hashlib.sha256(data).hexdigest()
blobs[digest] = data
return {"digest": digest, "size": len(data)}
layer_data = layer.getvalue()
docker = "docker" in str(manifest.get("mediaType", ""))
layer_descriptor = blob(layer_data)
layer_descriptor["mediaType"] = ("application/vnd.docker.image.rootfs.diff.tar" if docker
else "application/vnd.oci.image.layer.v1.tar")
config.setdefault("rootfs", {"type": "layers", "diff_ids": []}).setdefault("diff_ids", []).append(
layer_descriptor["digest"])
if isinstance(config.get("history"), list):
config["history"].append({"created_by": "ProxMenux: " + " ".join(missing) + " for Proxmox VE"})
manifest["layers"] = list(manifest.get("layers") or []) + [layer_descriptor]
manifest["config"] = {**manifest["config"], **blob(json.dumps(config).encode())}
index["manifests"] = [{**manifest_descriptor, **blob(json.dumps(manifest).encode())}]
index_data = json.dumps(index).encode()
partial = destination.with_name(destination.name + ".partial")
try:
with tarfile.open(partial, mode="w", format=tarfile.PAX_FORMAT) as output:
for member in archive:
if member.name.removeprefix("./") == "index.json":
continue
output.addfile(member, archive.extractfile(member) if member.isfile() else None)
for digest, data in blobs.items():
output.addfile(_tar_entry(_blob_name(digest), tarfile.REGTYPE, 0o644, len(data)), io.BytesIO(data))
output.addfile(_tar_entry("index.json", tarfile.REGTYPE, 0o644, len(index_data)),
io.BytesIO(index_data))
os.replace(partial, destination)
finally:
partial.unlink(missing_ok=True)
return missing
def _arguments(value: Any, label: str) -> list[str]:
if isinstance(value, str):
return shlex.split(value)
@@ -139,6 +279,23 @@ def main() -> int:
return 1
print(home)
return 0
if len(sys.argv) == 3 and sys.argv[1] == "--user-databases":
try:
missing = missing_user_databases(Path(sys.argv[2]))
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError, RuntimeResolutionError) as exc:
print(f"{translate('Cannot read the OCI archive:')} {exc}", file=sys.stderr)
return 1
if missing:
print(" ".join(missing))
return 3
return 0
if len(sys.argv) == 4 and sys.argv[1] == "--add-user-databases":
try:
print(" ".join(add_user_databases(Path(sys.argv[2]), Path(sys.argv[3]))))
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError, RuntimeResolutionError) as exc:
print(f"{translate('Cannot prepare the OCI archive:')} {exc}", file=sys.stderr)
return 1
return 0
if len(sys.argv) not in (3, 4):
print(
f"{translate('Usage:')} {sys.argv[0]} OCI_ARCHIVE COMMAND_JSON [ENTRYPOINT_JSON]",