mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-07 22:16:39 +00:00
OCI catalog verification, console start marks and log cleanup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Executable
+9
@@ -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
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user