Generalize OCI device setup and remove unused catalog hashes

This commit is contained in:
MacRimi
2026-09-26 01:22:37 +02:00
parent 88acceb8ef
commit f7266e7b44
26 changed files with 689 additions and 487 deletions
@@ -649,7 +649,7 @@ const initMessage = {
return
}
if (value === "cancel" || value === "") {
if (value === "cancel") {
setCurrentInteraction(null)
setInteractionInput("")
handleCloseModal()
@@ -755,7 +755,7 @@ const initMessage = {
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
style={{
+3 -3
View File
@@ -3598,7 +3598,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// user tapped the terminal's Close button and got kicked
// all the way back to the guest list.
if (open) return
if (applyOpen || terminalOpen) return
if (applyOpen || terminalOpen || ociAction) return
setSelectedVM(null)
setVMDetails(null)
setCurrentView("main")
@@ -3624,10 +3624,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// remaining vectors (mobile tap slop reaching the parent's
// scrim, ESC not consumed by the child) at the DOM level.
onInteractOutside={(e) => {
if (applyOpen || terminalOpen) e.preventDefault()
if (applyOpen || terminalOpen || ociAction) e.preventDefault()
}}
onEscapeKeyDown={(e) => {
if (applyOpen || terminalOpen) e.preventDefault()
if (applyOpen || terminalOpen || ociAction) e.preventDefault()
}}
>
{currentView === "main" ? (
+15 -14
View File
@@ -760,7 +760,7 @@
"Checking the device permissions for the application user...": "Verificación de los permisos del dispositivo para el usuario de la aplicación...",
"Checking the image compatibility:": "Verificación de la compatibilidad de la imagen:",
"Checking the image in the registry...": "Revisando la imagen en el registro...",
"Checking the interrupted operation...": "Revisando la interrumpida operación...",
"Checking the interrupted operation...": "Comprobando la operación interrumpida...",
"Checking the interrupted stack operation...": "Revisando la operación de pila interrumpida...",
"Checking the new image without starting it:": "Comprobando la nueva imagen sin comenzarla:",
"Checking the remote...": "Revisando el control remoto...",
@@ -1081,7 +1081,7 @@
"Container started successfully": "El contenedor se inició correctamente",
"Container started successfully.": "El contenedor se inició correctamente.",
"Container started.": "Contenedor iniciado.",
"Container stopped": "Container stopped",
"Container stopped": "Contenedor detenido",
"Container stopped.": "El contenedor se detuvo.",
"Container successfully converted to privileged.": "Contenedor convertido exitosamente a privilegiado.",
"Container template— LXC templates": "Plantilla de contenedor: plantillas LXC",
@@ -1352,7 +1352,7 @@
"Creating the backup": "Crear el backup",
"Creating the container...": "Creando el contenedor...",
"Creating the initial administrator...": "Crear el administrador inicial...",
"Creating the temporary data container": "Creación del contenedor de datos temporales",
"Creating the temporary data container": "Creando el contenedor temporal para los datos",
"Creative & Design": "Creatividad y diseño",
"Credentials are correct": "Las credenciales son correctas",
"Credentials cleared. jwt_secret and API tokens preserved.": "Credenciales borradas. Se conservan los tokens jwt_secret y API.",
@@ -3479,8 +3479,8 @@
"Mount Name": "Nombre de montaje",
"Mount Name:": "Nombre de montaje:",
"Mount Options": "Opciones de montaje",
"Mount Path": "Camino del monte",
"Mount Path:": "Camino de montaje:",
"Mount Path": "Ruta de montaje",
"Mount Path:": "Ruta de montaje:",
"Mount Point": "Punto de montaje",
"Mount Point ID": "ID del punto de montaje",
"Mount Point Removal Summary:": "Resumen de eliminación del punto de montaje:",
@@ -3501,7 +3501,7 @@
"Mount options:": "Opciones de montaje:",
"Mount path must be an absolute path starting with /": "La ruta de montaje debe ser una ruta absoluta que comience con /",
"Mount path:": "Ruta de montaje:",
"Mount paths must not overlap": "Los caminos del monte no deben sobreponerse",
"Mount paths must not overlap": "Las rutas de montaje no deben solaparse",
"Mount point created": "Punto de montaje creado",
"Mount point created.": "Punto de montaje creado.",
"Mount point is visible but NOT writable from inside the container": "El punto de montaje es visible pero NO se puede escribir desde el interior del contenedor.",
@@ -3512,7 +3512,7 @@
"Mount point:": "Punto de montaje:",
"Mount points added:": "Puntos de montaje añadidos:",
"Mount read-only": "Montaje solo lectura",
"Mount shares on HOST first": "Montar acciones en HOST primero",
"Mount shares on HOST first": "Montar primero los recursos compartidos en el host",
"Mount specific dataset": "Montar conjunto de datos específico",
"Mount status:": "Estado de montaje:",
"Mount this device and use it as the backup destination?": "¿Montar este dispositivo y usarlo como destino de respaldo?",
@@ -3527,14 +3527,14 @@
"Mounting CIFS share...": "Montando recurso compartido CIFS...",
"Mounting NFS share...": "Montando recurso compartido NFS...",
"Mounting container filesystem": "Montaje del sistema de archivos del contenedor",
"Mounting disk...": "Disco de montaje...",
"Mounting disk...": "Montando el disco...",
"Mounting existing": "Montaje existente",
"Mounting existing filesystem ({filesystem})...": "Montando el sistema de archivos existente ({filesystem})...",
"Mounting here will hide existing files until unmounted.": "Montar aquí ocultará los archivos existentes hasta que se desmonten.",
"Move that copy offsite (USB, password manager, another host). Delete it from this path when done.": "mueva esa copia fuera del sitio (USB, administrador de contraseñas, otro host).Elimínelo de esta ruta cuando haya terminado.",
"Move to target VM (remove from source VM config)": "Mover a la VM de destino (eliminar de la configuración de la VM de origen)",
"Moving the data volumes aside": "Mover los volúmenes de datos a un lado",
"Moving the data volumes aside...": "Apartando los volúmenes de datos...",
"Moving the data volumes aside": "Separando temporalmente los volúmenes de datos",
"Moving the data volumes aside...": "Separando temporalmente los volúmenes de datos...",
"Multi-container application (experimental)": "Aplicación multicontenedor (experimental)",
"Multi-line variables are not supported": "No se admiten variables multilíneas",
"Multiple networks or external networks are not yet supported": "Aún no se admiten múltiples redes o redes externas",
@@ -3655,7 +3655,7 @@
"NVIDIA patch not applied.": "Parche de NVIDIA no aplicado.",
"NVIDIA per-BDF VFIO binding configured": "Enlace NVIDIA por BDF VFIO configurado",
"NVIDIA permissions or device nodes differ from the official inventory": "Los permisos o nodos de dispositivo NVIDIA difieren del inventario oficial",
"NVIDIA refresh validated; the container is stopped and its settings are kept": "NVIDIA refrescante validado; el contenedor se detiene y sus ajustes se mantienen",
"NVIDIA refresh validated; the container is stopped and its settings are kept": "Actualización de NVIDIA validada; el contenedor está detenido y se conserva su configuración",
"NVIDIA runtime libraries or components are missing": "Faltan bibliotecas o componentes de tiempo de ejecución de NVIDIA",
"NVIDIA selection not supported by this profile": "Selección NVIDIA no compatible con este perfil",
"NVIDIA services stopped and disabled.": "Los servicios de NVIDIA se detuvieron y deshabilitaron.",
@@ -4660,7 +4660,7 @@
"Recover the keyfile using your recovery passphrase?": "¿Recuperar el archivo clave usando su frase de contraseña de recuperación?",
"Recover the previous installation": "Recuperar la instalación anterior",
"Recoverable:": "Recuperable:",
"Recovering the previous installation": "Recuperar la instalación anterior",
"Recovering the previous installation": "Recuperando la instalación anterior",
"Recovery blob upload failed — main backup is OK, but keyfile recovery from PBS will not be available for this backup.": "Error en la carga del blob de recuperación: la copia de seguridad principal está bien, pero la recuperación del archivo clave de PBS no estará disponible para esta copia de seguridad.",
"Recovery blob:": "blob de recuperación:",
"Recovery completed. The container had not been modified yet.": "Recuperación completada. El contenedor aún no había sido modificado.",
@@ -5843,6 +5843,7 @@
"The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "El directorio de destino del archivo está DENTRO de una de las rutas de las que está a punto de realizar una copia de seguridad. Escribir el archivo allí copiaría la copia de seguridad en sí mismo, lo que produciría un archivo corrupto o crecería sin límite hasta que el disco se llenara.",
"The backup could not be identified; the image is not replaced": "El backup no se pudo identificar; la imagen no es reemplazada",
"The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "los metadatos de la copia de seguridad se compararon con este host. Se SALTARÁN los siguientes elementos para mantener el arranque seguro:",
"The backup did not pass its integrity check; creating it again...": "El backup no superó la comprobación de integridad; creándolo de nuevo...",
"The backup of a member could not be identified": "No se pudo identificar el backup de un miembro",
"The backup storage does not exist:": "El almacenamiento de backups no existe:",
"The backup was altered; recovery blocked": "El backup fue alterado; la recuperación bloqueada",
@@ -6046,7 +6047,7 @@
"The official startup of the application is missing": "Falta el inicio oficial de la aplicación",
"The operation already finished; it is not restored automatically": "La operación ya terminada; no se restaura automáticamente",
"The operation could not be completed": "La operación no pudo completarse",
"The operation stopped halfway. Choose \"Recover\" for this container in the OCI management menu to restore the previous installation.": "La operación se detuvo a mitad de camino. Elija \"Recover\" para este contenedor en el menú de gestión OCI para restaurar la instalación anterior.",
"The operation stopped halfway. Choose \"Recover\" for this container in the OCI management menu to restore the previous installation.": "La operación se detuvo a mitad de camino. Elija \"Recuperar la instalación anterior\" para este contenedor en el menú de gestión OCI para restaurar la instalación anterior.",
"The operation was stopped because a shared directory changed its identity:": "La operación se detuvo porque un directorio compartido cambió su identidad:",
"The original MOTD backup is unavailable; no changes were made": "The original MOTD backup is unavailable;no se hicieron cambios",
"The original MOTD configuration has been restored": "La configuración MOTD original ha sido restaurada",
@@ -6094,7 +6095,7 @@
"The record no longer belongs to this operation": "El registro ya no pertenece a esta operación",
"The record of a member was replaced; the assembly is not resumed": "El registro de un miembro fue reemplazado; la asamblea no se reanuda",
"The record or diagnosis could not be completed; no update was run.": "El registro o el diagnóstico no se pudo completar; no se realizó ninguna actualización.",
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "La recuperación no terminó. Revise el registro y elija \"Recover\" de nuevo para este contenedor en el menú de gestión OCI.",
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "La recuperación no terminó. Revise el registro y elija \"Recuperar la instalación anterior\" de nuevo para este contenedor en el menú de gestión OCI.",
"The remote does not exist; create and authorize it first in the WebUI:": "El remoto no existe; crear y autorizar primero en el WebUI:",
"The remote installer must run as root on Proxmox VE": "El instalador remoto debe funcionar como root en Proxmox VE",
"The remote must already be created and authorized in the Rclone web UI. This operation restarts the CT and publishes two FUSE views on the host.": "El remoto ya debe estar creado y autorizado en la interfaz web de Rclone. Esta operación reinicia el CT y publica dos puntos de vista FUSE sobre el host.",
+1 -1
View File
@@ -82,7 +82,7 @@
{
"name": "ND_BASEURL",
"example": "",
"required": true,
"required": false,
"sensitive": false,
"source": "docker-compose"
},
File diff suppressed because it is too large Load Diff
+25 -3
View File
@@ -20,6 +20,28 @@ die() {
exit 1
}
container_is_running() {
local status attempt
for (( attempt=1; attempt<=3; attempt++ )); do
if status=$(pct status "$VMID" 2>/dev/null); then
case "$status" in
'status: running') return 0 ;;
'status: stopped') return 1 ;;
esac
fi
# A failed pct probe must not be mistaken for a stopped container.
oci_log "pct status could not confirm CT $VMID (attempt $attempt); checking lxc-info"
if status=$(lxc-info -n "$VMID" -sH 2>/dev/null); then
case "$status" in
RUNNING) return 0 ;;
STOPPED) return 1 ;;
esac
fi
(( attempt < 3 )) && sleep 1
done
return 1
}
mount_ct_rootfs() {
oci_quiet pct mount "$VMID" || die "$(translate "Could not mount the container filesystem:") CT $VMID"
}
@@ -107,7 +129,7 @@ check_native_device_permissions() {
msg_info "$(translate "Checking the device permissions for the application user...")"
oci_log "Checking device access as abc (this is not a codec test)."
for (( attempt=0; attempt<60; attempt++ )); do
pct status "$VMID" | grep -q 'status: running' \
container_is_running \
|| die "$(translate "The container stopped before the GPU permissions were verified:") CT $VMID"
if pct exec "$VMID" -- s6-setuidgid abc sh -c \
'for path do test -r "$path" && test -w "$path" || exit 1; done' \
@@ -1792,7 +1814,7 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
oci_log "Waiting for the service: $HC_URL"
msg_info "$(translate "Waiting for the application to respond...")"
while (( HC_ELAPSED < HC_TIMEOUT )); do
if [[ $(pct status "$VMID" 2>/dev/null || true) != "status: running" ]]; then
if ! container_is_running; then
oci_log "The container stopped during its first start. Last console messages:"
[[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}"
die "$(translate "The container stopped before the application responded:") CT $VMID"
@@ -1838,7 +1860,7 @@ if [[ $START_AFTER == 1 && $HAS_RUNNING_CHECK == 1 ]]; then
msg_info "$(translate "Checking that the container keeps running...")"
RC_START=$(date +%s)
while (( $(date +%s) - RC_START < RC_STABILITY )); do
if [[ $(pct status "$VMID" 2>/dev/null || true) != "status: running" ]]; then
if ! container_is_running; then
oci_log "The container stopped after starting. Last console messages:"
[[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}"
die "$(translate "The container stopped after starting:") CT $VMID"
+2 -6
View File
@@ -60,11 +60,7 @@ def render(template, digest, instance_id, ip=''):
source = template.get('source') or {}
image_url = (source.get('image_repository_url') or image_page(reference)
or ui.get('repository') or source.get('repository'))
resources = [('Image', image_url), ('App', ui.get('website')),
('App docs', ui.get('documentation'))]
repository = ui.get('repository') or source.get('repository')
if safe_url(repository) and repository != image_url:
resources.append(('Repository', repository))
resources = [('Image', image_url), ('App', ui.get('website'))]
resources = [link(label, url) for label, url in resources]
resources = [item for item in resources if item]
try:
@@ -88,7 +84,7 @@ def render(template, digest, instance_id, ip=''):
if not isinstance(path, str) or not path.startswith('/'):
path = '/'
url = f'{scheme}://{address}:{port}{path}'
item = f'{link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
item = f'&#127760; {link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
if item:
access.append(item)
badges = (
+4
View File
@@ -97,6 +97,10 @@ def propose(record, config):
before = parse_config(record['observed']['config'].encode())
current = parse_config(config)
changed = sorted(key for key in before.keys() | current.keys() if before.get(key) != current.get(key))
# Notes do not describe a mount, device or runtime setting. The OCI marker
# was checked above, so a presentation-only change needs no adoption.
if 'description' in changed:
changed.remove('description')
new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)]
unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE]
if unsupported:
+77 -11
View File
@@ -24,6 +24,7 @@ import stat
import sys
import time
import uuid
from urllib.parse import unquote
import oci_instances as instances
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
@@ -204,6 +205,23 @@ def recovery_hint(after_recovery=False):
msg_warn(translate('The operation stopped halfway. Choose "Recover" for this container in the OCI management menu to restore the previous installation.'))
def recover_untouched(root, journal):
"""Close an operation that failed before the container was changed:
start the container again and restore its record, so nothing is left to
recover by hand. Returns whether it did."""
try:
state = json.loads(Path(journal).read_text())
if state.get('coordinated') or state.get('phase') in TERMINAL:
return False
if run('pct', 'config', str(state['vmid'])).decode() != state['before_config']:
return False
recover(root, Path(journal))
return True
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
log(f'automatic recovery skipped: {error}')
return False
def fit(text):
"""A step line that is rewritten in place must not wrap."""
width = max(shutil.get_terminal_size((80, 24)).columns - 8, 30)
@@ -211,7 +229,11 @@ def fit(text):
def run(*args):
log('$ ' + shlex.join(str(arg) for arg in args))
private_description = args[:2] == ('pct', 'set') and '--description' in args
shown = [str(arg) for arg in args]
if private_description:
shown[shown.index('--description') + 1] = '[notes redacted]'
log('$ ' + shlex.join(shown))
# OCI extraction enters an unprivileged user namespace; PVE's newly created
# traversal directories must not inherit a caller's restrictive umask.
pve_creation = (args[:2] in (('pct', 'create'), ('pct', 'restore'))
@@ -224,12 +246,37 @@ def run(*args):
raise
if result.returncode:
log(f' exit {result.returncode}')
log_output(None if tuple(args[:2]) in DATA_COMMANDS else result.stdout, result.stderr)
log_output(None if private_description or tuple(args[:2]) in DATA_COMMANDS else result.stdout,
None if private_description else result.stderr)
if result.returncode:
raise RuntimeError(f"{args[0]} {translate('failed with exit code')} {result.returncode}")
return result.stdout
def verified_backup(vmid, directory, compression, unidentified, show=False):
"""A stop-mode vzdump of vmid in directory that passes its integrity
check. An archive that fails the check is written once more before the
operation gives up."""
suffix = 'zst' if compression == 'zstd' else 'gz'
for attempt in (1, 2):
run('vzdump', str(vmid), '--mode', 'stop', '--compress', compression,
'--dumpdir', str(directory), '--tmpdir', '/var/tmp')
backups = list(directory.glob(f'vzdump-lxc-*.tar.{suffix}'))
if len(backups) != 1:
raise ValueError(unidentified)
try:
run('zstd' if compression == 'zstd' else 'gzip', '-t', str(backups[0]))
return backups[0]
except RuntimeError:
if attempt == 2:
raise
log('backup attempt 1/2 failed its integrity check')
for damaged in directory.glob('vzdump-lxc-*'):
damaged.unlink()
if show:
msg_warn(translate('The backup did not pass its integrity check; creating it again...'))
def filehash(path):
value = hashlib.sha256()
with Path(path).open('rb') as source:
@@ -322,6 +369,10 @@ def external_changes(record, config, adopt=True):
return {}
before, now = parse_config(record['observed']['config'].encode()), parse_config(config)
changed = sorted(key for key in before.keys() | now.keys() if before.get(key) != now.get(key))
if ('description' in changed
and instances.identity(record['observed']['config'].encode()) == record['installation_id']
and instances.identity(config) == record['installation_id']):
changed.remove('description')
cores_key = 'cpulimit' if 'cpulimit' in before and 'cores' not in before else 'cores'
values = {}
for key in changed if adopt else ():
@@ -682,6 +733,22 @@ def restore_firewall(vmid, state):
Path(f'/etc/pve/firewall/{vmid}.fw').write_text(saved)
def original_description(state):
"""Return the user's original Notes, including text added outside ProxMenux."""
description = state.get('original_description')
if description is None:
# Journals created before this safeguard only have pct's escaped config.
description = unquote(parse_config(state['before_config'].encode()).get('description', ''))
if not isinstance(description, str) or instances.identity(
json.dumps({'description': description}).encode()) != state['record']['installation_id']:
raise ValueError(translate('The container identity changed; nothing was adopted'))
return description
def restore_description(vmid, state):
run('pct', 'set', str(vmid), '--description', original_description(state))
def release_stage(state):
"""After a commit the holder CT only keeps its own rootfs: every parked
volume went back to the application. Anything still attached keeps it."""
@@ -791,6 +858,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
candidate = candidate_contract(record, operation, proposal)
before = run('pct', 'config', str(vmid))
cfg, actual, mac = preflight(record, candidate, before, coordinated)
description = original_description({'record': record, 'before_config': before.decode()})
original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data)
original_gpu = gpu_devices.planned(record['deployment'])
desired_gpu = gpu_devices.planned(candidate['deployment'])
@@ -823,6 +891,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
for p, m in actual.items() if p in required and not m['volume'].startswith('/')])
state = {'schema_version': 1, 'id': directory.name, 'vmid': vmid, 'operation': operation,
'record': record, 'candidate_contract': candidate, 'before_config': before.decode(),
'original_description': description,
'archive': str(archive), 'archive_sha256': filehash(archive),
'registry_digest': registry_digest or image['manifest_digest'],
'runtime_template': candidate['template'], 'runtime_deployment': runtime_deployment,
@@ -867,14 +936,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
else:
backup_dir = directory / 'backup'
private_directory(backup_dir)
run('vzdump', str(vmid), '--mode', 'stop', '--compress', backup_compression,
'--dumpdir', str(backup_dir), '--tmpdir', '/var/tmp')
suffix = 'zst' if backup_compression == 'zstd' else 'gz'
backups = list(backup_dir.glob(f'vzdump-lxc-*.tar.{suffix}'))
if len(backups) != 1:
raise ValueError(translate('The backup could not be identified; the image is not replaced'))
run('zstd' if backup_compression == 'zstd' else 'gzip', '-t', str(backups[0]))
state.update(backup=str(backups[0]), backup_sha256=filehash(backups[0]))
backup = verified_backup(vmid, backup_dir, backup_compression,
translate('The backup could not be identified; the image is not replaced'), show)
state.update(backup=str(backup), backup_sha256=filehash(backup))
checkpoint(journal, state, 'backup-ready')
if show:
msg_ok(translate('Backup created'))
@@ -920,6 +984,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
if show:
progress = translate('Installing the new image:') if update else translate('Recreating the container:')
install_candidate(root, journal, state, progress)
restore_description(vmid, state)
restore_firewall(vmid, state)
if coordinated:
for key, value in state['preserved_stack_config'].items():
@@ -1239,7 +1304,8 @@ def main():
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
with contextlib.redirect_stdout(output):
report_error(error, f'oci-{args.action}-{args.vmid}')
if pending_journal():
journal = pending_journal()
if journal and (args.action == 'recover' or not recover_untouched(args.root, journal)):
recovery_hint(after_recovery=args.action == 'recover')
return 1
+18
View File
@@ -223,6 +223,24 @@ def identity(config):
return match.group(1) if match else None
def same_config_except_notes(record, config):
"""Accept a presentation-only note edit, never a changed OCI identity or LXC setting."""
previous = record['observed']['config'].encode()
expected = record['installation_id']
if identity(previous) != expected or identity(config) != expected:
return False
def without_notes(value):
lines = value.splitlines(keepends=True)
notes = [line for line in lines if line.startswith(b'description: ')]
if len(notes) != 1:
return None
return b''.join(line for line in lines if not line.startswith(b'description: '))
original = without_notes(previous)
return original is not None and original == without_notes(config)
def save_assembly(root, primary_id, template, deployment, members):
path = location(root, primary_id).parent / 'stack-assembly.json'
if path.exists() or path.is_symlink():
+1 -2
View File
@@ -76,8 +76,7 @@ def refresh(root, vmid, apply=False):
if not nv.enabled(record['deployment']):
raise ValueError(translate('The instance does not use NVIDIA'))
config = instances.command('pct', 'config', str(vmid))
if (instances.identity(config) != record['installation_id']
or instances.sha(config) != record['observed']['config_sha256']):
if not instances.same_config_except_notes(record, config):
raise ValueError(translate('The container identity or configuration changed'))
previous = record['observed']['gpu_devices'][nv.KEY]
plan = nv.refresh_plan(config, previous)
+4 -8
View File
@@ -191,7 +191,7 @@ class NativeAdapter:
config = instances.command('pct', 'config', str(vmid))
if instances.identity(config) != record['installation_id']:
raise ValueError(translate('The identity of a member was replaced'))
if (not self.journal.exists() or not self.state().get('replacement_intent')) and sha(config) != record['observed']['config_sha256']:
if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config):
raise ValueError(translate('A member configuration changed during the preparation'))
else:
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
@@ -321,13 +321,9 @@ class NativeAdapter:
self.validate(self.plan)
directory = self.journal.parent / ('backup-%s' % vmid)
private_directory(directory)
member_tx.run('vzdump', str(vmid), '--mode', 'stop', '--compress', 'zstd',
'--dumpdir', str(directory), '--tmpdir', '/var/tmp')
backups = list(directory.glob('vzdump-lxc-*.tar.zst'))
if len(backups) != 1:
raise ValueError(translate('The backup of a member could not be identified'))
member_tx.run('zstd', '-t', str(backups[0]))
return {'archive': str(backups[0]), 'sha256': member_tx.filehash(backups[0])}
archive = member_tx.verified_backup(vmid, directory, 'zstd',
translate('The backup of a member could not be identified'))
return {'archive': str(archive), 'sha256': member_tx.filehash(archive)}
def verify_backups(self, backups):
for backup in backups.values():
+2 -1
View File
@@ -192,7 +192,8 @@ def main():
return 1
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
transaction.report_error(error, f'update-{args.vmid}')
if transaction.pending_journal():
journal = transaction.pending_journal()
if journal and not transaction.recover_untouched(instances.ROOT, journal):
transaction.recovery_hint()
return 1
+2
View File
@@ -115,6 +115,8 @@ def build_suite(template, ui, mode='advanced'):
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, services, storage)
from .extra_devices import ask_stack_extra_devices
ask_stack_extra_devices(ui, services)
return {'deployment_kind':'generic-multi-lxc-stack','suite_arr':True,'lifecycle_mode':'independent','stack_name':name,
'base_vmid':int(base) if base else None,'services':services,'shared_media':shared,'media_player':player,
'completion_notes':[
+24 -9
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import json
import hashlib
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
@@ -176,7 +175,6 @@ class Catalog:
),
"template": f"apps/{repo.app_id}.json" if repo.app_id in existing else None,
"template_status": existing.get(repo.app_id),
"content_hash": self._template_hash(repo.app_id) if repo.app_id in existing else None,
}
for repo, summary in discovered
]
@@ -291,7 +289,6 @@ class Catalog:
"category_label": metadata.get("category_label"),
"template": f"apps/{catalog_id}.json" if catalog_id in existing else None,
"template_status": existing.get(catalog_id),
"content_hash": self._template_hash(catalog_id) if catalog_id in existing else None,
}
)
@@ -515,6 +512,24 @@ class Catalog:
except (OSError, json.JSONDecodeError, KeyError, TypeError):
return
def _preserve_optional_environment(self, app_id: str, template: dict[str, Any]) -> None:
existing_path = self.apps_dir / f"{app_id}.json"
if not existing_path.is_file():
return
try:
existing = json.loads(existing_path.read_text(encoding="utf-8"))
if (existing["container_contract"]["image"]["repository"] !=
template["container_contract"]["image"]["repository"]):
return
optional = {item["name"]: item for item in existing["container_contract"]["environment"]
if item.get("required") is False}
for item in template["container_contract"]["environment"]:
previous = optional.get(item["name"])
if previous and previous.get("example") == item.get("example"):
item["required"] = False
except (OSError, json.JSONDecodeError, KeyError, TypeError):
return
def generate(self, app_id: str) -> tuple[Path, dict[str, Any]]:
item = self.find_item(app_id)
provider = item.get("provider", "linuxserver.io")
@@ -541,6 +556,7 @@ class Catalog:
raise ConversionError(f"Proveedor no soportado: {provider}")
catalog_id = item["id"]
self._apply_overlay(catalog_id, template)
self._preserve_optional_environment(catalog_id, template)
self._preserve_registry_state(catalog_id, template)
self.validate(template)
self.apps_dir.mkdir(parents=True, exist_ok=True)
@@ -593,6 +609,7 @@ class Catalog:
else:
raise ConversionError(f"Proveedor no soportado: {item_provider}")
self._apply_overlay(item["id"], template)
self._preserve_optional_environment(item["id"], template)
self._preserve_registry_state(item["id"], template)
self.validate(template)
return item["id"], template
@@ -661,7 +678,6 @@ class Catalog:
item["architectures"] = supported_architectures(
template["catalog_ui"]["architectures"]
)
item["content_hash"] = self._template_hash(app_id)
self._write_json(self.index_path, index)
generated.sort()
failed.sort(key=lambda item: item["id"])
@@ -759,7 +775,6 @@ class Catalog:
"curated_path": str(path.relative_to(self.root)),
"template": f"apps/{app_id}.json" if app_id in existing else None,
"template_status": existing.get(app_id),
"content_hash": self._template_hash(app_id) if app_id in existing else None,
}
)
return result
@@ -768,7 +783,11 @@ class Catalog:
path = self.overlays_dir / f"{app_id}.json"
if path.exists():
overlay = json.loads(path.read_text(encoding="utf-8"))
environment_overrides = overlay.pop("environment_overrides", {})
self._deep_merge(template, overlay)
for item in template.get("container_contract", {}).get("environment", []):
if item.get("name") in environment_overrides:
item.update(environment_overrides[item["name"]])
from .stack import apply_stack_support
apply_stack_support(template)
from .gpu import apply_gpu_contract
@@ -809,7 +828,6 @@ class Catalog:
"untranslated_blockers": template["compatibility"][
"untranslated_blockers"
],
"content_hash": self._template_hash(app_id),
}
)
if item.get("template_family") == "curated-profile":
@@ -826,6 +844,3 @@ class Catalog:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
temporary.replace(path)
def _template_hash(self, app_id: str) -> str:
return hashlib.sha256((self.apps_dir / f"{app_id}.json").read_bytes()).hexdigest()
+17 -3
View File
@@ -366,10 +366,24 @@ def _install(catalog: Catalog, ui, item: dict[str, Any], mode: str) -> None:
def install_template(ui, template: dict[str, Any], identifier: str, mode: str) -> dict[str, Any] | None:
"""Configures and installs one template, from the catalog or written from a
definition the user gave."""
deployment = build_deployment(template, ui, mode)
if not ui.review(_deployment_summary_text(template, deployment), translate("Installation summary"),
question=translate("Install with this configuration?")):
from .ui import BacktrackUI, RestartWizard
wizard = BacktrackUI(ui)
try:
while True:
candidate = copy.deepcopy(template)
try:
deployment = build_deployment(candidate, wizard, mode)
approved = wizard.review(_deployment_summary_text(candidate, deployment),
translate("Installation summary"),
question=translate("Install with this configuration?"))
break
except RestartWizard:
wizard.restart()
finally:
wizard.close()
if not approved:
return None
template = candidate
console.show_logo()
console.msg_title(f"{source_text(template['catalog_ui']['title']) or identifier} · {APP_TITLE}")
try:
+104
View File
@@ -0,0 +1,104 @@
"""Explicit native LXC devices beyond an application's curated profile."""
import copy
import re
from pathlib import Path
from .i18n import translate
from .ui import UserCancelled
GPU_NODE = re.compile(r'/dev/dri/(?:renderD|card)[0-9]+|/dev/kfd')
USB_NODE = re.compile(r'/dev/(?:ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})')
CORAL_NODE = re.compile(r'/dev/apex_[0-9]+')
def ask_extra_devices(ui, devices, unprivileged, allow_coral=False):
"""Keep manual attachments separate from image-owned GPU profiles."""
result = list(devices)
while ui.confirm(translate('Add another GPU or USB device manually?'), False):
options = [
('gpu', translate('Intel/AMD DRM node (device only)')),
('nvidia', translate('NVIDIA runtime (device and host driver libraries)')),
('usb', translate('USB or serial device node')),
]
if allow_coral:
options.append(('coral', translate('Coral PCIe/M.2 device node')))
kind = ui.choose(translate('Device to attach'), options)
if kind is None:
raise UserCancelled(translate('Device configuration cancelled'))
if kind == 'nvidia':
if any(item.get('kind') == 'nvidia-runtime' for item in result):
raise ValueError(translate('NVIDIA is already attached'))
if not ui.confirm(translate('Passing NVIDIA does not enable acceleration inside the application. '
'The host needs NVIDIA Container Toolkit and a compatible image. Continue?'), False):
continue
result.append({'id': 'manual-nvidia', 'kind': 'nvidia-runtime',
'device_selection': 'all-requested-by-compose',
'runtime_mode': 'dynamic' if unprivileged else 'static'})
continue
if kind == 'gpu':
candidates = sorted(str(path) for path in Path('/dev/dri').glob('renderD*'))
default = candidates[0] if candidates else '/dev/dri/renderD128'
path = ui.ask(translate('Host DRM node (e.g. /dev/dri/renderD128)'), default)
valid = GPU_NODE.fullmatch(path)
elif kind == 'usb':
path = ui.ask(translate('Host USB node (e.g. /dev/ttyACM0 or /dev/bus/usb/003/004)'),
'/dev/ttyACM0')
valid = USB_NODE.fullmatch(path)
else:
path = ui.ask(translate('Host Coral node (e.g. /dev/apex_0)'), '/dev/apex_0')
valid = CORAL_NODE.fullmatch(path)
if not valid:
raise ValueError(translate('Choose a specific supported GPU or USB node'))
if any(item.get('host_path') == path for item in result):
raise ValueError(translate('This device is already attached'))
if kind == 'usb' and '/bus/usb/' in path:
ui.info(translate('USB bus numbers can change after reconnecting or rebooting.'))
result.append({'id': 'manual-' + path.removeprefix('/dev/').replace('/', '-'),
'kind': 'character-device', 'host_path': path, 'container_path': path,
'mode': '0660', 'deny_write': False,
'gid_strategy': 'host-device-gid'})
return result
def device_permissions(image, devices, existing=None):
if existing:
return existing
repository = image.split('@', 1)[0].rsplit(':', 1)[0]
if repository.startswith(('lscr.io/linuxserver/', 'linuxserver/', 'docker.io/linuxserver/')) and any(
item.get('kind') == 'character-device' for item in devices):
return {'strategy': 'linuxserver-native-init', 'service_user': 'abc',
'environment': 'ATTACHED_DEVICES_PERMS',
'paths': 'all-resolved-selected-character-devices'}
return None
def ask_stack_extra_devices(ui, services):
"""Ask once per device, then select the stack members that need it."""
devices = ask_extra_devices(ui, [], True)
if not devices:
return
options = [(service['name'], service['name']) for service in services]
for device in devices:
selected = ui.checklist(
f"{translate('Containers that will receive this device')}: "
f"{device.get('host_path', 'NVIDIA')}", options,
[service['name'] for service in services if service.get('main')] or [options[-1][0]])
if not selected or set(selected) - {name for name, _ in options}:
raise ValueError(translate('Select at least one stack container'))
for service in services:
if service['name'] not in selected:
continue
plan = service['deployment']
existing = plan.setdefault('devices', [])
if any(item.get('host_path') == device.get('host_path') if device.get('host_path')
else item.get('kind') == 'nvidia-runtime' for item in existing):
raise ValueError(translate('This device is already attached'))
member_device = copy.deepcopy(device)
if member_device['kind'] == 'nvidia-runtime':
member_device['runtime_mode'] = (
'dynamic' if plan.get('security', {}).get('unprivileged', True) else 'static')
existing.append(member_device)
image = service['template']['container_contract']['image']['reference']
plan['device_permissions'] = device_permissions(
image, existing, plan.get('device_permissions'))
+13 -1
View File
@@ -20,6 +20,7 @@ from . import network as access
from .i18n import translate
from .ui import DialogUI, TerminalUI, UserCancelled
from .custom_mounts import ask_custom_mounts
from .extra_devices import device_permissions
class InstallError(RuntimeError):
@@ -379,6 +380,15 @@ def build_deployment(
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
installer_profile, environment, unprivileged, ui, mode)
if advanced:
from .extra_devices import ask_extra_devices
reference = template['container_contract']['image']['reference']
repository = reference.split('@', 1)[0].rsplit(':', 1)[0]
devices = ask_extra_devices(
ui, devices, unprivileged,
allow_coral=repository in ('ghcr.io/blakeblackshear/frigate',
'codeproject/ai-server', 'docker.io/codeproject/ai-server'))
from .gpu import apply_profile_image
apply_profile_image(template, selected_hardware_profile)
@@ -455,7 +465,9 @@ def build_deployment(
"tmpfs_mounts": tmpfs_mounts,
"devices": devices,
"hardware_profile": selected_hardware_profile,
"device_permissions": installer_profile.get("device_permissions") if devices else None,
"device_permissions": (device_permissions(template['container_contract']['image']['reference'],
devices, installer_profile.get('device_permissions'))
if devices else None),
"post_start_configurations": post_start_configurations,
"extra_hosts": installer_profile.get("extra_hosts", []),
}
+14 -3
View File
@@ -216,10 +216,21 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()):
if action == 'recreate':
from .recreation import edit_recreation
from .cli import _deployment_summary_text
proposal = edit_recreation(record, ui)
if not ui.review(_deployment_summary_text(proposal['candidate']['template'],
from .ui import BacktrackUI, RestartWizard
wizard = BacktrackUI(ui)
try:
while True:
try:
proposal = edit_recreation(record, wizard)
approved = wizard.review(_deployment_summary_text(proposal['candidate']['template'],
proposal['candidate']['deployment']), translate('Recreate OCI'),
question=translate('Recreate with these options?'), default=True):
question=translate('Recreate with these options?'), default=True)
break
except RestartWizard:
wizard.restart()
finally:
wizard.close()
if not approved:
return False
elif not ui.review(translate('The current image of the saved channel will be checked and downloaded. Resources, paths and GPU are kept. The CT is stopped during the replacement and a native backup is created first.'),
translate('Update OCI'), question=translate('Update now?'), default=True):
+20 -51
View File
@@ -104,31 +104,6 @@ def edit_environment(deployment, ui):
environment.append(item)
def edit_peripherals(deployment, ui, allow_coral=False):
devices = deployment.setdefault('devices', [])
label = 'Coral/USB' if allow_coral else 'USB'
example = '/dev/apex_0, ' if allow_coral else ''
while ui.confirm(f"{translate('Add or change a device')} ({label})?", False):
path = ui.ask(f"{translate('Host device node')} ({translate('e.g.')} {example}/dev/ttyACM0, /dev/bus/usb/003/004)")
if not re.fullmatch(r'/dev/(apex_[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path):
raise ValueError(translate('Select a specific Coral or USB node, not the whole /dev'))
if path.startswith('/dev/apex_') and not allow_coral:
raise ValueError(translate('Coral is only offered for Frigate and CodeProject.AI'))
if '/bus/usb/' in path:
ui.info(translate('The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.'))
old = next((d for d in devices if d.get('host_path') == path), None)
mode = ui.ask(translate('Node octal permissions (e.g. 0660)'), (old or {}).get('mode', '0660'))
if not re.fullmatch(r'0?[0-7]{3}', mode):
raise ValueError(translate('Invalid octal permissions'))
item = dict(old or {}, id=(old or {}).get('id', 'peripheral-' + path.removeprefix('/dev/').replace('/', '-')),
kind='character-device', host_path=path, container_path=path,
mode=mode, gid_strategy='host-device-gid', deny_write=False)
if old:
devices[devices.index(old)] = item
else:
devices.append(item)
def edit_recreation(record, ui):
candidate = copy.deepcopy(record)
refresh_template(candidate, ui)
@@ -136,32 +111,21 @@ def edit_recreation(record, ui):
resources = deployment['resources']
resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores'])
resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128)
while ui.confirm(translate('Add a custom data path?'), False):
target = absolute_path(ui.ask(translate('Path inside the container'), '/data/custom'))
existing = [m['container_path'].rstrip('/') for m in deployment['mounts']]
if any(target == p or target.startswith(p + '/') or p.startswith(target + '/') for p in existing):
raise ValueError(translate('The path overlaps an existing mount'))
mode = ui.choose(translate('Persistence for the new path'),
[('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
'managed-volume')
if mode is None:
raise UserCancelled(translate('Custom path cancelled'))
mount = {'type': mode, 'container_path': target, 'read_only': False}
if mode == 'managed-volume':
mount.update(source=ui.ask(translate('Proxmox storage for the volume'), deployment['rootfs']['storage']),
size_gb=positive_integer(ui, translate('Volume size in GB'), 4), backup=True)
else:
mount.update(source=absolute_path(ui.ask(translate('Host directory'),
'/mnt/oci-shared/custom')), size_gb=None, backup=False,
create_if_missing=True)
deployment['mounts'].append(mount)
from .custom_mounts import ask_custom_mounts
deployment['mounts'] = ask_custom_mounts(
ui, deployment['mounts'], deployment['rootfs']['storage'])
if ui.confirm(translate('Change the access network?'), False):
edit_network(deployment, ui)
edit_acceleration(candidate, ui)
from .extra_devices import ask_extra_devices
reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '')
repository = reference.split('@')[0].rsplit(':', 1)[0]
edit_peripherals(deployment, ui, repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server'))
deployment['devices'] = ask_extra_devices(
ui, deployment.get('devices', []), deployment.get('security', {}).get('unprivileged', True),
allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server'))
from .extra_devices import device_permissions
deployment['device_permissions'] = device_permissions(
reference, deployment['devices'], deployment.get('device_permissions'))
edit_environment(deployment, ui)
proposal = {'operation': 'recreate', 'candidate': candidate}
if record.get('observed', {}).get('config_sha256'):
@@ -172,14 +136,19 @@ def edit_recreation(record, ui):
def refresh_template(candidate, ui):
from .catalog import Catalog
old = candidate.get('template', {})
name = old.get('id', '').removeprefix('image-')
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', name):
template_id = old.get('id', '')
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', template_id):
return
root = Path(__file__).resolve().parents[2]
path = root / 'catalog' / 'apps' / (name + '.json')
if not path.is_file() or not old.get('container_contract', {}).get('image'):
if not old.get('container_contract', {}).get('image'):
return
latest = Catalog(root).load_template(name, generate_if_missing=False)
catalog = Catalog(root)
matching = [item for item in catalog.load_index()['applications']
if item.get('template_id') == template_id]
if len(matching) != 1:
return
name = matching[0]['id']
latest = catalog.compose(name)
if latest == old:
return
if not ui.confirm(translate('Apply the options from the current catalog template? Your data and configuration are kept.'), True):
+2
View File
@@ -355,6 +355,8 @@ def build_stack(template, ui, mode='advanced'):
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, plans, volumes)
from .extra_devices import ask_stack_extra_devices
ask_stack_extra_devices(ui, plans)
return {'deployment_kind':'generic-multi-lxc-stack','stack_name':name,'base_vmid':int(vmid) if vmid else None,
'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]),
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
+96 -3
View File
@@ -16,17 +16,96 @@ class UserCancelled(RuntimeError):
pass
class BackRequested(RuntimeError):
pass
class RestartWizard(RuntimeError):
pass
class BacktrackUI:
"""Replay prior answers when returning to a previous wizard question."""
def __init__(self, base):
self.base = base
self.answers = []
self.cursor = 0
self.previous_back_enabled = getattr(base, 'back_enabled', False)
base.back_enabled = True
def __getattr__(self, name):
return getattr(self.base, name)
def close(self):
self.base.back_enabled = self.previous_back_enabled
def restart(self):
self.cursor = 0
def _call(self, name, *args, **kwargs):
if self.cursor < len(self.answers):
saved_name, value = self.answers[self.cursor]
if saved_name != name:
self.answers = self.answers[:self.cursor]
else:
self.cursor += 1
return value
try:
value = getattr(self.base, name)(*args, **kwargs)
except BackRequested:
if self.cursor:
self.answers = self.answers[:self.cursor - 1]
self.cursor = 0
raise RestartWizard()
raise UserCancelled(translate('Wizard cancelled'))
self.answers.append((name, value))
self.cursor += 1
return value
def ask(self, *args, **kwargs):
return self._call('ask', *args, **kwargs)
def password(self, *args, **kwargs):
return self._call('password', *args, **kwargs)
def confirm(self, *args, **kwargs):
return self._call('confirm', *args, **kwargs)
def choose(self, *args, **kwargs):
return self._call('choose', *args, **kwargs)
def checklist(self, *args, **kwargs):
return self._call('checklist', *args, **kwargs)
def detail_menu(self, *args, **kwargs):
return self._call('detail_menu', *args, **kwargs)
def review(self, *args, **kwargs):
try:
return self.base.review(*args, **kwargs)
except BackRequested:
if self.cursor:
self.answers = self.answers[:self.cursor - 1]
self.cursor = 0
raise RestartWizard()
raise UserCancelled(translate('Wizard cancelled'))
APP_TITLE = "OCI manager Apps (beta)"
@dataclass
class TerminalUI:
title: str = APP_TITLE
back_enabled: bool = False
def ask(self, text: str, default: str | None = None, required: bool = True) -> str:
suffix = f" [{default}]" if default not in (None, "") else ""
while True:
value = input(f"{text}{suffix}: ").strip()
if self.back_enabled and value == ':back':
raise BackRequested()
if value:
return value
if default is not None:
@@ -38,18 +117,25 @@ class TerminalUI:
def password(self, text: str, required: bool = True) -> str:
while True:
value = getpass.getpass(f"{text}: ")
if self.back_enabled and value == ':back':
raise BackRequested()
if not value:
if not required:
return ""
print(translate("This value is required."))
continue
if value == getpass.getpass(f"{translate('Repeat to confirm')}: "):
repeated = getpass.getpass(f"{translate('Repeat to confirm')}: ")
if self.back_enabled and repeated == ':back':
raise BackRequested()
if value == repeated:
return value
print(translate("The values do not match. Enter them again."))
def confirm(self, text: str, default: bool = False) -> bool:
suffix = " [Y/n]" if default else " [y/N]"
value = input(f"{text}{suffix}: ").strip().casefold()
if self.back_enabled and value == ':back':
raise BackRequested()
if not value:
return default
return value in {"y", "yes", "s", "si"}
@@ -93,6 +179,7 @@ class DialogUI:
title: str = APP_TITLE
backtitle: str = "ProxMenux"
back_enabled: bool = False
@staticmethod
def available() -> bool:
@@ -103,10 +190,16 @@ class DialogUI:
if environment.get("TERM", "").casefold() in {"", "dumb", "unknown"}:
environment["TERM"] = "xterm-256color"
# dialog draws on the terminal and writes the selection to stderr.
return subprocess.run(
["dialog", "--no-collapse", "--backtitle", self.backtitle, "--title", title or self.title, *widget],
back_widget = ['--extra-button', '--extra-label', 'Volver'] if self.back_enabled and any(
flag in widget for flag in ('--inputbox', '--passwordbox', '--yesno', '--menu', '--checklist')) else []
result = subprocess.run(
["dialog", "--no-collapse", "--backtitle", self.backtitle, "--title", title or self.title,
*back_widget, *widget],
stdout=None, stderr=subprocess.PIPE, text=True, check=False, env=environment,
)
if result.returncode == 3 and back_widget:
raise BackRequested()
return result
@staticmethod
def _size(text: str, min_height: int, width: int, extra: int = 6) -> tuple[str, str]:
+58
View File
@@ -5,6 +5,7 @@ import sys
import tempfile
import unittest
from unittest.mock import patch
from subprocess import CompletedProcess
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
@@ -35,6 +36,63 @@ class MountTests(unittest.TestCase):
class ProposalTests(unittest.TestCase):
def test_note_only_comparison_keeps_other_lxc_settings_strict(self):
marker = '11111111-1111-1111-1111-111111111111'
before = f'description: Old proxmenux-instance={marker}\ncores: 2\n'.encode()
record = {'installation_id': marker, 'observed': {'config': before.decode()}}
updated = f'description: New proxmenux-instance={marker}\ncores: 2\n'.encode()
self.assertTrue(reconcile.instances.same_config_except_notes(record, updated))
self.assertFalse(reconcile.instances.same_config_except_notes(record, updated.replace(b'cores: 2', b'cores: 4')))
self.assertFalse(reconcile.instances.same_config_except_notes(record, b'description: Other instance\ncores: 2\n'))
def test_replacement_restores_user_notes_verbatim(self):
marker = '11111111-1111-1111-1111-111111111111'
notes = f'<p>Nota personal: no borrar</p><!-- proxmenux-instance={marker} -->'
state = {'record': {'installation_id': marker}, 'original_description': notes}
with patch.object(reconcile.transaction, 'run') as run:
reconcile.transaction.restore_description(200, state)
run.assert_called_once_with('pct', 'set', '200', '--description', notes)
state.pop('original_description')
state['before_config'] = f'description: {notes.replace("%", "%25").replace("<", "%3C").replace(">", "%3E")}\n'
self.assertEqual(reconcile.transaction.original_description(state), notes)
def test_restoring_notes_does_not_log_their_contents(self):
notes = '<p>Nota privada del usuario</p>'
with (patch.object(reconcile.transaction, 'log') as log,
patch.object(reconcile.transaction.subprocess, 'run',
return_value=CompletedProcess([], 0, b'', b'')) as command):
reconcile.transaction.run('pct', 'set', '200', '--description', notes)
command.assert_called_once()
self.assertNotIn(notes, str(log.call_args))
def test_notes_only_change_does_not_require_adoption_or_block_update(self):
marker = '11111111-1111-1111-1111-111111111111'
config = f'description: Old notes proxmenux-instance={marker}\n'.encode()
updated = f'description: New notes proxmenux-instance={marker}\n'.encode()
record = {
'vmid': 200, 'status': 'installed', 'installation_id': marker,
'deployment': {'mounts': [], 'devices': []},
'observed': {'config': config.decode(), 'config_sha256': sha(config)},
}
self.assertIsNone(reconcile.propose(record, updated))
self.assertEqual(reconcile.transaction.external_changes(record, updated), {})
def test_notes_cannot_hide_identity_or_network_changes(self):
marker = '11111111-1111-1111-1111-111111111111'
config = f'description: Old notes proxmenux-instance={marker}\n'.encode()
record = {
'vmid': 200, 'status': 'installed', 'installation_id': marker,
'deployment': {'mounts': [], 'devices': []},
'observed': {'config': config.decode(), 'config_sha256': sha(config)},
}
with self.assertRaises(ValueError):
reconcile.propose(record, b'description: Different instance\n')
changed = f'description: New notes proxmenux-instance={marker}\nnet0: name=eth0,bridge=vmbr1\n'.encode()
with self.assertRaises(ValueError):
reconcile.propose(record, changed)
with self.assertRaises(ValueError):
reconcile.transaction.external_changes(record, changed)
def test_new_volume_requires_confirmation_before_contract_changes(self):
config = b'description: proxmenux-instance=11111111-1111-1111-1111-111111111111\n'
added = config + b'mp2: local-lvm:vm-200-disk-3,mp=/media,size=8G,backup=1\n'
+11
View File
@@ -29,10 +29,13 @@ class DescriptionTests(unittest.TestCase):
self.assertIn('Image: <code>adguard/adguardhome:latest</code> ', notes)
self.assertIn('>Image</a> &middot; ', notes)
self.assertIn('>App</a>', notes)
self.assertNotIn('>App docs</a>', notes)
self.assertNotIn('>Repository</a>', notes)
self.assertIn('href="http://192.168.0.42:3000/" target="_blank" rel="noopener noreferrer">Setup (first run)</a>', notes)
self.assertIn('>http://192.168.0.42:3000/</a>', notes)
self.assertIn('href="http://192.168.0.42:80/" target="_blank" rel="noopener noreferrer">Web UI (after setup)</a>', notes)
self.assertIn('>http://192.168.0.42:80/</a>', notes)
self.assertEqual(notes.count('&#127760; '), 2)
def test_missing_ip_does_not_publish_placeholder_links(self):
template = json.loads((CATALOG / 'adguard-home.json').read_text())
@@ -40,6 +43,14 @@ class DescriptionTests(unittest.TestCase):
self.assertNotIn('http://:3000', notes)
self.assertIn('proxmenux-instance=' + INSTANCE, notes)
def test_chromium_notes_keep_only_image_and_app_links(self):
template = json.loads((CATALOG / 'chromium.json').read_text())
notes = render(template, '', INSTANCE, '192.168.0.37')
self.assertIn('>Image</a>', notes)
self.assertIn('>App</a>', notes)
self.assertNotIn('>App docs</a>', notes)
self.assertNotIn('>Repository</a>', notes)
def test_untrusted_title_is_escaped(self):
template = {'id': 'example', 'catalog_ui': {'title': {'en_US': '<script>x</script>'}},
'container_contract': {'image': {'reference': 'example:latest'}}}
+69
View File
@@ -0,0 +1,69 @@
"""Regression tests for catalog options added after an OCI installation."""
import copy
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.recreation import refresh_template
class ConfirmingUI:
def confirm(self, _message, _default=False):
return True
def info(self, _message):
pass
def ask(self, _message, default=''):
return default
class RecreationCatalogTests(unittest.TestCase):
def test_catalog_index_has_no_unused_template_hashes(self):
applications = Catalog(ROOT).load_index()['applications']
self.assertTrue(applications)
self.assertTrue(all('content_hash' not in item for item in applications))
def test_internal_template_id_resolves_current_catalog_template(self):
catalog = Catalog(ROOT)
previous = catalog.load_template('chromium', generate_if_missing=False)
self.assertEqual(previous['id'], 'linuxserver-chromium')
previous = copy.deepcopy(previous)
previous['catalog_ui']['title']['en_US'] = 'Old Chromium'
candidate = {'template': previous, 'deployment': {
'rootfs': {'storage': 'local-lvm'},
'mounts': [{'container_path': '/config'}],
'environment': [{'name': item['name'], 'value': item.get('example') or 'value',
'sensitive': item['sensitive']}
for item in previous['container_contract']['environment']
if item['required']],
'security': {},
}}
refresh_template(candidate, ConfirmingUI())
self.assertEqual(candidate['template']['catalog_ui']['title']['en_US'],
catalog.compose('chromium')['catalog_ui']['title']['en_US'])
def test_navidrome_baseurl_is_optional(self):
catalog = Catalog(ROOT)
template = catalog.compose('navidrome')
base_url = next(item for item in template['container_contract']['environment']
if item['name'] == 'ND_BASEURL')
self.assertFalse(base_url['required'])
self.assertEqual(base_url['example'], '')
regenerated = copy.deepcopy(template)
target = next(item for item in regenerated['container_contract']['environment']
if item['name'] == 'ND_BASEURL')
target['required'] = True
catalog._preserve_optional_environment('navidrome', regenerated)
self.assertFalse(target['required'])
if __name__ == '__main__':
unittest.main()
+104
View File
@@ -0,0 +1,104 @@
"""Shared wizard navigation and device choices do not depend on app overlays."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
from subprocess import CompletedProcess
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from proxmenux_oci.extra_devices import (ask_extra_devices, ask_stack_extra_devices,
device_permissions)
from proxmenux_oci.ui import BackRequested, BacktrackUI, DialogUI, RestartWizard
class SequenceUI:
def __init__(self, answers):
self.answers = iter(answers)
self.back_enabled = False
def ask(self, *_args, **_kwargs):
result = next(self.answers)
if result == 'back':
raise BackRequested()
return result
def confirm(self, *_args, **_kwargs):
return next(self.answers)
def choose(self, *_args, **_kwargs):
return next(self.answers)
def checklist(self, *_args, **_kwargs):
return next(self.answers)
def info(self, _text):
pass
class WizardTests(unittest.TestCase):
def test_back_returns_to_previous_answer_without_reasking_first(self):
base = SequenceUI(['first', 'second', 'back', 'corrected', 'third'])
wizard = BacktrackUI(base)
try:
self.assertEqual(wizard.ask('first'), 'first')
self.assertEqual(wizard.ask('second'), 'second')
with self.assertRaises(RestartWizard):
wizard.ask('third')
wizard.restart()
self.assertEqual(wizard.ask('first'), 'first')
self.assertEqual(wizard.ask('second'), 'corrected')
self.assertEqual(wizard.ask('third'), 'third')
finally:
wizard.close()
self.assertFalse(base.back_enabled)
def test_back_from_review_reopens_last_question(self):
class ReviewUI(SequenceUI):
def review(self, *_args, **_kwargs):
raise BackRequested()
wizard = BacktrackUI(ReviewUI(['value', 'replacement']))
try:
self.assertEqual(wizard.ask('value'), 'value')
with self.assertRaises(RestartWizard):
wizard.review('summary')
wizard.restart()
self.assertEqual(wizard.ask('value'), 'replacement')
finally:
wizard.close()
def test_manual_usb_is_available_without_profile(self):
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False])
devices = ask_extra_devices(ui, [], True)
self.assertEqual(devices[0]['host_path'], '/dev/ttyACM0')
self.assertEqual(devices[0]['container_path'], '/dev/ttyACM0')
def test_linuxserver_device_permissions_are_generic(self):
permissions = device_permissions('lscr.io/linuxserver/chromium:latest',
[{'kind': 'character-device'}])
self.assertEqual(permissions['strategy'], 'linuxserver-native-init')
def test_stack_device_goes_only_to_selected_member(self):
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False, ['server']])
services = [
{'name': name, 'main': name == 'server',
'template': {'container_contract': {'image': {'reference': 'example/app:latest'}}},
'deployment': {'devices': [], 'security': {'unprivileged': True}}}
for name in ('database', 'server')
]
ask_stack_extra_devices(ui, services)
self.assertEqual(services[0]['deployment']['devices'], [])
self.assertEqual(services[1]['deployment']['devices'][0]['host_path'], '/dev/ttyACM0')
def test_dialog_back_button_is_only_on_wizard_inputs(self):
ui = DialogUI(back_enabled=True)
with patch('proxmenux_oci.ui.subprocess.run', return_value=CompletedProcess([], 3, '', '')) as run:
with self.assertRaises(BackRequested):
ui._run(['--inputbox', 'Name', '10', '50', ''])
self.assertIn('--extra-button', run.call_args.args[0])
if __name__ == '__main__':
unittest.main()