mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 10:06:41 +00:00
Generalize OCI device setup and remove unused catalog hashes
This commit is contained in:
@@ -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={{
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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'🌐 {link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
|
||||
if item:
|
||||
access.append(item)
|
||||
badges = (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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':[
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'))
|
||||
@@ -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", []),
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -29,10 +29,13 @@ class DescriptionTests(unittest.TestCase):
|
||||
self.assertIn('Image: <code>adguard/adguardhome:latest</code> ', notes)
|
||||
self.assertIn('>Image</a> · ', 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('🌐 '), 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'}}}
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user