2026-09-22 18:24:59 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Resolve the effective OCI process after Compose runtime overrides."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-10-25 13:17:54 +01:00
|
|
|
import hashlib
|
|
|
|
|
import io
|
2026-09-22 18:24:59 +02:00
|
|
|
import json
|
2026-10-17 11:23:38 +00:00
|
|
|
import os
|
2026-09-22 18:24:59 +02:00
|
|
|
import shlex
|
2026-10-25 13:17:54 +01:00
|
|
|
import shutil
|
2026-10-17 11:23:38 +00:00
|
|
|
import stat
|
2026-10-25 13:17:54 +01:00
|
|
|
import subprocess
|
2026-09-22 18:24:59 +02:00
|
|
|
import sys
|
|
|
|
|
import tarfile
|
2026-10-25 13:17:54 +01:00
|
|
|
import threading
|
2026-09-22 18:24:59 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from oci_ui import translate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RuntimeResolutionError(RuntimeError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _member(archive: tarfile.TarFile, name: str) -> tarfile.TarInfo:
|
|
|
|
|
for item in archive.getmembers():
|
|
|
|
|
if item.name.lstrip("./") == name:
|
|
|
|
|
return item
|
|
|
|
|
raise RuntimeResolutionError(f"{translate('Not found in the OCI archive:')} {name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_member(archive: tarfile.TarFile, name: str) -> dict[str, Any]:
|
|
|
|
|
source = archive.extractfile(_member(archive, name))
|
|
|
|
|
if source is None:
|
|
|
|
|
raise RuntimeResolutionError(f"{translate('Cannot read')} {name}")
|
|
|
|
|
value = json.load(source)
|
|
|
|
|
if not isinstance(value, dict):
|
|
|
|
|
raise RuntimeResolutionError(f"{translate('Not a JSON object:')} {name}")
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _blob_name(digest: str) -> str:
|
|
|
|
|
algorithm, separator, value = digest.partition(":")
|
|
|
|
|
if separator != ":" or algorithm != "sha256" or len(value) != 64:
|
|
|
|
|
raise RuntimeResolutionError(f"{translate('Unsupported OCI digest:')} {digest}")
|
|
|
|
|
return f"blobs/sha256/{value}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def image_entrypoint(archive_path: Path) -> list[str]:
|
|
|
|
|
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"])))
|
|
|
|
|
entrypoint = (config.get("config") or {}).get("Entrypoint") or []
|
|
|
|
|
if isinstance(entrypoint, str):
|
|
|
|
|
return [entrypoint]
|
|
|
|
|
if not isinstance(entrypoint, list) or not all(isinstance(item, str) for item in entrypoint):
|
|
|
|
|
raise RuntimeResolutionError(translate("Invalid OCI Entrypoint"))
|
|
|
|
|
return entrypoint
|
|
|
|
|
|
|
|
|
|
|
2026-10-25 13:17:54 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 18:24:59 +02:00
|
|
|
def _arguments(value: Any, label: str) -> list[str]:
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return shlex.split(value)
|
|
|
|
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
|
|
|
return value
|
|
|
|
|
raise RuntimeResolutionError(f"{translate('The Compose value must be text or a list:')} {label}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def effective_entrypoint(
|
|
|
|
|
archive_path: Path,
|
|
|
|
|
command: Any,
|
|
|
|
|
compose_entrypoint: Any = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
entrypoint = (
|
|
|
|
|
image_entrypoint(archive_path)
|
|
|
|
|
if compose_entrypoint is None
|
|
|
|
|
else _arguments(compose_entrypoint, "entrypoint")
|
|
|
|
|
)
|
|
|
|
|
if command is None:
|
|
|
|
|
arguments: list[str] = []
|
|
|
|
|
elif isinstance(command, str):
|
|
|
|
|
arguments = shlex.split(command)
|
|
|
|
|
elif isinstance(command, list) and all(isinstance(item, str) for item in command):
|
|
|
|
|
arguments = command
|
|
|
|
|
else:
|
|
|
|
|
raise RuntimeResolutionError(f"{translate('The Compose value must be text or a list:')} command")
|
|
|
|
|
process = entrypoint + arguments
|
|
|
|
|
if not process:
|
|
|
|
|
raise RuntimeResolutionError(translate("The Entrypoint/Cmd combination is empty"))
|
|
|
|
|
return " ".join(shlex.quote(item) for item in process)
|
|
|
|
|
|
|
|
|
|
|
2026-09-30 10:46:34 +02:00
|
|
|
def home_directory(passwd_path: Path, uid: int) -> str | None:
|
2026-10-17 11:23:38 +00:00
|
|
|
"""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():
|
2026-09-30 10:46:34 +02:00
|
|
|
fields = line.split(":")
|
2026-10-17 11:23:38 +00:00
|
|
|
if len(fields) < 3 or fields[2] != str(uid):
|
2026-09-30 10:46:34 +02:00
|
|
|
continue
|
2026-10-17 11:23:38 +00:00
|
|
|
if len(fields) != 7:
|
|
|
|
|
return None
|
2026-09-30 10:46:34 +02:00
|
|
|
home = fields[5]
|
2026-10-17 11:23:38 +00:00
|
|
|
safe = home.startswith("/") and not any(ord(char) < 32 or ord(char) == 127 for char in home)
|
|
|
|
|
return home if safe else None
|
|
|
|
|
return "/"
|
2026-09-30 10:46:34 +02:00
|
|
|
|
|
|
|
|
|
2026-09-22 18:24:59 +02:00
|
|
|
def main() -> int:
|
2026-09-30 10:46:34 +02:00
|
|
|
if len(sys.argv) == 4 and sys.argv[1] == "--home":
|
|
|
|
|
try:
|
|
|
|
|
uid = int(sys.argv[3])
|
|
|
|
|
home = home_directory(Path(sys.argv[2]), uid)
|
|
|
|
|
except (OSError, ValueError) as exc:
|
|
|
|
|
print(f"{translate('Cannot resolve the container home directory:')} {exc}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
if home is None:
|
|
|
|
|
return 1
|
|
|
|
|
print(home)
|
|
|
|
|
return 0
|
2026-10-25 13:17:54 +01:00
|
|
|
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
|
2026-09-22 18:24:59 +02:00
|
|
|
if len(sys.argv) not in (3, 4):
|
|
|
|
|
print(
|
|
|
|
|
f"{translate('Usage:')} {sys.argv[0]} OCI_ARCHIVE COMMAND_JSON [ENTRYPOINT_JSON]",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
|
|
|
|
return 2
|
|
|
|
|
try:
|
|
|
|
|
command = json.loads(sys.argv[2])
|
|
|
|
|
compose_entrypoint = json.loads(sys.argv[3]) if len(sys.argv) == 4 else None
|
|
|
|
|
print(effective_entrypoint(Path(sys.argv[1]), command, compose_entrypoint))
|
|
|
|
|
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError, RuntimeResolutionError) as exc:
|
|
|
|
|
print(f"{translate('Cannot apply the Compose command:')} {exc}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|