feat(oci): Recreate for multi-container apps and CPU/memory in every advanced install

This commit is contained in:
MacRimi
2026-10-01 19:39:01 +02:00
parent d137bb3d31
commit a4cb9936a5
29 changed files with 1405 additions and 110 deletions
@@ -17,9 +17,9 @@ REMOVE = ROOT / 'oci/remote/oci_remove.py'
PREVIEW_HOST = 'Host paths found in container configs or saved records (not targeted for removal):'
EMPTY_HOST = 'No host directories found in the available container configs or saved records.'
POST_HOST = 'Host directory listed in saved records (not targeted for removal):'
UPDATE = ('All images are downloaded and verified first, and native backups are taken with the stack stopped. '
'Contracts are published after the whole set is checked. If a step fails, recovery is attempted '
'where needed; recovery can also fail.')
UPDATE = ('All {count} containers of the application are updated together (main CT: {vmid}). If there are new '
'versions, all images are downloaded and verified, the application is stopped, each container is backed '
'up and replaced with its new image. If anything fails, the backups are restored.')
PENDING = ('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack '
'where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.')
KEYS = (PREVIEW_HOST, EMPTY_HOST, POST_HOST, UPDATE, PENDING)
@@ -126,10 +126,9 @@ class RecoveryMessages(unittest.TestCase):
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
return next(message for kind, message in self.calls if kind == 'review')
def test_update_preview_describes_recovery_attempt_not_guarantee(self):
def test_update_preview_names_the_stack_and_what_happens(self):
message = self.manage()
self.assertIn(UPDATE, message)
self.assertNotIn('all members are recovered', message)
self.assertIn(UPDATE.format(count=len(self.members), vmid=101), message)
def test_pending_terminal_and_recovery_failed_states_share_bounded_wording(self):
for phase in ('committed', 'rolled-back', 'recovery-failed'):
@@ -210,7 +209,7 @@ class RecoveryMessages(unittest.TestCase):
self.assertIn('Tradotto: ' + PREVIEW_HOST + '\n /bind', preview)
# Exercise the actual management consumer, not just the provider.
self.scope['translate'] = module.translate
self.assertIn('Tradotto: ' + UPDATE, self.manage())
self.assertIn(('Tradotto: ' + UPDATE).format(count=len(self.members), vmid=101), self.manage())
self.journal.write_text(json.dumps({'phase': 'committed', 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
self.calls.clear()
+31 -3
View File
@@ -322,6 +322,7 @@
"All types (images, backup, iso, vztmpl, snippets)": "Todo tipo (imágenes, copias de seguridad, iso, vztmpl, fragmentos)",
"All user-installed packages from the backup are present on this host": "todos los paquetes instalados por el usuario desde la copia de seguridad están presentes en este host",
"All users with UID and GID": "Todos los usuarios con UID y GID",
"All {count} containers of the application are updated together (main CT: {vmid}). If there are new versions, all images are downloaded and verified, the application is stopped, each container is backed up and replaced with its new image. If anything fails, the backups are restored.": "Se actualizan juntos los {count} contenedores de la aplicación (CT principal: {vmid}). Si hay versiones nuevas, se descargan y verifican todas las imágenes, se detiene la aplicación, se hace un backup de cada contenedor y se sustituyen por las nuevas imágenes. Si algo falla, se restauran los backups.",
"Allocate CPU Cores": "Asignar núcleos de CPU",
"Allocate RAM in MiB": "Asignar RAM en MiB",
"Allow TCP port {port} from {subnet} through the host firewall? Existing firewall rules are not changed.": "¿Permitir el puerto TCP {port} desde {subnet} en el cortafuegos del host? Las reglas existentes no se modifican.",
@@ -364,7 +365,9 @@
"Analyzing system for available PCIe storage devices...": "Analizando el sistema para dispositivos de almacenamiento PCIe disponibles...",
"Another OCI operation is using the instance registry": "Otra operación OCI está utilizando el registro de instancias",
"Another OCI operation is using the instance registry. Wait for it to finish and open this menu again; no container is modified.": "Otra operación OCI está usando el registro de instancias. Espera a que termine y vuelve a abrir este menú; no se modifica ningún contenedor.",
"Another OCI operation is using the instance registry. Wait for it to finish.": "Otra operación OCI está usando el registro de instancias. Espera a que termine.",
"Another OCI operation is using the registry. This operation was not started.": "Otro operación OCI está utilizando el registro. Esta operación no se inició.",
"Another device of the network already answers on this address:": "Otro dispositivo de la red ya responde en esta dirección:",
"Another instance uses": "Otro caso utiliza",
"Another node, typed by hand": "Otro nodo, escrito a mano",
"Another stack operation is pending": "Hay otra operación de pila pendiente",
@@ -952,6 +955,7 @@
"Configuration has been stopped to prevent an unusable VM state.": "La configuración se ha detenido para evitar un estado de VM inutilizable.",
"Configuration has been stopped to prevent leaving the VM in an unusable state.": "La configuración se ha detenido para evitar dejar la máquina virtual en un estado inutilizable.",
"Configuration name:": "Nombre de configuración:",
"Configuration saved": "Configuración guardada",
"Configuration sections that will be REMOVED": "Secciones de configuración que serán ELIMINADAS",
"Configuration size in GB": "Tamaño de configuración en GB",
"Configuration to be Removed": "Configuración que se eliminará",
@@ -1100,7 +1104,7 @@
"Container removed:": "Contenedor eliminado:",
"Container restarted successfully": "El contenedor se reinició correctamente",
"Container running steadily": "Contenedor funcionando de manera constante",
"Container started": "Container started",
"Container started": "Contenedor iniciado",
"Container started successfully": "El contenedor se inició correctamente",
"Container started successfully.": "El contenedor se inició correctamente.",
"Container started.": "Contenedor iniciado.",
@@ -1108,6 +1112,7 @@
"Container stopped.": "El contenedor se detuvo.",
"Container successfully converted to privileged.": "Contenedor convertido exitosamente a privilegiado.",
"Container template— LXC templates": "Plantilla de contenedor: plantillas LXC",
"Container to change": "Contenedor que se va a cambiar",
"Container volume": "Volumen de contenedor",
"Container volume (included in backups)": "Volumen de contenedor (incluido en backups)",
"Container will pick up the mount on next start": "El contenedor recogerá la montura en el próximo inicio.",
@@ -1217,7 +1222,7 @@
"Could not add": "No se pudo agregar",
"Could not add disk": "No se pudo agregar el disco",
"Could not add the confirmed host firewall rule": "No se pudo añadir la regla confirmada al cortafuegos del host",
"Could not add the device to the container:": "No podía añadir el dispositivo al contenedor:",
"Could not add the device to the container:": "No se pudo añadir el dispositivo al contenedor:",
"Could not add the mount point:": "No podía añadir el punto de montaje:",
"Could not apply the Compose extra hosts": "No podía aplicar los hosts adicionales Compose",
"Could not apply the Compose supplementary groups": "No podía aplicar los grupos complementarios de Compose",
@@ -1538,6 +1543,7 @@
"Delete a CT (irreversible). Use the correct <ctid>": "Eliminar un CT (irreversible). Utilice el <ctid> correcto",
"Delete a VM (irreversible). Use the correct <vmid>": "Eliminar una VM (irreversible). Utilice el <vmid> correcto",
"Delete archive": "Eliminar archivo",
"Delete it?": "¿Eliminarla?",
"Delete job": "Eliminar tarea de copia de seguridad",
"Delete scheduled backup job?": "¿Eliminar la tarea de copia de seguridad programada?",
"Delete the image to free the space?": "¿Eliminar la imagen para liberar el espacio?",
@@ -1598,6 +1604,7 @@
"Device GID does not match the host": "El GID del dispositivo no coincide con el del host",
"Device In Use": "Dispositivo en uso",
"Device added": "Dispositivo agregado",
"Device added:": "Dispositivo añadido:",
"Device already present in target VM — existing hostpci entry reused": "Dispositivo ya presente en la máquina virtual de destino: se reutiliza la entrada hostpci existente",
"Device assignments will be written now and become active after reboot.": "Las asignaciones de dispositivos se escribirán ahora y se activarán después del reinicio.",
"Device configuration cancelled": "Configuración del dispositivo cancelada",
@@ -1606,11 +1613,15 @@
"Device outside the supported profiles; NVIDIA and device trees require another profile": "Dispositivo fuera de los perfiles soportados; NVIDIA y árboles de dispositivos requieren otro perfil",
"Device path mismatch. Format cancelled.": "la ruta del dispositivo no coincide. Formato cancelado.",
"Device permissions verified for the application user": "Autorizaciones de dispositivo verificadas para el usuario de la aplicación",
"Device removed:": "Dispositivo eliminado:",
"Device to attach": "Dispositivo que se va a añadir",
"Device:": "Dispositivo:",
"Devices": "Dispositivos",
"Devices added to the container:": "Dispositivos añadidos al contenedor:",
"Devices added:": "Dispositivos añadidos:",
"Devices of the host it asks for:": "Dispositivos del host que solicita:",
"Devices to add to VM": "Dispositivos para agregar a VM",
"Devices to keep (unmark one to remove it)": "Dispositivos que se mantienen (desmarca uno para eliminarlo)",
"Diff: current system vs backup (--- system +++ backup)": "Diferencia: sistema actual vs copia de seguridad (--- sistema +++ copia de seguridad)",
"Different host. Backup from:": "Host diferente. Copia de seguridad procedente de:",
"Direct conversion completed for container": "Conversión directa completada para contenedor.",
@@ -2090,6 +2101,8 @@
"External credential is empty or spans multiple lines": "La credencial externa está vacía o abarca varias líneas",
"External disk for backup": "Disco externo para copia de seguridad",
"External field not reserved:": "Campo externo no reservado:",
"Extra paths": "Rutas extra",
"Extra paths to keep (unmark one to remove it)": "Rutas extra que se mantienen (desmarca una para eliminarla)",
"Extracting NVIDIA installer on host...": "Extrayendo el instalador de NVIDIA en el host...",
"Extracting OVA archive...": "Extrayendo archivo OVA...",
"Extracting archive...": "Extrayendo archivo...",
@@ -3091,7 +3104,7 @@
"Invalid device UID:": "Dispositivo no válido UID:",
"Invalid device mode": "Modo de dispositivo inválido",
"Invalid device mode:": "Modo de dispositivo inválido:",
"Invalid device path:": "Camino del dispositivo inválido:",
"Invalid device path:": "Ruta de dispositivo no válida:",
"Invalid device paths for": "Rutas de dispositivo no válidas para",
"Invalid device permissions": "Permisos del dispositivo no válidos",
"Invalid group name. Use letters, digits, underscore or hyphen, and start with a letter or underscore.": "Nombre de grupo no válido. Utilice letras, dígitos, guiones bajos o guiones y comience con una letra o un guión bajo.",
@@ -4131,6 +4144,7 @@
"Nothing to remove": "Nada que quitar",
"Nothing to restore": "Nada que restaurar",
"Nothing to schedule for reboot from selected paths.": "No hay nada que programar para reiniciar desde las rutas seleccionadas.",
"Nothing was changed.": "No se ha cambiado nada.",
"Nouveau module is loaded, attempting to unload...": "El módulo Nouveau está cargado, intentando descargarse...",
"Number of CPU cores (default: 2)": "Número de núcleos de CPU (predeterminado: 2)",
"Nzbget is a usenet downloader, written in C++ and designed with performance in mind to achieve maximum download speed by using very little system resources.": "Nzbget es un descargador de Usenet, escrito en C++ y diseñado con el rendimiento en mente para lograr la máxima velocidad de descarga utilizando muy pocos recursos del sistema.",
@@ -4373,6 +4387,7 @@
"Paste its docker run command in the terminal": "Pegar su comando docker run en la terminal",
"Paste the UUP Dump URL here": "Pegue la URL del volcado UUP aquí",
"Patching source for kernel compatibility...": "Fuente de parcheo para compatibilidad del kernel...",
"Path added:": "Ruta añadida:",
"Path does not exist.": "La ruta no existe.",
"Path inside the container": "Ruta dentro del contenedor",
"Path inside the container (e.g. /media-extra)": "Ruta dentro del contenedor (por ejemplo /media-extra)",
@@ -4381,6 +4396,7 @@
"Path must be absolute (start with /).": "La ruta debe ser absoluta (comenzar con /).",
"Path not found": "Ruta no encontrada",
"Path of the Compose file": "ruta del archivo de redacción",
"Path removed:": "Ruta eliminada:",
"Path:": "Ruta:",
"Paths applied:": "Rutas aplicadas:",
"Paths included in backup": "Rutas incluidas en la copia de seguridad",
@@ -4754,6 +4770,7 @@
"Recreate": "Recrear",
"Recreate OCI": "Recrear OCI",
"Recreate with these options?": "¿Recrear con estas opciones?",
"Recreate: add or remove extra paths and devices": "Recrear: añadir o quitar rutas extra y dispositivos",
"Recreate: edit resources, network, paths and GPU": "Recrear: editar recursos, red, rutas y GPU",
"Recreating requires a confirmed proposal": "Recreando requires una propuesta confirmada",
"Recreating the container...": "Recreando el contenedor...",
@@ -4901,6 +4918,7 @@
"Removing the containers...": "Eliminando los contenedores...",
"Removing the incomplete stack...": "Eliminando la pila incompleta...",
"Removing the previous container": "Remoción del contenedor anterior",
"Removing this path deletes its container volume and the data in it:": "Eliminar esta ruta borra su volumen de contenedor y los datos que contiene:",
"Removing utilities installed by ProxMenux...": "Eliminando utilidades instaladas por ProxMenux...",
"Removing zfs-auto-snapshot...": "Eliminando zfs-auto-snapshot...",
"Renamed": "Renombrado",
@@ -5153,6 +5171,7 @@
"Save this Borg target so you don't need to enter the details again?": "¿Guardar este objetivo Borg para no tener que volver a introducir los detalles?",
"Saved record removed": "Registro guardado eliminado",
"Saving the new configuration": "Salvando la nueva configuración",
"Saving the new configuration of the application...": "Guardando la nueva configuración de la aplicación...",
"Saving the new configuration...": "Guardando la nueva configuración...",
"Saving the stack records...": "Guardando los registros de la pila...",
"Scan storage for new content": "Escanear el almacenamiento en busca de contenido nuevo",
@@ -5720,6 +5739,8 @@
"Storage for the Nextcloud data": "Almacenamiento para los datos Nextcloud",
"Storage for the OCI image cache": "Almacenamiento para la caché de imágenes OCI",
"Storage for the consume and export folders": "Almacenamiento para las carpetas de consumo y exportación",
"Storage for the container and its data": "Almacenamiento para el contenedor y sus datos",
"Storage for the containers and their data": "Almacenamiento para los contenedores y sus datos",
"Storage for the persistent configuration": "Almacenamiento para la configuración persistente",
"Storage is now available in Proxmox web interface under Datacenter > Storage": "El almacenamiento ahora está disponible en la interfaz web de Proxmox en Centro de datos > Almacenamiento",
"Storage plan selection cancelled.": "Se canceló la selección del plan de almacenamiento.",
@@ -5926,10 +5947,12 @@
"The administrator user contains characters that are not allowed": "El usuario administrador contiene caracteres que no se permiten",
"The all-in-one AI application.": "La aplicación de IA todo en uno.",
"The application configuration needs a first start to complete.": "La configuración de la aplicación necesita un primer arranque para completarse.",
"The application could not be recreated:": "No se pudo recrear la aplicación:",
"The application did not complete its initial setup:": "La aplicación no completó su configuración inicial:",
"The application did not get an address on the access network:": "La aplicación no obtuvo una dirección en la red de acceso:",
"The application did not pass its HTTP check:": "La aplicación no superó la comprobación HTTP:",
"The application did not respond in time:": "La aplicación no respondió a tiempo:",
"The application has been recreated with the new options.": "La aplicación se ha recreado con las nuevas opciones.",
"The application stopped during its first start:": "La aplicación se detuvo durante su primer comienzo:",
"The application was removed": "La aplicación se ha eliminado",
"The archive could not be extracted.": "No se pudo extraer el archivo.",
@@ -5975,12 +5998,14 @@
"The container does not need it any more; an update downloads the new version when there is one.": "El contenedor ya no lo necesita; una actualización descarga la nueva versión cuando la hay.",
"The container gets its own address and its own volumes, so the networks and volumes declared in the file are not used.": "El contenedor obtiene su propia dirección y sus propios volúmenes, por lo que las redes y volúmenes declarados en el archivo no se utilizan.",
"The container has advanced Proxmox settings outside the supported profile": "El contenedor ha avanzado la configuración Proxmox fuera del perfil soportado",
"The container has an operation pending; finish or recover it first": "El contenedor tiene una operación pendiente; termínala o recupérala primero",
"The container identity changed; nothing was adopted": "Ha cambiado la identidad del contenedor; no se ha incorporado nada",
"The container identity changed; the container is not replaced": "La identidad del contenedor cambió; el contenedor no es reemplazado",
"The container identity does not match": "La identidad del contenedor no coincide",
"The container identity or configuration changed": "La identidad o configuración del contenedor cambió",
"The container is currently stopped. Do you want to start it now to install the package?": "El contenedor se encuentra actualmente detenido. ¿Quieres iniciarlo ahora para instalar el paquete?",
"The container is not modified because a host directory is not available:": "El contenedor no se modifica porque un directorio host no está disponible:",
"The container is restarted to apply the changes. Its own data, the database and the other containers of the application are not touched.": "El contenedor se reinicia para aplicar los cambios. Sus datos propios, la base de datos y los demás contenedores de la aplicación no se tocan.",
"The container no longer exists:": "El contenedor ya no existe:",
"The container of a member was replaced; the assembly is not resumed": "El contenedor de un miembro fue reemplazado; la asamblea no se reanuda",
"The container runs on another node of the cluster; migrate it back to this node to remove it:": "El contenedor está en otro nodo del clúster; migra el contenedor de vuelta a este nodo para eliminarlo:",
@@ -6014,6 +6039,7 @@
"The device directory does not exist:": "El directorio del dispositivo no existe:",
"The device must keep its /dev path inside the LXC:": "El dispositivo debe mantener su camino /dev dentro del LXC:",
"The device must keep its native path without duplicates": "El dispositivo debe mantener su ruta nativa sin duplicados",
"The device to remove is not attached:": "El dispositivo que se quiere eliminar no está conectado:",
"The directory contains no character devices:": "El directorio no contiene dispositivos de carácter:",
"The directory does not exist in the CT.": "El directorio no existe en el CT.",
"The disk": "el disco",
@@ -6169,6 +6195,7 @@
"The path escapes the rootfs:": "El camino escapa a los rootfs:",
"The path must be absolute and normalized": "La ruta debe ser absoluta y estar normalizada",
"The path overlaps an existing mount": "La ruta se superpone a un montaje existente",
"The path to remove is not mounted:": "La ruta que se quiere eliminar no está montada:",
"The persistent NVIDIA hook does not match the installer:": "El gancho NVIDIA persistente no coincide con el instalador:",
"The persistent WebUI credentials were not found": "No se encontraron las persistentes credentiales WebUI",
"The physical NVIDIA selection changed": "La selección física de NVIDIA cambió",
@@ -6359,6 +6386,7 @@
"This container does not have apt-get. NFS client installation only supports Debian/Ubuntu containers.": "Este contenedor no tiene apt-get. La instalación del cliente NFS solo admite contenedores Debian/Ubuntu.",
"This container does not have apt-get. Samba client installation only supports Debian/Ubuntu containers.": "Este contenedor no tiene apt-get. La instalación del cliente Samba solo admite contenedores Debian/Ubuntu.",
"This container has no GPU configured. Coral TPU works best alongside hardware video decoding (Quick Sync, VA-API, NVENC) for apps like Frigate.": "Este contenedor no tiene GPU configurada. Coral TPU funciona mejor junto con la decodificación de video por hardware (Quick Sync, VA-API, NVENC) para aplicaciones como Frigate.",
"This container is not a member of a multi-container application": "Este contenedor no forma parte de una aplicación de varios contenedores",
"This container is not a registered OCI instance.": "Este contenedor no es una instancia OCI registrada.",
"This converts all directory UIDs/GIDs by adding 100000": "Esto convierte todos los UID/GID del directorio agregando 100000",
"This converts all file UIDs/GIDs by adding 100000": "Esto convierte todos los archivos UID/GID agregando 100000",
+8 -1
View File
@@ -120,6 +120,11 @@ MEDIA_STORAGE=$(jq -r '.media.storage // empty' "$DEPLOYMENT_FILE")
MEDIA_SIZE=$(jq -r '.media.size_gb // empty' "$DEPLOYMENT_FILE")
MEDIA_ROOT=$(jq -r '.media.host_path // empty' "$DEPLOYMENT_FILE")
TIMEZONE=$(jqr '.timezone')
APPLICATION_CORES=$(jqr '.resources.cores // 4')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 3072')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
@@ -449,13 +454,15 @@ fi
msg_info "$(translate "Creating the container...")"
oci_create_container "$SERVER_ID" "$SERVER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:16" \
--mp0 "$SERVER_MEDIA_MOUNT" --hostname "${STACK_NAME}-server" \
--cores 4 --memory 3072 --swap 1024 \
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${SERVER_ADDRESS},type=veth" \
"${SERVER_DEVICE_ARGS[@]}" --unprivileged 1 --features nesting=1 --cmode console \
--onboot "$ONBOOT" --startup order=40,up=10,down=30 --tags "$TAGS" \
--description 'Immich server native OCI'
created_ids+=("$SERVER_ID")
oci_apply_extra_mounts "$SERVER_ID"
oci_apply_extra_devices "$SERVER_ID"
oci_quiet pct mount "$SERVER_ID"
SERVER_ROOT="/var/lib/lxc/${SERVER_ID}/rootfs"
+8 -1
View File
@@ -116,6 +116,11 @@ APACHE_BODY_LIMIT=$(jqr '.application.apache_body_limit')
TIMEZONE=$(jqr '.timezone')
MAINTENANCE_WINDOW=$(jqr '.maintenance_window_start_utc')
PHONE_REGION=$(jqr '.default_phone_region')
APPLICATION_CORES=$(jqr '.resources.cores // 2')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 2048')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
@@ -395,13 +400,15 @@ msg_ok "$(translate "Container created:") CT $CACHE_ID (Redis)"
msg_info "$(translate "Creating the container...")"
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "$APPLICATION_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 1024 \
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=30,up=15,down=30 --tags "$TAGS" \
--description 'Nextcloud Apache native OCI'
created_ids+=("$APPLICATION_ID")
oci_apply_extra_mounts "$APPLICATION_ID"
oci_apply_extra_devices "$APPLICATION_ID"
oci_quiet pct mount "$APPLICATION_ID"
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
+8 -1
View File
@@ -116,6 +116,11 @@ TRANSFER_ROOT=$(jq -r '.transfer.host_path // empty' "$DEPLOYMENT_FILE")
ADMIN_USERNAME=$(jqr '.application.admin_username')
OCR_LANGUAGE=$(jqr '.application.ocr_language')
TIMEZONE=$(jqr '.timezone')
APPLICATION_CORES=$(jqr '.resources.cores // 2')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 2048')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
@@ -414,13 +419,15 @@ oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS
--mp0 "${APPLICATION_STORAGE}:${DATA_SIZE},mp=/usr/src/paperless/data,backup=1" \
--mp1 "${APPLICATION_STORAGE}:${MEDIA_SIZE},mp=/usr/src/paperless/media,backup=1" \
--mp2 "$EXPORT_MOUNT" --mp3 "$CONSUME_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 1024 \
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=30,up=15,down=30 --tags "$TAGS" \
--description 'Paperless-ngx native OCI'
created_ids+=("$APPLICATION_ID")
oci_apply_extra_mounts "$APPLICATION_ID"
oci_apply_extra_devices "$APPLICATION_ID"
oci_quiet pct mount "$APPLICATION_ID"
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
+8 -1
View File
@@ -129,6 +129,11 @@ ALLOWED_HOSTS=$(jqr '.application.allowed_hosts')
ADMIN_USERNAME=$(jqr '.application.admin_username')
ADMIN_EMAIL=$(jqr '.application.admin_email')
TIMEZONE=$(jqr '.timezone')
APPLICATION_CORES=$(jqr '.resources.cores // 2')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 2048')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 512')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
@@ -392,13 +397,15 @@ msg_info "$(translate "Creating the container...")"
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
--mp0 "${APPLICATION_STORAGE}:${STATIC_SIZE},mp=/opt/recipes/staticfiles,backup=1" \
--mp1 "$MEDIA_MOUNT" --hostname "$STACK_NAME" \
--cores 2 --memory 2048 --swap 512 \
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
--startup order=20,up=15,down=30 --tags "$TAGS" \
--description 'Tandoor Recipes native OCI'
created_ids+=("$APPLICATION_ID")
oci_apply_extra_mounts "$APPLICATION_ID"
oci_apply_extra_devices "$APPLICATION_ID"
oci_quiet pct mount "$APPLICATION_ID"
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
+7
View File
@@ -20,6 +20,7 @@ import contextlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
@@ -42,6 +43,12 @@ def start_mark_hook(vmid: int) -> str:
# `test`, not `[`: a bracket in the configuration reads as a snapshot section.
return (f"lxc.hook.pre-start: /bin/sh -c 'mkdir -p {LOG_DIR}; "
f"test -x {script} && {script} {int(vmid)}; exit 0'")
def is_start_mark_hook(line: str) -> bool:
"""Whether a configuration line is exactly the start hook written here."""
vmid = re.search(r" (\d+); exit 0'$", line)
return bool(vmid) and line == start_mark_hook(int(vmid[1]))
LOGROTATE = Path('/etc/logrotate.d/proxmenux-oci')
# copytruncate, because liblxc keeps the file open for as long as the
# container runs; moving it away would leave the application writing into the
+1 -3
View File
@@ -6,7 +6,6 @@ the same profile.
"""
from pathlib import Path
import hashlib
import re
import oci_console
import oci_nvidia_runtime as nv
@@ -15,8 +14,7 @@ from oci_ui import translate
def console_start_hook(value):
"""The hook ProxMenux adds to mark each start in the console log."""
vmid = re.search(r' (\d+); exit 0\'$', value)
return bool(vmid) and oci_console.start_mark_hook(int(vmid[1])) == f'lxc.hook.pre-start: {value}'
return oci_console.is_start_mark_hook(f'lxc.hook.pre-start: {value}')
def gpu_identity(inventory):
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Add or remove the extra paths and devices of one member of an installed
multi-container application, without rebuilding any of its containers."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import time
import oci_gpu_devices as gpu_devices
import oci_host_mounts as host_mounts
import oci_instance_reconcile as reconcile
import oci_instances as instances
import oci_stack_replay as replay
from oci_ui import msg_error, msg_info, msg_ok, translate
CONVERTERS = {'install_nextcloud_stack.sh': replay.nextcloud_record,
'install_paperless_stack.sh': replay.paperless_record,
'install_tandoor_stack.sh': replay.tandoor_record,
'install_immich_stack.sh': replay.immich_record}
def run(*command):
result = subprocess.run(command, capture_output=True, text=True, check=False)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip().splitlines()[-1:] or ['']
raise RuntimeError(f"{' '.join(command[:3])}: {detail[0]}")
return result.stdout
def entries(vmid, prefix):
"""The `prefix`N lines of the container configuration, in order."""
result = {}
for line in run('pct', 'config', str(vmid)).splitlines():
key, separator, value = line.partition(': ')
if separator and re.fullmatch(prefix + '[0-9]+', key):
result[key] = value
return result
def options(value):
return dict(part.split('=', 1) for part in value.split(',')[1:] if '=' in part)
def free_key(vmid, prefix):
used = entries(vmid, prefix)
return next(f'{prefix}{index}' for index in range(256) if f'{prefix}{index}' not in used)
def device_path(value):
fields = dict(part.split('=', 1) for part in value.split(',') if '=' in part)
return fields.get('path') or value.split(',', 1)[0]
def validate(vmid, changes):
"""Everything that can be refused is refused before the container stops."""
mounts = {options(value).get('mp'): (key, value) for key, value in entries(vmid, 'mp').items()}
devices = {device_path(value): key for key, value in entries(vmid, 'dev').items()}
for path in changes['remove_mounts']:
if path not in mounts:
raise ValueError(f"{translate('The path to remove is not mounted:')} {path}")
for path in changes['remove_devices']:
if path not in devices:
raise ValueError(f"{translate('The device to remove is not attached:')} {path}")
kept = [path for path in mounts if path not in changes['remove_mounts']]
for mount in changes['add_mounts']:
target = host_mounts.valid_path(mount['container_path'])
if any(target == other or target.startswith(other.rstrip('/') + '/')
or other.startswith(target.rstrip('/') + '/') for other in kept):
raise ValueError(f"{translate('The custom path overlaps another mount')}: {target}")
kept.append(target)
if mount['type'] == 'managed-volume':
if not isinstance(mount.get('size_gb'), int) or mount['size_gb'] < 1 \
or not re.fullmatch(r'[A-Za-z0-9_-]+', mount.get('source') or ''):
raise ValueError(f"{translate('Invalid volume size:')} {target}")
elif mount['type'] == 'host-bind':
host_mounts.validate_source(mount['source'], allow_missing=True)
else:
raise ValueError(f"{translate('Unsupported mount type:')} {mount['type']}")
for device in changes['add_devices']:
path = device.get('host_path')
if device.get('kind') != 'character-device' or not (
gpu_devices.gpu_path(path) or gpu_devices.peripheral_path(path)):
raise ValueError(f"{translate('Device outside the supported profiles; NVIDIA and device trees require another profile')}: {path}")
if path in devices and path not in changes['remove_devices']:
raise ValueError(f"{translate('This device is already attached')}: {path}")
gpu_devices.snapshot(path)
def apply(vmid, changes):
for mount in changes['add_mounts']:
target = mount['container_path']
if mount['type'] == 'managed-volume':
value = f"{mount['source']}:{mount['size_gb']},mp={target},backup=1"
else:
source = Path(mount['source'])
if not source.exists():
source.mkdir(parents=True, mode=0o775)
os.chown(source, 100000, 100000)
value = f"{source},mp={target},backup=0"
if mount.get('read_only'):
value += ',ro=1'
run('pct', 'set', str(vmid), '--' + free_key(vmid, 'mp'), value)
msg_ok(f"{translate('Path added:')} {target}")
for path in changes['remove_devices']:
key = next(key for key, value in entries(vmid, 'dev').items() if device_path(value) == path)
run('pct', 'set', str(vmid), '--delete', key)
msg_ok(f"{translate('Device removed:')} {path}")
for device in changes['add_devices']:
path = device['host_path']
value = (f"path={path},mode={device.get('mode', '0660')},"
f"deny-write={'1' if device.get('deny_write') else '0'},gid={os.stat(path).st_gid}")
run('pct', 'set', str(vmid), '--' + free_key(vmid, 'dev'), value)
msg_ok(f"{translate('Device added:')} {path}")
for path in changes['remove_mounts']:
key, value = next((key, value) for key, value in entries(vmid, 'mp').items()
if options(value).get('mp') == path)
source = value.split(',', 1)[0]
run('pct', 'set', str(vmid), '--delete', key)
if not source.startswith('/'):
# A detached disk would be destroyed with the container on its next
# update, so the volume of a removed path is deleted here, as confirmed.
unused = next((key for key, value in entries(vmid, 'unused').items() if value == source), None)
if unused:
run('pct', 'set', str(vmid), '--delete', unused)
msg_ok(f"{translate('Path removed:')} {path}")
def recorded_mounts(vmid):
"""The mounts of a member as its installation recorded them."""
result = []
for value in entries(vmid, 'mp').values():
source = value.split(',', 1)[0]
mount = options(value)
result.append({'container_path': mount['mp'], 'source': source,
'type': 'host-bind' if source.startswith('/') else 'managed-volume',
'backup': mount.get('backup') == '1', 'read_only': mount.get('ro') == '1',
'existing_volume': True})
return result
def register(root, vmid):
"""Record the member as it is now, the way a stack update leaves it, and
refresh the copy its main container keeps."""
record = instances.read(root, vmid)
previous = record['observed']
record['observed'] = instances.observe(vmid, record['installation_id'], previous['archive_path'],
previous['resolved_registry_digest'], previous['image'])
plan = record['deployment']
adapter = (plan.get('replay_profile') or {}).get('adapter')
if adapter in CONVERTERS:
if 'native_config' in plan:
plan['native_config'] = record['observed']['config']
if 'member_replay_projection' in plan:
plan['member_replay_projection'] = replay.normalize(record)
plan['mounts'] = recorded_mounts(vmid)
else:
converted = CONVERTERS[adapter](record)
plan['mounts'] = converted['deployment']['mounts']
plan['devices'] = converted['deployment'].get('devices', [])
# The paths an update must find are the ones mounted now.
record['template']['container_contract']['volumes'] = \
converted['template']['container_contract']['volumes']
# The stack must stay updatable with what was just changed.
CONVERTERS[adapter](record)
else:
known = {mount['container_path']: mount for mount in plan.get('mounts', [])}
plan['mounts'] = [known.get(options(value).get('mp')) or reconcile._mount(key, value, vmid)
for key, value in entries(vmid, 'mp').items()]
attached = {device_path(value): (key, value) for key, value in entries(vmid, 'dev').items()}
kept = [device for device in plan.get('devices', [])
if device.get('kind') != 'character-device' or device.get('host_path') in attached]
listed = {device.get('host_path') for device in kept}
plan['devices'] = kept + [reconcile._device(key, value) for path, (key, value) in attached.items()
if path not in listed and (gpu_devices.gpu_path(path)
or gpu_devices.peripheral_path(path))]
instances.write(instances.location(root, vmid), record)
primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
primary = instances.read(root, primary_id)
snapshot = instances.read(root, vmid)
snapshot.pop('stack', None)
members = primary.get('stack', {}).get('members', [])
for index, member in enumerate(members):
if member.get('vmid') == vmid:
members[index] = snapshot
instances.write(instances.location(root, primary_id), primary)
def is_running(vmid):
return 'running' in run('pct', 'status', str(vmid))
def modify(root, vmid, changes):
record = instances.read(root, vmid)
if record.get('status') != 'installed' or record.get('pending_transaction') \
or record.get('pending_stack_transaction'):
raise ValueError(translate('The container has an operation pending; finish or recover it first'))
if not (record.get('stack_member') or record.get('stack')):
raise ValueError(translate('This container is not a member of a multi-container application'))
if instances.identity(instances.command('pct', 'config', str(vmid))) != record['installation_id']:
raise ValueError(translate('The container identity does not match'))
validate(vmid, changes)
backup = instances.location(root, vmid).parent / f"config-before-recreate-{time.strftime('%Y%m%d-%H%M%S')}.conf"
backup.write_text(run('pct', 'config', str(vmid)))
backup.chmod(0o600)
running = is_running(vmid)
if running:
msg_info(translate('Stopping the container...'))
try:
run('pct', 'shutdown', str(vmid), '--timeout', '60')
except RuntimeError:
run('pct', 'stop', str(vmid))
msg_ok(translate('Container stopped'))
try:
apply(vmid, changes)
msg_info(translate('Saving the new configuration of the application...'))
register(root, vmid)
msg_ok(translate('Configuration saved'))
finally:
if running:
msg_info(translate('Starting the container...'))
run('pct', 'start', str(vmid))
msg_ok(translate('Container started'))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int)
parser.add_argument('--changes', type=Path, required=True)
parser.add_argument('--root', type=Path, default=instances.ROOT)
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
changes = {'remove_mounts': [], 'add_mounts': [], 'remove_devices': [], 'add_devices': [],
**json.loads(args.changes.read_text())}
try:
with instances.locked(args.root):
modify(args.root, args.vmid, changes)
except BlockingIOError:
msg_error(translate('Another OCI operation is using the instance registry. Wait for it to finish.'))
return 1
except (OSError, ValueError, KeyError, RuntimeError, StopIteration, subprocess.TimeoutExpired) as error:
msg_error(f"{translate('The application could not be recreated:')} {error}")
return 1
msg_ok(translate('The application has been recreated with the new options.'))
return 0
if __name__ == '__main__':
sys.exit(main())
+51 -8
View File
@@ -7,6 +7,8 @@ import stat
import shlex
import os
import oci_console
import oci_gpu_devices
from oci_ui import translate
@@ -75,6 +77,9 @@ def immich_record(record):
path = fields.get('path')
if cuda and path and path.startswith('/dev/nvidia'):
continue
if oci_gpu_devices.peripheral_path(path):
devices.append(peripheral_device(fields))
continue
if not path or not re.fullmatch(r'/dev/dri/renderD[0-9]+', path):
raise ValueError(translate('Immich device without a validated translation'))
devices.append({'kind': 'character-device', 'host_path': path, 'container_path': path,
@@ -219,25 +224,59 @@ def adapter_prerequisites(rootfs, role, image, adapter, required):
return checked
def peripheral_device(fields):
"""A native devN entry as the device the installer attaches again."""
path = fields['path']
device = {'id': 'native-' + path.removeprefix('/dev/').replace('/', '-'), 'kind': 'character-device',
'host_path': path, 'container_path': path, 'mode': fields.get('mode', '0660'),
'deny_write': fields.get('deny-write', '0') == '1',
'gid_strategy': 'host-device-gid' if 'gid' in fields else 'none'}
if 'uid' in fields:
device['uid'] = int(fields['uid'])
return device
def translated_devices(projection):
"""The devices of a member the update keeps: USB, serial and GPU nodes.
Anything else has no translation and stops the update before it starts."""
devices = []
for item in projection['native_devices']:
fields = dict(part.split('=', 1) for part in item['value'].split(',') if '=' in part)
path = fields.get('path')
if not (oci_gpu_devices.gpu_path(path) or oci_gpu_devices.peripheral_path(path)):
raise ValueError(translate('The stack contains devices or directives without a translation'))
devices.append(peripheral_device(fields))
return devices
def with_devices(record, result):
result['deployment']['devices'] = translated_devices(normalize(record))
return result
def nextcloud_record(record):
"""Build portable desired state from evidence, without writing the registry."""
return portable_record(record, nextcloud_installer_profile(record))
return with_devices(record, portable_record(record, nextcloud_installer_profile(record)))
def paperless_record(record):
"""Translate captured Paperless state; native activation remains separate."""
return portable_record(record, paperless_installer_profile(record))
return with_devices(record, portable_record(record, paperless_installer_profile(record)))
def tandoor_record(record):
"""Project the two-member Tandoor recipe without activating replacement."""
return portable_record(record, tandoor_installer_profile(record))
return with_devices(record, portable_record(record, tandoor_installer_profile(record)))
def portable_record(record, profile):
"""Preserve data mounts and provenance without first-install preparations."""
projection = normalize(record)
saved = record['deployment'].get('member_replay_projection')
if saved is not None:
# A projection saved while the start hook was still listed carries it.
saved = dict(saved, preserved_raw_runtime=[line for line in saved.get('preserved_raw_runtime', [])
if not oci_console.is_start_mark_hook(line)])
if saved is not None and saved != projection:
raise ValueError(translate('The saved projection does not match the native evidence'))
result = copy.deepcopy(record)
@@ -297,8 +336,9 @@ def official_application_profile(record, adapter, adapted_entrypoints):
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != adapter:
raise ValueError(translate('Stack adapter not recognized by the translator'))
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
if projection.get('preserved_raw_runtime'):
raise ValueError(translate('The stack contains devices or directives without a translation'))
translated_devices(projection)
runtime = projection['runtime']
entrypoint = runtime.get('entrypoint', '')
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
@@ -338,8 +378,9 @@ def nextcloud_installer_profile(record):
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != 'install_nextcloud_stack.sh':
raise ValueError(translate('This translator only supports the Nextcloud stack'))
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
if projection.get('preserved_raw_runtime'):
raise ValueError(translate('The stack contains devices or directives without a translation'))
translated_devices(projection)
runtime = projection['runtime']
entrypoint = runtime.get('entrypoint', '')
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
@@ -563,11 +604,13 @@ def normalize(record):
preserved = {key: single(key) for key in ('arch', 'ostype', 'cmode', 'console', 'tty', 'cpuunits',
'net0', 'net1', 'startup', 'hookscript', 'features', 'tags') if key in values}
devices = [{'key': key, 'value': single(key)} for key in values if re.fullmatch(r'dev[0-9]+', key)]
# The console log line is not replayed: the installer that rebuilds the
# member sets it itself, and replaying it too would leave two of them.
# The console log line and its start hook are not replayed: the installer
# that rebuilds the member sets them itself, and replaying them too would
# leave two of each.
raw_runtime = [line for line in config.splitlines() if line.startswith('lxc.')
and line.partition(': ')[0] not in ('lxc.environment.runtime', 'lxc.init.cwd',
'lxc.signal.halt', 'lxc.console.logfile')]
'lxc.signal.halt', 'lxc.console.logfile')
and not oci_console.is_start_mark_hook(line)]
return {'schema_version': 1, 'deployment': plan, 'runtime': runtime,
'preserved_native': preserved, 'generated_files': copy.deepcopy(files),
'native_devices': devices, 'preserved_raw_runtime': raw_runtime,
+62
View File
@@ -158,6 +158,68 @@ oci_create_container() {
return "$status"
}
# The extra paths the user added to the application container of a
# multi-container application, from `.extra_mounts` of the deployment.
# Argument: VMID. A path on the host is created for the root of the container.
oci_apply_extra_mounts() {
local vmid=$1 type target source size read_only index value count=0
while IFS=$'\t' read -r type target source size read_only; do
[[ -n $type ]] || continue
[[ $target == /* && $target != *","* && $target != *[[:space:]]* ]] \
|| die "$(translate "Invalid container path:") $target"
index=0
while pct config "$vmid" | grep -q "^mp${index}:"; do index=$((index + 1)); done
if [[ $type == managed-volume ]]; then
[[ $size =~ ^[0-9]+$ && $size -ge 1 && $source != /* && $source != *","* ]] \
|| die "$(translate "Invalid volume size:") $target"
value="${source}:${size},mp=${target},backup=1"
elif [[ $type == host-bind ]]; then
[[ $source == /* && $source != *","* ]] || die "$(translate "Invalid host path:") $source"
if [[ ! -e $source ]]; then
install -d -m 0775 -o 100000 -g 100000 "$source"
oci_log "Shared directory created: $source (uid=100000 gid=100000)"
fi
[[ -d $source ]] || die "$(translate "The host bind source is not a regular file or directory:") $source"
value="${source},mp=${target},backup=0"
else
die "$(translate "Unsupported mount type:") $type"
fi
[[ $read_only == true ]] && value="${value},ro=1"
oci_quiet pct set "$vmid" "--mp${index}" "$value" \
|| die "$(translate "Could not add the mount point:") $target"
count=$((count + 1))
done < <(jq -r '.extra_mounts[]? | [.type, .container_path, .source, (.size_gb // "-"), (.read_only // false)] | @tsv' "$DEPLOYMENT_FILE")
if (( count > 0 )); then
msg_ok "$(translate "Mount points added:") $count"
fi
return 0
}
# The USB, serial or GPU nodes the user added to the application container of
# a multi-container application, from `.extra_devices` of the deployment.
# Argument: VMID. Each node keeps its path, with the group it has on the host.
oci_apply_extra_devices() {
local vmid=$1 path mode deny_write gid index count=0
while IFS=$'\t' read -r path mode deny_write; do
[[ -n $path ]] || continue
[[ $path == /dev/* && $path != *","* && $path != *[[:space:]]* && $path != *".."* ]] \
|| die "$(translate "Invalid device path:") $path"
[[ -c $path ]] || die "$(translate "The character device does not exist:") $path"
[[ $mode =~ ^0?[0-7]{3}$ ]] || die "$(translate "Invalid device mode:") $mode"
pct config "$vmid" | grep -Eq "^dev[0-9]+: (.*,)?path=${path}(,|$)" && continue
index=0
while pct config "$vmid" | grep -q "^dev${index}:"; do index=$((index + 1)); done
gid=$(stat -c '%g' "$path")
oci_quiet pct set "$vmid" "--dev${index}" "path=${path},mode=${mode},deny-write=${deny_write},gid=${gid}" \
|| die "$(translate "Could not add the device to the container:") $path"
count=$((count + 1))
done < <(jq -r '.extra_devices[]? | select(.kind == "character-device") | [.host_path, (.mode // "0660"), (if .deny_write then 1 else 0 end)] | @tsv' "$DEPLOYMENT_FILE")
if (( count > 0 )); then
msg_ok "$(translate "Devices added:") $count"
fi
return 0
}
# Last lines of the log, for the error report.
oci_log_tail() {
[[ -n $OCI_LOG && -s $OCI_LOG ]] || return 0
+15 -6
View File
@@ -12,8 +12,11 @@ MEDIA_APPS = {'sonarr','radarr','lidarr','qbittorrent','sabnzbd','bazarr','unpac
class SuiteChildUI(DefaultsUI):
"""Reuse image hardware questions without repeating the stack storage wizard."""
def __init__(self, ui, profile):
def __init__(self, ui, profile, label=''):
self.ui = ui
self.label = label
# Each application of the suite asks its own CPU and memory.
self.resources = {translate('CPU cores'), translate('Memory in MB')}
self.prompts = set()
def visit(value):
if isinstance(value, dict):
@@ -28,6 +31,8 @@ class SuiteChildUI(DefaultsUI):
visit(profile)
def ask(self, text, default=None, required=True):
if text in self.resources:
return self.ui.ask(f"{self.label}: {text}", default, required)
return self.ui.ask(text,default,required) if text in self.prompts else super().ask(text,default,required)
def choose(self, text, options, default=None):
@@ -56,12 +61,16 @@ def build_suite(template, ui, mode='advanced'):
raise StackError(translate('Select at least one suite application'))
if 'unpackerr' in selected and not set(selected) & {'sonarr','radarr','lidarr'}:
raise StackError(translate('Unpackerr requires Sonarr, Radarr or Lidarr in this suite'))
name = _hostname_default(ui.ask(translate('Stack name'), 'suite-arr'))
base = ui.ask(translate('Base VMID (empty = next free block)'), '', required=False)
# A default installation takes the name, the VMID and the timezone from the
# recipe; it still asks the storage, the addresses and how the suite starts.
quiet = DefaultsUI() if mode == DEFAULT_MODE else ui
name = _hostname_default(quiet.ask(translate('Stack name'), 'suite-arr'))
base = quiet.ask(translate('Base VMID (empty = next free block)'), '', required=False)
from . import host
from . import network as access
from .installer import ask_bridge, ask_storage
storage = ask_storage(ui, translate('Storage for rootfs and private configuration'), 'rootdir', 'local-lvm', mode)
storage = ask_storage(ui, translate('Storage for the containers and their data') if mode == DEFAULT_MODE
else translate('Storage for rootfs and private configuration'), 'rootdir', 'local-lvm')
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', 'local', mode)
shared = ui.ask(translate('Shared host media directory'), '/mnt/oci-shared/media') if set(selected) & MEDIA_APPS else None
if shared and (not shared.startswith('/') or shared == '/' or '..' in shared.split('/') or any(c in shared for c in ',\n\r')):
@@ -79,7 +88,7 @@ def build_suite(template, ui, mode='advanced'):
reachable = [app for app in selected if app != 'unpackerr']
labels, gateway = access.ask_addresses(ui, bridge, [app.capitalize() for app in reachable])
addresses = dict(zip(reachable, labels.values()))
timezone = ui.ask(translate('Timezone'), host.timezone())
timezone = quiet.ask(translate('Timezone'), host.timezone())
services = []
credentials = None
if 'qbittorrent' in selected:
@@ -93,7 +102,7 @@ def build_suite(template, ui, mode='advanced'):
if not child['compatibility']['automatic_install_candidate']:
raise StackError(f"{app}: {translate('individual template is blocked')}")
child_ui = (DefaultsUI() if mode == DEFAULT_MODE else
SuiteChildUI(ui, child['proxmox'].get('installer_profile', {})))
SuiteChildUI(ui, child['proxmox'].get('installer_profile', {}), app))
plan = build_deployment(copy.deepcopy(child), child_ui, mode)
plan.update(hostname=_hostname_default(name+'-'+app), start_after_create=False, template_storage=cache)
plan['rootfs']['storage'] = storage
+12
View File
@@ -267,6 +267,18 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
row(translate(label), f"{translate('host directory')} {storage.get('host_path', '-')}")
else:
row(translate(label), f"{storage.get('size_gb', '-')} GB {on} {storage.get('storage', '-')}")
if isinstance(plan.get("resources"), dict):
row(translate("Resources"), f"{plan['resources']['cores']} CPU, {plan['resources']['memory_mb']} MB RAM")
if plan.get("extra_mounts"):
lines.append(f"{translate('Extra paths') + ':':<16}")
for mount in plan["extra_mounts"]:
target = (f"{translate('host directory')} {mount['source']}" if mount["type"] == "host-bind"
else f"{translate('Container volume')} {mount.get('size_gb', '-')} GB {on} {mount['source']}")
if mount.get("read_only"):
target += f" ({translate('read-only')})"
lines.append(f" {mount['container_path']} → {target}")
if plan.get("extra_devices"):
row(translate("Devices"), ", ".join(device["host_path"] for device in plan["extra_devices"]))
else:
row(translate("Container"), f"CT {plan.get('vmid') or translate('next free')} · {plan.get('hostname', '-')}")
+3 -3
View File
@@ -28,15 +28,15 @@ def choose_usb_device(ui, title, default=None, attached=()):
return ui.ask(manual_prompt, default or '/dev/ttyACM0') if selected == 'manual' else selected
def ask_extra_devices(ui, devices, unprivileged, allow_coral=False):
def ask_extra_devices(ui, devices, unprivileged, allow_coral=False, kinds=('gpu', 'nvidia', 'usb')):
"""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 = [
options = [option for option in (
('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 option[0] in kinds]
if allow_coral:
options.append(('coral', translate('Coral PCIe/M.2 device node')))
kind = ui.choose(translate('Device to attach'), options)
+14
View File
@@ -101,6 +101,20 @@ def gib(value: Any) -> int:
return 0
def address_answers(address: str, bridge: str) -> bool:
"""Whether a device of the network already answers on this address. The
neighbour table tells, so a device that drops pings is found too; without a
host address on that network nothing can be asked and nothing is assumed."""
try:
subprocess.run(["ping", "-c", "1", "-W", "1", "-I", bridge, address],
capture_output=True, timeout=5, check=False)
result = subprocess.run(["ip", "-4", "neigh", "show", address, "dev", bridge],
capture_output=True, text=True, timeout=5, check=False)
except (OSError, subprocess.TimeoutExpired):
return False
return " lladdr " in f" {result.stdout} "
# USB classes as the Monitor labels them.
_USB_CLASSES = {
"01": "Audio", "02": "Communications", "03": "HID", "06": "Imaging", "07": "Printer",
+153 -45
View File
@@ -124,6 +124,57 @@ def ask_storage(ui, text: str, content: str, default: str, mode: str = ADVANCED_
return selected
def essential_ui(ui):
"""The interface for what every installation decides — its storage, its
address and how it starts — which a default installation asks as well."""
return getattr(ui, "real", None) or ui
def _answers_from_recipe(ui) -> bool:
"""Whether this container is being planned as a member of a stack, which
has already asked what the user decides."""
from .stack import DefaultsUI
return isinstance(ui, DefaultsUI)
def ask_application_extra_paths(ui, existing: list[str], storage: str) -> list[dict[str, Any]]:
"""Extra paths for the application container of a multi-container
application. An advanced installation asks them; a default one does not."""
if _answers_from_recipe(ui):
return []
planned = [{"type": "managed-volume", "container_path": path} for path in existing]
return [mount for mount in ask_custom_mounts(ui, planned, storage) if mount.get("custom")]
def ask_application_extra_devices(ui, kinds: tuple[str, ...] = ("gpu", "usb")) -> list[dict[str, Any]]:
"""USB, serial or GPU nodes for the application container of a
multi-container application; asked in an advanced installation only."""
if _answers_from_recipe(ui):
return []
from .extra_devices import ask_extra_devices
return ask_extra_devices(ui, [], True, kinds=kinds)
def ask_application_resources(ui, cores: int, memory_mb: int, swap_mb: int) -> dict[str, int]:
"""CPU and memory of the application container of a multi-container
application. An advanced installation asks them; a default one does not."""
if not _answers_from_recipe(ui):
cores = int(ui.ask(translate("CPU cores"), str(cores)))
memory_mb = int(ui.ask(translate("Memory in MB"), str(memory_mb)))
if cores < 1 or memory_mb < 256:
raise InstallError(translate("Invalid resources"))
return {"cores": cores, "memory_mb": memory_mb, "swap_mb": swap_mb}
def ask_default_storage(ui, preferred: str) -> str | None:
"""In a default installation, the one storage that holds the containers and
their data; None in an advanced one, which asks each storage separately."""
real = getattr(ui, "real", None)
if real is None:
return None
return ask_storage(real, translate("Storage for the containers and their data"), "rootdir", preferred)
def ask_bridge(ui, text: str, default: str, mode: str = ADVANCED_MODE) -> str:
if mode == DEFAULT_MODE:
return host.default_bridge(default)
@@ -197,7 +248,7 @@ def build_deployment(
'image-immich', 'image-nextcloud-stack', 'image-paperless-ngx', 'image-tandoor'
}:
from .stack import DefaultsUI
ui = DefaultsUI()
ui = DefaultsUI(ui)
if template.get("id") == "image-immich":
return _build_immich_deployment(template, ui)
if template.get("id") == "image-nextcloud-stack":
@@ -283,6 +334,9 @@ def build_deployment(
memory_default = installer_profile.get('resources', {}).get('memory_default_mb', defaults['memory_mb'])
mac_address = installer_profile.get("network", {}).get("mac_address")
timezone = host.timezone()
# A default installation still asks its storage, its address and how it
# starts, unless a stack is planning this container and asked them itself.
silent = not advanced and _answers_from_recipe(ui)
if advanced:
vmid_text = ui.ask(translate("VMID (empty = next free)"), "", required=False)
hostname = ui.ask(translate("Hostname"), hostname_default)
@@ -301,8 +355,15 @@ def build_deployment(
else:
vmid_text = ""
hostname = hostname_default
rootfs_storage = ask_storage(ui, "", "rootdir", defaults["rootfs_storage"], DEFAULT_MODE)
volume_storage = ask_storage(ui, "", "rootdir", defaults["volume_storage"], DEFAULT_MODE)
if silent:
rootfs_storage = ask_storage(ui, "", "rootdir", defaults["rootfs_storage"], DEFAULT_MODE)
volume_storage = ask_storage(ui, "", "rootdir", defaults["volume_storage"], DEFAULT_MODE)
else:
# One storage for the container and its data; the advanced
# installation is where each one is chosen separately.
rootfs_storage = ask_storage(ui, translate("Storage for the container and its data"), "rootdir",
defaults["rootfs_storage"])
volume_storage = rootfs_storage
template_storage = ask_storage(ui, "", "vztmpl", defaults["template_storage"], DEFAULT_MODE)
rootfs_size = int(defaults["rootfs_size_gb"])
cores = int(defaults["cores"])
@@ -400,8 +461,12 @@ def build_deployment(
ipv4, gateway = access.ask_ipv4(ui, bridge)
else:
bridge = ask_bridge(ui, "", defaults["bridge"], DEFAULT_MODE)
ipv4 = "host" if host_monitor else defaults["ipv4"]
gateway = None
if host_monitor:
ipv4, gateway = "host", None
elif silent:
ipv4, gateway = defaults["ipv4"], None
else:
ipv4, gateway = access.ask_ipv4(ui, bridge)
host_firewall = confirm_host_monitor_firewall(ui, template, bridge) if host_monitor else None
@@ -493,9 +558,12 @@ def build_deployment(
if advanced:
onboot = ui.confirm(translate("Start with Proxmox"), defaults["onboot"])
start_after = ui.confirm(translate("Start when finished"), True)
else:
elif silent:
onboot = bool(defaults["onboot"])
start_after = True
else:
onboot = ui.confirm(translate("Start with Proxmox"), defaults["onboot"])
start_after = ui.confirm(translate("Start when finished"), True)
if post_start_configurations and not start_after:
if not ui.confirm(translate("The application configuration needs a first start to complete.")
@@ -583,6 +651,10 @@ def _build_rclone_deployment(
vmid_text = ui.ask(translate("VMID (empty = next free)"), "", required=False)
hostname = ui.ask(translate("Hostname"), schema["hostname"]["default"])
cores = int(ui.ask(translate("CPU cores"), str(defaults["cores"])))
memory = int(ui.ask(translate("Memory in MB"), str(defaults["memory_mb"])))
if cores < 1 or memory < 256:
raise InstallError(translate("Invalid resources"))
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", schema["rootfs_storage"]["default"])
config_storage = ask_storage(ui, translate("Storage for the persistent configuration"), "rootdir",
schema["config_storage"]["default"])
@@ -613,8 +685,8 @@ def _build_rclone_deployment(
"template_storage": template_storage,
"rootfs": {"storage": rootfs_storage, "size_gb": rootfs_size},
"resources": {
"cores": defaults["cores"],
"memory_mb": defaults["memory_mb"],
"cores": cores,
"memory_mb": memory,
"swap_mb": defaults["swap_mb"],
"cpu_units": None,
},
@@ -726,9 +798,12 @@ def _build_immich_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
resources = ask_application_resources(ui, 4, 3072, 1024)
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
media_mode = ui.choose(
media_mode = essential_ui(ui).choose(
translate("Where to store the Immich library"),
[
("managed-volume", translate("Dedicated container volume (included in backups)")),
@@ -739,25 +814,26 @@ def _build_immich_deployment(
if media_mode is None:
raise UserCancelled(translate("Immich configuration cancelled"))
if media_mode == "managed-volume":
media_storage = ask_storage(ui, translate("Storage for the Immich library"), "rootdir", pve_defaults["volume_storage"])
media_size = int(ui.ask(translate("Library size in GB"), "100"))
media_storage = ask_storage(ui, translate("Storage for the Immich library"), "rootdir", chosen_storage or pve_defaults["volume_storage"])
media_size = int(essential_ui(ui).ask(translate("Library size in GB"), "100"))
if media_size < 8:
raise InstallError(translate("The Immich library needs at least 8 GB"))
media_root = None
else:
media_default = defaults["shared_media_root"].replace("${stack_name}", stack_name)
media_root = ui.ask(translate("Shared host directory"), media_default)
media_root = essential_ui(ui).ask(translate("Shared host directory"), media_default)
media_storage = None
media_size = None
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", chosen_storage or defaults["database_storage"])
database_size = int(
ui.ask(translate("PostgreSQL volume size in GB"), str(defaults["database_size_gb"]))
)
if database_size < 8:
raise InstallError(translate("The PostgreSQL volume needs at least 8 GB"))
extra_mounts = ask_application_extra_paths(ui, ["/data"], media_storage or rootfs_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Immich"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(
ui, frontend_bridge, [translate("Immich server"), translate("Immich machine learning")])
essential_ui(ui), frontend_bridge, [translate("Immich server"), translate("Immich machine learning")])
server_ipv4, ml_ipv4 = addresses.values()
timezone = ui.ask(translate("Timezone"), host.timezone())
video_acceleration = ui.choose(
@@ -800,10 +876,14 @@ def _build_immich_deployment(
"were tested in the lab and are not a universal minimum. Compatibility depends on the "
"GPU, the models and the kernel. NVIDIA uses the GPUs of the Toolkit inventory; Intel "
"keeps the CPU topology."))
extra_devices = ask_application_extra_devices(ui, ("usb",))
return {
"deployment_kind": "immich-four-lxc-stack",
"base_vmid": int(vmid_text) if vmid_text else None,
"stack_name": stack_name,
"resources": resources,
"extra_mounts": extra_mounts,
"extra_devices": extra_devices,
"template_storage": template_storage,
"rootfs_storage": rootfs_storage,
"database_storage": database_storage,
@@ -816,8 +896,8 @@ def _build_immich_deployment(
"backup": media_mode == "managed-volume",
},
"timezone": timezone,
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": ui.confirm(translate("Start when finished"), True),
"onboot": essential_ui(ui).confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": essential_ui(ui).confirm(translate("Start when finished"), True),
"network": {
"frontend_bridge": frontend_bridge,
"frontend_ipv4": server_ipv4,
@@ -858,10 +938,13 @@ def _build_nextcloud_stack_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
resources = ask_application_resources(ui, 2, 2048, 1024)
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
application_mode = ui.choose(
application_mode = essential_ui(ui).choose(
translate("Where to store the Nextcloud files, configuration and data"),
[
("managed-volume", translate("Dedicated container volume (included in backups)")),
@@ -872,9 +955,9 @@ def _build_nextcloud_stack_deployment(
if application_mode is None:
raise UserCancelled(translate("Nextcloud configuration cancelled"))
if application_mode == "managed-volume":
application_storage = ask_storage(ui, translate("Storage for the Nextcloud data"), "rootdir", defaults["application_storage"])
application_storage = ask_storage(ui, translate("Storage for the Nextcloud data"), "rootdir", chosen_storage or defaults["application_storage"])
application_size = int(
ui.ask(
essential_ui(ui).ask(
translate("Nextcloud volume size in GB"),
str(defaults["application_volume_size_gb"]),
)
@@ -886,11 +969,11 @@ def _build_nextcloud_stack_deployment(
shared_default = defaults["shared_application_root"].replace(
"${stack_name}", stack_name
)
application_root = ui.ask(translate("Shared host directory"), shared_default)
application_root = essential_ui(ui).ask(translate("Shared host directory"), shared_default)
application_storage = None
application_size = None
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", chosen_storage or defaults["database_storage"])
database_size = int(
ui.ask(
translate("PostgreSQL volume size in GB"),
@@ -900,8 +983,9 @@ def _build_nextcloud_stack_deployment(
if database_size < 8:
raise InstallError(translate("The PostgreSQL volume needs at least 8 GB"))
extra_mounts = ask_application_extra_paths(ui, ["/var/www/html"], application_storage or rootfs_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Nextcloud"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(ui, frontend_bridge, [""])
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
timezone = ui.ask(translate("Timezone"), host.timezone())
admin_username = ui.ask(
translate("Initial administrator user"), defaults["application"]["admin_username"]
@@ -909,11 +993,15 @@ def _build_nextcloud_stack_deployment(
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", admin_username):
raise InstallError(translate("The administrator user contains characters that are not allowed"))
extra_devices = ask_application_extra_devices(ui)
private = defaults["private_network"]
return {
"deployment_kind": "nextcloud-three-lxc-stack",
"base_vmid": int(vmid_text) if vmid_text else None,
"stack_name": stack_name,
"resources": resources,
"extra_mounts": extra_mounts,
"extra_devices": extra_devices,
"template_storage": template_storage,
"rootfs_storage": rootfs_storage,
"application": {
@@ -932,8 +1020,8 @@ def _build_nextcloud_stack_deployment(
"timezone": timezone,
"maintenance_window_start_utc": defaults["maintenance_window_start_utc"],
"default_phone_region": defaults["default_phone_region"],
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": ui.confirm(translate("Start when finished"), True),
"onboot": essential_ui(ui).confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": essential_ui(ui).confirm(translate("Start when finished"), True),
"network": {
"frontend_bridge": frontend_bridge,
"frontend_ipv4": addresses[""],
@@ -961,21 +1049,24 @@ def _build_paperless_stack_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
resources = ask_application_resources(ui, 2, 2048, 1024)
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
application_storage = ask_storage(ui, translate("Storage for Paperless data and documents"), "rootdir", defaults["application_storage"])
application_storage = ask_storage(ui, translate("Storage for Paperless data and documents"), "rootdir", chosen_storage or defaults["application_storage"])
data_size = int(
ui.ask(translate("Data volume size in GB"), str(defaults["data_volume_size_gb"]))
)
media_size = int(
ui.ask(
essential_ui(ui).ask(
translate("Documents volume size in GB"),
str(defaults["media_volume_size_gb"]),
)
)
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", chosen_storage or defaults["database_storage"])
database_size = int(
ui.ask(
translate("PostgreSQL volume size in GB"),
@@ -985,7 +1076,7 @@ def _build_paperless_stack_deployment(
if min(data_size, database_size) < 8 or media_size < 8:
raise InstallError(translate("The Paperless persistent volumes need at least 8 GB"))
transfer_mode = ui.choose(
transfer_mode = essential_ui(ui).choose(
translate("Where to store the consume and export folders"),
[
("host-bind", translate("Shared host directories (not included in Proxmox backups)")),
@@ -999,7 +1090,7 @@ def _build_paperless_stack_deployment(
transfer_root = None
if transfer_mode == "managed-volume":
transfer_size = int(
ui.ask(
essential_ui(ui).ask(
translate("Size of each consume/export volume in GB"),
str(defaults["transfer_volume_size_gb"]),
)
@@ -1010,12 +1101,15 @@ def _build_paperless_stack_deployment(
transfer_default = defaults["shared_transfer_root"].replace(
"${stack_name}", stack_name
)
transfer_root = ui.ask(
transfer_root = essential_ui(ui).ask(
translate("Shared directory for consume and export"), transfer_default
)
extra_mounts = ask_application_extra_paths(
ui, ["/usr/src/paperless/data", "/usr/src/paperless/media", "/usr/src/paperless/consume",
"/usr/src/paperless/export"], application_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Paperless"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(ui, frontend_bridge, [""])
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
timezone = ui.ask(translate("Timezone"), host.timezone())
ocr_language = ui.ask(translate("OCR language (Tesseract code)"), defaults["ocr_language"])
if not re.fullmatch(r"[a-z]{3}(?:\+[a-z]{3})*", ocr_language):
@@ -1026,11 +1120,15 @@ def _build_paperless_stack_deployment(
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", admin_username):
raise InstallError(translate("The administrator user contains characters that are not allowed"))
extra_devices = ask_application_extra_devices(ui)
private = defaults["private_network"]
return {
"deployment_kind": "paperless-three-lxc-stack",
"base_vmid": int(vmid_text) if vmid_text else None,
"stack_name": stack_name,
"resources": resources,
"extra_mounts": extra_mounts,
"extra_devices": extra_devices,
"template_storage": template_storage,
"rootfs_storage": rootfs_storage,
"application_storage": application_storage,
@@ -1051,8 +1149,8 @@ def _build_paperless_stack_deployment(
"ocr_language": ocr_language,
},
"timezone": timezone,
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": ui.confirm(translate("Start when finished"), True),
"onboot": essential_ui(ui).confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
"start_after_create": essential_ui(ui).confirm(translate("Start when finished"), True),
"network": {
"frontend_bridge": frontend_bridge,
"frontend_ipv4": addresses[""],
@@ -1080,11 +1178,14 @@ def _build_tandoor_stack_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
resources = ask_application_resources(ui, 2, 2048, 512)
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
media_mode = ui.choose(
media_mode = essential_ui(ui).choose(
translate("Where to store the recipe images and files"),
[
("managed-volume", translate("Dedicated container volume (included in backups)")),
@@ -1095,9 +1196,9 @@ def _build_tandoor_stack_deployment(
if media_mode is None:
raise UserCancelled(translate("Tandoor configuration cancelled"))
if media_mode == "managed-volume":
media_storage = ask_storage(ui, translate("Storage for Tandoor files"), "rootdir", defaults["application_storage"])
media_storage = ask_storage(ui, translate("Storage for Tandoor files"), "rootdir", chosen_storage or defaults["application_storage"])
media_size = int(
ui.ask(
essential_ui(ui).ask(
translate("Files volume size in GB"),
str(defaults["media_volume_size_gb"]),
)
@@ -1109,18 +1210,18 @@ def _build_tandoor_stack_deployment(
media_default = defaults["shared_media_root"].replace(
"${stack_name}", stack_name
)
media_root = ui.ask(translate("Shared host directory"), media_default)
media_root = essential_ui(ui).ask(translate("Shared host directory"), media_default)
media_storage = None
media_size = None
static_storage = ask_storage(ui, translate("Storage for staticfiles"), "rootdir", defaults["application_storage"])
static_storage = ask_storage(ui, translate("Storage for staticfiles"), "rootdir", chosen_storage or defaults["application_storage"])
static_size = int(
ui.ask(
translate("staticfiles volume size in GB"),
str(defaults["static_volume_size_gb"]),
)
)
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", chosen_storage or defaults["database_storage"])
database_size = int(
ui.ask(
translate("PostgreSQL volume size in GB"),
@@ -1132,8 +1233,10 @@ def _build_tandoor_stack_deployment(
translate("Tandoor needs at least 1 GB for staticfiles and 4 GB for PostgreSQL")
)
extra_mounts = ask_application_extra_paths(
ui, ["/opt/recipes/mediafiles", "/opt/recipes/staticfiles"], static_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Tandoor"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(ui, frontend_bridge, [""])
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
timezone = ui.ask(translate("Timezone"), host.timezone())
allowed_hosts = ui.ask(
translate("Allowed hosts (comma separated; * allows access through the assigned IP)"),
@@ -1152,7 +1255,9 @@ def _build_tandoor_stack_deployment(
if not re.fullmatch(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", admin_email):
raise InstallError(translate("The administrator email is not valid"))
onboot = ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"])
extra_devices = ask_application_extra_devices(ui)
onboot = essential_ui(ui).confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"])
# The first start creates the administrator, so a default installation does not ask it.
start_after = ui.confirm(translate("Start when finished"), True)
if not start_after:
raise UserCancelled(
@@ -1164,6 +1269,9 @@ def _build_tandoor_stack_deployment(
"deployment_kind": "tandoor-two-lxc-stack",
"base_vmid": int(vmid_text) if vmid_text else None,
"stack_name": stack_name,
"resources": resources,
"extra_mounts": extra_mounts,
"extra_devices": extra_devices,
"template_storage": template_storage,
"rootfs_storage": rootfs_storage,
"application_storage": static_storage,
+19 -12
View File
@@ -373,27 +373,34 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
if not members:
ui.message(translate('This stack has no saved members to update.'), translate('OCI stack management'))
return False
if needs_replay and not (
updatable = not needs_replay or (
oci_stack_replay.nextcloud_menu_ready(primary) or
oci_stack_replay.paperless_menu_ready(primary) or
oci_stack_replay.tandoor_menu_ready(primary) or
oci_stack_replay.immich_menu_ready(primary)):
oci_stack_replay.immich_menu_ready(primary))
if not updatable and action in (None, 'update'):
ui.message(translate('This stack needs rootfs adaptations that coordinated updates cannot replay yet.'), translate('OCI stack management'))
return False
if action == 'recreate':
ui.message(translate('A multi-container application is not recreated: its containers are updated together.'), translate('OCI stack management'))
return False
if action == 'update':
return False
if action is None:
action = ui.choose(translate('Manage OCI stack'),
[('update', translate('Update every container of the application')),
('remove', translate('Remove: delete the application and its containers'))], 'update')
# A stack that cannot be updated can still be removed.
options = [('update', translate('Update every container of the application'))] if updatable else []
options.append(('recreate', translate('Recreate: add or remove extra paths and devices')))
options.append(('remove', translate('Remove: delete the application and its containers')))
action = ui.choose(translate('Manage OCI stack'), options, options[0][0])
if action is None:
return False
if action == 'remove':
return _remove(project, ui, primary_id)
if not ui.review(f"{translate('All stack members are updated together. Main CT:')} {primary_id}, "
f"{translate('members:')} {len(members)}. "
f"{translate('All images are downloaded and verified first, and native backups are taken with the stack stopped. Contracts are published after the whole set is checked. If a step fails, recovery is attempted where needed; recovery can also fail.')}",
if action == 'recreate':
# Nothing is rebuilt: only the extra paths and devices of the
# application container change.
from .stack_recreation import recreate_stack
return recreate_stack(project, ui, primary, _run_lifecycle)
if not ui.review(translate('All {count} containers of the application are updated together (main CT: {vmid}). '
'If there are new versions, all images are downloaded and verified, the application '
'is stopped, each container is backed up and replaced with its new image. If anything '
'fails, the backups are restored.').format(count=len(members), vmid=primary_id),
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
return False
else:
+4
View File
@@ -71,6 +71,10 @@ def _static_address(ui, bridge: str, label: str, default: str, taken: set[str])
if str(interface.ip) in taken:
ui.message(f"{translate('The address is already assigned on this host or cluster:')} {interface.ip}")
continue
# The address this container already has answers because it is its own.
if value != default and host.address_answers(str(interface.ip), bridge):
ui.message(f"{translate('Another device of the network already answers on this address:')} {interface.ip}")
continue
return interface
+35 -12
View File
@@ -261,6 +261,11 @@ def resolve_environments(services, timezone, generators=None):
class DefaultsUI:
"""Answers every question with the value of the recipe. `real` is the
interface the installer uses for the few questions a default installation
still asks."""
def __init__(self, real=None):
self.real = real
def ask(self, text, default=None, required=True):
return default if default is not None else ''
def choose(self, text, options, default=None):
@@ -295,29 +300,44 @@ def build_stack(template, ui, mode='advanced'):
raise StackError('; '.join(problems))
services = copy.deepcopy(ordered_services(template))
defaults = template['proxmox']['defaults']
name = _hostname_default(ui.ask(translate('Stack name'), template['compose_stack']['project_name']))
vmid = ui.ask(translate('Base VMID (empty = next free block)'), '', required=False)
# A default installation takes the name, the VMID and the settings from the
# recipe; it still asks the storage, the address and how the stack starts.
quiet = DefaultsUI() if mode == DEFAULT_MODE else ui
name = _hostname_default(quiet.ask(translate('Stack name'), template['compose_stack']['project_name']))
vmid = quiet.ask(translate('Base VMID (empty = next free block)'), '', required=False)
from . import host
from . import network as access
from .installer import _ask_size, ask_bridge, ask_storage
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'], mode)
if mode == DEFAULT_MODE:
root = ask_storage(ui, translate('Storage for the containers and their data'), 'rootdir',
defaults['rootfs_storage'])
else:
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'])
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', defaults['template_storage'], mode)
generators = template.get('proxmox', {}).get('stack_generators', {})
# The data paths of every member are settled before the network and the settings.
volumes = (ask_storage(ui, '', 'rootdir', defaults['volume_storage'], mode) if mode == DEFAULT_MODE else root)
volumes = root
draft_envs = resolve_environments(copy.deepcopy(services), host.timezone(), generators)
drafts = []
for s in services:
draft = copy.deepcopy(s)
draft['compose']['environment'] = draft_envs[s['name']]
_, plan = _service_plan(template, draft)
if mode != DEFAULT_MODE and s['name'] == template['compose_stack']['main_service']:
resources = {'cores': int(ui.ask(translate('CPU cores'), str(plan['resources']['cores']))),
'memory_mb': int(ui.ask(translate('Memory in MB'), str(plan['resources']['memory_mb'])))}
if resources['cores'] < 1 or resources['memory_mb'] < 256:
raise StackError(translate('Invalid resources'))
# The paths of the application itself hold the user's data, so where
# they go is asked in a default installation too; the databases and
# caches beside it keep the values of the recipe.
asked = mode != DEFAULT_MODE or s['name'] == template['compose_stack']['main_service']
for m in plan['mounts']:
label = f"{s['name']}: {m['container_path']}"
mount_mode = ('managed-volume' if mode == DEFAULT_MODE else
ui.choose(f"{translate('Where to store')} {label}",
mount_mode = (ui.choose(f"{translate('Where to store')} {label}",
[('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
'managed-volume'))
'managed-volume') if asked else 'managed-volume')
if mount_mode is None:
raise StackError(translate('Storage selection cancelled'))
m.update(type=mount_mode, source=volumes, backup=mount_mode=='managed-volume')
@@ -325,9 +345,10 @@ def build_stack(template, ui, mode='advanced'):
m['source'] = ui.ask(f"{translate('Host path for')} {label}",
'/mnt/oci-shared/'+name+'/'+s['name']+'/'+m['container_path'].strip('/').replace('/','-'))
m['size_gb'] = None
elif mode != DEFAULT_MODE:
volumes = ask_storage(ui, f"{translate('Storage for')} {label}", 'rootdir', volumes)
m['source'] = volumes
elif asked:
if mode != DEFAULT_MODE:
volumes = ask_storage(ui, f"{translate('Storage for')} {label}", 'rootdir', volumes)
m['source'] = volumes
m['size_gb'] = _ask_size(ui, label, max(8, m['size_gb'] or 8))
if m['size_gb'] is not None and m['size_gb'] < 1:
raise StackError(translate('Invalid volume size'))
@@ -338,13 +359,13 @@ def build_stack(template, ui, mode='advanced'):
ask_stack_custom_mounts(ui, drafts, volumes)
bridge = ask_bridge(ui, translate('Access bridge'), defaults['bridge'], mode)
addresses, gateway = access.ask_addresses(ui, bridge, [''])
timezone = ui.ask(translate('Timezone'), host.timezone())
timezone = quiet.ask(translate('Timezone'), host.timezone())
envs = resolve_environments(services, timezone, generators)
for group in template.get('proxmox', {}).get('stack_optional_environment', []):
service_name = group['service']
if service_name not in envs:
raise StackError(f"{translate('Unknown credential service:')} {service_name}")
if not ui.confirm(f"{translate('Configure')} {group['label']} ({translate('optional')})", False):
if not quiet.confirm(f"{translate('Configure')} {group['label']} ({translate('optional')})", False):
continue
for field in group['fields']:
if field['name'] not in envs[service_name] or envs[service_name][field['name']] != '':
@@ -375,6 +396,8 @@ def build_stack(template, ui, mode='advanced'):
if 'memory_default_mb' not in single['proxmox'].get('installer_profile', {}).get('resources', {}):
plan['resources']['memory_mb'] = max(1024 if k in {'postgres','mariadb','linuxserver/mariadb','mongo','getmeili/meilisearch'} else 512, plan['resources']['memory_mb'])
plan['mounts'] = drafts[index]['deployment']['mounts']
if main and mode != DEFAULT_MODE:
plan['resources'].update(resources)
if k == 'postgres':
health = {'type':'exec','timeout_seconds':180,'argv':['pg_isready','-h','127.0.0.1','-U',env.get('POSTGRES_USER','postgres'),'-d',env.get('POSTGRES_DB',env.get('POSTGRES_USER','postgres'))]}
elif k == 'mariadb':
+171
View File
@@ -0,0 +1,171 @@
"""Recreate for a multi-container application: add or remove the extra paths
and devices of its application container. Its own data, its database and the
other containers are never part of it."""
from __future__ import annotations
import json
import re
import subprocess
import sys
import tempfile
from .custom_mounts import ask_custom_mounts
from .extra_devices import ask_extra_devices
from .i18n import translate
# The paths each recipe mounts in its application container: its own data,
# which Recreate never offers to remove.
RECIPE_PATHS = {
'install_nextcloud_stack.sh': ('/var/www/html',),
'install_paperless_stack.sh': ('/usr/src/paperless/data', '/usr/src/paperless/media',
'/usr/src/paperless/consume', '/usr/src/paperless/export'),
'install_tandoor_stack.sh': ('/opt/recipes/mediafiles', '/opt/recipes/staticfiles'),
'install_immich_stack.sh': ('/data',),
}
APPLICATION_ROLES = ('application', 'server')
GPU_NODE = re.compile(r'/dev/dri/(?:renderD|card)[0-9]+|/dev/kfd')
PERIPHERAL_NODE = re.compile(
r'/dev/(?:apex_[0-9]+|accel/accel[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})')
def _config(vmid):
result = subprocess.run(['pct', 'config', str(vmid)], capture_output=True, text=True, check=True)
lines = [line.partition(': ') for line in result.stdout.splitlines()]
return {key: value for key, separator, value in lines if separator}
def _options(value):
return dict(part.split('=', 1) for part in value.split(',')[1:] if '=' in part)
def _device_path(value):
fields = dict(part.split('=', 1) for part in value.split(',') if '=' in part)
return fields.get('path') or value.split(',', 1)[0]
def application_members(primary):
"""The members whose paths and devices can be edited: the application of a
recipe, the main service of a Compose stack, or every application of a
suite that has no main one."""
members = primary.get('stack', {}).get('members', [])
roles = [m for m in members
if (m.get('deployment', {}).get('replay_profile') or {}).get('role') in APPLICATION_ROLES]
if roles:
return roles
main = (primary.get('stack', {}).get('template') or {}).get('compose_stack', {}).get('main_service')
named = [m for m in members if (m.get('stack_member') or {}).get('name') == main]
return named or members
def recipe_paths(member):
deployment = member.get('deployment', {})
adapter = (deployment.get('replay_profile') or {}).get('adapter')
if adapter in RECIPE_PATHS:
return set(RECIPE_PATHS[adapter])
return {mount['container_path'] for mount in deployment.get('mounts', []) if not mount.get('custom')}
def current_state(member):
"""The mounts and devices of the member now, split into its own and the extra ones."""
config = _config(member['vmid'])
own = recipe_paths(member)
immich = (member.get('deployment', {}).get('replay_profile') or {}).get('adapter') == 'install_immich_stack.sh'
mounts, devices = [], []
for key, value in config.items():
if re.fullmatch(r'mp[0-9]+', key):
source, target = value.split(',', 1)[0], _options(value).get('mp')
mounts.append({'container_path': target, 'source': source, 'size': _options(value).get('size'),
'type': 'host-bind' if source.startswith('/') else 'managed-volume',
'extra': target not in own})
elif re.fullmatch(r'dev[0-9]+', key):
path = _device_path(value)
# The video device of Immich belongs to its acceleration profile.
removable = bool(PERIPHERAL_NODE.fullmatch(path)) or (not immich and bool(GPU_NODE.fullmatch(path)))
devices.append({'host_path': path, 'removable': removable})
rootfs = config.get('rootfs', 'local-lvm:').split(':', 1)[0]
return mounts, devices, rootfs, immich
def _describe(mount):
if mount['type'] == 'host-bind':
return f"{translate('host directory')} {mount['source']}"
return f"{translate('Container volume')} {mount.get('size') or ''} {translate('on')} {mount['source'].split(':', 1)[0]}"
def plan_changes(ui, member):
"""Ask what to remove and what to add. None when nothing changes."""
mounts, devices, storage, immich = current_state(member)
changes = {'remove_mounts': [], 'add_mounts': [], 'remove_devices': [], 'add_devices': []}
extra = [mount for mount in mounts if mount['extra']]
if extra:
kept = ui.checklist(translate('Extra paths to keep (unmark one to remove it)'),
[(mount['container_path'], _describe(mount)) for mount in extra],
[mount['container_path'] for mount in extra])
if kept is None:
return None
for mount in extra:
if mount['container_path'] in kept:
continue
if mount['type'] == 'managed-volume' and not ui.confirm(
f"{translate('Removing this path deletes its container volume and the data in it:')} "
f"{mount['container_path']}\n\n{translate('Delete it?')}", False):
continue
changes['remove_mounts'].append(mount['container_path'])
remaining = [{'type': mount['type'], 'container_path': mount['container_path']} for mount in mounts
if mount['container_path'] not in changes['remove_mounts']]
changes['add_mounts'] = [mount for mount in ask_custom_mounts(ui, remaining, storage) if mount.get('custom')]
removable = [device for device in devices if device['removable']]
if removable:
kept = ui.checklist(translate('Devices to keep (unmark one to remove it)'),
[(device['host_path'], device['host_path']) for device in removable],
[device['host_path'] for device in removable])
if kept is None:
return None
changes['remove_devices'] = [device['host_path'] for device in removable if device['host_path'] not in kept]
attached = [{'host_path': device['host_path'], 'kind': 'character-device'} for device in devices
if device['host_path'] not in changes['remove_devices']]
proposed = ask_extra_devices(ui, attached, True, kinds=('usb',) if immich else ('gpu', 'usb'))
changes['add_devices'] = proposed[len(attached):]
return changes if any(changes.values()) else None
def summary(member, changes):
name = (member.get('stack_member') or {}).get('name') or member['vmid']
lines = [f"{translate('Container')}: CT {member['vmid']} ({name})", '']
for mount in changes['add_mounts']:
target = (f"{translate('host directory')} {mount['source']}" if mount['type'] == 'host-bind'
else f"{translate('Container volume')} {mount['size_gb']} GB {translate('on')} {mount['source']}")
lines.append(f"+ {mount['container_path']} → {target}")
lines += [f"- {path}" for path in changes['remove_mounts']]
lines += [f"+ {device['host_path']}" for device in changes['add_devices']]
lines += [f"- {path}" for path in changes['remove_devices']]
lines += ['', translate('The container is restarted to apply the changes. Its own data, the database '
'and the other containers of the application are not touched.')]
return '\n'.join(lines)
def recreate_stack(project, ui, primary, run_lifecycle):
members = application_members(primary)
if not members:
ui.message(translate('This stack has no saved members to update.'), translate('OCI stack management'))
return False
member = members[0]
if len(members) > 1:
options = [(str(m['vmid']), f"CT {m['vmid']} · {(m.get('stack_member') or {}).get('name', '')}")
for m in members]
selected = ui.choose(translate('Container to change'), options, options[0][0])
if selected is None:
return False
member = next(m for m in members if str(m['vmid']) == selected)
changes = plan_changes(ui, member)
if changes is None:
ui.message(translate('Nothing was changed.'), translate('Recreate OCI'))
return False
if not ui.review(summary(member, changes), translate('Recreate OCI'),
question=translate('Recreate with these options?'), default=True):
return False
with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as file:
json.dump(changes, file)
file.flush()
return run_lifecycle([sys.executable, str(project / 'remote/oci_stack_modify.py'),
str(member['vmid']), '--changes', file.name], translate('Recreate OCI'))
+9 -4
View File
@@ -103,11 +103,16 @@ class AdvancedFlowOrderTests(unittest.TestCase):
localtime = next(m for m in plan["mounts"] if m["container_path"] == "/etc/localtime")
self.assertEqual((localtime["type"], localtime["source"]), ("host-bind", "/etc/localtime"))
def test_default_mode_asks_no_storage(self, *_):
ui = RecordingUI()
def test_default_mode_asks_one_storage_the_address_and_the_start(self, *_):
ui = RecordingUI({"Storage for the container and its data": "Public"})
plan = self.build("jellyfin", ui, DEFAULT_MODE)
self.assertFalse([text for text in ui.asked if text.startswith("Storage for")])
self.assertEqual({m["source"] for m in plan["mounts"]}, {"local-lvm"})
self.assertEqual(ui.asked[0], "Storage for the container and its data")
self.assertEqual(ui.asked[-2:], ["Start with Proxmox", "Start when finished"])
self.assertFalse([text for text in ui.asked if text.startswith("Storage for /")])
self.assertEqual(plan["rootfs"]["storage"], "Public")
self.assertEqual({m["source"] for m in plan["mounts"]}, {"Public"})
plan = self.build("jellyfin", RecordingUI(), DEFAULT_MODE)
self.assertEqual({plan["rootfs"]["storage"]} | {m["source"] for m in plan["mounts"]}, {"local-lvm"})
def assert_follows(self, asked, *texts):
positions = [asked.index(text) for text in texts]
@@ -0,0 +1,119 @@
"""A default installation of a multi-container application still asks its
storage, its address and how it starts; everything else comes from the recipe."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import DEFAULT_MODE, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
STORAGE = "Storage for the containers and their data"
ADDRESS = "<address>"
def asked_addresses(ui, bridge, names, *args):
# The address belongs to the questions asked through the real interface.
ui.asked.append(ADDRESS)
return addresses(ui, bridge, names)
def storages_used(plan):
used = {value for key, value in plan.items()
if key.endswith("storage") and key != "template_storage" and isinstance(value, str)}
used |= {plan[key]["storage"] for key in ("media", "application", "transfer")
if isinstance(plan.get(key), dict) and plan[key].get("storage")}
for service in plan.get("services", []):
used.add(service["deployment"]["rootfs"]["storage"])
used |= {m["source"] for m in service["deployment"]["mounts"] if m["type"] == "managed-volume"}
return used
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=asked_addresses)
class DefaultInstallEssentialsTests(unittest.TestCase):
catalog = Catalog(ROOT)
def build(self, app, answers=None):
ui = RecordingUI(answers)
return ui, build_deployment(self.catalog.compose(app), ui, DEFAULT_MODE)
def test_every_stack_asks_storage_address_and_start(self, *_):
for app in ("nextcloud-stack", "paperless-ngx", "tandoor", "immich", "linkwarden", "suite-arr"):
ui, _plan = self.build(app)
self.assertIn(STORAGE, ui.asked, app)
self.assertIn(ADDRESS, ui.asked, app)
self.assertTrue(any("with Proxmox" in text for text in ui.asked), app)
self.assertLess(ui.asked.index(STORAGE), ui.asked.index(ADDRESS), app)
def test_the_users_data_is_placed_by_the_user_and_the_rest_comes_from_the_recipe(self, *_):
expected = {
"nextcloud-stack": [STORAGE, "Where to store the Nextcloud files, configuration and data",
"Nextcloud volume size in GB", ADDRESS, "Start the stack with Proxmox",
"Start when finished"],
"immich": [STORAGE, "Where to store the Immich library", "Library size in GB", ADDRESS,
"Start the stack with Proxmox", "Start when finished"],
"tandoor": [STORAGE, "Where to store the recipe images and files", "Files volume size in GB", ADDRESS,
"Start the stack with Proxmox"],
"paperless-ngx": [STORAGE, "Documents volume size in GB", "Where to store the consume and export folders",
"Shared directory for consume and export", ADDRESS, "Start the stack with Proxmox",
"Start when finished"],
"linkwarden": [STORAGE, "Where to store linkwarden: /data/data", "Size in GB of linkwarden: /data/data",
ADDRESS, "Start the stack with Proxmox"],
}
for app, asked in expected.items():
self.assertEqual(self.build(app)[0].asked, asked, app)
for text in ("Stack name", "Base VMID (empty = next free block)", "Timezone"):
self.assertNotIn(text, self.build("suite-arr")[0].asked)
def test_a_host_directory_can_be_chosen_for_the_data(self, *_):
_ui, plan = self.build("nextcloud-stack", {
"Where to store the Nextcloud files, configuration and data": "host-bind",
"Shared host directory": "/mnt/tank/nextcloud"})
self.assertEqual((plan["application"]["mode"], plan["application"]["host_path"], plan["application"]["backup"]),
("host-bind", "/mnt/tank/nextcloud", False))
_ui, plan = self.build("nextcloud-stack", {"Nextcloud volume size in GB": "200"})
self.assertEqual((plan["application"]["mode"], plan["application"]["size_gb"]), ("managed-volume", 200))
def test_the_chosen_storage_holds_the_containers_and_their_data(self, *_):
for app in ("nextcloud-stack", "paperless-ngx", "tandoor", "immich", "linkwarden", "suite-arr"):
_ui, plan = self.build(app, {STORAGE: "Public"})
self.assertEqual(storages_used(plan), {"Public"}, app)
_ui, plan = self.build(app)
self.assertEqual(storages_used(plan), {"local-lvm"}, app)
def test_a_single_image_application_asks_the_same_essentials(self, *_):
single = "Storage for the container and its data"
for app in ("jellyfin", "frigate", "uptimekuma"):
ui, plan = self.build(app, {single: "Public"})
self.assertEqual(ui.asked[0], single, app)
self.assertIn(ADDRESS, ui.asked, app)
self.assertEqual(ui.asked[-2:], ["Start with Proxmox", "Start when finished"], app)
self.assertEqual(plan["rootfs"]["storage"], "Public", app)
self.assertEqual({m["source"] for m in plan["mounts"] if m["type"] == "managed-volume"} - {"Public"},
set(), app)
def test_the_members_of_a_stack_ask_nothing_of_their_own(self, *_):
for app in ("linkwarden", "suite-arr"):
ui, _plan = self.build(app)
self.assertEqual(ui.asked.count(ADDRESS), 1, app)
self.assertNotIn("Storage for the container and its data", ui.asked, app)
self.assertNotIn("Start with Proxmox", ui.asked, app)
self.assertNotIn("Start when finished", ui.asked, app)
def test_the_start_answers_are_the_users(self, *_):
_ui, plan = self.build("nextcloud-stack", {"Start the stack with Proxmox": True, "Start when finished": False})
self.assertEqual((plan["onboot"], plan["start_after_create"]), (True, False))
if __name__ == "__main__":
unittest.main()
+12 -1
View File
@@ -19,11 +19,22 @@ INVENTORY = {"gpus": ["NVIDIA GeForce RTX 3060, GPU-1234, 550.0"]}
class ConsoleHookTests(unittest.TestCase):
def test_only_the_proxmenux_start_hook_is_recognised(self):
hook = oci_console.start_mark_hook(165).split(": ", 1)[1]
line = oci_console.start_mark_hook(165)
hook = line.split(": ", 1)[1]
self.assertTrue(oci_console.is_start_mark_hook(line))
self.assertFalse(oci_console.is_start_mark_hook(line.replace("pre-start", "post-stop")))
self.assertTrue(dynamic.console_start_hook(hook))
self.assertFalse(dynamic.console_start_hook(hook.replace("exit 0", "rm -rf /; exit 0")))
self.assertFalse(dynamic.console_start_hook("/bin/sh -c 'curl example | sh; exit 0'"))
def test_the_stack_replay_does_not_keep_the_start_hook_as_an_unknown_directive(self):
import oci_stack_replay
source = Path(oci_stack_replay.__file__).read_text(encoding="utf-8")
self.assertIn("and not oci_console.is_start_mark_hook(line)]", source)
saved = {"preserved_raw_runtime": [oci_console.start_mark_hook(129), "lxc.cap.drop: sys_admin"]}
kept = [l for l in saved["preserved_raw_runtime"] if not oci_console.is_start_mark_hook(l)]
self.assertEqual(kept, ["lxc.cap.drop: sys_admin"])
@unittest.skipUnless(hasattr(os, "geteuid") and os.geteuid() == 0, "the NVIDIA hook must belong to root")
def test_a_container_with_the_console_hook_passes_validation(self):
with tempfile.TemporaryDirectory() as tmp:
+104
View File
@@ -0,0 +1,104 @@
"""Every multi-container application offers extra paths in an advanced installation."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.cli import _deployment_summary_text
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
EXTRA = "Add an extra custom path"
SPECIAL = ("nextcloud-stack", "paperless-ngx", "tandoor", "immich")
class ExtraPathUI(RecordingUI):
"""Adds one container volume the first time the extra path is offered."""
def __init__(self):
super().__init__({"Path inside the container (e.g. /media-extra)": "/media-extra",
"Volume size in GB": "20"})
self.offered = 0
def confirm(self, text, default=False):
if text == EXTRA:
self.asked.append(text)
self.offered += 1
return self.offered == 1
return super().confirm(text, default)
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
class StackExtraPathTests(unittest.TestCase):
catalog = Catalog(ROOT)
def test_the_advanced_installation_offers_an_extra_path_before_the_network(self, *_):
for app in SPECIAL:
ui = ExtraPathUI()
template = self.catalog.compose(app)
plan = build_deployment(template, ui, ADVANCED_MODE)
self.assertEqual(ui.asked.count(EXTRA), 2, app)
bridge = next(text for text in ui.asked if text.startswith("Access bridge"))
self.assertLess(ui.asked.index(EXTRA), ui.asked.index(bridge), app)
self.assertEqual([(m["type"], m["container_path"], m["size_gb"]) for m in plan["extra_mounts"]],
[("managed-volume", "/media-extra", 20)], app)
self.assertIn("/media-extra", _deployment_summary_text(template, plan), app)
def test_an_extra_path_cannot_hide_the_data_of_the_application(self, *_):
ui = ExtraPathUI()
ui.answers["Path inside the container (e.g. /media-extra)"] = "/var/www/html/data"
with self.assertRaises(ValueError):
build_deployment(self.catalog.compose("nextcloud-stack"), ui, ADVANCED_MODE)
def test_the_advanced_installation_offers_usb_devices_for_the_application(self, *_):
device = "Add another GPU or USB device manually?"
usb = [{"path": "/dev/ttyACM1", "name": "Z-Stick", "kind": "Communications"}]
for app in SPECIAL:
ui = RecordingUI()
offered = []
def confirm(text, default=False, ui=ui, offered=offered):
ui.asked.append(text)
if text == device:
offered.append(text)
return len(offered) == 1
return default
def choose(text, options, default=None, ui=ui):
ui.asked.append(text)
tags = [tag for tag, _label in options]
if text == "Device to attach":
self.assertNotIn("nvidia", tags, app)
return "usb"
return "/dev/ttyACM1" if "/dev/ttyACM1" in tags else default
ui.confirm, ui.choose = confirm, choose
template = self.catalog.compose(app)
with patch("proxmenux_oci.extra_devices.host.usb_devices", return_value=usb):
plan = build_deployment(template, ui, ADVANCED_MODE)
self.assertEqual([(d["kind"], d["host_path"], d["gid_strategy"]) for d in plan["extra_devices"]],
[("character-device", "/dev/ttyACM1", "host-device-gid")], app)
self.assertIn("/dev/ttyACM1", _deployment_summary_text(template, plan), app)
def test_the_default_installation_does_not_ask_it(self, *_):
for app in SPECIAL:
ui = RecordingUI()
plan = build_deployment(self.catalog.compose(app), ui, DEFAULT_MODE)
self.assertNotIn(EXTRA, ui.asked, app)
self.assertEqual(plan["extra_mounts"], [], app)
self.assertEqual(plan["extra_devices"], [], app)
self.assertNotIn("Add another GPU or USB device manually?", ui.asked, app)
if __name__ == "__main__":
unittest.main()
+127
View File
@@ -0,0 +1,127 @@
"""Recreate on a multi-container application edits the extra paths and devices
of its application container, and never its own data."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci import stack_recreation
MEMBER = {"vmid": 129, "stack_member": {"name": "application"},
"deployment": {"replay_profile": {"adapter": "install_tandoor_stack.sh", "role": "application"}}}
DATABASE = {"vmid": 130, "stack_member": {"name": "database"},
"deployment": {"replay_profile": {"adapter": "install_tandoor_stack.sh", "role": "database"}}}
CONFIG = {
"rootfs": "local-lvm:vm-129-disk-0,size=8G",
"mp0": "local-lvm:vm-129-disk-1,mp=/opt/recipes/staticfiles,backup=1,size=2G",
"mp1": "local-lvm:vm-129-disk-2,mp=/opt/recipes/mediafiles,backup=1,size=16G",
"mp2": "local-lvm:vm-129-disk-3,mp=/media-extra,backup=1,size=2G",
"mp3": "/mnt/share,mp=/host-extra,backup=0",
"dev0": "path=/dev/ttyACM1,mode=0660,deny-write=0,gid=20",
}
class ScriptedUI:
def __init__(self, kept_paths, kept_devices, delete=True):
self.kept = {"Extra paths to keep (unmark one to remove it)": kept_paths,
"Devices to keep (unmark one to remove it)": kept_devices}
self.delete = delete
self.offered = {}
def checklist(self, text, options, default=None):
self.offered[text] = [tag for tag, _label in options]
return self.kept[text]
def confirm(self, text, default=False):
return self.delete if "deletes its container volume" in text else False
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch.object(stack_recreation, "_config", return_value=CONFIG)
class StackRecreationTests(unittest.TestCase):
def test_only_the_application_container_is_edited(self, *_):
primary = {"stack": {"members": [DATABASE, MEMBER]}}
self.assertEqual([m["vmid"] for m in stack_recreation.application_members(primary)], [129])
def test_the_data_of_the_recipe_is_never_offered_for_removal(self, *_):
ui = ScriptedUI(["/media-extra", "/host-extra"], ["/dev/ttyACM1"])
self.assertIsNone(stack_recreation.plan_changes(ui, MEMBER))
self.assertEqual(ui.offered["Extra paths to keep (unmark one to remove it)"], ["/media-extra", "/host-extra"])
def test_unmarked_paths_and_devices_are_removed(self, *_):
changes = stack_recreation.plan_changes(ScriptedUI([], []), MEMBER)
self.assertEqual(changes["remove_mounts"], ["/media-extra", "/host-extra"])
self.assertEqual(changes["remove_devices"], ["/dev/ttyACM1"])
self.assertEqual((changes["add_mounts"], changes["add_devices"]), ([], []))
def test_a_volume_is_kept_when_its_deletion_is_not_confirmed(self, *_):
changes = stack_recreation.plan_changes(ScriptedUI([], ["/dev/ttyACM1"], delete=False), MEMBER)
self.assertEqual(changes["remove_mounts"], ["/host-extra"])
def test_the_summary_lists_what_changes(self, *_):
changes = {"remove_mounts": ["/media-extra"], "remove_devices": [], "add_devices": [
{"host_path": "/dev/ttyACM0"}], "add_mounts": [
{"type": "host-bind", "container_path": "/scans", "source": "/mnt/scans"}]}
text = stack_recreation.summary(MEMBER, changes)
for expected in ("CT 129", "+ /scans", "- /media-extra", "+ /dev/ttyACM0", "are not touched"):
self.assertIn(expected, text)
class StackModifyValidationTests(unittest.TestCase):
def setUp(self):
import oci_stack_modify
self.modify = oci_stack_modify
lines = "\n".join(f"{key}: {value}" for key, value in CONFIG.items()) + "\n"
patcher = patch.object(oci_stack_modify, "run", return_value=lines)
patcher.start()
self.addCleanup(patcher.stop)
def changes(self, **values):
return {"remove_mounts": [], "add_mounts": [], "remove_devices": [], "add_devices": [], **values}
def test_a_path_that_overlaps_the_data_is_refused(self):
mount = {"type": "managed-volume", "container_path": "/opt/recipes/mediafiles/sub", "source": "local-lvm",
"size_gb": 2}
with self.assertRaises(ValueError):
self.modify.validate(129, self.changes(add_mounts=[mount]))
def test_removing_something_that_is_not_there_is_refused(self):
with self.assertRaises(ValueError):
self.modify.validate(129, self.changes(remove_mounts=["/nope"]))
with self.assertRaises(ValueError):
self.modify.validate(129, self.changes(remove_devices=["/dev/ttyACM0"]))
def test_an_unsupported_device_is_refused(self):
device = {"kind": "character-device", "host_path": "/dev/sda"}
with self.assertRaises(ValueError):
self.modify.validate(129, self.changes(add_devices=[device]))
def test_valid_removals_pass(self):
self.modify.validate(129, self.changes(remove_mounts=["/media-extra"], remove_devices=["/dev/ttyACM1"]))
def test_an_updated_application_stops_requiring_a_removed_path(self):
kept = [{"container_path": "/opt/recipes/mediafiles", "required": True}]
record = {"installation_id": "id", "stack_member": {"primary_vmid": 129},
"observed": {"archive_path": "a", "resolved_registry_digest": "d", "image": {}},
"template": {"container_contract": {"volumes": kept + [
{"container_path": "/media-extra", "required": True}]}},
"deployment": {"replay_profile": {"adapter": "install_tandoor_stack.sh"}, "mounts": []}}
converted = {"deployment": {"mounts": [{"container_path": "/opt/recipes/mediafiles"}], "devices": []},
"template": {"container_contract": {"volumes": kept}}}
written = {}
with patch.object(self.modify.instances, "read", return_value=record), \
patch.object(self.modify.instances, "observe", return_value={"config": {}}), \
patch.object(self.modify.instances, "location", side_effect=lambda root, vmid: vmid), \
patch.object(self.modify.instances, "write", side_effect=written.__setitem__), \
patch.dict(self.modify.CONVERTERS, {"install_tandoor_stack.sh": lambda record: converted}):
self.modify.register(Path("/nowhere"), 129)
self.assertEqual(written[129]["template"]["container_contract"]["volumes"], kept)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
"""A stack update keeps the USB, serial and GPU nodes of a member, and stops on anything else."""
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_stack_replay
def projection(*values):
return {"native_devices": [{"key": f"dev{index}", "value": value} for index, value in enumerate(values)]}
class StackReplayDeviceTests(unittest.TestCase):
def test_usb_serial_and_gpu_nodes_are_translated(self):
devices = oci_stack_replay.translated_devices(projection(
"path=/dev/ttyACM1,mode=0660,deny-write=0,gid=20",
"path=/dev/bus/usb/003/002,mode=0664,deny-write=1",
"path=/dev/dri/renderD128,mode=0660,gid=104,uid=1000"))
self.assertEqual(devices[0], {
"id": "native-ttyACM1", "kind": "character-device", "host_path": "/dev/ttyACM1",
"container_path": "/dev/ttyACM1", "mode": "0660", "deny_write": False,
"gid_strategy": "host-device-gid"})
self.assertEqual((devices[1]["gid_strategy"], devices[1]["deny_write"], devices[1]["mode"]),
("none", True, "0664"))
self.assertEqual((devices[2]["host_path"], devices[2]["uid"]), ("/dev/dri/renderD128", 1000))
def test_a_member_without_devices_has_none(self):
self.assertEqual(oci_stack_replay.translated_devices(projection()), [])
def test_an_unknown_device_has_no_translation(self):
for value in ("path=/dev/sda,mode=0660", "path=/dev/nvidia0,mode=0666", "mode=0660"):
with self.assertRaises(ValueError):
oci_stack_replay.translated_devices(projection(value))
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
"""An advanced installation of a multi-container application asks the CPU and
the memory of its application container; a default one keeps the recipe."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.cli import _deployment_summary_text
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, InstallError, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
SPECIAL = {"nextcloud-stack": (2, 2048), "paperless-ngx": (2, 2048), "tandoor": (2, 2048), "immich": (4, 3072)}
REMOTE = {"nextcloud-stack": "nextcloud", "paperless-ngx": "paperless", "tandoor": "tandoor", "immich": "immich"}
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
class StackResourceTests(unittest.TestCase):
catalog = Catalog(ROOT)
def test_the_advanced_installation_asks_cpu_and_memory_before_the_storage(self, *_):
for app in SPECIAL:
ui = RecordingUI({"CPU cores": "6", "Memory in MB": "4096"})
template = self.catalog.compose(app)
plan = build_deployment(template, ui, ADVANCED_MODE)
self.assertEqual((plan["resources"]["cores"], plan["resources"]["memory_mb"]), (6, 4096), app)
self.assertLess(ui.asked.index("Memory in MB"), ui.asked.index("Storage for rootfs"), app)
self.assertIn("6 CPU, 4096 MB RAM", _deployment_summary_text(template, plan), app)
def test_the_default_installation_keeps_the_resources_of_the_recipe(self, *_):
for app, expected in SPECIAL.items():
ui = RecordingUI()
plan = build_deployment(self.catalog.compose(app), ui, DEFAULT_MODE)
self.assertEqual((plan["resources"]["cores"], plan["resources"]["memory_mb"]), expected, app)
self.assertNotIn("CPU cores", ui.asked, app)
def test_resources_that_cannot_run_the_application_are_refused(self, *_):
ui = RecordingUI({"CPU cores": "0"})
with self.assertRaises(InstallError):
build_deployment(self.catalog.compose("tandoor"), ui, ADVANCED_MODE)
def test_the_installers_create_the_application_with_the_chosen_resources(self, *_):
for app, (cores, memory) in SPECIAL.items():
script = (ROOT / f"remote/install_{REMOTE[app]}_stack.sh").read_text()
self.assertIn(f"jqr '.resources.cores // {cores}'", script, app)
self.assertIn(f"jqr '.resources.memory_mb // {memory}'", script, app)
self.assertIn('--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY"', script, app)
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
"""A static address another device of the network already uses is not accepted."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from proxmenux_oci import network
BRIDGES = [{"iface": "vmbr0", "cidr": "192.168.0.50/24", "gateway": "192.168.0.1"}]
class SequenceUI:
def __init__(self, answers):
self.answers = iter(answers)
self.messages = []
def choose(self, *_args, **_kwargs):
return next(self.answers)
def ask(self, *_args, **_kwargs):
return next(self.answers)
def message(self, text):
self.messages.append(text)
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch.object(network.host, "bridges", return_value=BRIDGES)
@patch.object(network, "addresses_in_use", return_value=set())
class StaticAddressInUseTests(unittest.TestCase):
def test_an_address_that_answers_is_asked_again(self, *_):
ui = SequenceUI([network.STATIC, "192.168.0.99/24", "192.168.0.170/24", "192.168.0.1"])
with patch.object(network.host, "address_answers", side_effect=lambda ip, bridge: ip == "192.168.0.99") as probe:
addresses, gateway = network.ask_addresses(ui, "vmbr0", [""])
self.assertEqual((addresses[""], gateway), ("192.168.0.170/24", "192.168.0.1"))
self.assertEqual([call.args for call in probe.call_args_list],
[("192.168.0.99", "vmbr0"), ("192.168.0.170", "vmbr0")])
self.assertEqual(len(ui.messages), 1)
self.assertIn("192.168.0.99", ui.messages[0])
def test_the_address_the_container_already_has_is_not_probed(self, *_):
ui = SequenceUI([network.STATIC, "192.168.0.169/24", "192.168.0.1"])
with patch.object(network.host, "address_answers", return_value=True) as probe:
addresses, _gateway = network.ask_addresses(ui, "vmbr0", [""], {"": "192.168.0.169/24"}, "192.168.0.1")
self.assertEqual(addresses[""], "192.168.0.169/24")
probe.assert_not_called()
self.assertEqual(ui.messages, [])
if __name__ == "__main__":
unittest.main()
+7 -2
View File
@@ -13,6 +13,9 @@ from proxmenux_oci.extra_devices import (ask_extra_devices, ask_stack_extra_devi
from proxmenux_oci.ui import BackRequested, BacktrackUI, DialogUI, RestartWizard
USB = [{'path': '/dev/ttyACM0', 'name': 'ConBee II', 'kind': 'Communications'}]
class SequenceUI:
def __init__(self, answers):
self.answers = iter(answers)
@@ -69,7 +72,8 @@ class WizardTests(unittest.TestCase):
finally:
wizard.close()
def test_manual_usb_is_available_without_profile(self):
@patch('proxmenux_oci.extra_devices.host.usb_devices', return_value=USB)
def test_manual_usb_is_available_without_profile(self, _usb):
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False])
devices = ask_extra_devices(ui, [], True)
self.assertEqual(devices[0]['host_path'], '/dev/ttyACM0')
@@ -80,7 +84,8 @@ class WizardTests(unittest.TestCase):
[{'kind': 'character-device'}])
self.assertEqual(permissions['strategy'], 'linuxserver-native-init')
def test_stack_device_goes_only_to_selected_member(self):
@patch('proxmenux_oci.extra_devices.host.usb_devices', return_value=USB)
def test_stack_device_goes_only_to_selected_member(self, _usb):
ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False, ['server']])
services = [
{'name': name, 'main': name == 'server',