Files
ProxMenux/oci/remote/verify_oci_archive.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

131 lines
4.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Verify blob digests and gzip integrity in an OCI image archive."""
from __future__ import annotations
import argparse
import hashlib
import os
import sys
import tarfile
import zlib
from pathlib import Path
from oci_ui import log, translate
CHUNK_SIZE = 4 * 1024 * 1024
PROGRESS_STEP = 128 * 1024 * 1024
class VerificationError(RuntimeError):
pass
def human_mib(size: int) -> str:
return f"{size / (1024 * 1024):.1f} MiB"
def progress(text: str) -> None:
"""Per-blob detail belongs to the run log, never to the terminal."""
try:
log(os.environ.get("OCI_LOG"), text)
except OSError:
pass
def verify_blob(archive: tarfile.TarFile, member: tarfile.TarInfo, position: int, total: int) -> None:
expected_digest = member.name.rsplit("/", 1)[-1]
source = archive.extractfile(member)
if source is None:
raise VerificationError(f"{translate('Cannot read')} {member.name}")
progress(f" Verifying blob {position}/{total}: {expected_digest[:12]} ({human_mib(member.size)})")
digest = hashlib.sha256()
decompressor: zlib.Decompress | None = None
processed = 0
next_progress = PROGRESS_STEP
while True:
chunk = source.read(CHUNK_SIZE)
if not chunk:
break
digest.update(chunk)
if processed == 0 and chunk.startswith(b"\x1f\x8b"):
decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS)
if decompressor is not None:
try:
decompressor.decompress(chunk)
except zlib.error as exc:
raise VerificationError(
f"{translate('Corrupted gzip layer')} {expected_digest[:16]}: {exc}"
) from exc
processed += len(chunk)
if member.size >= PROGRESS_STEP and processed >= next_progress:
percentage = min(100, processed * 100 // member.size)
progress(f" {human_mib(processed)} / {human_mib(member.size)} ({percentage}%)")
next_progress += PROGRESS_STEP
if processed != member.size:
raise VerificationError(
f"{translate('Wrong size in')} {expected_digest[:16]}: {processed} != {member.size}"
)
actual_digest = digest.hexdigest()
if actual_digest != expected_digest:
raise VerificationError(
f"{translate('Wrong SHA-256 in')} {expected_digest[:16]}: {actual_digest[:16]}"
)
if decompressor is not None:
try:
decompressor.flush()
except zlib.error as exc:
raise VerificationError(
f"{translate('Corrupted gzip layer')} {expected_digest[:16]}: {exc}"
) from exc
if not decompressor.eof:
raise VerificationError(f"{translate('Incomplete gzip layer')} {expected_digest[:16]}")
def verify_archive(path: Path) -> None:
if not path.is_file() or path.stat().st_size == 0:
raise VerificationError(f"{translate('The OCI archive does not exist or is empty:')} {path}")
try:
with tarfile.open(path, mode="r:*") as archive:
members = archive.getmembers()
names = {member.name.lstrip("./") for member in members}
missing = {"index.json", "oci-layout"} - names
if missing:
raise VerificationError(
f"{translate('Missing OCI metadata:')} " + ", ".join(sorted(missing))
)
blobs = [
member
for member in members
if member.isfile()
and member.name.lstrip("./").startswith("blobs/sha256/")
]
if not blobs:
raise VerificationError(translate("The OCI archive contains no SHA-256 blobs"))
for position, member in enumerate(blobs, start=1):
verify_blob(archive, member, position, len(blobs))
except (tarfile.TarError, OSError) as exc:
raise VerificationError(f"{translate('Cannot read the OCI archive:')} {exc}") from exc
progress(f"OCI integrity verified: {path}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("archive", type=Path)
args = parser.parse_args()
try:
verify_archive(args.archive)
except VerificationError as exc:
print(f"{translate('OCI verification failed:')} {exc}", file=sys.stderr, flush=True)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())