mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
Generalize OCI device setup and remove unused catalog hashes
This commit is contained in:
@@ -649,7 +649,7 @@ const initMessage = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === "cancel" || value === "") {
|
if (value === "cancel") {
|
||||||
setCurrentInteraction(null)
|
setCurrentInteraction(null)
|
||||||
setInteractionInput("")
|
setInteractionInput("")
|
||||||
handleCloseModal()
|
handleCloseModal()
|
||||||
@@ -755,7 +755,7 @@ const initMessage = {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose() }}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
|
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -3598,7 +3598,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
// user tapped the terminal's Close button and got kicked
|
// user tapped the terminal's Close button and got kicked
|
||||||
// all the way back to the guest list.
|
// all the way back to the guest list.
|
||||||
if (open) return
|
if (open) return
|
||||||
if (applyOpen || terminalOpen) return
|
if (applyOpen || terminalOpen || ociAction) return
|
||||||
setSelectedVM(null)
|
setSelectedVM(null)
|
||||||
setVMDetails(null)
|
setVMDetails(null)
|
||||||
setCurrentView("main")
|
setCurrentView("main")
|
||||||
@@ -3624,10 +3624,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
// remaining vectors (mobile tap slop reaching the parent's
|
// remaining vectors (mobile tap slop reaching the parent's
|
||||||
// scrim, ESC not consumed by the child) at the DOM level.
|
// scrim, ESC not consumed by the child) at the DOM level.
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
if (applyOpen || terminalOpen) e.preventDefault()
|
if (applyOpen || terminalOpen || ociAction) e.preventDefault()
|
||||||
}}
|
}}
|
||||||
onEscapeKeyDown={(e) => {
|
onEscapeKeyDown={(e) => {
|
||||||
if (applyOpen || terminalOpen) e.preventDefault()
|
if (applyOpen || terminalOpen || ociAction) e.preventDefault()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentView === "main" ? (
|
{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 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 compatibility:": "Verificación de la compatibilidad de la imagen:",
|
||||||
"Checking the image in the registry...": "Revisando la imagen en el registro...",
|
"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 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 new image without starting it:": "Comprobando la nueva imagen sin comenzarla:",
|
||||||
"Checking the remote...": "Revisando el control remoto...",
|
"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 successfully.": "El contenedor se inició correctamente.",
|
"Container started successfully.": "El contenedor se inició correctamente.",
|
||||||
"Container started.": "Contenedor iniciado.",
|
"Container started.": "Contenedor iniciado.",
|
||||||
"Container stopped": "Container stopped",
|
"Container stopped": "Contenedor detenido",
|
||||||
"Container stopped.": "El contenedor se detuvo.",
|
"Container stopped.": "El contenedor se detuvo.",
|
||||||
"Container successfully converted to privileged.": "Contenedor convertido exitosamente a privilegiado.",
|
"Container successfully converted to privileged.": "Contenedor convertido exitosamente a privilegiado.",
|
||||||
"Container template— LXC templates": "Plantilla de contenedor: plantillas LXC",
|
"Container template— LXC templates": "Plantilla de contenedor: plantillas LXC",
|
||||||
@@ -1352,7 +1352,7 @@
|
|||||||
"Creating the backup": "Crear el backup",
|
"Creating the backup": "Crear el backup",
|
||||||
"Creating the container...": "Creando el contenedor...",
|
"Creating the container...": "Creando el contenedor...",
|
||||||
"Creating the initial administrator...": "Crear el administrador inicial...",
|
"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",
|
"Creative & Design": "Creatividad y diseño",
|
||||||
"Credentials are correct": "Las credenciales son correctas",
|
"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.",
|
"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 Name:": "Nombre de montaje:",
|
"Mount Name:": "Nombre de montaje:",
|
||||||
"Mount Options": "Opciones de montaje",
|
"Mount Options": "Opciones de montaje",
|
||||||
"Mount Path": "Camino del monte",
|
"Mount Path": "Ruta de montaje",
|
||||||
"Mount Path:": "Camino de montaje:",
|
"Mount Path:": "Ruta de montaje:",
|
||||||
"Mount Point": "Punto de montaje",
|
"Mount Point": "Punto de montaje",
|
||||||
"Mount Point ID": "ID del punto de montaje",
|
"Mount Point ID": "ID del punto de montaje",
|
||||||
"Mount Point Removal Summary:": "Resumen de eliminación 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 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 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 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 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.",
|
"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 point:": "Punto de montaje:",
|
||||||
"Mount points added:": "Puntos de montaje añadidos:",
|
"Mount points added:": "Puntos de montaje añadidos:",
|
||||||
"Mount read-only": "Montaje solo lectura",
|
"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 specific dataset": "Montar conjunto de datos específico",
|
||||||
"Mount status:": "Estado de montaje:",
|
"Mount status:": "Estado de montaje:",
|
||||||
"Mount this device and use it as the backup destination?": "¿Montar este dispositivo y usarlo como destino de respaldo?",
|
"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 CIFS share...": "Montando recurso compartido CIFS...",
|
||||||
"Mounting NFS share...": "Montando recurso compartido NFS...",
|
"Mounting NFS share...": "Montando recurso compartido NFS...",
|
||||||
"Mounting container filesystem": "Montaje del sistema de archivos del contenedor",
|
"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": "Montaje existente",
|
||||||
"Mounting existing filesystem ({filesystem})...": "Montando el sistema de archivos existente ({filesystem})...",
|
"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.",
|
"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 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)",
|
"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": "Separando temporalmente los volúmenes de datos",
|
||||||
"Moving the data volumes aside...": "Apartando 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-container application (experimental)": "Aplicación multicontenedor (experimental)",
|
||||||
"Multi-line variables are not supported": "No se admiten variables multilíneas",
|
"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",
|
"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 patch not applied.": "Parche de NVIDIA no aplicado.",
|
||||||
"NVIDIA per-BDF VFIO binding configured": "Enlace NVIDIA por BDF VFIO configurado",
|
"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 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 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 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.",
|
"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 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",
|
"Recover the previous installation": "Recuperar la instalación anterior",
|
||||||
"Recoverable:": "Recuperable:",
|
"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 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 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.",
|
"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 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 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 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 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 storage does not exist:": "El almacenamiento de backups no existe:",
|
||||||
"The backup was altered; recovery blocked": "El backup fue alterado; la recuperación bloqueada",
|
"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 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 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 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 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 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",
|
"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 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 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 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 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 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.",
|
"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",
|
"name": "ND_BASEURL",
|
||||||
"example": "",
|
"example": "",
|
||||||
"required": true,
|
"required": false,
|
||||||
"sensitive": false,
|
"sensitive": false,
|
||||||
"source": "docker-compose"
|
"source": "docker-compose"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,28 @@ die() {
|
|||||||
exit 1
|
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() {
|
mount_ct_rootfs() {
|
||||||
oci_quiet pct mount "$VMID" || die "$(translate "Could not mount the container filesystem:") CT $VMID"
|
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...")"
|
msg_info "$(translate "Checking the device permissions for the application user...")"
|
||||||
oci_log "Checking device access as abc (this is not a codec test)."
|
oci_log "Checking device access as abc (this is not a codec test)."
|
||||||
for (( attempt=0; attempt<60; attempt++ )); do
|
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"
|
|| die "$(translate "The container stopped before the GPU permissions were verified:") CT $VMID"
|
||||||
if pct exec "$VMID" -- s6-setuidgid abc sh -c \
|
if pct exec "$VMID" -- s6-setuidgid abc sh -c \
|
||||||
'for path do test -r "$path" && test -w "$path" || exit 1; done' \
|
'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"
|
oci_log "Waiting for the service: $HC_URL"
|
||||||
msg_info "$(translate "Waiting for the application to respond...")"
|
msg_info "$(translate "Waiting for the application to respond...")"
|
||||||
while (( HC_ELAPSED < HC_TIMEOUT )); do
|
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:"
|
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}"
|
[[ -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"
|
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...")"
|
msg_info "$(translate "Checking that the container keeps running...")"
|
||||||
RC_START=$(date +%s)
|
RC_START=$(date +%s)
|
||||||
while (( $(date +%s) - RC_START < RC_STABILITY )); do
|
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:"
|
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}"
|
[[ -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"
|
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 {}
|
source = template.get('source') or {}
|
||||||
image_url = (source.get('image_repository_url') or image_page(reference)
|
image_url = (source.get('image_repository_url') or image_page(reference)
|
||||||
or ui.get('repository') or source.get('repository'))
|
or ui.get('repository') or source.get('repository'))
|
||||||
resources = [('Image', image_url), ('App', ui.get('website')),
|
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 = [link(label, url) for label, url in resources]
|
resources = [link(label, url) for label, url in resources]
|
||||||
resources = [item for item in resources if item]
|
resources = [item for item in resources if item]
|
||||||
try:
|
try:
|
||||||
@@ -88,7 +84,7 @@ def render(template, digest, instance_id, ip=''):
|
|||||||
if not isinstance(path, str) or not path.startswith('/'):
|
if not isinstance(path, str) or not path.startswith('/'):
|
||||||
path = '/'
|
path = '/'
|
||||||
url = f'{scheme}://{address}:{port}{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:
|
if item:
|
||||||
access.append(item)
|
access.append(item)
|
||||||
badges = (
|
badges = (
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ def propose(record, config):
|
|||||||
before = parse_config(record['observed']['config'].encode())
|
before = parse_config(record['observed']['config'].encode())
|
||||||
current = parse_config(config)
|
current = parse_config(config)
|
||||||
changed = sorted(key for key in before.keys() | current.keys() if before.get(key) != current.get(key))
|
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)]
|
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]
|
unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE]
|
||||||
if unsupported:
|
if unsupported:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import stat
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import oci_instances as instances
|
import oci_instances as instances
|
||||||
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
|
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.'))
|
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):
|
def fit(text):
|
||||||
"""A step line that is rewritten in place must not wrap."""
|
"""A step line that is rewritten in place must not wrap."""
|
||||||
width = max(shutil.get_terminal_size((80, 24)).columns - 8, 30)
|
width = max(shutil.get_terminal_size((80, 24)).columns - 8, 30)
|
||||||
@@ -211,7 +229,11 @@ def fit(text):
|
|||||||
|
|
||||||
|
|
||||||
def run(*args):
|
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
|
# OCI extraction enters an unprivileged user namespace; PVE's newly created
|
||||||
# traversal directories must not inherit a caller's restrictive umask.
|
# traversal directories must not inherit a caller's restrictive umask.
|
||||||
pve_creation = (args[:2] in (('pct', 'create'), ('pct', 'restore'))
|
pve_creation = (args[:2] in (('pct', 'create'), ('pct', 'restore'))
|
||||||
@@ -224,12 +246,37 @@ def run(*args):
|
|||||||
raise
|
raise
|
||||||
if result.returncode:
|
if result.returncode:
|
||||||
log(f' exit {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:
|
if result.returncode:
|
||||||
raise RuntimeError(f"{args[0]} {translate('failed with exit code')} {result.returncode}")
|
raise RuntimeError(f"{args[0]} {translate('failed with exit code')} {result.returncode}")
|
||||||
return result.stdout
|
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):
|
def filehash(path):
|
||||||
value = hashlib.sha256()
|
value = hashlib.sha256()
|
||||||
with Path(path).open('rb') as source:
|
with Path(path).open('rb') as source:
|
||||||
@@ -322,6 +369,10 @@ def external_changes(record, config, adopt=True):
|
|||||||
return {}
|
return {}
|
||||||
before, now = parse_config(record['observed']['config'].encode()), parse_config(config)
|
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))
|
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'
|
cores_key = 'cpulimit' if 'cpulimit' in before and 'cores' not in before else 'cores'
|
||||||
values = {}
|
values = {}
|
||||||
for key in changed if adopt else ():
|
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)
|
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):
|
def release_stage(state):
|
||||||
"""After a commit the holder CT only keeps its own rootfs: every parked
|
"""After a commit the holder CT only keeps its own rootfs: every parked
|
||||||
volume went back to the application. Anything still attached keeps it."""
|
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)
|
candidate = candidate_contract(record, operation, proposal)
|
||||||
before = run('pct', 'config', str(vmid))
|
before = run('pct', 'config', str(vmid))
|
||||||
cfg, actual, mac = preflight(record, candidate, before, coordinated)
|
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_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data)
|
||||||
original_gpu = gpu_devices.planned(record['deployment'])
|
original_gpu = gpu_devices.planned(record['deployment'])
|
||||||
desired_gpu = gpu_devices.planned(candidate['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('/')])
|
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,
|
state = {'schema_version': 1, 'id': directory.name, 'vmid': vmid, 'operation': operation,
|
||||||
'record': record, 'candidate_contract': candidate, 'before_config': before.decode(),
|
'record': record, 'candidate_contract': candidate, 'before_config': before.decode(),
|
||||||
|
'original_description': description,
|
||||||
'archive': str(archive), 'archive_sha256': filehash(archive),
|
'archive': str(archive), 'archive_sha256': filehash(archive),
|
||||||
'registry_digest': registry_digest or image['manifest_digest'],
|
'registry_digest': registry_digest or image['manifest_digest'],
|
||||||
'runtime_template': candidate['template'], 'runtime_deployment': runtime_deployment,
|
'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:
|
else:
|
||||||
backup_dir = directory / 'backup'
|
backup_dir = directory / 'backup'
|
||||||
private_directory(backup_dir)
|
private_directory(backup_dir)
|
||||||
run('vzdump', str(vmid), '--mode', 'stop', '--compress', backup_compression,
|
backup = verified_backup(vmid, backup_dir, backup_compression,
|
||||||
'--dumpdir', str(backup_dir), '--tmpdir', '/var/tmp')
|
translate('The backup could not be identified; the image is not replaced'), show)
|
||||||
suffix = 'zst' if backup_compression == 'zstd' else 'gz'
|
state.update(backup=str(backup), backup_sha256=filehash(backup))
|
||||||
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]))
|
|
||||||
checkpoint(journal, state, 'backup-ready')
|
checkpoint(journal, state, 'backup-ready')
|
||||||
if show:
|
if show:
|
||||||
msg_ok(translate('Backup created'))
|
msg_ok(translate('Backup created'))
|
||||||
@@ -920,6 +984,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
|||||||
if show:
|
if show:
|
||||||
progress = translate('Installing the new image:') if update else translate('Recreating the container:')
|
progress = translate('Installing the new image:') if update else translate('Recreating the container:')
|
||||||
install_candidate(root, journal, state, progress)
|
install_candidate(root, journal, state, progress)
|
||||||
|
restore_description(vmid, state)
|
||||||
restore_firewall(vmid, state)
|
restore_firewall(vmid, state)
|
||||||
if coordinated:
|
if coordinated:
|
||||||
for key, value in state['preserved_stack_config'].items():
|
for key, value in state['preserved_stack_config'].items():
|
||||||
@@ -1239,7 +1304,8 @@ def main():
|
|||||||
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
|
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
|
||||||
with contextlib.redirect_stdout(output):
|
with contextlib.redirect_stdout(output):
|
||||||
report_error(error, f'oci-{args.action}-{args.vmid}')
|
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')
|
recovery_hint(after_recovery=args.action == 'recover')
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,24 @@ def identity(config):
|
|||||||
return match.group(1) if match else None
|
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):
|
def save_assembly(root, primary_id, template, deployment, members):
|
||||||
path = location(root, primary_id).parent / 'stack-assembly.json'
|
path = location(root, primary_id).parent / 'stack-assembly.json'
|
||||||
if path.exists() or path.is_symlink():
|
if path.exists() or path.is_symlink():
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ def refresh(root, vmid, apply=False):
|
|||||||
if not nv.enabled(record['deployment']):
|
if not nv.enabled(record['deployment']):
|
||||||
raise ValueError(translate('The instance does not use NVIDIA'))
|
raise ValueError(translate('The instance does not use NVIDIA'))
|
||||||
config = instances.command('pct', 'config', str(vmid))
|
config = instances.command('pct', 'config', str(vmid))
|
||||||
if (instances.identity(config) != record['installation_id']
|
if not instances.same_config_except_notes(record, config):
|
||||||
or instances.sha(config) != record['observed']['config_sha256']):
|
|
||||||
raise ValueError(translate('The container identity or configuration changed'))
|
raise ValueError(translate('The container identity or configuration changed'))
|
||||||
previous = record['observed']['gpu_devices'][nv.KEY]
|
previous = record['observed']['gpu_devices'][nv.KEY]
|
||||||
plan = nv.refresh_plan(config, previous)
|
plan = nv.refresh_plan(config, previous)
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ class NativeAdapter:
|
|||||||
config = instances.command('pct', 'config', str(vmid))
|
config = instances.command('pct', 'config', str(vmid))
|
||||||
if instances.identity(config) != record['installation_id']:
|
if instances.identity(config) != record['installation_id']:
|
||||||
raise ValueError(translate('The identity of a member was replaced'))
|
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'))
|
raise ValueError(translate('A member configuration changed during the preparation'))
|
||||||
else:
|
else:
|
||||||
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
|
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():
|
||||||
@@ -321,13 +321,9 @@ class NativeAdapter:
|
|||||||
self.validate(self.plan)
|
self.validate(self.plan)
|
||||||
directory = self.journal.parent / ('backup-%s' % vmid)
|
directory = self.journal.parent / ('backup-%s' % vmid)
|
||||||
private_directory(directory)
|
private_directory(directory)
|
||||||
member_tx.run('vzdump', str(vmid), '--mode', 'stop', '--compress', 'zstd',
|
archive = member_tx.verified_backup(vmid, directory, 'zstd',
|
||||||
'--dumpdir', str(directory), '--tmpdir', '/var/tmp')
|
translate('The backup of a member could not be identified'))
|
||||||
backups = list(directory.glob('vzdump-lxc-*.tar.zst'))
|
return {'archive': str(archive), 'sha256': member_tx.filehash(archive)}
|
||||||
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])}
|
|
||||||
|
|
||||||
def verify_backups(self, backups):
|
def verify_backups(self, backups):
|
||||||
for backup in backups.values():
|
for backup in backups.values():
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ def main():
|
|||||||
return 1
|
return 1
|
||||||
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
|
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
|
||||||
transaction.report_error(error, f'update-{args.vmid}')
|
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()
|
transaction.recovery_hint()
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ def build_suite(template, ui, mode='advanced'):
|
|||||||
if mode != DEFAULT_MODE:
|
if mode != DEFAULT_MODE:
|
||||||
from .custom_mounts import ask_stack_custom_mounts
|
from .custom_mounts import ask_stack_custom_mounts
|
||||||
ask_stack_custom_mounts(ui, services, storage)
|
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,
|
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,
|
'base_vmid':int(base) if base else None,'services':services,'shared_media':shared,'media_player':player,
|
||||||
'completion_notes':[
|
'completion_notes':[
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import hashlib
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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": f"apps/{repo.app_id}.json" if repo.app_id in existing else None,
|
||||||
"template_status": existing.get(repo.app_id),
|
"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
|
for repo, summary in discovered
|
||||||
]
|
]
|
||||||
@@ -291,7 +289,6 @@ class Catalog:
|
|||||||
"category_label": metadata.get("category_label"),
|
"category_label": metadata.get("category_label"),
|
||||||
"template": f"apps/{catalog_id}.json" if catalog_id in existing else None,
|
"template": f"apps/{catalog_id}.json" if catalog_id in existing else None,
|
||||||
"template_status": existing.get(catalog_id),
|
"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):
|
except (OSError, json.JSONDecodeError, KeyError, TypeError):
|
||||||
return
|
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]]:
|
def generate(self, app_id: str) -> tuple[Path, dict[str, Any]]:
|
||||||
item = self.find_item(app_id)
|
item = self.find_item(app_id)
|
||||||
provider = item.get("provider", "linuxserver.io")
|
provider = item.get("provider", "linuxserver.io")
|
||||||
@@ -541,6 +556,7 @@ class Catalog:
|
|||||||
raise ConversionError(f"Proveedor no soportado: {provider}")
|
raise ConversionError(f"Proveedor no soportado: {provider}")
|
||||||
catalog_id = item["id"]
|
catalog_id = item["id"]
|
||||||
self._apply_overlay(catalog_id, template)
|
self._apply_overlay(catalog_id, template)
|
||||||
|
self._preserve_optional_environment(catalog_id, template)
|
||||||
self._preserve_registry_state(catalog_id, template)
|
self._preserve_registry_state(catalog_id, template)
|
||||||
self.validate(template)
|
self.validate(template)
|
||||||
self.apps_dir.mkdir(parents=True, exist_ok=True)
|
self.apps_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -593,6 +609,7 @@ class Catalog:
|
|||||||
else:
|
else:
|
||||||
raise ConversionError(f"Proveedor no soportado: {item_provider}")
|
raise ConversionError(f"Proveedor no soportado: {item_provider}")
|
||||||
self._apply_overlay(item["id"], template)
|
self._apply_overlay(item["id"], template)
|
||||||
|
self._preserve_optional_environment(item["id"], template)
|
||||||
self._preserve_registry_state(item["id"], template)
|
self._preserve_registry_state(item["id"], template)
|
||||||
self.validate(template)
|
self.validate(template)
|
||||||
return item["id"], template
|
return item["id"], template
|
||||||
@@ -661,7 +678,6 @@ class Catalog:
|
|||||||
item["architectures"] = supported_architectures(
|
item["architectures"] = supported_architectures(
|
||||||
template["catalog_ui"]["architectures"]
|
template["catalog_ui"]["architectures"]
|
||||||
)
|
)
|
||||||
item["content_hash"] = self._template_hash(app_id)
|
|
||||||
self._write_json(self.index_path, index)
|
self._write_json(self.index_path, index)
|
||||||
generated.sort()
|
generated.sort()
|
||||||
failed.sort(key=lambda item: item["id"])
|
failed.sort(key=lambda item: item["id"])
|
||||||
@@ -759,7 +775,6 @@ class Catalog:
|
|||||||
"curated_path": str(path.relative_to(self.root)),
|
"curated_path": str(path.relative_to(self.root)),
|
||||||
"template": f"apps/{app_id}.json" if app_id in existing else None,
|
"template": f"apps/{app_id}.json" if app_id in existing else None,
|
||||||
"template_status": existing.get(app_id),
|
"template_status": existing.get(app_id),
|
||||||
"content_hash": self._template_hash(app_id) if app_id in existing else None,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
@@ -768,7 +783,11 @@ class Catalog:
|
|||||||
path = self.overlays_dir / f"{app_id}.json"
|
path = self.overlays_dir / f"{app_id}.json"
|
||||||
if path.exists():
|
if path.exists():
|
||||||
overlay = json.loads(path.read_text(encoding="utf-8"))
|
overlay = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
environment_overrides = overlay.pop("environment_overrides", {})
|
||||||
self._deep_merge(template, overlay)
|
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
|
from .stack import apply_stack_support
|
||||||
apply_stack_support(template)
|
apply_stack_support(template)
|
||||||
from .gpu import apply_gpu_contract
|
from .gpu import apply_gpu_contract
|
||||||
@@ -809,7 +828,6 @@ class Catalog:
|
|||||||
"untranslated_blockers": template["compatibility"][
|
"untranslated_blockers": template["compatibility"][
|
||||||
"untranslated_blockers"
|
"untranslated_blockers"
|
||||||
],
|
],
|
||||||
"content_hash": self._template_hash(app_id),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if item.get("template_family") == "curated-profile":
|
if item.get("template_family") == "curated-profile":
|
||||||
@@ -826,6 +844,3 @@ class Catalog:
|
|||||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
temporary.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
temporary.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
||||||
temporary.replace(path)
|
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:
|
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
|
"""Configures and installs one template, from the catalog or written from a
|
||||||
definition the user gave."""
|
definition the user gave."""
|
||||||
deployment = build_deployment(template, ui, mode)
|
from .ui import BacktrackUI, RestartWizard
|
||||||
if not ui.review(_deployment_summary_text(template, deployment), translate("Installation summary"),
|
wizard = BacktrackUI(ui)
|
||||||
question=translate("Install with this configuration?")):
|
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
|
return None
|
||||||
|
template = candidate
|
||||||
console.show_logo()
|
console.show_logo()
|
||||||
console.msg_title(f"{source_text(template['catalog_ui']['title']) or identifier} · {APP_TITLE}")
|
console.msg_title(f"{source_text(template['catalog_ui']['title']) or identifier} · {APP_TITLE}")
|
||||||
try:
|
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 .i18n import translate
|
||||||
from .ui import DialogUI, TerminalUI, UserCancelled
|
from .ui import DialogUI, TerminalUI, UserCancelled
|
||||||
from .custom_mounts import ask_custom_mounts
|
from .custom_mounts import ask_custom_mounts
|
||||||
|
from .extra_devices import device_permissions
|
||||||
|
|
||||||
|
|
||||||
class InstallError(RuntimeError):
|
class InstallError(RuntimeError):
|
||||||
@@ -379,6 +380,15 @@ def build_deployment(
|
|||||||
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
|
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
|
||||||
installer_profile, environment, unprivileged, ui, mode)
|
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
|
from .gpu import apply_profile_image
|
||||||
apply_profile_image(template, selected_hardware_profile)
|
apply_profile_image(template, selected_hardware_profile)
|
||||||
|
|
||||||
@@ -455,7 +465,9 @@ def build_deployment(
|
|||||||
"tmpfs_mounts": tmpfs_mounts,
|
"tmpfs_mounts": tmpfs_mounts,
|
||||||
"devices": devices,
|
"devices": devices,
|
||||||
"hardware_profile": selected_hardware_profile,
|
"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,
|
"post_start_configurations": post_start_configurations,
|
||||||
"extra_hosts": installer_profile.get("extra_hosts", []),
|
"extra_hosts": installer_profile.get("extra_hosts", []),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,10 +216,21 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()):
|
|||||||
if action == 'recreate':
|
if action == 'recreate':
|
||||||
from .recreation import edit_recreation
|
from .recreation import edit_recreation
|
||||||
from .cli import _deployment_summary_text
|
from .cli import _deployment_summary_text
|
||||||
proposal = edit_recreation(record, ui)
|
from .ui import BacktrackUI, RestartWizard
|
||||||
if not ui.review(_deployment_summary_text(proposal['candidate']['template'],
|
wizard = BacktrackUI(ui)
|
||||||
proposal['candidate']['deployment']), translate('Recreate OCI'),
|
try:
|
||||||
question=translate('Recreate with these options?'), default=True):
|
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)
|
||||||
|
break
|
||||||
|
except RestartWizard:
|
||||||
|
wizard.restart()
|
||||||
|
finally:
|
||||||
|
wizard.close()
|
||||||
|
if not approved:
|
||||||
return False
|
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.'),
|
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):
|
translate('Update OCI'), question=translate('Update now?'), default=True):
|
||||||
|
|||||||
@@ -104,31 +104,6 @@ def edit_environment(deployment, ui):
|
|||||||
environment.append(item)
|
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):
|
def edit_recreation(record, ui):
|
||||||
candidate = copy.deepcopy(record)
|
candidate = copy.deepcopy(record)
|
||||||
refresh_template(candidate, ui)
|
refresh_template(candidate, ui)
|
||||||
@@ -136,32 +111,21 @@ def edit_recreation(record, ui):
|
|||||||
resources = deployment['resources']
|
resources = deployment['resources']
|
||||||
resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores'])
|
resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores'])
|
||||||
resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128)
|
resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128)
|
||||||
while ui.confirm(translate('Add a custom data path?'), False):
|
from .custom_mounts import ask_custom_mounts
|
||||||
target = absolute_path(ui.ask(translate('Path inside the container'), '/data/custom'))
|
deployment['mounts'] = ask_custom_mounts(
|
||||||
existing = [m['container_path'].rstrip('/') for m in deployment['mounts']]
|
ui, deployment['mounts'], deployment['rootfs']['storage'])
|
||||||
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)
|
|
||||||
if ui.confirm(translate('Change the access network?'), False):
|
if ui.confirm(translate('Change the access network?'), False):
|
||||||
edit_network(deployment, ui)
|
edit_network(deployment, ui)
|
||||||
edit_acceleration(candidate, ui)
|
edit_acceleration(candidate, ui)
|
||||||
|
from .extra_devices import ask_extra_devices
|
||||||
reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '')
|
reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '')
|
||||||
repository = reference.split('@')[0].rsplit(':', 1)[0]
|
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)
|
edit_environment(deployment, ui)
|
||||||
proposal = {'operation': 'recreate', 'candidate': candidate}
|
proposal = {'operation': 'recreate', 'candidate': candidate}
|
||||||
if record.get('observed', {}).get('config_sha256'):
|
if record.get('observed', {}).get('config_sha256'):
|
||||||
@@ -172,14 +136,19 @@ def edit_recreation(record, ui):
|
|||||||
def refresh_template(candidate, ui):
|
def refresh_template(candidate, ui):
|
||||||
from .catalog import Catalog
|
from .catalog import Catalog
|
||||||
old = candidate.get('template', {})
|
old = candidate.get('template', {})
|
||||||
name = old.get('id', '').removeprefix('image-')
|
template_id = old.get('id', '')
|
||||||
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', name):
|
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', template_id):
|
||||||
return
|
return
|
||||||
root = Path(__file__).resolve().parents[2]
|
root = Path(__file__).resolve().parents[2]
|
||||||
path = root / 'catalog' / 'apps' / (name + '.json')
|
if not old.get('container_contract', {}).get('image'):
|
||||||
if not path.is_file() or not old.get('container_contract', {}).get('image'):
|
|
||||||
return
|
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:
|
if latest == old:
|
||||||
return
|
return
|
||||||
if not ui.confirm(translate('Apply the options from the current catalog template? Your data and configuration are kept.'), True):
|
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:
|
if mode != DEFAULT_MODE:
|
||||||
from .custom_mounts import ask_stack_custom_mounts
|
from .custom_mounts import ask_stack_custom_mounts
|
||||||
ask_stack_custom_mounts(ui, plans, volumes)
|
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,
|
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',[]),
|
'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]),
|
||||||
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
|
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
|
||||||
|
|||||||
@@ -16,17 +16,96 @@ class UserCancelled(RuntimeError):
|
|||||||
pass
|
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)"
|
APP_TITLE = "OCI manager Apps (beta)"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TerminalUI:
|
class TerminalUI:
|
||||||
title: str = APP_TITLE
|
title: str = APP_TITLE
|
||||||
|
back_enabled: bool = False
|
||||||
|
|
||||||
def ask(self, text: str, default: str | None = None, required: bool = True) -> str:
|
def ask(self, text: str, default: str | None = None, required: bool = True) -> str:
|
||||||
suffix = f" [{default}]" if default not in (None, "") else ""
|
suffix = f" [{default}]" if default not in (None, "") else ""
|
||||||
while True:
|
while True:
|
||||||
value = input(f"{text}{suffix}: ").strip()
|
value = input(f"{text}{suffix}: ").strip()
|
||||||
|
if self.back_enabled and value == ':back':
|
||||||
|
raise BackRequested()
|
||||||
if value:
|
if value:
|
||||||
return value
|
return value
|
||||||
if default is not None:
|
if default is not None:
|
||||||
@@ -38,18 +117,25 @@ class TerminalUI:
|
|||||||
def password(self, text: str, required: bool = True) -> str:
|
def password(self, text: str, required: bool = True) -> str:
|
||||||
while True:
|
while True:
|
||||||
value = getpass.getpass(f"{text}: ")
|
value = getpass.getpass(f"{text}: ")
|
||||||
|
if self.back_enabled and value == ':back':
|
||||||
|
raise BackRequested()
|
||||||
if not value:
|
if not value:
|
||||||
if not required:
|
if not required:
|
||||||
return ""
|
return ""
|
||||||
print(translate("This value is required."))
|
print(translate("This value is required."))
|
||||||
continue
|
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
|
return value
|
||||||
print(translate("The values do not match. Enter them again."))
|
print(translate("The values do not match. Enter them again."))
|
||||||
|
|
||||||
def confirm(self, text: str, default: bool = False) -> bool:
|
def confirm(self, text: str, default: bool = False) -> bool:
|
||||||
suffix = " [Y/n]" if default else " [y/N]"
|
suffix = " [Y/n]" if default else " [y/N]"
|
||||||
value = input(f"{text}{suffix}: ").strip().casefold()
|
value = input(f"{text}{suffix}: ").strip().casefold()
|
||||||
|
if self.back_enabled and value == ':back':
|
||||||
|
raise BackRequested()
|
||||||
if not value:
|
if not value:
|
||||||
return default
|
return default
|
||||||
return value in {"y", "yes", "s", "si"}
|
return value in {"y", "yes", "s", "si"}
|
||||||
@@ -93,6 +179,7 @@ class DialogUI:
|
|||||||
|
|
||||||
title: str = APP_TITLE
|
title: str = APP_TITLE
|
||||||
backtitle: str = "ProxMenux"
|
backtitle: str = "ProxMenux"
|
||||||
|
back_enabled: bool = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def available() -> bool:
|
def available() -> bool:
|
||||||
@@ -103,10 +190,16 @@ class DialogUI:
|
|||||||
if environment.get("TERM", "").casefold() in {"", "dumb", "unknown"}:
|
if environment.get("TERM", "").casefold() in {"", "dumb", "unknown"}:
|
||||||
environment["TERM"] = "xterm-256color"
|
environment["TERM"] = "xterm-256color"
|
||||||
# dialog draws on the terminal and writes the selection to stderr.
|
# dialog draws on the terminal and writes the selection to stderr.
|
||||||
return subprocess.run(
|
back_widget = ['--extra-button', '--extra-label', 'Volver'] if self.back_enabled and any(
|
||||||
["dialog", "--no-collapse", "--backtitle", self.backtitle, "--title", title or self.title, *widget],
|
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,
|
stdout=None, stderr=subprocess.PIPE, text=True, check=False, env=environment,
|
||||||
)
|
)
|
||||||
|
if result.returncode == 3 and back_widget:
|
||||||
|
raise BackRequested()
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _size(text: str, min_height: int, width: int, extra: int = 6) -> tuple[str, str]:
|
def _size(text: str, min_height: int, width: int, extra: int = 6) -> tuple[str, str]:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
|
||||||
|
|
||||||
@@ -35,6 +36,63 @@ class MountTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ProposalTests(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):
|
def test_new_volume_requires_confirmation_before_contract_changes(self):
|
||||||
config = b'description: proxmenux-instance=11111111-1111-1111-1111-111111111111\n'
|
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'
|
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: <code>adguard/adguardhome:latest</code> ', notes)
|
||||||
self.assertIn('>Image</a> · ', notes)
|
self.assertIn('>Image</a> · ', notes)
|
||||||
self.assertIn('>App</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('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('>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('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.assertIn('>http://192.168.0.42:80/</a>', notes)
|
||||||
|
self.assertEqual(notes.count('🌐 '), 2)
|
||||||
|
|
||||||
def test_missing_ip_does_not_publish_placeholder_links(self):
|
def test_missing_ip_does_not_publish_placeholder_links(self):
|
||||||
template = json.loads((CATALOG / 'adguard-home.json').read_text())
|
template = json.loads((CATALOG / 'adguard-home.json').read_text())
|
||||||
@@ -40,6 +43,14 @@ class DescriptionTests(unittest.TestCase):
|
|||||||
self.assertNotIn('http://:3000', notes)
|
self.assertNotIn('http://:3000', notes)
|
||||||
self.assertIn('proxmenux-instance=' + INSTANCE, 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):
|
def test_untrusted_title_is_escaped(self):
|
||||||
template = {'id': 'example', 'catalog_ui': {'title': {'en_US': '<script>x</script>'}},
|
template = {'id': 'example', 'catalog_ui': {'title': {'en_US': '<script>x</script>'}},
|
||||||
'container_contract': {'image': {'reference': 'example:latest'}}}
|
'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