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
+44 -8
View File
@@ -24,12 +24,20 @@ interface ConsoleLogResponse {
inode: number | null
reset?: boolean
head_truncated?: boolean
start_marks?: boolean
error?: string
}
const LINE_OPTIONS = [100, 500, 1000] as const
const POLL_MS = 2000
// Written into the log by the container's pre-start hook at every start.
const START_MARK_RE = /^=== ProxMenux: container started (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ===$/
const lastStart = (lines: string[]) => {
for (let i = lines.length - 1; i >= 0; i--) if (START_MARK_RE.test(lines[i])) return i
return -1
}
const lineTone = (line: string) => {
if (/\b(error|fatal|panic|critical|exception)\b/i.test(line)) return "text-red-400"
if (/\bwarn(ing)?\b/i.test(line)) return "text-amber-400"
@@ -45,6 +53,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
const [error, setError] = useState<string | null>(null)
const [follow, setFollow] = useState(true)
const [filter, setFilter] = useState("")
const [sinceStart, setSinceStart] = useState(true)
const position = useRef<{ offset: number; inode: number | null } | null>(null)
const scroller = useRef<HTMLDivElement>(null)
const inFlight = useRef(false)
@@ -53,7 +62,9 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
setLoading(true)
setError(null)
try {
const r = await fetchApi<ConsoleLogResponse>(`/api/lxc/${vmid}/console-log?lines=${lineCount}`)
const r = await fetchApi<ConsoleLogResponse>(
`/api/lxc/${vmid}/console-log?lines=${lineCount}${sinceStart ? "&since_start=1" : ""}`,
)
setEnabled(r.enabled)
setLines(r.lines || [])
position.current = { offset: r.offset, inode: r.inode }
@@ -63,7 +74,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
} finally {
setLoading(false)
}
}, [vmid, lineCount])
}, [vmid, lineCount, sinceStart])
const poll = useCallback(async () => {
const pos = position.current
@@ -79,7 +90,10 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
setLines(r.lines || [])
} else if (r.lines?.length) {
setLines((prev) => {
const next = prev.concat(r.lines)
let next = prev.concat(r.lines)
// A restart while following opens a new session.
const mark = sinceStart ? lastStart(next) : -1
if (mark > 0) next = next.slice(mark)
return next.length > lineCount ? next.slice(next.length - lineCount) : next
})
}
@@ -88,7 +102,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
} finally {
inFlight.current = false
}
}, [vmid, lineCount])
}, [vmid, lineCount, sinceStart])
useEffect(() => {
load()
@@ -105,7 +119,7 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
const shown = useMemo(() => {
const needle = filter.trim().toLowerCase()
return needle ? lines.filter((l) => l.toLowerCase().includes(needle)) : lines
return needle ? lines.filter((l) => START_MARK_RE.test(l) || l.toLowerCase().includes(needle)) : lines
}, [lines, filter])
useEffect(() => {
@@ -145,6 +159,15 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
</div>
{enabled && (
<div className="flex items-center gap-2 flex-wrap">
<Select value={sinceStart ? "start" : "all"} onValueChange={(v) => setSinceStart(v === "start")}>
<SelectTrigger className="h-7 w-auto text-xs gap-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start" className="text-xs">{t("vmLxc.consoleLog.sinceStart")}</SelectItem>
<SelectItem value="all" className="text-xs">{t("vmLxc.consoleLog.allHistory")}</SelectItem>
</SelectContent>
</Select>
<Select value={String(lineCount)} onValueChange={(v) => setLineCount(Number(v))}>
<SelectTrigger className="h-7 w-auto text-xs gap-1">
<SelectValue />
@@ -242,9 +265,22 @@ export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: b
className="rounded-md border border-border bg-background/50 flex-1 overflow-y-auto min-h-0"
>
<pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
{shown.map((line, idx) => (
<div key={idx} className={lineTone(line)}>{line || " "}</div>
))}
{shown.map((line, idx) => {
const mark = START_MARK_RE.exec(line)
if (mark) {
const when = new Date(`${mark[1]}T${mark[2]}`)
return (
<div key={idx} className="my-1.5 flex items-center gap-2 text-sky-400/90 select-none">
<span className="h-px flex-1 bg-sky-500/30" />
<span>{t("vmLxc.consoleLog.startedAt", {
date: Number.isNaN(when.getTime()) ? `${mark[1]} ${mark[2]}` : when.toLocaleString(),
})}</span>
<span className="h-px flex-1 bg-sky-500/30" />
</div>
)
}
return <div key={idx} className={lineTone(line)}>{line || " "}</div>
})}
</pre>
</div>
)}
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Folgen",
"pause": "Pausieren",
"refresh": "Aktualisieren",
"sinceStart": "Seit dem letzten Start",
"allHistory": "Gesamter Verlauf",
"startedAt": "Container gestartet · {date}",
"download": "Herunterladen",
"filter": "Zeilen filtern",
"loading": "Konsolenausgabe wird geladen...",
+3
View File
@@ -1199,6 +1199,9 @@
"follow": "Follow",
"pause": "Pause",
"refresh": "Refresh",
"sinceStart": "Since last start",
"allHistory": "Full history",
"startedAt": "Container started · {date}",
"download": "Download",
"filter": "Filter lines",
"loading": "Loading console output...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Seguir",
"pause": "Pausar",
"refresh": "Actualizar",
"sinceStart": "Desde el último arranque",
"allHistory": "Todo el historial",
"startedAt": "Contenedor iniciado · {date}",
"download": "Descargar",
"filter": "Filtrar líneas",
"loading": "Cargando la salida de consola...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Suivre",
"pause": "Pause",
"refresh": "Actualiser",
"sinceStart": "Depuis le dernier démarrage",
"allHistory": "Historique complet",
"startedAt": "Conteneur démarré · {date}",
"download": "Télécharger",
"filter": "Filtrer les lignes",
"loading": "Chargement de la sortie console...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Segui",
"pause": "Pausa",
"refresh": "Aggiorna",
"sinceStart": "Dall'ultimo avvio",
"allHistory": "Cronologia completa",
"startedAt": "Container avviato · {date}",
"download": "Scarica",
"filter": "Filtra righe",
"loading": "Caricamento dell'output della console...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Seguir",
"pause": "Pausar",
"refresh": "Atualizar",
"sinceStart": "Desde a última inicialização",
"allHistory": "Histórico completo",
"startedAt": "Contêiner iniciado · {date}",
"download": "Transferir",
"filter": "Filtrar linhas",
"loading": "A carregar a saída da consola...",
+3
View File
@@ -1199,6 +1199,9 @@
"follow": "Sledovať",
"pause": "Pozastaviť",
"refresh": "Obnoviť",
"sinceStart": "Od posledného spustenia",
"allHistory": "Celá história",
"startedAt": "Kontajner spustený · {date}",
"download": "Stiahnuť",
"filter": "Filtrovať riadky",
"loading": "Načítava sa výstup konzoly...",
+3
View File
@@ -1178,6 +1178,9 @@
"follow": "Följ",
"pause": "Pausa",
"refresh": "Uppdatera",
"sinceStart": "Sedan senaste start",
"allHistory": "Hela historiken",
"startedAt": "Containern startade · {date}",
"download": "Ladda ned",
"filter": "Filtrera rader",
"loading": "Läser in konsolutdata...",
+37 -1
View File
@@ -733,6 +733,36 @@ def _lxc_isolated_ips(vmid):
return isolated
def _lxc_shares_host_network(vmid):
"""Whether the container uses the host's network namespace instead of its
own, as a ProxMenux host-monitor (Glances, Netdata) does: lxc-info then
lists every address of the host, private bridges included."""
try:
with open(f"/etc/pve/lxc/{int(vmid)}.conf", encoding="utf-8") as handle:
text = handle.read().split("\n[", 1)[0]
except (OSError, ValueError):
return False
return ("lxc.include: /etc/pve/lxc/proxmenux-host-monitor" in text
or ("lxc.net.0.type: none" in text and not re.search(r"^net\d+:", text, re.M)))
def _host_default_route_ipv4():
"""The host's IPv4 address on the interface that carries its default
route: the one a reader on the LAN reaches."""
try:
route = subprocess.run(['ip', '-4', 'route', 'show', 'default'],
capture_output=True, text=True, timeout=3).stdout
match = re.search(r"\bdev\s+(\S+)", route)
if not match:
return None
addr = subprocess.run(['ip', '-4', '-o', 'addr', 'show', 'dev', match.group(1), 'scope', 'global'],
capture_output=True, text=True, timeout=3).stdout
found = re.search(r"\binet\s+([0-9.]+)/", addr)
return found.group(1) if found else None
except (OSError, subprocess.SubprocessError):
return None
def get_lxc_ip_from_lxc_info(vmid):
"""Get LXC IP addresses using lxc-info command (for DHCP containers)
Returns a dict with all IPs and classification"""
@@ -763,6 +793,10 @@ def get_lxc_ip_from_lxc_info(vmid):
isolated = _lxc_isolated_ips(vmid)
if isolated:
real_ips.sort(key=lambda ip: ip in isolated)
if _lxc_shares_host_network(vmid):
host_ip = _host_default_route_ipv4()
if host_ip in real_ips:
real_ips.sort(key=lambda ip: ip != host_ip)
return {
'all_ips': ips,
@@ -15349,7 +15383,9 @@ def api_lxc_console_log(vmid):
lines = request.args.get('lines', default=200, type=int)
offset = request.args.get('offset', type=int)
inode = request.args.get('inode', type=int)
return jsonify(oci_console_logs.read(vmid, lines=lines, offset=offset, inode=inode))
since_start = request.args.get('since_start', '') in ('1', 'true')
return jsonify(oci_console_logs.read(vmid, lines=lines, offset=offset, inode=inode,
since_start=since_start))
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
+14 -6
View File
@@ -78,7 +78,11 @@ def clean(text: str) -> list[str]:
return lines
def _read_tail(handle, size: int, lines: int) -> tuple[list[str], bool]:
# Written by the container's pre-start hook at every start (oci_console_mark.sh).
START_MARK = "=== ProxMenux: container started "
def _read_tail(handle, size: int, lines: int, since_start: bool = False) -> tuple[list[str], bool, bool]:
start = max(0, size - TAIL_READ_LIMIT)
handle.seek(start)
data = handle.read(size - start).decode("utf-8", errors="replace")
@@ -87,11 +91,15 @@ def _read_tail(handle, size: int, lines: int) -> tuple[list[str], bool]:
result = result[1:] # the first line was cut by the read window
if result and result[-1] == "":
result = result[:-1]
head_cut = start > 0 or len(result) > lines
return result[-lines:], head_cut
marks = [index for index, line in enumerate(result) if line.startswith(START_MARK)]
if since_start and marks:
result = result[marks[-1]:]
head_cut = (start > 0 and not (since_start and marks)) or len(result) > lines
return result[-lines:], head_cut, bool(marks)
def read(vmid: int, lines: int = 200, offset: int | None = None, inode: int | None = None) -> dict:
def read(vmid: int, lines: int = 200, offset: int | None = None, inode: int | None = None,
since_start: bool = False) -> dict:
"""The last `lines` lines, or what was appended after `offset`.
`lines=0` reads nothing: it only says whether the container has a
@@ -115,9 +123,9 @@ def read(vmid: int, lines: int = 200, offset: int | None = None, inode: int | No
if offset is None or replaced or offset > size:
# First open, or the file the viewer followed is gone: rotated by
# copytruncate, or recreated with the container.
result, head_cut = _read_tail(handle, size, max(lines, 1))
result, head_cut, has_marks = _read_tail(handle, size, max(lines, 1), since_start)
return {**base, "lines": result, "offset": size, "reset": offset is not None,
"head_truncated": head_cut}
"head_truncated": head_cut, "start_marks": has_marks}
handle.seek(offset)
chunk = handle.read(min(size - offset, FOLLOW_READ_LIMIT))
# Only whole lines are returned; an unfinished last line is left for the
+1
View File
@@ -1,4 +1,5 @@
{
"Tested by the community": "Von der Community getestet",
"# Alternative if you prefer screen": "# Alternative, wenn Sie den Bildschirm bevorzugen",
"# Recommended: avoids disconnection during upgrade": "# Empfohlen: Verhindert Verbindungsabbrüche während des Upgrades",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Überprüfte Einträge werden entfernt. Deaktivieren Sie die Markierung, um sie in VM zu behalten.)",
+1
View File
@@ -1,4 +1,5 @@
{
"Tested by the community": "Probado por la comunidad",
"# Alternative if you prefer screen": "# Alternativa si prefieres la pantalla",
"# Recommended: avoids disconnection during upgrade": "# Recomendado: evita la desconexión durante la actualización",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Las entradas marcadas se eliminarán. Desmarque para mantener en VM).",
+1
View File
@@ -1,4 +1,5 @@
{
"Tested by the community": "Testé par la communauté",
"# Alternative if you prefer screen": "# Alternative si vous préférez l'écran",
"# Recommended: avoids disconnection during upgrade": "# Recommandé : évite la déconnexion lors de la mise à niveau",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Les entrées cochées seront supprimées. Décochez pour conserver dans la VM.)",
+1
View File
@@ -1,4 +1,5 @@
{
"Tested by the community": "Testato dalla comunità",
"# Alternative if you prefer screen": "# Alternativa se preferisci lo schermo",
"# Recommended: avoids disconnection during upgrade": "# Consigliato: evita la disconnessione durante l'aggiornamento",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Le voci selezionate verranno rimosse. Deseleziona per mantenerle nella VM.)",
+1
View File
@@ -1,4 +1,5 @@
{
"Tested by the community": "Testado pela comunidade",
"# Alternative if you prefer screen": "# Alternativa se você preferir tela",
"# Recommended: avoids disconnection during upgrade": "# Recomendado: evita desconexão durante a atualização",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(As entradas marcadas serão removidas. Desmarque para manter na VM.)",
+1
View File
@@ -1,4 +1,5 @@
{
"Tested by the community": "Otestované komunitou",
"# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen",
"# Recommended: avoids disconnection during upgrade": "# Odporúčané: zabráni odpojeniu počas aktualizácie",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Označené položky sa odstránia. Zrušte označenie, ak ich chcete ponechať vo VM.)",
+1
View File
@@ -1,4 +1,5 @@
{
"Tested by the community": "Testad av communityn",
"# Alternative if you prefer screen": "# Alternativ om du föredrar skärm",
"# Recommended: avoids disconnection during upgrade": "# Rekommenderas: undviker frånkoppling under uppgradering",
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Markerade poster kommer att tas bort. Avmarkera för att behålla i VM.)",
+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:
@@ -55,6 +55,9 @@
},
{
"p": "Selecting an application shows its description, the image it runs and two installation modes: <strong>Install with default settings</strong>, which asks almost nothing, and <strong>Install with advanced settings</strong>, which offers the real storage, bridge and resource selectors of the node."
},
{
"p": "An application that has been tested for real shows it in that view: <strong>Verified by ProxMenux</strong> when it was tested in the ProxMenux lab, marked with ✓ in the lists, or <strong>Tested by the community</strong>, with who tested it and when."
}
]
},
@@ -150,6 +150,8 @@
"items": [
"The output is kept on the host in <code>/var/log/proxmenux/oci/VMID.console.log</code> (mode 0600), from the first start and across restarts, so it can be read with the container stopped.",
"The file is rotated at 10 MB, keeping three compressed copies (<code>/etc/logrotate.d/proxmenux-oci</code>).",
"Each start of the container is marked in the output. The tab shows the output since the last start by default; <strong>Full history</strong> also shows the previous ones.",
"When the application is removed, its output is removed with it.",
"The last 100, 500 or 1000 lines are shown. While the container runs, new lines are followed live; scrolling up pauses the follow, and <strong>Follow</strong> resumes it.",
"A filter shows only the lines that contain a text, and <strong>Download</strong> saves the lines loaded.",
"Colour codes are removed and a line that a progress bar redraws is shown in its final state.",
@@ -55,6 +55,9 @@
},
{
"p": "Al seleccionar una aplicación se muestra su descripción, la imagen que ejecuta y dos modos de instalación: <strong>Instalar con la configuración predeterminada</strong>, que apenas hace preguntas, e <strong>Instalar con la configuración avanzada</strong>, que ofrece los selectores reales de almacenamiento, bridge y recursos del nodo."
},
{
"p": "Una aplicación probada en real lo indica en esa vista: <strong>Verificado por ProxMenux</strong> si se probó en el laboratorio de ProxMenux, marcada con ✓ en las listas, o <strong>Probado por la comunidad</strong>, con quién la probó y cuándo."
}
]
},
@@ -150,6 +150,8 @@
"items": [
"La salida se guarda en el host en <code>/var/log/proxmenux/oci/VMID.console.log</code> (modo 0600), desde el primer arranque y entre reinicios, así que puede leerse con el contenedor detenido.",
"El archivo se rota al llegar a 10 MB y se conservan tres copias comprimidas (<code>/etc/logrotate.d/proxmenux-oci</code>).",
"Cada arranque del contenedor queda marcado en la salida. La pestaña muestra por defecto la salida desde el último arranque; <strong>Todo el historial</strong> muestra también los anteriores.",
"Al eliminar la aplicación, su salida se elimina con ella.",
"Se muestran las últimas 100, 500 o 1000 líneas. Con el contenedor en marcha, las líneas nuevas se siguen en directo; al desplazarse hacia arriba el seguimiento se pausa, y <strong>Seguir</strong> lo reanuda.",
"Un filtro muestra solo las líneas que contienen un texto, y <strong>Descargar</strong> guarda las líneas cargadas.",
"Los códigos de color se eliminan y una línea que redibuja una barra de progreso se muestra en su estado final.",