OCI catalog verification, console start marks and log cleanup

This commit is contained in:
MacRimi
2026-09-27 21:02:16 +02:00
parent 24617f9924
commit f3c4959fa4
34 changed files with 365 additions and 20 deletions
+6
View File
@@ -227,6 +227,12 @@ Generated templates start as `generated-unvalidated`. Promotion requires:
5. Image replacement with persistent volumes preserved.
6. Review of every platform adaptation and unsupported feature.
Real tests are recorded in `catalog/verification.json`, which the catalog
applies on top of the generated templates and the overlays, so a regeneration
keeps them: `"status": "laboratory-validated"` for an application tested in
the ProxMenux lab, or `"community_tested": {"by": "<GitHub user>", "date":
"<YYYY-MM-DD>"}` for one tested by the community.
The mini changelog comes from the LinuxServer README `Versions` section. At
installation, the architecture-specific registry digest and image labels are
recorded back into the local app JSON for future update comparisons.
+4
View File
@@ -506,5 +506,9 @@
},
"change_detection": "compare-compose-sha256-and-resolved-latest-image-digest",
"automatic_unattended_updates": false
},
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
}
}
+4
View File
@@ -396,5 +396,9 @@
},
"change_detection": "compare-compose-sha256-and-resolved-latest-image-digest",
"automatic_unattended_updates": false
},
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
}
}
+8
View File
@@ -3644,6 +3644,10 @@
"category_label": "Monitoring & Analytics",
"template": "apps/glances.json",
"template_status": "generated-unvalidated",
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
},
"automatic_install_candidate": true,
"untranslated_blockers": [],
"requires_privileged_lxc": true,
@@ -8548,6 +8552,10 @@
"category_label": "Databases",
"template": "apps/pocketbase.json",
"template_status": "generated-unvalidated",
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
},
"automatic_install_candidate": true,
"untranslated_blockers": [],
"requires_privileged_lxc": false,
+125
View File
@@ -0,0 +1,125 @@
{
"_comment": "Applications tested for real. \"status\": \"laboratory-validated\" when tested in the ProxMenux lab; \"community_tested\" with the tester's GitHub user and the date when tested by the community. Applied on top of the generated templates and the overlays, so a catalog regeneration keeps it.",
"applications": {
"2fauth": {
"status": "laboratory-validated"
},
"adguardhome-sync": {
"status": "laboratory-validated"
},
"albyhub": {
"status": "laboratory-validated"
},
"alist": {
"status": "laboratory-validated"
},
"amule": {
"status": "laboratory-validated"
},
"codeproject-ai": {
"status": "laboratory-validated"
},
"copyparty": {
"status": "laboratory-validated"
},
"crafty": {
"status": "laboratory-validated"
},
"ddclient": {
"status": "laboratory-validated"
},
"duplicati": {
"status": "laboratory-validated"
},
"etherpad": {
"status": "laboratory-validated"
},
"filebrowser-quantum": {
"status": "laboratory-validated"
},
"flaresolverr": {
"status": "laboratory-validated"
},
"flexget": {
"status": "laboratory-validated"
},
"frigate": {
"status": "laboratory-validated"
},
"glances": {
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
}
},
"grafana": {
"status": "laboratory-validated"
},
"immich": {
"status": "laboratory-validated"
},
"jdownloader": {
"status": "laboratory-validated"
},
"jenkins": {
"status": "laboratory-validated"
},
"linkwarden": {
"status": "laboratory-validated"
},
"memos": {
"status": "laboratory-validated"
},
"mineos-node": {
"status": "laboratory-validated"
},
"motioneye": {
"status": "laboratory-validated"
},
"nextcloud": {
"status": "laboratory-validated"
},
"openlist": {
"status": "laboratory-validated"
},
"openssh-server": {
"status": "laboratory-validated"
},
"paperless-ngx": {
"status": "laboratory-validated"
},
"phpmyadmin": {
"status": "laboratory-validated"
},
"pocketbase": {
"community_tested": {
"by": "Vaso73",
"date": "2026-09-27"
}
},
"qbittorrent": {
"status": "laboratory-validated"
},
"rclone": {
"status": "laboratory-validated"
},
"rdtclient": {
"status": "laboratory-validated"
},
"snapotter": {
"status": "laboratory-validated"
},
"thelounge": {
"status": "laboratory-validated"
},
"trilium": {
"status": "laboratory-validated"
},
"wg-easy": {
"status": "laboratory-validated"
},
"wireguard": {
"status": "laboratory-validated"
}
}
}
+22 -2
View File
@@ -25,6 +25,17 @@ import sys
NO_LOGIN = ('nologin', 'false')
LOG_DIR = Path('/var/log/proxmenux/oci')
# Each start is marked in the console log by a pre-start hook. The hook runs
# the script only when it exists and always succeeds: a hook that fails would
# stop the container from starting.
START_MARK_SCRIPT = Path(__file__).resolve().with_name('oci_console_mark.sh')
START_MARK = '=== ProxMenux: container started '
def start_mark_hook(vmid: int) -> str:
script = START_MARK_SCRIPT
# `test`, not `[`: a bracket in the configuration reads as a snapshot section.
return f"lxc.hook.pre-start: /bin/sh -c 'test -x {script} && {script} {int(vmid)}; exit 0'"
LOGROTATE = Path('/etc/logrotate.d/proxmenux-oci')
# copytruncate, because liblxc keeps the file open for as long as the
# container runs; moving it away would leave the application writing into the
@@ -165,8 +176,9 @@ def enable_log(vmid: int) -> Path:
text = conf.read_text()
current, _, snapshots = text.partition('\n[')
wanted = f'lxc.console.logfile: {path}'
kept = [line for line in current.splitlines() if not line.startswith('lxc.console.logfile:')]
kept.append(wanted)
kept = [line for line in current.splitlines()
if not line.startswith('lxc.console.logfile:') and START_MARK_SCRIPT.name not in line]
kept += [wanted, start_mark_hook(vmid)]
rebuilt = '\n'.join(kept) + '\n'
if snapshots:
rebuilt += '\n[' + snapshots
@@ -176,6 +188,14 @@ def enable_log(vmid: int) -> Path:
return path
def remove_log(vmid: int) -> None:
"""Delete the console log of a removed container and its rotated copies."""
base = log_path(vmid)
for path in [base, *LOG_DIR.glob(f'{base.name}.*')]:
if path.is_file() and not path.is_symlink():
path.unlink()
def configure(vmid: int) -> dict:
"""Console log and Proxmox terminal of a container being created."""
path = enable_log(vmid)
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Marks each start of an OCI container in its console log, so the log can be
# read from the last start. Run by the container's lxc.hook.pre-start.
case "$1" in
''|*[!0-9]*) exit 0 ;;
esac
printf '\n=== ProxMenux: container started %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
>> "/var/log/proxmenux/oci/$1.console.log" 2>/dev/null
exit 0
+3 -1
View File
@@ -44,7 +44,9 @@ BASIC = {'arch', 'cmode', 'console', 'tty', 'cores', 'cpulimit', 'cpuunits', 'de
'lxc.signal.halt', 'lxc.environment.runtime',
# The container's console log, set by the installer on every
# creation; the rebuilt container gets it again the same way.
'lxc.console.logfile'}
'lxc.console.logfile',
# The hook that marks each start in that log, set the same way.
'lxc.hook.pre-start'}
# Their output is data (and may hold saved secrets); it is never logged.
DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get')}
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
+3
View File
@@ -18,6 +18,7 @@ import sys
import oci_image_cache as image_cache
import oci_instances as instances
from oci_installation_state import parse_config
import oci_console
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
# The private networks ProxMenux creates for multi-container applications.
@@ -118,11 +119,13 @@ def remove(root, vmid):
config = guest_config(member)
if config is None:
msg_warn(f"{translate('The container no longer exists:')} CT {member}")
oci_console.remove_log(member)
elif instances.identity(config) != record['installation_id']:
msg_warn(f"{translate('The VMID belongs to another container now and is not touched:')} CT {member}")
else:
subprocess.run(['pct', 'stop', str(member), '--skiplock', '1'], check=False, capture_output=True)
run('pct', 'destroy', str(member), '--purge', '1', '--destroy-unreferenced-disks', '1')
oci_console.remove_log(member)
msg_ok(f"{translate('Container removed:')} CT {member}")
if bridge and not bridge_in_use(bridge, set(members)):
release_bridge(bridge)
+9
View File
@@ -23,6 +23,15 @@
"status": {
"enum": ["generated-unvalidated", "generated-review-required", "laboratory-validated", "stable"]
},
"community_tested": {
"type": "object",
"required": ["by"],
"additionalProperties": false,
"properties": {
"by": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}$"},
"date": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"}
}
},
"catalog_ui": {
"type": "object",
"required": ["title", "description", "category", "architectures", "launch", "mini_changelog"],
+29
View File
@@ -414,6 +414,7 @@ class Catalog:
ui = template["catalog_ui"]
item["hidden"] = overlay_ui.get("hidden", ui.get("hidden", False))
item["template_status"] = template["status"]
self._index_community_tested(item, template)
item["automatic_install_candidate"] = compatibility[
"automatic_install_candidate"
]
@@ -639,6 +640,7 @@ class Catalog:
item = index_items[app_id]
item["template"] = f"apps/{app_id}.json"
item["template_status"] = template["status"]
self._index_community_tested(item, template)
item["automatic_install_candidate"] = template["compatibility"][
"automatic_install_candidate"
]
@@ -792,6 +794,33 @@ class Catalog:
apply_stack_support(template)
from .gpu import apply_gpu_contract
apply_gpu_contract(template)
# Last, so the stack compiler does not reset it.
self._apply_verification(app_id, template)
def _apply_verification(self, app_id: str, template: dict[str, Any]) -> None:
"""Real tests recorded in verification.json, kept across regenerations."""
path = self.catalog_dir / "verification.json"
if not path.exists():
return
entry = json.loads(path.read_text(encoding="utf-8")).get("applications", {}).get(app_id)
if not isinstance(entry, dict):
return
# An application that can no longer be installed is not shown as verified.
installable = template.get("compatibility", {}).get("automatic_install_candidate", False)
if entry.get("status") == "laboratory-validated" and installable:
template["status"] = "laboratory-validated"
if isinstance(entry.get("community_tested"), dict):
template["community_tested"] = dict(entry["community_tested"])
@staticmethod
def _index_community_tested(item: dict[str, Any], template: dict[str, Any]) -> None:
"""Who tested the application for real outside the ProxMenux lab, and
when, as recorded in its overlay."""
tested = template.get("community_tested")
if isinstance(tested, dict) and tested.get("by"):
item["community_tested"] = {"by": str(tested["by"]), "date": str(tested.get("date") or "")}
else:
item.pop("community_tested", None)
@classmethod
def _deep_merge(cls, target: dict[str, Any], overlay: dict[str, Any]) -> None:
+7 -2
View File
@@ -167,8 +167,13 @@ def _app_detail_text(catalog: Catalog, item: dict[str, Any], template: dict[str,
lines.append(f"{label + ':':<17} {value}")
row(translate("Source"), publisher(item))
row(translate("Status"), translate("Verified by ProxMenux") if is_tested(item)
else translate("Not yet verified by ProxMenux (beta)"))
# Only a real test is shown; OCI manager Apps is labelled beta as a whole.
community = item.get("community_tested") or {}
if is_tested(item):
row(translate("Status"), translate("Verified by ProxMenux"))
elif community.get("by"):
who = " · ".join(part for part in (f"@{community['by']}", community.get("date")) if part)
row(translate("Status"), f"{translate('Tested by the community')} ({who})")
row(translate("Architectures"), _display_architectures(ui))
endpoints = template.get("first_run", {}).get("endpoints", [])
if endpoints: