Files

317 lines
13 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""Resolve the effective OCI process after Compose runtime overrides."""
from __future__ import annotations
import hashlib
import io
import json
2026-10-17 11:23:38 +00:00
import os
import shlex
import shutil
2026-10-17 11:23:38 +00:00
import stat
import subprocess
import sys
import tarfile
import threading
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
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)
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
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
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]",
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())