mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
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:
@@ -378,6 +378,7 @@ cleanup_failed_install() {
|
||||
stop_spinner
|
||||
cleanup_volume_seed_staging || true
|
||||
cleanup_canonical_inputs || true
|
||||
[[ -z ${USER_DATABASES_ARCHIVE:-} ]] || rm -f "$USER_DATABASES_ARCHIVE" "${USER_DATABASES_ARCHIVE}.partial"
|
||||
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
|
||||
# The transaction owns recovery. Destroying this CT could destroy reused data.
|
||||
cleanup_runtime_console_log || true
|
||||
@@ -1757,8 +1758,25 @@ fi
|
||||
|
||||
# pct keeps the OCI Entrypoint/Cmd/Env/User/WorkingDir/StopSignal metadata.
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_create_container "${CREATE_ARGS[@]}" \
|
||||
|| die "$(translate "Could not create the container:") CT $VMID"
|
||||
# Proxmox resolves the image User through /etc/passwd and /etc/group, even a
|
||||
# numeric one, and cannot create the container from an image without them.
|
||||
user_databases_rc=0
|
||||
missing_user_databases=$(python3 "$OCI_RUNTIME_RESOLVER" --user-databases "$ARCHIVE_PATH" \
|
||||
2>>"${OCI_LOG:-/dev/stderr}") || user_databases_rc=$?
|
||||
if (( user_databases_rc == 3 )); then
|
||||
oci_log "The image declares a user but lacks: ${missing_user_databases}"
|
||||
# A numeric User is created from a copy with those files; the cached image
|
||||
# and its digest stay as they were downloaded.
|
||||
USER_DATABASES_ARCHIVE="${ARCHIVE_PATH%.tar}.user-databases.$$.tar"
|
||||
python3 "$OCI_RUNTIME_RESOLVER" --add-user-databases "$ARCHIVE_PATH" "$USER_DATABASES_ARCHIVE" \
|
||||
>>"${OCI_LOG:-/dev/null}" 2>&1 \
|
||||
|| die "$(translate "Proxmox VE cannot create a container from this image: it declares a user but does not include /etc/passwd and /etc/group. Nothing was created.")"
|
||||
CREATE_ARGS[1]=$USER_DATABASES_ARCHIVE
|
||||
fi
|
||||
create_status=0
|
||||
oci_create_container "${CREATE_ARGS[@]}" || create_status=$?
|
||||
[[ -z ${USER_DATABASES_ARCHIVE:-} ]] || rm -f "$USER_DATABASES_ARCHIVE"
|
||||
(( create_status == 0 )) || die "$(translate "Could not create the container:") CT $VMID"
|
||||
CT_CREATED=1
|
||||
msg_ok "$(translate "Container created:") CT $VMID ($HOSTNAME)"
|
||||
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -26,6 +26,7 @@ UNIT_FILE = Path('/etc/systemd/system') / UNIT
|
||||
JOURNAL = Path('/usr/local/share/proxmenux/scripts/global/pmx_journal.sh')
|
||||
CGROUPS = Path('/sys/fs/cgroup/lxc')
|
||||
CONFIGS = Path('/etc/pve/lxc')
|
||||
STACK_CONTRACTS = Path('/etc/pve/priv')
|
||||
HA_RESOURCES = Path('/etc/pve/ha/resources.cfg')
|
||||
TASKS = (Path('/var/log/pve/tasks/active'), Path('/var/log/pve/tasks/index'))
|
||||
INTERVAL = 10
|
||||
@@ -137,13 +138,27 @@ def is_running(vmid):
|
||||
return (CGROUPS / str(vmid)).is_dir()
|
||||
|
||||
|
||||
def is_busy(vmid, now):
|
||||
"""Something is working on the container or on the host: look again later."""
|
||||
def is_locked(vmid, missing=True):
|
||||
try:
|
||||
config = (CONFIGS / f'{vmid}.conf').read_text(errors='replace')
|
||||
except OSError:
|
||||
return True
|
||||
if any(line.startswith('lock:') for line in config.split('\n[', 1)[0].splitlines()):
|
||||
return missing
|
||||
return any(line.startswith('lock:') for line in config.split('\n[', 1)[0].splitlines())
|
||||
|
||||
|
||||
def dependencies(vmid):
|
||||
"""The containers the hook of a multi-container application starts first."""
|
||||
try:
|
||||
contract = json.loads((STACK_CONTRACTS / f'proxmenux-stack-{vmid}.json').read_text())
|
||||
return [int(item['vmid']) for item in contract.get('dependencies', [])]
|
||||
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
|
||||
def is_busy(vmid, now):
|
||||
"""Something is working on the container, on a container it depends on,
|
||||
such as a backup, or on the host: look again later."""
|
||||
if is_locked(vmid) or any(is_locked(dependency, missing=False) for dependency in dependencies(vmid)):
|
||||
return True
|
||||
try:
|
||||
mark = json.loads((oci_operation_notice.MARKERS / str(vmid)).read_text())
|
||||
|
||||
@@ -3,6 +3,19 @@ set -Eeuo pipefail
|
||||
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
HOOK_NAME=proxmenux-stack-dependencies.sh
|
||||
# Longest wait for a dependency that is being backed up, and for any other lock.
|
||||
BACKUP_LOCK_WAIT=${PROXMENUX_STACK_BACKUP_LOCK_WAIT:-14400}
|
||||
OTHER_LOCK_WAIT=${PROXMENUX_STACK_OTHER_LOCK_WAIT:-300}
|
||||
# vzdump starts again a guest that was running before its backup right after releasing it.
|
||||
RESTART_GRACE=${PROXMENUX_STACK_RESTART_GRACE:-20}
|
||||
POLL=${PROXMENUX_STACK_POLL:-5}
|
||||
# Earlier published versions of this script, replaced on update.
|
||||
PREVIOUS_RELEASES=(
|
||||
c28b69acd7cc8e66d3aec3e8b2ec86bda0e4f91193ec78549f1965f2c859c0d1
|
||||
02c50ef17f2411ef22f4d382c51b34a7a0974a446d4a155ecb63fb1e805a7c59
|
||||
)
|
||||
|
||||
die() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
@@ -51,7 +64,7 @@ install_hook() {
|
||||
' "$source_config" >/dev/null || die "Invalid dependency contract"
|
||||
|
||||
storage=$(find_snippet_storage)
|
||||
hook_volume="${storage}:snippets/proxmenux-stack-dependencies.sh"
|
||||
hook_volume="${storage}:snippets/${HOOK_NAME}"
|
||||
hook_path=$(pvesm path "$hook_volume")
|
||||
install -D -m 0755 "$0" "$hook_path"
|
||||
|
||||
@@ -63,6 +76,57 @@ install_hook() {
|
||||
"$main_id" "$hook_volume"
|
||||
}
|
||||
|
||||
# The installed copies of an earlier release take this version; a copy
|
||||
# changed by hand is left as it is.
|
||||
refresh_hooks() {
|
||||
local volume path current known
|
||||
while IFS= read -r volume; do
|
||||
path=$(pvesm path "$volume" 2>/dev/null) || continue
|
||||
[[ -f $path && ! -L $path ]] || continue
|
||||
current=$(sha256sum "$path" | cut -d' ' -f1)
|
||||
for known in "${PREVIOUS_RELEASES[@]}"; do
|
||||
if [[ $current == "$known" ]]; then
|
||||
install -m 0755 "$0" "$path.new" && mv -f "$path.new" "$path"
|
||||
printf 'Updated %s\n' "$path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done < <(sed -n "s/^hookscript:[[:space:]]*\([^[:space:]]*:snippets\/${HOOK_NAME//./\\.}\)[[:space:]]*$/\1/p" \
|
||||
/etc/pve/lxc/*.conf 2>/dev/null | sort -u)
|
||||
}
|
||||
|
||||
is_running() {
|
||||
[[ $(pct status "$1" 2>/dev/null || true) == "status: running" ]]
|
||||
}
|
||||
|
||||
container_lock() {
|
||||
pct config "$1" 2>/dev/null | sed -n '/^lock:/{s/^lock:[[:space:]]*//p;q;}' || true
|
||||
}
|
||||
|
||||
# A dependency stopped for its own backup stays locked until vzdump has
|
||||
# saved it, and vzdump starts it again when it was running before.
|
||||
wait_for_release() {
|
||||
local id=$1 label=$2 lock limit waited=0
|
||||
lock=$(container_lock "$id")
|
||||
[[ -n $lock ]] || return 0
|
||||
limit=$OTHER_LOCK_WAIT
|
||||
if [[ $lock == backup ]]; then
|
||||
limit=$BACKUP_LOCK_WAIT
|
||||
fi
|
||||
printf 'Dependency %s (CT %s) is locked (%s); waiting until it is released...\n' "$label" "$id" "$lock"
|
||||
while lock=$(container_lock "$id"); [[ -n $lock ]]; do
|
||||
(( waited < limit )) || die "$label (CT $id) is still locked ($lock) after ${waited}s"
|
||||
sleep "$POLL"
|
||||
waited=$((waited + POLL))
|
||||
done
|
||||
printf 'Dependency %s (CT %s) was released.\n' "$label" "$id"
|
||||
waited=0
|
||||
while (( waited < RESTART_GRACE )) && ! is_running "$id"; do
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
}
|
||||
|
||||
dependency_is_healthy() {
|
||||
local id=$1 healthcheck=$2 type url
|
||||
type=$(jq -r '.type' <<<"$healthcheck")
|
||||
@@ -74,7 +138,7 @@ dependency_is_healthy() {
|
||||
local -a command=()
|
||||
mapfile -t command < <(jq -r '.argv[]' <<<"$healthcheck")
|
||||
((${#command[@]} > 0)) || return 1
|
||||
pct exec "$id" -- "${command[@]}" >/dev/null 2>&1
|
||||
pct exec "$id" -- "${command[@]}" >/dev/null 2>&1 9>&-
|
||||
;;
|
||||
http)
|
||||
url=$(jq -r '.url' <<<"$healthcheck")
|
||||
@@ -103,9 +167,11 @@ start_dependencies() {
|
||||
pct config "$id" >/dev/null 2>&1 \
|
||||
|| die "Dependency $label (CT $id) does not exist"
|
||||
|
||||
if [[ $(pct status "$id" 2>/dev/null || true) != "status: running" ]]; then
|
||||
is_running "$id" || wait_for_release "$id" "$label"
|
||||
if ! is_running "$id"; then
|
||||
printf 'Starting dependency %s (CT %s)...\n' "$label" "$id"
|
||||
pct start "$id" || die "Could not start $label (CT $id)"
|
||||
# What pct start leaves running, such as the DHCP client, must not keep the lock.
|
||||
pct start "$id" 9>&- || is_running "$id" || die "Could not start $label (CT $id)"
|
||||
else
|
||||
printf 'Dependency %s (CT %s) was already running.\n' "$label" "$id"
|
||||
fi
|
||||
@@ -131,6 +197,10 @@ if [[ ${1:-} == "--install" ]]; then
|
||||
install_hook "$@"
|
||||
exit 0
|
||||
fi
|
||||
if [[ ${1:-} == "--refresh" ]]; then
|
||||
refresh_hooks
|
||||
exit 0
|
||||
fi
|
||||
|
||||
vmid=${1:?missing VMID}
|
||||
phase=${2:?missing lifecycle phase}
|
||||
|
||||
Reference in New Issue
Block a user