mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 14:36:38 +00:00
feat(oci): coherent advanced installer flow, container creation progress and USB device list
This commit is contained in:
@@ -1632,7 +1632,7 @@
|
||||
"restoreFailed": "No se pudo restaurar la detección",
|
||||
"managedApp": "aplicación administrada",
|
||||
"managedStatus": "administrado",
|
||||
"checkedAt": "Marcado {date}",
|
||||
"checkedAt": "Comprobado {date}",
|
||||
"latestUpstream": "Última",
|
||||
"loadingApplications": "Cargando aplicaciones…",
|
||||
"nameSearchPlaceholder": "Escribe para buscar más de 700 aplicaciones o escribe la tuya propia",
|
||||
|
||||
+26
-3
@@ -366,8 +366,10 @@
|
||||
"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 registry. This operation was not started.": "Otro operación OCI está utilizando el registro. Esta operación no se inició.",
|
||||
"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",
|
||||
"Application": "Aplicación",
|
||||
"Application Specific": "Específico de la aplicación",
|
||||
"Application responding:": "Solicitud de respuesta:",
|
||||
"Application responding; checking its stability...": "Aplicación respondiendo; comprobando su estabilidad...",
|
||||
"Application suite: one independent LXC per selected application": "Suite de aplicación: un LXC independiente por aplicación seleccionada",
|
||||
@@ -448,6 +450,7 @@
|
||||
"Attention: Removing the subscription banner may cause issues in the web interface after a future update.": "Atención: Eliminar el banner de suscripción puede causar problemas en la interfaz web después de una actualización futura.",
|
||||
"Audacity is an easy-to-use, multi-track audio editor and recorder. Developed by a group of volunteers as open source.": "Audacity es un editor y grabador de audio de fácil uso. Desarrollado por un grupo de voluntarios como fuente abierta.",
|
||||
"Audio device directory": "Directorio de dispositivos de audio",
|
||||
"Audio/Video": "Audio/vídeo",
|
||||
"Audiobookshelf is a self-hosted audiobook and podcast server.": "Audiobookshelf es un servidor de audiolibros y podcast.",
|
||||
"Audit completed. Press Enter to continue...": "Auditoría completada. Presione Entrar para continuar...",
|
||||
"Audit socket disabled or not required": "Socket de auditoría deshabilitado o no requerido",
|
||||
@@ -626,6 +629,7 @@
|
||||
"Build dependencies installed.": "Construya dependencias instaladas.",
|
||||
"Build your personal knowledge base with TriliumNext Notes": "Construya su base de conocimiento personal con Trilium",
|
||||
"Business & ERP": "Negocio y ERP",
|
||||
"CDC Data": "Datos CDC",
|
||||
"CHANGES APPLIED SUCCESSFULLY": "CAMBIOS APLICADOS EXITOSAMENTE",
|
||||
"CIFS Client Tools: AVAILABLE": "Herramientas de cliente CIFS: DISPONIBLES",
|
||||
"CIFS Client Tools: NOT AVAILABLE - installing...": "Herramientas de cliente CIFS: NO DISPONIBLES - instalando...",
|
||||
@@ -890,6 +894,7 @@
|
||||
"Common Issues Check": "Verificación de problemas comunes",
|
||||
"Common root for the published views": "Raíz común para las vistas publicadas",
|
||||
"Communication & Community": "Comunicación y comunidad",
|
||||
"Communications": "Comunicaciones",
|
||||
"Community Scripts": "Scripts comunitarios",
|
||||
"Community single-container Home Assistant OS image": "Comunidad Imagen de un solo contenedor Home Assistant OS",
|
||||
"Compatibility check": "verificación de compatibilidad",
|
||||
@@ -1075,7 +1080,7 @@
|
||||
"Container configuration not found": "Configuración del contenedor no encontrada",
|
||||
"Container configured (not started):": "Contenedor configurado (no iniciado):",
|
||||
"Container converted to privileged": "Contenedor convertido a privilegiado",
|
||||
"Container created:": "Container created:",
|
||||
"Container created:": "Contenedor creado:",
|
||||
"Container data targeted for deletion:": "Datos de los contenedores que se eliminarán:",
|
||||
"Container did not become ready in time. Skipping driver installation.": "El contenedor no estuvo listo a tiempo. Saltarse la instalación del controlador.",
|
||||
"Container did not start in time.": "El contenedor no arrancó a tiempo.",
|
||||
@@ -1379,6 +1384,10 @@
|
||||
"Creating pigz wrapper script...": "Creando script contenedor pigz...",
|
||||
"Creating the backup": "Crear el backup",
|
||||
"Creating the container...": "Creando el contenedor...",
|
||||
"Creating the container: applying the image configuration...": "Creando el contenedor: aplicando la configuración de la imagen...",
|
||||
"Creating the container: extracting the image": "Creando el contenedor: extrayendo la imagen",
|
||||
"Creating the container: preparing its disk...": "Creando el contenedor: preparando su disco...",
|
||||
"Creating the container: reading the image...": "Creando el contenedor: leyendo la imagen...",
|
||||
"Creating the initial administrator...": "Crear el administrador inicial...",
|
||||
"Creating the temporary data container": "Creando el contenedor temporal para los datos",
|
||||
"Creative & Design": "Creatividad y diseño",
|
||||
@@ -2759,6 +2768,7 @@
|
||||
"Image that is not in the catalog": "Imagen que no está en el catálogo",
|
||||
"Image:": "Imagen:",
|
||||
"Images to import:": "Imágenes para importar:",
|
||||
"Imaging": "Imagen",
|
||||
"Immich CUDA requires NVIDIA driver 545 or later": "Immich CUDA requiere controlador NVIDIA 545 o posterior",
|
||||
"Immich GPU profile not validated": "Perfil GPU Immich no validado",
|
||||
"Immich configuration cancelled": "Configuración Immich cancelada",
|
||||
@@ -3900,6 +3910,7 @@
|
||||
"No Shares Found": "No se encontraron acciones",
|
||||
"No Shares to Test": "No hay acciones para probar",
|
||||
"No Storage Found": "No se encontró almacenamiento",
|
||||
"No USB device was found on this host.": "No se ha encontrado ningún dispositivo USB en este host.",
|
||||
"No USB drives detected. Enter the mountpoint path manually:": "No se detectaron unidades USB.Ingrese la ruta del punto de montaje manualmente:",
|
||||
"No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Aún no hay unidades USB montadas por ProxMenux. Monta uno primero para usarlo como objetivo.",
|
||||
"No UUP folder found.": "No se encontró ninguna carpeta UUP.",
|
||||
@@ -4515,6 +4526,7 @@
|
||||
"Previous installation restored": "Instalación anterior restaurada",
|
||||
"Previous shutdowns": "Paradas anteriores",
|
||||
"Primary GID for Emby": "GID primario para Emby",
|
||||
"Printer": "Impresora",
|
||||
"Privacy-first finance app with envelope budgeting and multi-device sync.": "Aplicación de privacidad-primera aplicación financiera con sobre budgeting y sincronización multidispositivo.",
|
||||
"Privacy-first, self-hosted PDF toolkit": "kit de herramientas PDF autohospedado que prioriza la privacidad",
|
||||
"Private installation record saved": "Registro de instalación privado guardado",
|
||||
@@ -5001,7 +5013,7 @@
|
||||
"Root privileges on the Proxmox node are required": "Los privilegios de raíz en el nodo Proxmox son necesarios",
|
||||
"Root shell/profile config": "Configuración de perfil/shell raíz",
|
||||
"Root user on the PVE host (default 'root'):": "Usuario root en el host PVE ('root' predeterminado):",
|
||||
"Rootfs size in GB": "Tamaño de los rootfs en GB",
|
||||
"Rootfs size in GB": "Tamaño del rootfs en GB",
|
||||
"Rotate the recovery passphrase": "rotar la frase de contraseña de recuperación",
|
||||
"Routing Information": "Información de ruta",
|
||||
"Routing Table": "Tabla de enrutamiento",
|
||||
@@ -5520,6 +5532,7 @@
|
||||
"Skipping installation.": "Saltarse la instalación.",
|
||||
"Skipping manual patches — feranick fork already supports this kernel.": "Saltarse parches manuales: feranick fork ya es compatible con este kernel.",
|
||||
"Sleek podcast downloader with GPodder sync": "descargador de podcast Sleek con sincronización GPodder",
|
||||
"Smart Card": "Tarjeta inteligente",
|
||||
"Smart restore plan — hardware compatibility check": "Plan de restauración inteligente: verificación de compatibilidad de hardware",
|
||||
"Smokeping keeps track of your network latency. For a full example of what this application is capable of visit UCDavis.": "Smokeping mantiene un seguimiento de la latencia de su red. Para un ejemplo completo de lo que esta aplicación es capaz de visitar UCDavis.",
|
||||
"SnapOtter": "SnapOtter",
|
||||
@@ -5684,6 +5697,7 @@
|
||||
"Storage and Disks Commands": "Comandos de almacenamiento y discos",
|
||||
"Storage controller: VirtIO SCSI": "Controlador de almacenamiento: VirtIO SCSI",
|
||||
"Storage disk identifier:": "Identificador del disco de almacenamiento:",
|
||||
"Storage for": "Almacenamiento para",
|
||||
"Storage for Nextcloud files, configuration and data": "Almacenamiento para archivos Nextcloud, configuración y datos",
|
||||
"Storage for Paperless data and documents": "Almacenamiento para los datos y documentos de Paperless",
|
||||
"Storage for Tandoor files": "Almacenamiento para archivos Tandoor",
|
||||
@@ -5691,6 +5705,7 @@
|
||||
"Storage for recipe images and files": "Almacenamiento para imágenes y archivos de receta",
|
||||
"Storage for rootfs": "Almacenamiento para rootfs",
|
||||
"Storage for rootfs and private configuration": "Almacenamiento para rootfs y configuración privada",
|
||||
"Storage for staticfiles": "Almacenamiento para los ficheros estáticos",
|
||||
"Storage for the Immich library": "Almacenamiento para la biblioteca Immich",
|
||||
"Storage for the Nextcloud data": "Almacenamiento para los datos Nextcloud",
|
||||
"Storage for the OCI image cache": "Almacenamiento para la caché de imágenes OCI",
|
||||
@@ -6492,6 +6507,7 @@
|
||||
"USB Accelerators:": "Aceleradores USB:",
|
||||
"USB bus directory": "Directorio de autobuses USB",
|
||||
"USB bus numbers can change after reconnecting or rebooting.": "Los números de bus USB pueden cambiar al volver a conectar el dispositivo o al reiniciar.",
|
||||
"USB device to attach": "Dispositivo USB que añadir",
|
||||
"USB disk target": "destino de disco USB",
|
||||
"USB drives mounted now:": "unidades USB montadas ahora:",
|
||||
"USB libedgetpu1": "USB libedgetpu1",
|
||||
@@ -6836,6 +6852,7 @@
|
||||
"Variables": "Variables",
|
||||
"Variables the installation asks for:": "Variables que pide la instalación:",
|
||||
"Vaultwarden Web Vault": "Vaultwarden Web Vault",
|
||||
"Vendor Specific": "Específico del proveedor",
|
||||
"Verbose pool status": "Estado detallado del grupo",
|
||||
"Verification": "Verificación",
|
||||
"Verified": "Verificado",
|
||||
@@ -6862,6 +6879,7 @@
|
||||
"Version:": "Versión:",
|
||||
"Version: Auto-negotiation (NFSv3/NFSv4)": "Versión: negociación automática (NFSv3/NFSv4)",
|
||||
"Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID and are new enough to build against the running kernel. DKMS compilation is the final validation. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "Las versiones mostradas pertenecen a ramas mantenidas de NVIDIA que enumeran su ID PCI de GPU y son lo suficientemente nuevas como para compilarse con el kernel en ejecución. La compilación DKMS es la validación final. La versión recomendada mantiene la rama actual o utiliza la rama de producción de NVIDIA en una instalación nueva.",
|
||||
"Video": "Vídeo",
|
||||
"Video acceleration and object detection are independent choices; the installer does not write camera or detector YAML.": "La aceleración de vídeo y la detección de objetos son opciones independientes; el instalador no escribe cámara o detector YAML.",
|
||||
"Video transcoding acceleration": "Aceleración de transcodificación de vídeo",
|
||||
"View CIFS Mounts (pvesm + fstab)": "Ver montajes CIFS (pvesm + fstab)",
|
||||
@@ -6977,6 +6995,10 @@
|
||||
"Where is the OVA/OVF file located?": "¿Dónde se encuentra el archivo OVA/OVF?",
|
||||
"Where to mount inside container?": "¿Dónde montarlo dentro del contenedor?",
|
||||
"Where to store": "Dónde almacenar",
|
||||
"Where to store the Immich library": "Dónde almacenar la biblioteca de Immich",
|
||||
"Where to store the Nextcloud files, configuration and data": "Dónde almacenar los archivos, la configuración y los datos de Nextcloud",
|
||||
"Where to store the consume and export folders": "Dónde almacenar las carpetas de consumo y exportación",
|
||||
"Where to store the recipe images and files": "Dónde almacenar las imágenes y archivos de las recetas",
|
||||
"While the server allows guest listing, no shares are actually accessible without authentication.": "Si bien el servidor permite la lista de invitados, en realidad no se puede acceder a ningún recurso compartido sin autenticación.",
|
||||
"Wikijs A modern, lightweight and powerful wiki app built on NodeJS.": "Wikijs Una moderna, ligera y potente aplicación wiki construida en NodeJS.",
|
||||
"Will be configured now": "Se configurará ahora",
|
||||
@@ -6991,6 +7013,7 @@
|
||||
"WireGuard Easy web interface": "Interfaz web WireGuard Easy",
|
||||
"WireGuard® is an extremely simple yet fast and modern VPN that utilizes state-of-the-art cryptography. It aims to be faster, simpler, leaner, and more useful than IPsec, while avoiding the massive headache. It intends to be considerably more performant than OpenVPN. WireGuard is designed as a general purpose VPN for running on embedded interfaces and super computers alike, fit for many different circumstances. Initially released for the Linux kernel, it is now cross-platform (Windows, macOS, BSD, iOS, Android) and widely deployable. It is currently under heavy development, but already it might be regarded as the most secure, easiest to use, and simplest VPN solution in the industry.": "WireGuard® es un VPN extremadamente sencillo pero rápido y moderno que utiliza la criptografía de última generación. Su objetivo es ser más rápido, más simple, más inclinado y más útil que IPsec, evitando al mismo tiempo el dolor de cabeza masivo. Tiene la intención de ser considerablemente más performante que OpenVPN. WireGuard está diseñado como una VPN de propósito general para ejecutar en interfaces incrustadas y super ordenadores por igual, encajan para muchas circunstancias diferentes. Inicialmente lanzado para el kernel de Linux, ahora es multiplataforma (Windows, macOS, BSD, iOS, Android) y ampliamente implementable. Actualmente está en desarrollo pesado, pero ya podría considerarse como la solución VPN más segura, fácil de usar y más simple de la industria.",
|
||||
"Wired NICs in backup missing on target:": "Faltan NIC cableadas en la copia de seguridad en el objetivo:",
|
||||
"Wireless Controller": "Controlador inalámbrico",
|
||||
"Wireshark is the world’s foremost and widely-used network protocol analyzer. It lets you see what’s happening on your network at a microscopic level and is the de facto (and often de jure) standard across many commercial and non-profit enterprises, government agencies, and educational institutions. Wireshark development thrives thanks to the volunteer contributions of networking experts around the globe and is the continuation of a project started by Gerald Combs in 1998.": "Wireshark es el analizador de protocolo de red más importante y ampliamente utilizado del mundo. Le permite ver lo que está sucediendo en su red a nivel microscópico y es el estándar de facto (y a menudo de jure) en muchas empresas comerciales y sin fines de lucro, agencias gubernamentales e instituciones educativas. El desarrollo de Wireshark prospera gracias a las contribuciones voluntarias de expertos en redes en todo el mundo y es la continuación de un proyecto iniciado por Gerald Combs en 1998.",
|
||||
"With dns validation, the DNSPLUGIN variable names the provider plugin. The advanced installation asks for it; otherwise add the line lxc.environment.runtime: DNSPLUGIN=<plugin> to /etc/pve/lxc/<CTID>.conf with the container stopped.": "Con dns validation, la variable DNSPLUGIN nombra el plugin del proveedor. La instalación avanzada lo pide; de lo contrario, añadir la línea lxc.environment.runtime: DNSPLUGIN=<plugin> a /etc/pve/lxc/<CTID>.conf con el contenedor detenido.",
|
||||
"With dns validation, write the provider credentials in /config/dns-conf/<plugin>.ini inside the container and restart it.": "Con la validación dns, escriba el proveedor credentials en /config/dns-conf/iereplugin Principe.ini dentro del contenedor y reiniciarlo.",
|
||||
@@ -7471,7 +7494,7 @@
|
||||
"started successfully.": "comenzó exitosamente.",
|
||||
"started.": "comenzó.",
|
||||
"startup/restart errors are likely.": "Es probable que se produzcan errores de inicio/reinicio.",
|
||||
"staticfiles volume size in GB": "tamaño del volumen de los ficheros estáticos en GB",
|
||||
"staticfiles volume size in GB": "Tamaño en GB del volumen de ficheros estáticos",
|
||||
"stop source VM first": "detener la VM de origen primero",
|
||||
"stopped": "interrumpido",
|
||||
"storage yet.": "almacenamiento todavía.",
|
||||
|
||||
@@ -361,7 +361,7 @@ fi
|
||||
TAGS="media;oci;proxmenux"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$DB_ID" "$DB_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
oci_create_container "$DB_ID" "$DB_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql/data,backup=1" \
|
||||
--hostname "${STACK_NAME}-db" --cores 2 --memory 2048 --swap 512 \
|
||||
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DB_ADDRESS},type=veth" \
|
||||
@@ -390,7 +390,7 @@ oci_quiet pct unmount "$DB_ID"
|
||||
msg_ok "$(translate "Container created:") CT $DB_ID (PostgreSQL)"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$VALKEY_ID" "$VALKEY_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
|
||||
oci_create_container "$VALKEY_ID" "$VALKEY_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
|
||||
--mp0 "${ROOTFS_STORAGE}:4,mp=/data,backup=1" \
|
||||
--hostname "${STACK_NAME}-valkey" --cores 1 --memory 512 --swap 256 \
|
||||
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${VALKEY_ADDRESS},type=veth" \
|
||||
@@ -412,7 +412,7 @@ set_lxc_directive "$VALKEY_ID" lxc.signal.halt SIGTERM
|
||||
msg_ok "$(translate "Container created:") CT $VALKEY_ID (Valkey)"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$ML_ID" "$ML_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:12" \
|
||||
oci_create_container "$ML_ID" "$ML_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:12" \
|
||||
--mp0 "${ROOTFS_STORAGE}:${MODEL_CACHE_SIZE},mp=/cache,backup=1" \
|
||||
--hostname "${STACK_NAME}-ml" "${ML_CPU_ARGS[@]}" --memory "$ML_MEMORY" --swap "$ML_SWAP" \
|
||||
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${ML_FRONTEND_NET},type=veth" \
|
||||
@@ -447,7 +447,7 @@ if [[ $VIDEO_ACCELERATION == vaapi ]]; then
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$SERVER_ID" "$SERVER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:16" \
|
||||
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 \
|
||||
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
|
||||
|
||||
@@ -343,7 +343,7 @@ fi
|
||||
TAGS="productivity;oci;proxmenux"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
oci_create_container "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql,backup=1" \
|
||||
--hostname "${STACK_NAME}-db" --cores 2 --memory 1024 --swap 512 \
|
||||
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DATABASE_ADDRESS},type=veth" \
|
||||
@@ -380,7 +380,7 @@ set_runtime_env "$DATABASE_ID" TZ "$TIMEZONE"
|
||||
msg_ok "$(translate "Container created:") CT $DATABASE_ID (PostgreSQL)"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$CACHE_ID" "$CACHE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
|
||||
oci_create_container "$CACHE_ID" "$CACHE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
|
||||
--hostname "${STACK_NAME}-redis" --cores 1 --memory 512 --swap 256 \
|
||||
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${CACHE_ADDRESS},type=veth" \
|
||||
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
|
||||
@@ -393,7 +393,7 @@ set_lxc_directive "$CACHE_ID" lxc.signal.halt SIGTERM
|
||||
msg_ok "$(translate "Container created:") CT $CACHE_ID (Redis)"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
--mp0 "$APPLICATION_MOUNT" --hostname "$STACK_NAME" \
|
||||
--cores 2 --memory 2048 --swap 1024 \
|
||||
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
|
||||
|
||||
@@ -1630,7 +1630,7 @@ fi
|
||||
|
||||
# pct keeps the OCI Entrypoint/Cmd/Env/User/WorkingDir/StopSignal metadata.
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "${CREATE_ARGS[@]}" \
|
||||
oci_create_container "${CREATE_ARGS[@]}" \
|
||||
|| die "$(translate "Could not create the container:") CT $VMID"
|
||||
CT_CREATED=1
|
||||
msg_ok "$(translate "Container created:") CT $VMID ($HOSTNAME)"
|
||||
|
||||
@@ -359,7 +359,7 @@ fi
|
||||
TAGS="productivity;oci;proxmenux"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
oci_create_container "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql,backup=1" \
|
||||
--hostname "${STACK_NAME}-db" --cores 2 --memory 1024 --swap 512 \
|
||||
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DATABASE_ADDRESS},type=veth" \
|
||||
@@ -393,7 +393,7 @@ set_runtime_env "$DATABASE_ID" TZ "$TIMEZONE"
|
||||
msg_ok "$(translate "Container created:") CT $DATABASE_ID (PostgreSQL)"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$BROKER_ID" "$BROKER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
|
||||
oci_create_container "$BROKER_ID" "$BROKER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
|
||||
--mp0 "${APPLICATION_STORAGE}:${BROKER_SIZE},mp=/data,backup=1" \
|
||||
--hostname "${STACK_NAME}-valkey" --cores 1 --memory 512 --swap 256 \
|
||||
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${BROKER_ADDRESS},type=veth" \
|
||||
@@ -410,7 +410,7 @@ set_lxc_directive "$BROKER_ID" lxc.signal.halt SIGTERM
|
||||
msg_ok "$(translate "Container created:") CT $BROKER_ID (Valkey)"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
--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" \
|
||||
|
||||
@@ -352,7 +352,7 @@ fi
|
||||
TAGS="productivity;oci;proxmenux"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
|
||||
oci_create_container "$DATABASE_ID" "$DATABASE_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:4" \
|
||||
--mp0 "${DATABASE_STORAGE}:${DATABASE_SIZE},mp=/var/lib/postgresql/data,backup=1" \
|
||||
--hostname "${STACK_NAME}-db" --cores 2 --memory 1024 --swap 512 \
|
||||
--net0 "name=eth0,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${DATABASE_ADDRESS},type=veth" \
|
||||
@@ -389,7 +389,7 @@ set_runtime_env "$DATABASE_ID" TZ "$TIMEZONE"
|
||||
msg_ok "$(translate "Container created:") CT $DATABASE_ID (PostgreSQL)"
|
||||
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_quiet pct create "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
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 \
|
||||
|
||||
@@ -115,6 +115,49 @@ oci_quiet() {
|
||||
fi
|
||||
}
|
||||
|
||||
# `pct create` of an OCI archive, showing how much of the image is already
|
||||
# unpacked into the rootfs. Arguments: VMID ARCHIVE [pct create options...]
|
||||
oci_create_container() {
|
||||
local vmid=$1 archive=$2
|
||||
shift 2
|
||||
local path=$archive rootfs="/var/lib/lxc/${vmid}/rootfs" log=${OCI_LOG:-/dev/null}
|
||||
local total_mib=0 base_mib="" used_mib extracted_mib percentage pid status=0 elapsed=0 mounted=0 extracting=0
|
||||
[[ $path == /* ]] || path=$(pvesm path "$archive" 2>/dev/null || true)
|
||||
if [[ -n ${VERIFY_OCI_ARCHIVE:-} && -f $path ]]; then
|
||||
total_mib=$(python3 "$VERIFY_OCI_ARCHIVE" --extracted-mib "$path" 2>/dev/null || printf '0')
|
||||
[[ $total_mib =~ ^[0-9]+$ ]] || total_mib=0
|
||||
fi
|
||||
pct create "$vmid" "$archive" "$@" >>"$log" 2>&1 &
|
||||
pid=$!
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
if mountpoint -q "$rootfs" 2>/dev/null; then
|
||||
mounted=1
|
||||
used_mib=$(df -BM --output=used "$rootfs" 2>/dev/null | tail -n 1 | tr -dc '0-9' || true)
|
||||
used_mib=${used_mib:-0}
|
||||
base_mib=${base_mib:-$used_mib}
|
||||
extracted_mib=$(( used_mib > base_mib ? used_mib - base_mib : 0 ))
|
||||
(( extracted_mib > 0 )) && extracting=1
|
||||
if (( ! extracting )); then
|
||||
msg_progress "$(translate "Creating the container: reading the image...") ${elapsed}s"
|
||||
elif (( total_mib > 0 )); then
|
||||
(( extracted_mib < total_mib )) || extracted_mib=$(( total_mib * 99 / 100 ))
|
||||
percentage=$(( extracted_mib * 100 / total_mib ))
|
||||
msg_progress "$(translate "Creating the container: extracting the image") ${extracted_mib} / ${total_mib} MiB (${percentage}%), ${elapsed}s"
|
||||
else
|
||||
msg_progress "$(translate "Creating the container: extracting the image") ${extracted_mib} MiB, ${elapsed}s"
|
||||
fi
|
||||
elif (( mounted )); then
|
||||
msg_progress "$(translate "Creating the container: applying the image configuration...") ${elapsed}s"
|
||||
else
|
||||
msg_progress "$(translate "Creating the container: preparing its disk...") ${elapsed}s"
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$pid" || status=$?
|
||||
return "$status"
|
||||
}
|
||||
|
||||
# Last lines of the log, for the error report.
|
||||
oci_log_tail() {
|
||||
[[ -n $OCI_LOG && -s $OCI_LOG ]] || return 0
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tarfile
|
||||
import zlib
|
||||
@@ -114,10 +116,63 @@ def verify_archive(path: Path) -> None:
|
||||
progress(f"OCI integrity verified: {path}")
|
||||
|
||||
|
||||
def _layer_extracted_size(raw, member: tarfile.TarInfo) -> int | None:
|
||||
"""What a layer takes once unpacked: the gzip trailer, the frame header of
|
||||
zstd or the tar itself."""
|
||||
raw.seek(member.offset_data)
|
||||
head = raw.read(18)
|
||||
if head.startswith(b"\x1f\x8b"):
|
||||
raw.seek(member.offset_data + member.size - 4)
|
||||
size = struct.unpack("<I", raw.read(4))[0]
|
||||
# The trailer keeps the size modulo 4 GiB.
|
||||
while size < member.size:
|
||||
size += 1 << 32
|
||||
return size
|
||||
if head.startswith(b"\x28\xb5\x2f\xfd") and len(head) >= 6:
|
||||
descriptor = head[4]
|
||||
single_segment = descriptor >> 5 & 1
|
||||
field = (1 if single_segment else 0, 2, 4, 8)[descriptor >> 6]
|
||||
if not field:
|
||||
return None
|
||||
start = 5 + (0 if single_segment else 1) + (0, 1, 2, 4)[descriptor & 3]
|
||||
value = int.from_bytes(head[start:start + field], "little")
|
||||
return value + 256 if field == 2 else value
|
||||
return member.size
|
||||
|
||||
|
||||
def extracted_size(path: Path) -> int | None:
|
||||
"""Estimated size of the image once its layers are unpacked, or None when
|
||||
a layer does not record it."""
|
||||
with tarfile.open(path, mode="r:") as archive, path.open("rb") as raw:
|
||||
members = {member.name.lstrip("./"): member for member in archive.getmembers()}
|
||||
|
||||
def blob(digest: str) -> tarfile.TarInfo:
|
||||
return members["blobs/" + digest.replace(":", "/")]
|
||||
|
||||
index = json.load(archive.extractfile(members["index.json"]))
|
||||
manifest = json.load(archive.extractfile(blob(index["manifests"][0]["digest"])))
|
||||
total = 0
|
||||
for layer in manifest.get("layers", []):
|
||||
size = _layer_extracted_size(raw, blob(layer["digest"]))
|
||||
if size is None:
|
||||
return None
|
||||
total += size
|
||||
return total
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("archive", type=Path)
|
||||
parser.add_argument("--extracted-mib", action="store_true",
|
||||
help="print the estimated unpacked size in MiB (0 when unknown)")
|
||||
args = parser.parse_args()
|
||||
if args.extracted_mib:
|
||||
try:
|
||||
size = extracted_size(args.archive)
|
||||
except (tarfile.TarError, OSError, KeyError, ValueError, IndexError):
|
||||
size = None
|
||||
print((size or 0) >> 20)
|
||||
return 0
|
||||
try:
|
||||
verify_archive(args.archive)
|
||||
except VerificationError as exc:
|
||||
|
||||
@@ -63,16 +63,23 @@ def build_suite(template, ui, mode='advanced'):
|
||||
from .installer import ask_bridge, ask_storage
|
||||
storage = ask_storage(ui, translate('Storage for rootfs and private configuration'), 'rootdir', 'local-lvm', mode)
|
||||
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')):
|
||||
raise StackError(translate('Invalid shared path'))
|
||||
ordered = list(selected)
|
||||
# Extra paths are asked with the data, against the paths each application will mount.
|
||||
drafts = [{'name': app, 'main': False, 'deployment': {'mounts': [
|
||||
{'type': 'managed-volume', 'container_path': '/app/config' if app == 'seerr' else '/config'},
|
||||
*([{'type': 'host-bind', 'container_path': '/data'}] if app in MEDIA_APPS else [])]}}
|
||||
for app in ordered]
|
||||
if mode != DEFAULT_MODE:
|
||||
from .custom_mounts import ask_stack_custom_mounts
|
||||
ask_stack_custom_mounts(ui, drafts, storage)
|
||||
bridge = ask_bridge(ui, translate('Access bridge'), 'vmbr0', mode)
|
||||
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())
|
||||
onboot = ui.confirm(translate('Start each LXC with Proxmox (no coordinated startup)'), False)
|
||||
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')):
|
||||
raise StackError(translate('Invalid shared path'))
|
||||
ordered = list(selected)
|
||||
services = []
|
||||
credentials = None
|
||||
if 'qbittorrent' in selected:
|
||||
@@ -88,7 +95,7 @@ def build_suite(template, ui, mode='advanced'):
|
||||
child_ui = (DefaultsUI() if mode == DEFAULT_MODE else
|
||||
SuiteChildUI(ui, child['proxmox'].get('installer_profile', {})))
|
||||
plan = build_deployment(copy.deepcopy(child), child_ui, mode)
|
||||
plan.update(hostname=_hostname_default(name+'-'+app), start_after_create=False, onboot=onboot, template_storage=cache)
|
||||
plan.update(hostname=_hostname_default(name+'-'+app), start_after_create=False, template_storage=cache)
|
||||
plan['rootfs']['storage'] = storage
|
||||
for env in plan['environment']:
|
||||
if env['name'] == 'TZ': env['value'] = timezone
|
||||
@@ -112,11 +119,14 @@ def build_suite(template, ui, mode='advanced'):
|
||||
services[-1]['deferred_setup'] = True
|
||||
if app == 'qbittorrent':
|
||||
services[-1]['setup_credentials'] = credentials
|
||||
for service, draft in zip(services, drafts):
|
||||
service['deployment']['mounts'] += [m for m in draft['deployment']['mounts'] if m.get('custom')]
|
||||
if mode != DEFAULT_MODE:
|
||||
from .custom_mounts import ask_stack_custom_mounts
|
||||
ask_stack_custom_mounts(ui, services, storage)
|
||||
from .extra_devices import ask_stack_extra_devices
|
||||
ask_stack_extra_devices(ui, services)
|
||||
onboot = ui.confirm(translate('Start each LXC with Proxmox (no coordinated startup)'), False)
|
||||
for service in services:
|
||||
service['deployment']['onboot'] = onboot
|
||||
return {'deployment_kind':'generic-multi-lxc-stack','suite_arr':True,'lifecycle_mode':'independent','stack_name':name,
|
||||
'base_vmid':int(base) if base else None,'services':services,'shared_media':shared,'media_player':player,
|
||||
'completion_notes':[
|
||||
|
||||
@@ -54,7 +54,8 @@ def ask_custom_mounts(ui, mounts, storage):
|
||||
'read_only': ui.confirm(translate('Mount read-only'), False),
|
||||
'create_if_missing': mode == 'host-bind'}
|
||||
if mode == 'managed-volume':
|
||||
mount['source'] = ui.ask(translate('Proxmox storage for the volume'), storage)
|
||||
from .installer import ask_storage
|
||||
mount['source'] = ask_storage(ui, translate('Proxmox storage for the volume'), 'rootdir', storage)
|
||||
mount['size_gb'] = int(ui.ask(translate('Volume size in GB'), '8'))
|
||||
else:
|
||||
mount['source'] = ui.ask(translate('Host directory (created if it does not exist)'), '/mnt/oci-shared/custom')
|
||||
|
||||
@@ -3,6 +3,7 @@ import copy
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from . import host
|
||||
from .i18n import translate
|
||||
from .ui import UserCancelled
|
||||
|
||||
@@ -12,6 +13,21 @@ USB_NODE = re.compile(r'/dev/(?:ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]
|
||||
CORAL_NODE = re.compile(r'/dev/apex_[0-9]+')
|
||||
|
||||
|
||||
def choose_usb_device(ui, title, default=None, attached=()):
|
||||
"""A USB device of the host picked by its name, or a node typed by hand."""
|
||||
options = [(row['path'], f"{row['name']} · {translate(row['kind'])}")
|
||||
for row in host.usb_devices() if row['path'] not in attached]
|
||||
manual_prompt = translate('Host USB node (e.g. /dev/ttyACM0 or /dev/bus/usb/003/004)')
|
||||
if not options:
|
||||
ui.message(translate('No USB device was found on this host.'))
|
||||
return ui.ask(manual_prompt, default or '/dev/ttyACM0')
|
||||
options.append(('manual', translate('Another node, typed by hand')))
|
||||
selected = ui.choose(title, options, default if default in dict(options) else options[0][0])
|
||||
if selected is None:
|
||||
raise UserCancelled(translate('Device configuration cancelled'))
|
||||
return ui.ask(manual_prompt, default or '/dev/ttyACM0') if selected == 'manual' else selected
|
||||
|
||||
|
||||
def ask_extra_devices(ui, devices, unprivileged, allow_coral=False):
|
||||
"""Keep manual attachments separate from image-owned GPU profiles."""
|
||||
result = list(devices)
|
||||
@@ -39,8 +55,8 @@ def ask_extra_devices(ui, devices, unprivileged, allow_coral=False):
|
||||
path = ui.ask(translate('Host DRM node (e.g. /dev/dri/renderD128)'), default)
|
||||
valid = GPU_NODE.fullmatch(path)
|
||||
elif kind == 'usb':
|
||||
path = ui.ask(translate('Host USB node (e.g. /dev/ttyACM0 or /dev/bus/usb/003/004)'),
|
||||
'/dev/ttyACM0')
|
||||
path = choose_usb_device(ui, translate('USB device to attach'),
|
||||
attached={item.get('host_path') for item in result})
|
||||
valid = USB_NODE.fullmatch(path)
|
||||
else:
|
||||
path = ui.ask(translate('Host Coral node (e.g. /dev/apex_0)'), '/dev/apex_0')
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Facts read from the local Proxmox node: storages, bridges and the timezone."""
|
||||
"""Facts read from the local Proxmox node: storages, bridges, the timezone and
|
||||
its USB devices."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -97,3 +99,70 @@ def gib(value: Any) -> int:
|
||||
return int(value) // 2**30
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
# USB classes as the Monitor labels them.
|
||||
_USB_CLASSES = {
|
||||
"01": "Audio", "02": "Communications", "03": "HID", "06": "Imaging", "07": "Printer",
|
||||
"0a": "CDC Data", "0b": "Smart Card", "0e": "Video", "10": "Audio/Video",
|
||||
"e0": "Wireless Controller", "ef": "Miscellaneous", "fe": "Application Specific",
|
||||
"ff": "Vendor Specific",
|
||||
}
|
||||
# UPS makers, whose devices report the HID class.
|
||||
_USB_UPS_VENDORS = {"0463", "051d", "0764", "0d9f", "06da", "09ae", "047c", "075d", "10af", "0665"}
|
||||
_USB_LEFT_OUT = {"08", "09"} # storage and hubs are not handed to an LXC as a device node
|
||||
_SERIAL_NODE = re.compile(r"tty(?:ACM|USB)[0-9]+")
|
||||
_LSUSB_LINE = re.compile(r"Bus\s+(\d+)\s+Device\s+(\d+):\s+ID\s+[0-9a-f]{4}:[0-9a-f]{4}\s*(.*)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _sysfs(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def usb_devices(root: Path = Path("/"), lsusb: str | None = None) -> list[dict[str, str]]:
|
||||
"""USB peripherals of this node an LXC can receive, named as the Monitor
|
||||
names them: a serial adapter by its tty node, any other device by its bus
|
||||
node."""
|
||||
if lsusb is None:
|
||||
try:
|
||||
lsusb = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5,
|
||||
check=False).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
lsusb = ""
|
||||
names = {}
|
||||
for line in lsusb.splitlines():
|
||||
match = _LSUSB_LINE.match(line)
|
||||
if match:
|
||||
names[(int(match[1]), int(match[2]))] = match[3].strip()
|
||||
serial: dict[str, list[str]] = {}
|
||||
for tty in sorted((root / "sys/class/tty").glob("tty*")):
|
||||
if not _SERIAL_NODE.fullmatch(tty.name):
|
||||
continue
|
||||
device = (tty / "device").resolve()
|
||||
while device != device.parent and not (device / "idVendor").exists():
|
||||
device = device.parent
|
||||
if (device / "idVendor").exists():
|
||||
serial.setdefault(device.name, []).append(tty.name)
|
||||
rows = []
|
||||
for device in sorted((root / "sys/bus/usb/devices").glob("*")):
|
||||
vendor = _sysfs(device / "idVendor").lower()
|
||||
if ":" in device.name or not vendor or vendor == "1d6b":
|
||||
continue
|
||||
device_class = _sysfs(device / "bDeviceClass").lower() or "00"
|
||||
if device_class == "00":
|
||||
device_class = _sysfs(device / f"{device.name}:1.0" / "bInterfaceClass").lower()
|
||||
if device_class in _USB_LEFT_OUT:
|
||||
continue
|
||||
kind = "UPS" if device_class == "03" and vendor in _USB_UPS_VENDORS else _USB_CLASSES.get(device_class, "USB")
|
||||
try:
|
||||
bus, number = int(_sysfs(device / "busnum")), int(_sysfs(device / "devnum"))
|
||||
except ValueError:
|
||||
continue
|
||||
name = (_sysfs(device / "product") or names.get((bus, number))
|
||||
or f"{vendor}:{_sysfs(device / 'idProduct').lower()}")
|
||||
for node in serial.get(device.name) or [f"bus/usb/{bus:03d}/{number:03d}"]:
|
||||
rows.append({"path": f"/dev/{node}", "name": name, "kind": kind})
|
||||
return rows
|
||||
|
||||
+135
-116
@@ -80,14 +80,16 @@ def _ask_size(ui, container_path: str, default: int) -> int:
|
||||
|
||||
|
||||
def ask_storage(ui, text: str, content: str, default: str, mode: str = ADVANCED_MODE) -> str:
|
||||
"""A storage of this node that accepts `content`, picked from a list; in
|
||||
default mode the preferred one when it exists, otherwise the one with most
|
||||
free space."""
|
||||
"""A storage of this node that accepts `content`, picked from a list unless
|
||||
it is the only one; in default mode the preferred one when it exists,
|
||||
otherwise the one with most free space."""
|
||||
if mode == DEFAULT_MODE:
|
||||
return host.default_storage(content, default)
|
||||
rows = host.storages(content)
|
||||
if not rows:
|
||||
return ui.ask(text, default)
|
||||
if len(rows) == 1:
|
||||
return rows[0]["storage"]
|
||||
options = [(row["storage"], f"{row.get('type', '')} {host.gib(row.get('avail'))} GB {translate('free')}")
|
||||
for row in rows]
|
||||
names = [name for name, _ in options]
|
||||
@@ -259,27 +261,18 @@ def build_deployment(
|
||||
if advanced:
|
||||
vmid_text = ui.ask(translate("VMID (empty = next free)"), "", required=False)
|
||||
hostname = ui.ask(translate("Hostname"), hostname_default)
|
||||
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
|
||||
volume_storage = ask_storage(ui, translate("Storage for persistent data"), "rootdir",
|
||||
defaults["volume_storage"])
|
||||
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl",
|
||||
defaults["template_storage"])
|
||||
rootfs_size = int(ui.ask(translate("Rootfs size in GB"), str(defaults["rootfs_size_gb"])))
|
||||
cores = int(ui.ask(translate("CPU cores"), str(defaults["cores"])))
|
||||
cpu_units = (int(ui.ask(translate("Relative CPU priority (cpuunits)"),
|
||||
str(_cpu_units_default(int(cpu_shares)))))
|
||||
if cpu_shares is not None else None)
|
||||
memory = int(ui.ask(translate("Memory in MB"), str(memory_default)))
|
||||
swap = int(ui.ask(translate("Swap in MB"), str(defaults["swap_mb"])))
|
||||
bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"])
|
||||
if mac_address:
|
||||
mac_address = ui.ask(translate("MAC address"), mac_address)
|
||||
if host_monitor:
|
||||
ipv4, gateway = "host", None
|
||||
else:
|
||||
ipv4, gateway = access.ask_ipv4(ui, bridge)
|
||||
onboot = ui.confirm(translate("Start with Proxmox"), defaults["onboot"])
|
||||
start_after = ui.confirm(translate("Start when finished"), True)
|
||||
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
|
||||
rootfs_size = int(ui.ask(translate("Rootfs size in GB"), str(defaults["rootfs_size_gb"])))
|
||||
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl",
|
||||
defaults["template_storage"])
|
||||
# Each container volume asks for its own storage; this is the first one proposed.
|
||||
volume_storage = rootfs_storage
|
||||
else:
|
||||
vmid_text = ""
|
||||
hostname = hostname_default
|
||||
@@ -291,11 +284,99 @@ def build_deployment(
|
||||
cpu_units = _cpu_units_default(int(cpu_shares)) if cpu_shares is not None else None
|
||||
memory = int(memory_default)
|
||||
swap = int(defaults["swap_mb"])
|
||||
|
||||
mounts: list[dict[str, Any]] = []
|
||||
host_bind_policies = installer_profile.get("host_bind_policies", {})
|
||||
labels = {"managed-volume": translate("Container volume (included in backups)"),
|
||||
"host-bind": translate("Host directory (not included in Proxmox backups)"),
|
||||
"skip": translate("Do not mount")}
|
||||
for item in template["container_contract"]["volumes"]:
|
||||
choices = item["installation_choice"]
|
||||
# A path that holds content of the user accepts a container volume or a
|
||||
# host directory, and that decision is the user's in both modes; a path
|
||||
# with a single option is never offered as a choice.
|
||||
offered = list(choices)
|
||||
chosen = advanced or {"managed-volume", "host-bind"} <= set(choices)
|
||||
if chosen and len(offered) > 1:
|
||||
# The answer is the user's: a host directory is never proposed.
|
||||
proposed = "managed-volume" if item["default"] == "host-bind" else item["default"]
|
||||
volume_mode = ui.choose(f"{translate('Where to store')} {item['container_path']}",
|
||||
[(choice, labels[choice]) for choice in offered], proposed)
|
||||
if volume_mode is None:
|
||||
raise UserCancelled(translate("Volume configuration cancelled"))
|
||||
else:
|
||||
volume_mode = item["default"]
|
||||
if volume_mode == "skip":
|
||||
continue
|
||||
if volume_mode == "managed-volume":
|
||||
size = item["managed_volume"]["default_size_gb"]
|
||||
if advanced:
|
||||
volume_storage = ask_storage(ui, f"{translate('Storage for')} {item['container_path']}",
|
||||
"rootdir", volume_storage)
|
||||
# Whoever chooses a container volume also chooses how big it is.
|
||||
if advanced or chosen:
|
||||
size = _ask_size(ui, item["container_path"], size)
|
||||
source = volume_storage
|
||||
backup = item["managed_volume"]["backup"]
|
||||
else:
|
||||
source = _shared_host_path_default(template, item)
|
||||
if chosen and not _is_system_bind(item):
|
||||
source = ui.ask(f"{translate('Host path for')} {item['container_path']}", source)
|
||||
size = None
|
||||
backup = False
|
||||
policy = host_bind_policies.get(item["container_path"], {})
|
||||
mounts.append(
|
||||
{
|
||||
"type": volume_mode,
|
||||
"container_path": item["container_path"],
|
||||
"source": source,
|
||||
"size_gb": size,
|
||||
"backup": backup,
|
||||
"read_only": item["read_only"],
|
||||
"create_if_missing": bool(
|
||||
policy.get("create_if_missing", not _is_system_bind(item))
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if advanced:
|
||||
mounts = ask_custom_mounts(ui, mounts, volume_storage)
|
||||
|
||||
if host_monitor:
|
||||
for item in installer_profile.get("host_monitor_mounts", []):
|
||||
mounts.append({"type": "host-bind", "container_path": item["target"],
|
||||
"source": item["source"], "size_gb": None, "backup": False,
|
||||
"read_only": True, "create_if_missing": False})
|
||||
|
||||
tmpfs_mounts: list[dict[str, Any]] = []
|
||||
for item in installer_profile.get("tmpfs_mounts", []):
|
||||
size_mb = int(item["default_size_mb"])
|
||||
if advanced and item.get("prompt_size", True):
|
||||
size_prompt = (translate(item["size_prompt"]) if item.get("size_prompt")
|
||||
else f"{translate('tmpfs size in MB for')} {item['container_path']}")
|
||||
size_mb = int(ui.ask(size_prompt, str(size_mb)))
|
||||
if size_mb < int(item.get("minimum_size_mb", 1)):
|
||||
raise InstallError(f"{translate('tmpfs size too small for')} {item['container_path']}")
|
||||
tmpfs_mounts.append(
|
||||
{
|
||||
"container_path": item["container_path"],
|
||||
"size_mb": size_mb,
|
||||
"mount_options": item.get("mount_options", ["rw", "nosuid", "nodev"]),
|
||||
}
|
||||
)
|
||||
|
||||
if advanced:
|
||||
bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"])
|
||||
if mac_address:
|
||||
mac_address = ui.ask(translate("MAC address"), mac_address)
|
||||
if host_monitor:
|
||||
ipv4, gateway = "host", None
|
||||
else:
|
||||
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
|
||||
onboot = bool(defaults["onboot"])
|
||||
start_after = True
|
||||
|
||||
host_firewall = confirm_host_monitor_firewall(ui, template, bridge) if host_monitor else None
|
||||
|
||||
@@ -341,83 +422,6 @@ def build_deployment(
|
||||
if value or item["required"]:
|
||||
environment.append({"name": name, "value": value, "sensitive": item["sensitive"]})
|
||||
|
||||
mounts: list[dict[str, Any]] = []
|
||||
host_bind_policies = installer_profile.get("host_bind_policies", {})
|
||||
labels = {"managed-volume": translate("Container volume (included in backups)"),
|
||||
"host-bind": translate("Host directory (not included in Proxmox backups)"),
|
||||
"skip": translate("Do not mount")}
|
||||
for item in template["container_contract"]["volumes"]:
|
||||
choices = item["installation_choice"]
|
||||
# A path that holds content of the user accepts a container volume or a
|
||||
# host directory, and that decision is the user's in both modes; a path
|
||||
# with a single option is never asked outside the advanced mode.
|
||||
offered = list(choices)
|
||||
chosen = advanced or {"managed-volume", "host-bind"} <= set(choices)
|
||||
if chosen:
|
||||
# The answer is the user's: a host directory is never proposed.
|
||||
proposed = "managed-volume" if item["default"] == "host-bind" else item["default"]
|
||||
volume_mode = ui.choose(f"{translate('Where to store')} {item['container_path']}",
|
||||
[(choice, labels[choice]) for choice in offered], proposed)
|
||||
if volume_mode is None:
|
||||
raise UserCancelled(translate("Volume configuration cancelled"))
|
||||
else:
|
||||
volume_mode = item["default"]
|
||||
if volume_mode == "skip":
|
||||
continue
|
||||
if volume_mode == "managed-volume":
|
||||
size = item["managed_volume"]["default_size_gb"]
|
||||
# Whoever chooses a container volume also chooses how big it is.
|
||||
if advanced or chosen:
|
||||
size = _ask_size(ui, item["container_path"], size)
|
||||
source = volume_storage
|
||||
backup = item["managed_volume"]["backup"]
|
||||
else:
|
||||
source = _shared_host_path_default(template, item)
|
||||
if chosen:
|
||||
source = ui.ask(f"{translate('Host path for')} {item['container_path']}", source)
|
||||
size = None
|
||||
backup = False
|
||||
policy = host_bind_policies.get(item["container_path"], {})
|
||||
mounts.append(
|
||||
{
|
||||
"type": volume_mode,
|
||||
"container_path": item["container_path"],
|
||||
"source": source,
|
||||
"size_gb": size,
|
||||
"backup": backup,
|
||||
"read_only": item["read_only"],
|
||||
"create_if_missing": bool(
|
||||
policy.get("create_if_missing", not _is_system_bind(item))
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if advanced:
|
||||
mounts = ask_custom_mounts(ui, mounts, volume_storage)
|
||||
|
||||
if host_monitor:
|
||||
for item in installer_profile.get("host_monitor_mounts", []):
|
||||
mounts.append({"type": "host-bind", "container_path": item["target"],
|
||||
"source": item["source"], "size_gb": None, "backup": False,
|
||||
"read_only": True, "create_if_missing": False})
|
||||
|
||||
tmpfs_mounts: list[dict[str, Any]] = []
|
||||
for item in installer_profile.get("tmpfs_mounts", []):
|
||||
size_mb = int(item["default_size_mb"])
|
||||
if advanced and item.get("prompt_size", True):
|
||||
size_prompt = (translate(item["size_prompt"]) if item.get("size_prompt")
|
||||
else f"{translate('tmpfs size in MB for')} {item['container_path']}")
|
||||
size_mb = int(ui.ask(size_prompt, str(size_mb)))
|
||||
if size_mb < int(item.get("minimum_size_mb", 1)):
|
||||
raise InstallError(f"{translate('tmpfs size too small for')} {item['container_path']}")
|
||||
tmpfs_mounts.append(
|
||||
{
|
||||
"container_path": item["container_path"],
|
||||
"size_mb": size_mb,
|
||||
"mount_options": item.get("mount_options", ["rw", "nosuid", "nodev"]),
|
||||
}
|
||||
)
|
||||
|
||||
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
|
||||
installer_profile, environment, unprivileged, ui, mode)
|
||||
devices, completion_notes = configure_detector(installer_profile, devices, ui)
|
||||
@@ -434,13 +438,6 @@ def build_deployment(
|
||||
from .gpu import apply_profile_image
|
||||
apply_profile_image(template, selected_hardware_profile)
|
||||
|
||||
if post_start_configurations and not start_after:
|
||||
if not ui.confirm(translate("The application configuration needs a first start to complete.")
|
||||
+ "\n\n" + translate("Start the LXC when finished to apply the selected configuration?"),
|
||||
True):
|
||||
raise UserCancelled(translate("The post-start configuration cannot be applied with the LXC stopped"))
|
||||
start_after = True
|
||||
|
||||
host_modules: list[str] = []
|
||||
for item in installer_profile.get("security", {}).get("host_modules", []):
|
||||
enabled = (ui.confirm(translate(item["enable_prompt"]), item.get("enabled_default", True))
|
||||
@@ -451,6 +448,20 @@ def build_deployment(
|
||||
continue
|
||||
host_modules.append(item["name"])
|
||||
|
||||
if advanced:
|
||||
onboot = ui.confirm(translate("Start with Proxmox"), defaults["onboot"])
|
||||
start_after = ui.confirm(translate("Start when finished"), True)
|
||||
else:
|
||||
onboot = bool(defaults["onboot"])
|
||||
start_after = True
|
||||
|
||||
if post_start_configurations and not start_after:
|
||||
if not ui.confirm(translate("The application configuration needs a first start to complete.")
|
||||
+ "\n\n" + translate("Start the LXC when finished to apply the selected configuration?"),
|
||||
True):
|
||||
raise UserCancelled(translate("The post-start configuration cannot be applied with the LXC stopped"))
|
||||
start_after = True
|
||||
|
||||
if installer_profile.get("haos_healthcheck"):
|
||||
data = [m for m in mounts if m['container_path'] == '/mnt/data']
|
||||
if (not unprivileged or defaults.get('ostype') != 'unmanaged'
|
||||
@@ -674,10 +685,9 @@ def _build_immich_deployment(
|
||||
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"])
|
||||
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
|
||||
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
|
||||
media_mode = ui.choose(
|
||||
translate("Storage for the Immich library"),
|
||||
translate("Where to store the Immich library"),
|
||||
[
|
||||
("managed-volume", translate("Dedicated container volume (included in backups)")),
|
||||
("host-bind", translate("Shared host directory (not included in Proxmox backups)")),
|
||||
@@ -697,6 +707,7 @@ def _build_immich_deployment(
|
||||
media_root = 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_size = int(
|
||||
ui.ask(translate("PostgreSQL volume size in GB"), str(defaults["database_size_gb"]))
|
||||
)
|
||||
@@ -809,7 +820,7 @@ def _build_nextcloud_stack_deployment(
|
||||
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir", defaults["rootfs_storage"])
|
||||
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
|
||||
application_mode = ui.choose(
|
||||
translate("Storage for Nextcloud files, configuration and data"),
|
||||
translate("Where to store the Nextcloud files, configuration and data"),
|
||||
[
|
||||
("managed-volume", translate("Dedicated container volume (included in backups)")),
|
||||
("host-bind", translate("Shared host directory (not included in Proxmox backups)")),
|
||||
@@ -910,10 +921,9 @@ def _build_paperless_stack_deployment(
|
||||
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"])
|
||||
application_storage = ask_storage(ui, translate("Storage for Paperless data and documents"), "rootdir", defaults["application_storage"])
|
||||
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_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"])
|
||||
data_size = int(
|
||||
ui.ask(translate("Data volume size in GB"), str(defaults["data_volume_size_gb"]))
|
||||
)
|
||||
@@ -923,6 +933,7 @@ def _build_paperless_stack_deployment(
|
||||
str(defaults["media_volume_size_gb"]),
|
||||
)
|
||||
)
|
||||
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
|
||||
database_size = int(
|
||||
ui.ask(
|
||||
translate("PostgreSQL volume size in GB"),
|
||||
@@ -933,7 +944,7 @@ def _build_paperless_stack_deployment(
|
||||
raise InstallError(translate("The Paperless persistent volumes need at least 8 GB"))
|
||||
|
||||
transfer_mode = ui.choose(
|
||||
translate("Storage for the consume and export folders"),
|
||||
translate("Where to store the consume and export folders"),
|
||||
[
|
||||
("host-bind", translate("Shared host directories (not included in Proxmox backups)")),
|
||||
("managed-volume", translate("Dedicated container volumes (included in backups)")),
|
||||
@@ -1029,11 +1040,10 @@ def _build_tandoor_stack_deployment(
|
||||
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"])
|
||||
database_storage = ask_storage(ui, translate("Local storage for PostgreSQL"), "rootdir", defaults["database_storage"])
|
||||
template_storage = ask_storage(ui, translate("Storage for the OCI image cache"), "vztmpl", pve_defaults["template_storage"])
|
||||
|
||||
media_mode = ui.choose(
|
||||
translate("Storage for recipe images and files"),
|
||||
translate("Where to store the recipe images and files"),
|
||||
[
|
||||
("managed-volume", translate("Dedicated container volume (included in backups)")),
|
||||
("host-bind", translate("Shared host directory (not included in Proxmox backups)")),
|
||||
@@ -1061,12 +1071,14 @@ def _build_tandoor_stack_deployment(
|
||||
media_storage = None
|
||||
media_size = None
|
||||
|
||||
static_storage = ask_storage(ui, translate("Storage for staticfiles"), "rootdir", 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_size = int(
|
||||
ui.ask(
|
||||
translate("PostgreSQL volume size in GB"),
|
||||
@@ -1098,6 +1110,7 @@ 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"])
|
||||
start_after = ui.confirm(translate("Start when finished"), True)
|
||||
if not start_after:
|
||||
raise UserCancelled(
|
||||
@@ -1111,7 +1124,7 @@ def _build_tandoor_stack_deployment(
|
||||
"stack_name": stack_name,
|
||||
"template_storage": template_storage,
|
||||
"rootfs_storage": rootfs_storage,
|
||||
"application_storage": defaults["application_storage"],
|
||||
"application_storage": static_storage,
|
||||
"static_size_gb": static_size,
|
||||
"database_storage": database_storage,
|
||||
"database_size_gb": database_size,
|
||||
@@ -1128,7 +1141,7 @@ def _build_tandoor_stack_deployment(
|
||||
"admin_email": admin_email,
|
||||
},
|
||||
"timezone": timezone,
|
||||
"onboot": ui.confirm(translate("Start the stack with Proxmox"), pve_defaults["onboot"]),
|
||||
"onboot": onboot,
|
||||
"start_after_create": start_after,
|
||||
"network": {
|
||||
"frontend_bridge": frontend_bridge,
|
||||
@@ -1547,8 +1560,14 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod
|
||||
]
|
||||
devices.append(device)
|
||||
else:
|
||||
host_path = (ui.ask(translate(item["path_prompt"]), item["host_path_default"]) if advanced
|
||||
else item["host_path_default"])
|
||||
if not advanced:
|
||||
host_path = item["host_path_default"]
|
||||
elif item.get("purpose") in ("serial", "user-selected-device"):
|
||||
from .extra_devices import choose_usb_device
|
||||
host_path = choose_usb_device(ui, translate(item["path_prompt"]), item["host_path_default"],
|
||||
{device.get("host_path") for device in devices})
|
||||
else:
|
||||
host_path = ui.ask(translate(item["path_prompt"]), item["host_path_default"])
|
||||
container_path = (
|
||||
host_path
|
||||
if item.get("container_path_strategy") == "same-as-host"
|
||||
|
||||
@@ -269,10 +269,27 @@ class DefaultsUI:
|
||||
return default
|
||||
def info(self, text):
|
||||
pass
|
||||
def message(self, text):
|
||||
pass
|
||||
|
||||
|
||||
def _service_plan(template, service):
|
||||
"""The single-container template and plan of one stack member."""
|
||||
from .installer import build_deployment, DEFAULT_MODE
|
||||
single = service_template(template, service, service['name'] == template['compose_stack']['main_service'])
|
||||
if kind(service['image']) == 'postgres':
|
||||
# Official PostgreSQL 18+ stores versioned PGDATA under this parent.
|
||||
for m in single['container_contract']['volumes']:
|
||||
if m['container_path'] == '/var/lib/postgresql/data' and service['image'].endswith(':latest'):
|
||||
m['container_path'] = normalized_dependency_mount_path(m['container_path'], service['image'])
|
||||
for e in single['container_contract']['environment']:
|
||||
e['required'] = bool(e['example'])
|
||||
e['example'] = 'stack-resolved-value' if e['example'] else ''
|
||||
return single, build_deployment(single, DefaultsUI(), DEFAULT_MODE)
|
||||
|
||||
|
||||
def build_stack(template, ui, mode='advanced'):
|
||||
from .installer import build_deployment, _hostname_default, DEFAULT_MODE
|
||||
from .installer import _hostname_default, DEFAULT_MODE
|
||||
problems = assess(template)
|
||||
if problems:
|
||||
raise StackError('; '.join(problems))
|
||||
@@ -282,15 +299,47 @@ def build_stack(template, ui, mode='advanced'):
|
||||
vmid = ui.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
|
||||
from .installer import _ask_size, ask_bridge, ask_storage
|
||||
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'], mode)
|
||||
volumes = ask_storage(ui, translate('Storage for persistent data'), 'rootdir', defaults['volume_storage'], mode)
|
||||
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)
|
||||
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)
|
||||
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}",
|
||||
[('managed-volume', translate('Container volume (included in backups)')),
|
||||
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
|
||||
'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')
|
||||
if mount_mode == 'host-bind':
|
||||
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
|
||||
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'))
|
||||
drafts.append({'name': s['name'], 'main': s['name'] == template['compose_stack']['main_service'],
|
||||
'deployment': {'mounts': plan['mounts']}})
|
||||
if mode != DEFAULT_MODE:
|
||||
from .custom_mounts import ask_stack_custom_mounts
|
||||
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())
|
||||
onboot = ui.confirm(translate('Start the stack with Proxmox'), False)
|
||||
envs = resolve_environments(services, timezone, template.get('proxmox', {}).get('stack_generators', {}))
|
||||
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:
|
||||
@@ -316,39 +365,16 @@ def build_stack(template, ui, mode='advanced'):
|
||||
endpoint = template['first_run']['endpoints'][0]
|
||||
env[key] = urlunsplit((url.scheme, '@STACK_LAN_IP@:'+str(endpoint['port']), url.path, url.query, url.fragment))
|
||||
s['compose']['environment'] = env
|
||||
single = service_template(template, s, main)
|
||||
single, plan = _service_plan(template, s)
|
||||
k = kind(s['image'])
|
||||
if k == 'postgres':
|
||||
# Official PostgreSQL 18+ stores versioned PGDATA under this parent.
|
||||
for m in single['container_contract']['volumes']:
|
||||
if m['container_path'] == '/var/lib/postgresql/data' and s['image'].endswith(':latest'):
|
||||
m['container_path'] = normalized_dependency_mount_path(m['container_path'], s['image'])
|
||||
for e in single['container_contract']['environment']:
|
||||
e['required'] = bool(e['example'])
|
||||
e['example'] = 'stack-resolved-value' if e['example'] else ''
|
||||
plan = build_deployment(single, DefaultsUI(), DEFAULT_MODE)
|
||||
# Values are already resolved; do not reinterpret user credentials as Compose variables.
|
||||
plan['environment'] = [{'name':key,'value':value,'sensitive':True} for key,value in env.items() if value != '']
|
||||
plan.update(hostname=_hostname_default(name+'-'+s['name']), template_storage=cache,
|
||||
onboot=onboot, start_after_create=False)
|
||||
start_after_create=False)
|
||||
plan['rootfs']['storage'] = root
|
||||
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'])
|
||||
for m in plan['mounts']:
|
||||
mount_mode = ('managed-volume' if mode == DEFAULT_MODE else
|
||||
ui.choose(f"{s['name']}: {m['container_path']}",
|
||||
[('managed-volume',translate('Container volume (included in backups)')),
|
||||
('host-bind',translate('Host directory'))], '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')
|
||||
if mount_mode == 'host-bind':
|
||||
m['source'] = ui.ask(translate('Host directory'), '/mnt/oci-shared/'+name+'/'+s['name']+'/'+m['container_path'].strip('/').replace('/','-'))
|
||||
m['size_gb'] = None
|
||||
elif mode != DEFAULT_MODE:
|
||||
m['size_gb'] = int(ui.ask(translate('Volume size in GB'), str(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'))
|
||||
plan['mounts'] = drafts[index]['deployment']['mounts']
|
||||
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':
|
||||
@@ -377,10 +403,11 @@ def build_stack(template, ui, mode='advanced'):
|
||||
if main:
|
||||
plans[-1]['frontend_ipv4'] = addresses['']
|
||||
if mode != DEFAULT_MODE:
|
||||
from .custom_mounts import ask_stack_custom_mounts
|
||||
ask_stack_custom_mounts(ui, plans, volumes)
|
||||
from .extra_devices import ask_stack_extra_devices
|
||||
ask_stack_extra_devices(ui, plans)
|
||||
onboot = ui.confirm(translate('Start the stack with Proxmox'), False)
|
||||
for plan in plans:
|
||||
plan['deployment']['onboot'] = onboot
|
||||
return {'deployment_kind':'generic-multi-lxc-stack','stack_name':name,'base_vmid':int(vmid) if vmid else None,
|
||||
'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]),
|
||||
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""The advanced installer asks for each data path's storage next to its size."""
|
||||
|
||||
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.catalog import Catalog
|
||||
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, build_deployment
|
||||
|
||||
STORAGES = [{"storage": "Public", "type": "nfs", "avail": 9 * 2**40},
|
||||
{"storage": "local-lvm", "type": "lvmthin", "avail": 700 * 2**30}]
|
||||
|
||||
|
||||
class RecordingUI:
|
||||
"""Answers every question with its default, or with a scripted answer."""
|
||||
|
||||
def __init__(self, answers=None):
|
||||
self.answers = answers or {}
|
||||
self.asked = []
|
||||
|
||||
def _answer(self, text, default):
|
||||
self.asked.append(text)
|
||||
return self.answers.get(text, default)
|
||||
|
||||
def ask(self, text, default=None, required=True):
|
||||
return self._answer(text, default if default is not None else "")
|
||||
|
||||
def password(self, text, required=True):
|
||||
return self._answer(text, "secret")
|
||||
|
||||
def choose(self, text, options, default=None):
|
||||
return self._answer(text, default)
|
||||
|
||||
def confirm(self, text, default=False):
|
||||
return self._answer(text, default)
|
||||
|
||||
def checklist(self, text, options, default=None):
|
||||
return self._answer(text, default)
|
||||
|
||||
def info(self, text):
|
||||
pass
|
||||
|
||||
def message(self, text):
|
||||
pass
|
||||
|
||||
|
||||
def storages(content):
|
||||
return STORAGES if content == "rootdir" else [{"storage": "local", "type": "dir", "avail": 2**35}]
|
||||
|
||||
|
||||
def addresses(ui, bridge, names, *args):
|
||||
return {name: "dhcp" for name in names}, None
|
||||
|
||||
|
||||
@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_ipv4", return_value=("dhcp", None))
|
||||
@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
|
||||
class AdvancedFlowOrderTests(unittest.TestCase):
|
||||
catalog = Catalog(ROOT)
|
||||
|
||||
def build(self, app, ui, mode=ADVANCED_MODE):
|
||||
return build_deployment(self.catalog.compose(app), ui, mode)
|
||||
|
||||
def test_each_volume_asks_its_storage_and_then_its_size(self, *_):
|
||||
ui = RecordingUI({"Storage for /data/movies": "Public"})
|
||||
plan = self.build("jellyfin", ui)
|
||||
volumes = {m["container_path"]: m["source"] for m in plan["mounts"]}
|
||||
self.assertEqual(volumes, {"/config": "local-lvm", "/data/tvshows": "local-lvm",
|
||||
"/data/movies": "Public"})
|
||||
asked = ui.asked
|
||||
self.assertEqual(asked.index("Size in GB of /config"), asked.index("Storage for /config") + 1)
|
||||
self.assertNotIn("Storage for persistent data", asked)
|
||||
self.assertNotIn("Where to store /config", asked)
|
||||
|
||||
def test_volumes_are_proposed_on_the_rootfs_storage(self, *_):
|
||||
ui = RecordingUI({"Storage for rootfs": "Public"})
|
||||
plan = self.build("jellyfin", ui)
|
||||
self.assertEqual({m["source"] for m in plan["mounts"]}, {"Public"})
|
||||
|
||||
def test_system_resources_then_data_then_network_then_start(self, *_):
|
||||
ui = RecordingUI()
|
||||
self.build("jellyfin", ui)
|
||||
asked = ui.asked
|
||||
order = ["Memory in MB", "Storage for rootfs", "Rootfs size in GB", "Storage for /config",
|
||||
"Where to store /data/movies", "Network bridge", "Value for TZ",
|
||||
"Start with Proxmox", "Start when finished"]
|
||||
self.assertEqual([asked.index(text) for text in order], sorted(asked.index(text) for text in order))
|
||||
self.assertEqual(asked[-2:], ["Start with Proxmox", "Start when finished"])
|
||||
|
||||
def test_single_option_and_system_paths_are_not_asked(self, *_):
|
||||
ui = RecordingUI()
|
||||
plan = self.build("frigate", ui)
|
||||
self.assertNotIn("Where to store /etc/localtime", ui.asked)
|
||||
self.assertNotIn("Host path for /etc/localtime", ui.asked)
|
||||
self.assertIn("Where to store /media/frigate", ui.asked)
|
||||
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()
|
||||
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"})
|
||||
|
||||
def assert_follows(self, asked, *texts):
|
||||
positions = [asked.index(text) for text in texts]
|
||||
self.assertEqual(positions, list(range(positions[0], positions[0] + len(texts))), texts)
|
||||
|
||||
def test_stack_asks_each_member_path_with_its_storage_and_size(self, *_):
|
||||
ui = RecordingUI({"Storage for linkwarden-postgres: /var/lib/postgresql": "Public"})
|
||||
plan = self.build("linkwarden", ui)
|
||||
asked = ui.asked
|
||||
self.assertNotIn("Storage for persistent data", asked)
|
||||
self.assert_follows(asked, "Where to store linkwarden-postgres: /var/lib/postgresql",
|
||||
"Storage for linkwarden-postgres: /var/lib/postgresql",
|
||||
"Size in GB of linkwarden-postgres: /var/lib/postgresql")
|
||||
self.assertLess(asked.index("Add extra paths to this stack"), asked.index("Access bridge"))
|
||||
self.assertLess(asked.index("Access bridge"), asked.index("Timezone"))
|
||||
self.assertEqual(asked[-1], "Start the stack with Proxmox")
|
||||
sources = {(s["name"], m["container_path"]): m["source"]
|
||||
for s in plan["services"] for m in s["deployment"]["mounts"]}
|
||||
self.assertEqual(sources[("linkwarden-postgres", "/var/lib/postgresql")], "Public")
|
||||
self.assertTrue(all(s["deployment"]["onboot"] is False for s in plan["services"]))
|
||||
|
||||
def test_arr_suite_asks_data_first_and_start_last(self, *_):
|
||||
ui = RecordingUI()
|
||||
plan = self.build("suite-arr", ui)
|
||||
asked = ui.asked
|
||||
self.assertLess(asked.index("Shared host media directory"), asked.index("Access bridge"))
|
||||
self.assertLess(asked.index("Add extra paths to this stack"), asked.index("Access bridge"))
|
||||
self.assertEqual(asked[-1], "Start each LXC with Proxmox (no coordinated startup)")
|
||||
self.assertTrue(all("onboot" in s["deployment"] for s in plan["services"]))
|
||||
|
||||
def test_special_stacks_ask_each_storage_next_to_its_size(self, *_):
|
||||
ui = RecordingUI()
|
||||
self.build("immich", ui)
|
||||
self.assert_follows(ui.asked, "Where to store the Immich library", "Storage for the Immich library",
|
||||
"Library size in GB", "Local storage for PostgreSQL", "PostgreSQL volume size in GB")
|
||||
ui = RecordingUI()
|
||||
self.build("paperless-ngx", ui)
|
||||
self.assert_follows(ui.asked, "Storage for Paperless data and documents", "Data volume size in GB",
|
||||
"Documents volume size in GB", "Local storage for PostgreSQL",
|
||||
"PostgreSQL volume size in GB")
|
||||
ui = RecordingUI()
|
||||
self.build("tandoor", ui)
|
||||
self.assert_follows(ui.asked, "Storage for staticfiles", "staticfiles volume size in GB",
|
||||
"Local storage for PostgreSQL", "PostgreSQL volume size in GB")
|
||||
self.assertEqual(ui.asked[-2:], ["Start the stack with Proxmox", "Start when finished"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""The container creation progress knows how big the image is once unpacked."""
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "remote"))
|
||||
|
||||
from verify_oci_archive import extracted_size
|
||||
|
||||
|
||||
def zstd_frame(content_size: int) -> bytes:
|
||||
# Magic, a descriptor with an 8-byte content size and single segment, the size.
|
||||
return b"\x28\xb5\x2f\xfd" + bytes([0b11100000]) + content_size.to_bytes(8, "little") + b"\x00" * 16
|
||||
|
||||
|
||||
class ExtractedSizeTests(unittest.TestCase):
|
||||
def archive(self, layers):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
path = Path(tmp.name) / "image.tar"
|
||||
blobs = {}
|
||||
|
||||
def add(data: bytes) -> str:
|
||||
digest = "sha256:" + hashlib.sha256(data).hexdigest()
|
||||
blobs[digest] = data
|
||||
return digest
|
||||
|
||||
manifest = {"layers": [{"digest": add(data), "mediaType": "x"} for data in layers]}
|
||||
index = {"manifests": [{"digest": add(json.dumps(manifest).encode())}]}
|
||||
with tarfile.open(path, "w") as tar:
|
||||
for name, data in [("oci-layout", b"{}"), ("index.json", json.dumps(index).encode()),
|
||||
*((f"blobs/{d.replace(':', '/')}", b) for d, b in blobs.items())]:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
return path
|
||||
|
||||
def test_gzip_zstd_and_plain_layers_add_up(self):
|
||||
path = self.archive([gzip.compress(b"a" * 300_000), zstd_frame(5 << 20), b"p" * 1000])
|
||||
self.assertEqual(extracted_size(path), 300_000 + (5 << 20) + 1000)
|
||||
|
||||
def test_zstd_layer_without_content_size_is_unknown(self):
|
||||
frame = b"\x28\xb5\x2f\xfd" + bytes([0b00000000, 0x50]) + b"\x00" * 16
|
||||
self.assertIsNone(extracted_size(self.archive([frame])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""USB devices are offered by name, as the Monitor shows them, with the node an LXC receives."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from proxmenux_oci import host
|
||||
from proxmenux_oci.extra_devices import ask_extra_devices, choose_usb_device
|
||||
|
||||
LSUSB = """Bus 003 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
|
||||
Bus 003 Device 002: ID 0463:ffff MGE UPS Systems UPS
|
||||
Bus 003 Device 003: ID 0658:0200 Sigma Designs, Inc. Aeotec Z-Stick Gen5 (ZW090) - UZB
|
||||
Bus 003 Device 004: ID 1cf1:0030 Dresden Elektronik ZigBee gateway [ConBee II]
|
||||
Bus 003 Device 005: ID 0781:5581 SanDisk Corp. Ultra
|
||||
"""
|
||||
|
||||
|
||||
class UsbDevicesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
pci = self.root / "sys/devices/pci0000:00/usb3"
|
||||
self.device(pci, "usb3", "1d6b", "09", 1)
|
||||
self.device(pci, "3-3", "0463", "00", 2, product="Ellipse ECO", interface="03")
|
||||
self.device(pci, "3-8", "0658", "02", 3, tty="ttyACM1")
|
||||
self.device(pci, "3-9", "1cf1", "02", 4, product="ConBee II", tty="ttyACM0")
|
||||
self.device(pci, "3-10", "0781", "00", 5, product="Ultra", interface="08")
|
||||
|
||||
def device(self, parent, name, vendor, device_class, number, product="", interface="", tty=""):
|
||||
path = parent / name
|
||||
path.mkdir(parents=True)
|
||||
for key, value in {"idVendor": vendor, "idProduct": "0001", "bDeviceClass": device_class,
|
||||
"busnum": "3", "devnum": str(number)}.items():
|
||||
(path / key).write_text(value + "\n")
|
||||
if product:
|
||||
(path / "product").write_text(product + "\n")
|
||||
if interface:
|
||||
(path / f"{name}:1.0").mkdir()
|
||||
(path / f"{name}:1.0" / "bInterfaceClass").write_text(interface + "\n")
|
||||
link = self.root / "sys/bus/usb/devices" / name
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.symlink(path, link)
|
||||
if tty:
|
||||
port = path / f"{name}:1.0" / "tty" / tty
|
||||
port.mkdir(parents=True)
|
||||
(self.root / "sys/class/tty").mkdir(parents=True, exist_ok=True)
|
||||
(self.root / "sys/class/tty" / tty).mkdir()
|
||||
os.symlink(path / f"{name}:1.0", self.root / "sys/class/tty" / tty / "device")
|
||||
|
||||
def test_devices_are_named_as_the_monitor_names_them(self):
|
||||
rows = host.usb_devices(self.root, LSUSB)
|
||||
self.assertEqual(rows, [
|
||||
{"path": "/dev/bus/usb/003/002", "name": "Ellipse ECO", "kind": "UPS"},
|
||||
{"path": "/dev/ttyACM1", "name": "Sigma Designs, Inc. Aeotec Z-Stick Gen5 (ZW090) - UZB",
|
||||
"kind": "Communications"},
|
||||
{"path": "/dev/ttyACM0", "name": "ConBee II", "kind": "Communications"},
|
||||
])
|
||||
|
||||
def test_the_menu_lists_the_devices_and_leaves_attached_ones_out(self):
|
||||
ui = Mock()
|
||||
ui.choose.return_value = "/dev/ttyACM1"
|
||||
with patch.object(host, "usb_devices", return_value=host.usb_devices(self.root, LSUSB)):
|
||||
self.assertEqual(choose_usb_device(ui, "USB", attached={"/dev/ttyACM0"}), "/dev/ttyACM1")
|
||||
tags = [tag for tag, _label in ui.choose.call_args.args[1]]
|
||||
self.assertEqual(tags, ["/dev/bus/usb/003/002", "/dev/ttyACM1", "manual"])
|
||||
ui.ask.assert_not_called()
|
||||
|
||||
def test_without_devices_the_node_is_typed(self):
|
||||
ui = Mock()
|
||||
ui.ask.return_value = "/dev/ttyUSB0"
|
||||
with patch.object(host, "usb_devices", return_value=[]):
|
||||
self.assertEqual(choose_usb_device(ui, "USB"), "/dev/ttyUSB0")
|
||||
ui.message.assert_called_once()
|
||||
ui.choose.assert_not_called()
|
||||
|
||||
def test_extra_usb_device_comes_from_the_list(self):
|
||||
ui = Mock()
|
||||
ui.confirm.side_effect = [True, False]
|
||||
ui.choose.side_effect = ["usb", "/dev/ttyACM0"]
|
||||
with patch.object(host, "usb_devices", return_value=host.usb_devices(self.root, LSUSB)):
|
||||
devices = ask_extra_devices(ui, [], True)
|
||||
self.assertEqual([(d["host_path"], d["container_path"]) for d in devices],
|
||||
[("/dev/ttyACM0", "/dev/ttyACM0")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -30,7 +30,7 @@
|
||||
"items": [
|
||||
{ "title": "Required paths", "body": "<code>/config</code>, <code>/data</code>, libraries, downloads and every volume the application declares are listed." },
|
||||
{ "title": "Location", "body": "Each path is placed on a disk of the container or on an existing host directory." },
|
||||
{ "title": "Storage and size", "body": "A container disk is created on a Proxmox VE storage, <code>local-lvm</code> by default, with the size given. It appears as <code>vm-VMID-disk-N</code> and is attached as <code>mpN</code>." },
|
||||
{ "title": "Storage and size", "body": "A container disk is created on a Proxmox VE storage with the size given. The default installation uses <code>local-lvm</code>; the advanced installation asks the storage of each path right before its size and proposes the one of the rootfs. It appears as <code>vm-VMID-disk-N</code> and is attached as <code>mpN</code>." },
|
||||
{ "title": "Host directory", "body": "A host directory is given by its path. A directory that does not exist is created, owned by the user the container maps." },
|
||||
{ "title": "Additional paths", "body": "More pairs of host path or volume and container path can be added before installing." },
|
||||
{ "title": "Summary", "body": "The complete mapping is shown before the CT is created and is stored in its instance contract." }
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"items": [
|
||||
{ "title": "Rutas requeridas", "body": "Se enumeran <code>/config</code>, <code>/data</code>, bibliotecas, descargas y cualquier volumen que declare la aplicación." },
|
||||
{ "title": "Ubicación", "body": "Cada ruta se sitúa en un disco del contenedor o en un directorio existente del host." },
|
||||
{ "title": "Almacenamiento y tamaño", "body": "Un disco del contenedor se crea en un almacenamiento de Proxmox VE, <code>local-lvm</code> por defecto, con el tamaño indicado. Aparece como <code>vm-VMID-disk-N</code> y se conecta como <code>mpN</code>." },
|
||||
{ "title": "Almacenamiento y tamaño", "body": "Un disco del contenedor se crea en un almacenamiento de Proxmox VE con el tamaño indicado. La instalación por defecto usa <code>local-lvm</code>; la instalación avanzada pregunta el almacenamiento de cada ruta justo antes de su tamaño y propone el del rootfs. Aparece como <code>vm-VMID-disk-N</code> y se conecta como <code>mpN</code>." },
|
||||
{ "title": "Directorio del host", "body": "Un directorio del host se indica por su ruta. Si no existe, se crea con el propietario que mapea el contenedor." },
|
||||
{ "title": "Rutas adicionales", "body": "Antes de instalar pueden añadirse más pares de ruta del host o volumen y ruta del contenedor." },
|
||||
{ "title": "Resumen", "body": "La relación completa se muestra antes de crear el CT y se guarda en su contrato de instancia." }
|
||||
|
||||
Reference in New Issue
Block a user