Files
ProxMenux/oci/remote/oci_runtime.py
T
MacRimiandClaude Opus 5 bcabcb618c feat(oci): run official container images as native LXC containers
Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:24:59 +02:00

111 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Resolve the effective OCI process after Compose runtime overrides."""
from __future__ import annotations
import json
import shlex
import sys
import tarfile
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 _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)
def main() -> int:
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())