feat(oci): update and modify every application, and keep its address

- The backup taken before an update or a modification is made on another
  storage when it does not fit on the system disk; an operation without
  room says so before anything is stopped.
- Privileged containers, relaxed AppArmor or seccomp, process limits, the
  host monitors Glances and Netdata and single-file host mounts are
  rebuilt and recovered like any other application.
- HAOS One installs the docker-haos image: port 8123, host time zone,
  unprivileged, watchdog proposed by default; it is updated and modified
  with Home Assistant's own check on the rebuilt container.
- A container that reboots itself gets its DHCP client back.
- The installer takes the address of the container's own interface, and
  the notes of a suite or stack container show its LAN address.
- Seerr installs: its volume belongs to the image user and
  no-new-privileges goes through the include of the container.
- Removing an application deletes its seccomp profile.
This commit is contained in:
MacRimi
2026-10-08 17:14:36 +02:00
parent b629938cf1
commit f5ad4072b5
31 changed files with 1784 additions and 390 deletions
@@ -80,6 +80,7 @@ class InventoryMessages(unittest.TestCase):
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
're': re, 'guest_node': lambda vmid: None,
'remove_host_state': lambda vmid: events.append(('host', vmid)),
@@ -117,6 +117,7 @@ class RemovalWordings(unittest.TestCase):
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
'translate': lambda text: text,
@@ -186,6 +187,7 @@ class RemovalWordings(unittest.TestCase):
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None,
'translate': lambda text: text,
@@ -264,6 +266,7 @@ class RemovalWordings(unittest.TestCase):
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
'translate': lambda text: text, 'msg_info': lambda text: None,
@@ -132,6 +132,7 @@ class UpdateWording(unittest.TestCase):
'translate': lambda text: text, 'check_selected': lambda project, row: row,
'images': SimpleNamespace(offer_removal=lambda *args: None),
'carry_records': lambda project, **options: {},
'_work_backup_ready': lambda project, ui, vmid: True,
'_run_lifecycle': lambda command, title: events.append(('run', command, title)) or True}
row = {'vmid': 101, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'}
decision = False
+21 -9
View File
@@ -390,7 +390,7 @@
"Application": "Aplicación",
"Application Specific": "Específico de la aplicación",
"Application registered on this host:": "Aplicación registrada en este host:",
"Application responding:": "Solicitud de respuesta:",
"Application responding:": "Aplicación respondiendo:",
"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",
"Application:": "Aplicación:",
@@ -570,7 +570,7 @@
"Backup kept in": "Backup conservado en",
"Backup location": "Ubicación del backup",
"Backup metadata": "Metadatos de backup",
"Backup of the previous installation verified": "Backup de la instalación anterior verificada",
"Backup of the previous installation verified": "Backup de la instalación anterior verificado",
"Backup on newer kernel:": "Backup de un kernel más nuevo:",
"Backup on older kernel:": "Backup de un kernel anterior:",
"Backup origin metadata:": "Metadatos de origen del backup:",
@@ -760,6 +760,7 @@
"Changes prepared": "Cambios preparados",
"Changes will apply after reboot.": "Los cambios se aplicarán después del reinicio.",
"Changing Release Channel": "Cambio de canal de release",
"Changing between a privileged and an unprivileged container, or the host monitor mode, requires installing the application again": "Cambiar entre contenedor privilegiado y sin privilegios, o el modo de monitor del host, requiere instalar la aplicación de nuevo",
"Changing the machine type on an existing installed VM is not safe: it changes the chipset and PCI slot layout, which typically prevents the guest OS from booting.": "Cambiar el tipo de máquina de una VM ya instalada no es seguro: cambia el chipset y la disposición de las ranuras PCI, lo que normalmente impide que arranque el sistema operativo de la VM.",
"Changing the rootfs or its storage requires a separate migration": "Cambiar los rootfs o su almacenamiento requiere una migración separada",
"Changing the storage or size of a disk requires a migration; empty disks are not created": "Cambiar el almacenamiento o el tamaño de un disco requiere una migración; los discos vacíos no se crean",
@@ -2434,6 +2435,7 @@
"Free and easy to use Minecraft server management tool.": "Herramienta gratuita y fácil de usar para gestionar servidores de Minecraft.",
"Free public Proxmox repository enabled": "Repositorio público gratuito de Proxmox habilitado",
"Free space OK:": "Espacio libre Aceptar:",
"Free space on the host, or choose another storage for this backup from Manage installed OCI applications.": "Libera espacio en el host, o elige otro almacenamiento para este backup desde Gestionar aplicaciones OCI instaladas.",
"Free up disk space": "Liberar espacio en disco",
"Free:": "Libre:",
"FreeCAD is a general-purpose parametric 3D computer-aided design (CAD) modeler and a building information modeling (BIM) software application with finite element method (FEM) support.": "FreeCAD es un modelador paramétrico 3D de diseño asistido por ordenador (CAD) de uso general y una aplicación de modelado de información de construcción (BIM) con soporte para el método de elementos finitos (FEM).",
@@ -3120,7 +3122,7 @@
"Internal error: missing arguments in pmx_prepare_host_shared_dir": "Error interno: faltan argumentos en pmx_prepare_host_shared_dir",
"Internal subnet of the tunnel (change it only if it clashes)": "Subred interna del túnel (cámbiala solo si entra en conflicto)",
"Interrupted operation": "Operación interrumpida",
"Interrupted operation:": "Interrupted operación:",
"Interrupted operation:": "Operación interrumpida:",
"Interrupted stack operation found": "Se ha encontrado una operación de pila interrumpida",
"Invalid 'proxmox-ve' candidate (not 9.x or none). Please verify your repository configuration and network, then retry.": "Candidato 'proxmox-ve' no válido (ni 9.x ni ninguno). Verifique la configuración y la red de su repositorio y luego vuelva a intentarlo.",
"Invalid ALLOWED_HOSTS value": "Valor inválido ALLOWED_HOSTS",
@@ -3262,6 +3264,7 @@
"It asks for a system directory of the host:": "Pide un directorio del sistema del host:",
"It asks for capabilities or a relaxed confinement profile.": "Pide capacidades o un perfil de confinamiento relajado.",
"It asks to see the processes of the host.": "Pide ver los procesos del host.",
"It can be made on another storage of this host. This backup is temporary: it is deleted when the operation ends. The choice is remembered for the next operations of this application.": "Se puede hacer en otro almacenamiento de este host. Este backup es temporal: se borra cuando termina la operación. La elección se recuerda para las siguientes operaciones de esta aplicación.",
"It cannot be removed on its own, because the application would stop working: continuing removes the whole application.": "No se puede eliminar por sí solo, porque la aplicación dejaría de funcionar: continuar elimina toda la aplicación.",
"It cannot be removed on its own, because the application would stop working: continuing targets the whole application for removal.": "No se puede eliminar por separado, porque la aplicación dejaría de funcionar: si continúas, se eliminará toda la aplicación.",
"It is created empty; existing data is not migrated automatically.": "Se crea vacía; los datos existentes no se migran automáticamente.",
@@ -3856,6 +3859,7 @@
"Name or part of the description of the application": "Nombre o parte de la descripción de la aplicación",
"Name:": "Nombre:",
"Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "Las cuentas con nombre necesitan el modo privado. Detén el contenedor, pon public: false en /config/config.js, vuelve a arrancarlo y crea cada usuario con: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"Needed:": "Necesario:",
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "No se encontraron /etc/kernel/cmdline ni /etc/default/grub.",
"Nesting feature enabled": "Función de anidamiento habilitada",
"NetBIOS Service: RUNNING": "Servicio NetBIOS: EN EJECUCIÓN",
@@ -4141,6 +4145,7 @@
"No new Controller/NVMe entries were added.": "No se agregaron nuevas entradas de Controlador/NVMe.",
"No online node has a guest of this job.": "Ningún nodo en línea tiene una VM o CT de este trabajo.",
"No optimizations detected to uninstall.": "No se detectaron optimizaciones para desinstalar.",
"No other storage of this host that accepts backups has that much free space. Free space and try again; the application was not modified.": "Ningún otro almacenamiento de este host que acepte backups tiene tanto espacio libre. Libera espacio e inténtalo de nuevo; la aplicación no se ha modificado.",
"No partitions found on disk. Nothing was wiped.": "No se encontraron particiones en el disco. No se borró nada.",
"No password provided.": "No se proporcionó contraseña.",
"No paths available": "No hay rutas disponibles",
@@ -4220,7 +4225,6 @@
"Not an OVH server, skipping RTM installation": "No es un servidor OVH, se salta la instalación de RTM",
"Not currently mounted": "Actualmente no montado",
"Not currently mounted — skipping umount.": "Actualmente no montado: omitiendo el desmontaje.",
"Not enough free space for the backup": "No suficiente espacio libre para el backup",
"Not found": "No encontrado",
"Not found in the OCI archive:": "No se encuentra en el archivo OCI:",
"Not imported:": "No importado:",
@@ -5123,7 +5127,7 @@
"Restore source location": "Ubicación del origen de la restauración",
"Restored OCI applications": "Aplicaciones OCI restauradas",
"Restored config is on disk; reboot the host to apply.": "La configuración restaurada está en el disco; reinicie el host para aplicar.",
"Restored installation checked": "Instalación restaurada",
"Restored installation checked": "Instalación restaurada revisada",
"Restored original /bin/gzip": "Restaurado original /bin/gzip",
"Restored original /etc/vzdump.conf from .bak": "Restaurado /etc/vzdump.conf original desde .bak",
"Restored:": "Restaurado:",
@@ -5138,7 +5142,7 @@
"Restoring the backup": "Restaurar el backup",
"Restoring the original rpcbind service state...": "Restaurando el estado original del servicio rpcbind...",
"Restoring the previous Rclone configuration...": "Restaurar la configuración anterior de Rclone...",
"Restoring the previous backup...": "Restaurar el backup anterior...",
"Restoring the previous backup...": "Restaurando el backup anterior...",
"Restoring the previous state of the stack...": "Restaurando el estado anterior de la pila...",
"Restoring the stack records...": "Restaurando los registros de la pila...",
"Results will be saved automatically to:": "Los resultados se guardarán automáticamente en:",
@@ -5849,7 +5853,7 @@
"Stop uploading?": "¿Dejar de subir?",
"Stopped": "Detenido",
"Stopped and disabled": "Detenido y deshabilitado",
"Stopped at:": "Detuvo:",
"Stopped at:": "Se detuvo en:",
"Stopping Coral kernel modules...": "Deteniendo los módulos del kernel de Coral...",
"Stopping LXC": "Detener LXC",
"Stopping NFS services...": "Deteniendo los servicios NFS...",
@@ -5888,6 +5892,7 @@
"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",
"Storage for the backup of this operation": "Almacenamiento para el backup de esta operación",
"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",
@@ -6112,14 +6117,17 @@
"The application has been modified with the new options.": "La aplicación se ha modificado con las nuevas opciones.",
"The application needs it for": "La aplicación lo necesita para",
"The application stopped during its first start:": "La aplicación se detuvo durante su primer comienzo:",
"The application was not modified.": "La aplicación no se ha modificado.",
"The application was removed": "La aplicación se ha eliminado",
"The archive could not be extracted.": "No se pudo extraer el archivo.",
"The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "El directorio de destino del archivo está DENTRO de una de las rutas de las que va a hacer backup. Escribir el archivo allí copiaría el backup dentro de sí mismo, lo que produciría un archivo dañado o que crecería sin límite hasta llenar el disco.",
"The backup could not be identified; the image is not replaced": "El backup no se pudo identificar; la imagen no es reemplazada",
"The backup could not be started on node": "No se pudo iniciar el backup en el nodo",
"The backup did not pass its integrity check; creating it again...": "El backup no superó la comprobación de integridad; creándolo de nuevo...",
"The backup made before changing the application does not fit on the system disk of the host.": "El backup que se hace antes de modificar la aplicación no cabe en el disco del sistema del host.",
"The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "Los metadatos del backup se compararon con este host. Los siguientes elementos se OMITIRÁN para mantener el arranque seguro:",
"The backup of a member could not be identified": "No se pudo identificar el backup de un miembro",
"The backup of this operation cannot be read. If it is on another storage, make it available and recover the operation again:": "No se puede leer el backup de esta operación. Si está en otro almacenamiento, haz que esté disponible y recupera la operación de nuevo:",
"The backup runs as a Proxmox task; follow it in the task log of Proxmox.": "El backup se ejecuta como tarea de Proxmox; sígalo en el registro de tareas de Proxmox.",
"The backup storage does not exist:": "El almacenamiento de backups no existe:",
"The backup was altered; recovery blocked": "El backup fue alterado; la recuperación bloqueada",
@@ -6328,6 +6336,7 @@
"The new image changes the PostgreSQL major version; the data must be migrated before updating": "La nueva imagen cambia la versión principal de PostgreSQL; los datos deben ser migrados antes de actualizar",
"The new image could not be installed": "La nueva imagen no se puede instalar",
"The new image could not be installed:": "La nueva imagen no se puede instalar:",
"The new image does not fit on the storage where images are downloaded:": "La imagen nueva no cabe en el almacenamiento donde se descargan las imágenes:",
"The new image does not keep a required executable": "La nueva imagen no mantiene un ejecutable necesario",
"The new image is more recent than the minimum age set for scheduled updates; it is not installed yet.": "La imagen nueva es más reciente que la antigüedad mínima fijada para las actualizaciones programadas; todavía no se instala.",
"The new image requires additional persistent paths": "La nueva imagen requiere caminos persistentes adicionales",
@@ -6499,6 +6508,8 @@
"The startup differs from the declared Nextcloud adapter": "La startup difiere del adaptador Nextcloud declarado",
"The startup differs from the declared adapter": "La startup difiere del adaptador declarado",
"The staticfiles volume needs at least 1 GB": "El volumen de los ficheros estáticos necesita al menos 1 GB",
"The storage chosen for this backup cannot be written:": "No se puede escribir en el almacenamiento elegido para este backup:",
"The storage chosen for this backup has no room either:": "El almacenamiento elegido para este backup tampoco tiene espacio:",
"The storage does not accept backups:": "El almacenamiento no admite backups:",
"The storage has been removed and the disk unmounted.": "Se eliminó el almacenamiento y se desmontó el disco.",
"The storage has less than 40 GB free for the ROCm image.": "El almacenamiento tiene menos de 40 GB libres para la imagen de ROCm.",
@@ -6557,6 +6568,7 @@
"This application is under watchdog: it is restarted automatically when it crashes. Turn the watchdog off?": "Esta aplicación está bajo vigilancia: se reinicia automáticamente cuando falla. ¿Desactivar la vigilancia?",
"This archive does not contain a recognized backup layout.": "Este archivo no contiene un diseño de backup reconocido.",
"This backup does not contain any restorable paths.": "Este backup no contiene rutas restaurables.",
"This backup does not fit on the system disk of the host; it is made on the storage:": "Este backup no cabe en el disco del sistema del host; se hace en el almacenamiento:",
"This backup includes /etc/zfs/zpool.cache (host-specific ZFS state).": "Este backup incluye /etc/zfs/zpool.cache (estado ZFS específico del host).",
"This backup is encrypted.": "Este backup está cifrado.",
"This backup was taken on kernel": "Este backup se realizó en el kernel",
@@ -6912,7 +6924,7 @@
"Updated:": "Actualizado:",
"Updates all Proxmox and Debian packages": "Actualiza todos los paquetes de Proxmox y Debian",
"Updates and Packages Commands": "Comandos de actualizaciones y paquetes",
"Updates are not available yet for this application in this beta": "Aún no hay actualizaciones para esta aplicación en este beta",
"Updates are not available yet for this application in this beta": "Aún no hay actualizaciones para esta aplicación en esta beta",
"Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Aún no se dispone de actualizaciones en esta beta para aplicaciones que utilizan un contenedor privilegiado o configuración avanzada LXC",
"Updates file is empty or unreadable.": "El archivo de actualizaciones está vacío o es ilegible.",
"Updating": "Actualizando",
@@ -7185,7 +7197,7 @@
"Wait for each node to complete before starting next": "Espere a que se complete cada nodo antes de comenzar el siguiente",
"Waiting for Home Assistant OS...": "Esperando a Home Assistant OS...",
"Waiting for the FUSE mount:": "Esperando el montaje FUSE:",
"Waiting for the application to respond...": "Esperando que la aplicación responda...",
"Waiting for the application to respond...": "Esperando a que la aplicación responda...",
"Waiting for the initial Jellyfin configuration...": "Esperando la configuración inicial de Jellyfin...",
"Waiting for the network address...": "Esperando la dirección de la red...",
"Waiting for the password of the application...": "Esperando la contraseña de la aplicación...",
+25 -15
View File
@@ -408,27 +408,37 @@ compatibility need separate review, not silent removal of these requirements.
### HAOS One native OCI profile
The community image `qweritos/haos-one:latest` is installable with the laboratory
adaptation: unprivileged LXC, `ostype=unmanaged`, `nesting=1`, `keyctl=1`, and a
managed `/mnt/data` volume included in Proxmox backups (32 GB default, 16 GB
minimum). The image's entrypoint, command and stop signal remain imported from
OCI metadata. No Docker daemon or compatibility proxy is installed by ProxMenux;
the nested runtime belongs to the image itself.
The community image `ghcr.io/sevenrats/haos:latest`, derived from HAOS One, is
installable with the laboratory adaptation: unprivileged LXC, `ostype=unmanaged`,
`nesting=1`, `keyctl=1`, and a managed `/mnt/data` volume included in Proxmox
backups (32 GB default, 16 GB minimum). The image's entrypoint, command and stop
signal remain imported from OCI metadata. No Docker daemon or compatibility proxy
is installed by ProxMenux; the nested runtime belongs to the image itself. The
profile is offered unprivileged only.
The installer asks for explicit acknowledgement of the experimental profile and
its inner AppArmor limitations. First boot may pull several images. A bounded
20-minute check reports progress and requires healthy/supported Supervisor, the
real Core container rather than the landing page, running CLI/DNS/audio/multicast/
Observer containers, and working Core and Observer HTTP endpoints. The final
Core URL is detected on port 80 or 8123 instead of assumed. A first-boot failure
preserves the LXC, data and root-only console log for diagnosis; it never reports
success. If starting is declined, no unverified URL is printed.
Observer containers, and working Core and Observer HTTP endpoints. The recipe sets
`SETUP_PORT=8123`, so Core answers on port 8123 during onboarding and afterwards;
the check probes 8123 first and then 80. A first-boot failure preserves the LXC,
data and root-only console log for diagnosis; it never reports success. If
starting is declined, no unverified URL is printed.
This installer path and the rolling latest image remain runtime-unvalidated.
The historical lab evidence stays in the template; it is not a guarantee for
new releases. Full backup restore, outer-image replacement, USB and multicast
discovery still need explicit tests. See the upstream project:
https://github.com/qweritos/haos-one
The upstream Compose file asks Docker to restart the container, so the watchdog
is proposed by default: a shutdown asked from Home Assistant itself is followed
by a new start, as it is under Docker. A reboot asked from Home Assistant is
handled by Proxmox, and the DHCP address of the container is given back by
`remote/oci_dhcp_lease.py`.
Update and Modify work as for any other application: the installer runs the
same check on the rebuilt container, and a recovery restores the backup and
waits for that check too. Rebuilding with the data kept and recovering from the
backup are tested; the update to a newer image waits for the first one the
project publishes. Home Assistant Core, Supervisor and add-ons update themselves
from Home Assistant. See the upstream project:
https://github.com/sevenrats/docker-haos
### Native Compose resource limits
+138 -142
View File
@@ -11,12 +11,12 @@
"en_US": "Community single-container Home Assistant OS image"
},
"description": {
"en_US": "Community HAOS One image adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data."
"en_US": "Community Home Assistant OS image, derived from HAOS One, adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data."
},
"category": "smarthome",
"category_label": "IoT & Smart Home",
"author": "qweritos",
"developer": "qweritos",
"author": "sevenrats",
"developer": "sevenrats",
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp",
"thumbnail": null,
"screenshots": [],
@@ -26,42 +26,42 @@
],
"launch": {
"scheme": "http",
"port": 80,
"port": 8123,
"path": "/"
},
"website": "https://github.com/qweritos/haos-one",
"documentation": "https://github.com/qweritos/haos-one#readme",
"repository": "https://github.com/qweritos/haos-one",
"website": "https://github.com/sevenrats/docker-haos",
"documentation": "https://github.com/sevenrats/docker-haos#readme",
"repository": "https://github.com/sevenrats/docker-haos",
"tips": [
"This is a third-party community image and is not affiliated with Home Assistant.",
"The validated profile uses a managed /mnt/data volume; preserve it during every image replacement.",
"Port 80 is used by current Core releases in the validated profile; port 8123 may appear during setup or on older releases.",
"Home Assistant answers on port 8123, during the first setup and afterwards.",
"Experimental unprivileged profile with nesting and keyctl. Internal AppArmor profiles may not be available.",
"The first start downloads internal images. Success is confirmed only with Supervisor healthy/supported and a real Core, internal services and Observer running.",
"The web port is detected between 80 and 8123. If the check fails, the CT, data and log are kept for diagnosis; success is not confirmed.",
"/mnt/data: Proxmox volume of 32 GB by default, minimum 16 GB, included in backup. Full restore and external image update pending testing."
"If the check of the first start fails, the CT, data and log are kept for diagnosis; success is not confirmed.",
"/mnt/data: Proxmox volume of 32 GB by default, minimum 16 GB, included in backup and kept when the application is updated or modified."
],
"mini_changelog": [],
"display_version": null,
"updated_at": "2026-08-20"
"updated_at": "2026-09-23"
},
"source": {
"provider": "qweritos",
"repository": "https://github.com/qweritos/haos-one",
"revision": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede",
"image_repository_url": "https://hub.docker.com/r/qweritos/haos-one",
"readme_pushed_at": "2026-08-20T12:29:50Z",
"compose_sha256": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede",
"generated_at": "2026-09-12T15:54:10+00:00",
"provider": "sevenrats",
"repository": "https://github.com/sevenrats/docker-haos",
"revision": "e7fe7f6728b84763b76658ab5a9669f50cb029b0",
"image_repository_url": "https://github.com/sevenrats/docker-haos/pkgs/container/haos",
"readme_pushed_at": "2026-09-23T15:30:10Z",
"compose_sha256": "4301a2454ca1a5ff58d596dc1f2f99eba52ab77a307f394d81e6d5f66f824111",
"generated_at": "2026-10-07T00:00:00+00:00",
"default_branch": "master"
},
"container_contract": {
"service_name": "haos-one",
"container_name": "haos-one",
"image": {
"reference": "qweritos/haos-one:latest",
"registry": "docker.io",
"repository": "qweritos/haos-one",
"reference": "ghcr.io/sevenrats/haos:latest",
"registry": "ghcr.io",
"repository": "sevenrats/haos",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
@@ -72,7 +72,7 @@
"example": "1",
"required": true,
"sensitive": false,
"source": "docker-compose",
"source": "image-default",
"prompt_user": false
},
{
@@ -80,16 +80,31 @@
"example": "auto",
"required": true,
"sensitive": false,
"source": "docker-compose",
"source": "image-default",
"prompt_user": false
},
{
"name": "DEV",
"example": "0",
"name": "SETUP_PORT",
"example": "8123",
"required": true,
"sensitive": false,
"source": "docker-compose",
"source": "image-default",
"prompt_user": false
},
{
"name": "ENABLE_IPV6",
"example": "1",
"required": true,
"sensitive": false,
"source": "image-default",
"prompt_user": false
},
{
"name": "TZ",
"example": "Europe/Madrid",
"required": true,
"sensitive": false,
"source": "upstream-compose-environment"
}
],
"volumes": [
@@ -111,8 +126,8 @@
],
"ports": [
{
"container_port": 80,
"published_example": 80,
"container_port": 8123,
"published_example": 8123,
"protocol": "tcp",
"required": true,
"proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
@@ -126,9 +141,9 @@
}
],
"related_services": [],
"restart": null,
"stop_grace_period": null,
"original_compose": "name: haos-one\nservices:\n haos-one:\n image: qweritos/haos-one:latest\n privileged: true\n environment:\n USE_DUMMY_NETWORKMANAGER: '1'\n USE_UDEV_SHIM: auto\n DEV: '0'\n ports:\n - 80:80\n - 4357:4357\n volumes:\n - haos-data:/mnt/data\n stop_signal: SIGRTMIN+3\nvolumes:\n haos-data: {}\n"
"restart": "unless-stopped",
"stop_grace_period": "2m",
"original_compose": "name: haos-one\nservices:\n haos-one:\n image: ghcr.io/sevenrats/haos:latest\n privileged: true\n restart: unless-stopped\n stop_grace_period: 2m\n environment:\n USE_DUMMY_NETWORKMANAGER: '1'\n USE_UDEV_SHIM: auto\n SETUP_PORT: '8123'\n ENABLE_IPV6: '1'\n TZ: Europe/Madrid\n ports:\n - 8123:8123\n - 4357:4357\n volumes:\n - haos-data:/mnt/data\n stop_signal: SIGRTMIN+3\nvolumes:\n haos-data: {}\n"
},
"compose_stack": {
"project_name": "haos-one",
@@ -139,7 +154,7 @@
"services": [
{
"name": "haos-one",
"image": "qweritos/haos-one:latest",
"image": "ghcr.io/sevenrats/haos:latest",
"is_main": true,
"role": "frontend",
"vmid_offset": 0,
@@ -147,15 +162,19 @@
"frontend_network": true,
"private_network": false,
"compose": {
"image": "qweritos/haos-one:latest",
"image": "ghcr.io/sevenrats/haos:latest",
"privileged": true,
"restart": "unless-stopped",
"stop_grace_period": "2m",
"environment": {
"USE_DUMMY_NETWORKMANAGER": "1",
"USE_UDEV_SHIM": "auto",
"DEV": "0"
"SETUP_PORT": "8123",
"ENABLE_IPV6": "1",
"TZ": "Europe/Madrid"
},
"ports": [
"80:80",
"8123:8123",
"4357:4357"
],
"volumes": [
@@ -230,9 +249,9 @@
{
"label": "Home Assistant",
"scheme": "http",
"port": 80,
"port": 8123,
"path": "/",
"source": "validated-current-core-profile"
"source": "image-setup-port"
},
{
"label": "Home Assistant Observer",
@@ -269,7 +288,7 @@
"nesting=1",
"keyctl=1"
],
"shutdown_timeout_seconds": 60
"shutdown_timeout_seconds": 120
},
"image_metadata_policy": {
"entrypoint": "import-from-oci-image",
@@ -290,7 +309,7 @@
},
{
"id": "nested-runtime-features",
"upstream_behavior": "haos-one starts systemd, Docker, Supervisor and nested Home Assistant containers.",
"upstream_behavior": "The image starts systemd, Docker, Supervisor and nested Home Assistant containers.",
"native_lxc_behavior": "Use an unprivileged LXC with nesting=1 and keyctl=1.",
"reason": "The inner Docker and containerd runtime require nested namespaces and keyring support.",
"behavioral_impact": "The LXC remains unprivileged; no AppArmor unconfined override was required.",
@@ -310,13 +329,21 @@
"native_lxc_behavior": "Use an upstream image digest that contains rewrite_create_request_payload; do not patch files locally.",
"reason": "Older builds fail to start Core and plug-ins with kernel.domainname permission denied.",
"behavioral_impact": "Uses the project-provided compatibility behavior unchanged.",
"validation": "passed-source-revision-5bffb27-and-runtime"
"validation": "passed-source-revision-e7fe7f6-and-runtime"
},
{
"id": "shutdown-grace",
"upstream_behavior": "The Compose file gives systemd two minutes to stop Home Assistant and its Docker daemon.",
"native_lxc_behavior": "Shut the LXC down with a timeout of 120 seconds.",
"reason": "A shorter wait ends the nested containers before they have written their state.",
"behavioral_impact": "No application behavior is changed.",
"validation": "passed-clean-shutdown-and-start"
}
],
"laboratory_contract": {
"requirements": {
"proxmox_min_version": "9.2",
"validated_proxmox_version": "9.2.11",
"validated_proxmox_version": "9.2.20",
"commands": [
"pct",
"pvesm",
@@ -358,7 +385,9 @@
"environment_defaults": {
"USE_DUMMY_NETWORKMANAGER": "1",
"USE_UDEV_SHIM": "auto",
"SETUP_PORT": null,
"SETUP_PORT": "8123",
"ENABLE_IPV6": "1",
"TZ": "UTC",
"DEV": "0"
},
"preserve_without_override": [
@@ -428,7 +457,7 @@
"type": "device-list",
"required": false,
"default": [],
"description": "Optional passthrough of Zigbee, Z-Wave, Bluetooth or other coordinators; not validated in this profile."
"description": "Optional passthrough of Zigbee, Z-Wave, Bluetooth or other coordinators."
}
},
"deployment": {
@@ -457,9 +486,9 @@
],
"ports": [
{
"port": 80,
"port": 8123,
"protocol": "tcp",
"purpose": "validated-home-assistant-web-for-core-2026.9.1"
"purpose": "home-assistant-web-fixed-by-SETUP_PORT"
},
{
"port": 4357,
@@ -469,10 +498,10 @@
],
"conditional_ports": [
{
"port": 8123,
"port": 80,
"protocol": "tcp",
"condition": "older-core-or-landing-page-stage-or-explicit-SETUP_PORT",
"warning": "Do not assume port 8123 remains active after onboarding on Home Assistant Core 2026.8 or newer."
"condition": "SETUP_PORT-emptied-on-core-2026.8-or-newer",
"warning": "The profile sets SETUP_PORT=8123. Port 80 is only used when that variable is emptied."
}
]
},
@@ -495,16 +524,16 @@
"data_role": "persistent-user-and-supervisor-state",
"restart_test": {
"status": "passed",
"method": "write-marker-shutdown-start-verify-hash-and-services",
"marker_sha256": "6c3762cede4f86dd5eeae16eff1067e188e9b47646f48ccc268c077711bffbd0",
"volume_before": "local-lvm:vm-128-disk-1",
"volume_after": "local-lvm:vm-128-disk-1",
"home_assistant_after_restart": "passed-http-200",
"method": "write-marker-reboot-shutdown-start-verify-hash-and-services",
"marker_sha256": "ddcc5557ff607ffc6ef0cdc54484f213c28f65b5ebde1c02cd8eefd2fa6b7c5e",
"volume_before": "local-lvm:vm-121-disk-1",
"volume_after": "local-lvm:vm-121-disk-1",
"home_assistant_after_restart": "passed-http-port-8123",
"inner_containers_after_restart": "passed-all-running"
},
"native_backup": {
"expected": "included-by-mp0-backup-1",
"full-vzdump-restore-test": "pending"
"full-vzdump-restore-test": "passed"
}
},
"installation_steps": [
@@ -516,7 +545,7 @@
"Attach a host-managed network interface to the selected bridge.",
"Preserve the imported /entrypoint.sh /sbin/init command and SIGRTMIN+3 stop signal.",
"Start the LXC and allow several minutes for the first pull of Supervisor, Core and plug-ins.",
"Discover the assigned address and probe both port 80 and port 8123.",
"Discover the assigned address and probe port 8123, then port 80.",
"Require Supervisor healthy=true and supported=true before reporting success.",
"Verify Core, CLI, DNS, audio, multicast and observer containers are running.",
"Report non-blocking HAOS resolution issues separately."
@@ -526,8 +555,8 @@
"name": "home-assistant-web",
"type": "http",
"candidate_urls": [
"http://${container_ip}/",
"http://${container_ip}:8123/"
"http://${container_ip}:8123/",
"http://${container_ip}/"
],
"expected_status": 200,
"startup_grace_seconds": 300
@@ -551,14 +580,14 @@
"update_strategy": {
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
"outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data",
"outer_oci_update_validation": "pending",
"outer_oci_update_validation": "rebuild-and-recovery-passed-newer-image-pending",
"required_before_update": [
"create-and-download-a-full-home-assistant-backup",
"create-a-native-proxmox-backup-including-mp0",
"record-current-image-digest",
"verify-new-image-contains-required-compatibility-rules"
],
"warning": "Do not claim in-place OCI image updates are validated until rootfs replacement and rollback have been tested without losing the managed /mnt/data volume."
"warning": "Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed."
},
"security": {
"image_is_official_home_assistant": false,
@@ -574,37 +603,30 @@
"Do not expose Home Assistant or Observer directly to the Internet without an authenticated reverse proxy and normal Home Assistant hardening."
]
},
"incompatible_builds": [
{
"reference": "docker.io/qweritos/haos-one:18.1-amd64",
"digest": "sha256:164d579522da0875c3eaa64283c5f7b875afae2013f1b878c86f80c1eac286af",
"status": "failed-native-unprivileged-oci",
"reason": "The bundled compatibility proxy does not rewrite nested container create requests, causing kernel.domainname permission denied for Core and plug-ins.",
"do_not_use_for_this_profile": true
}
],
"incompatible_builds": [],
"notes": [
"HAOS One is a community image, not an official Home Assistant image.",
"This is a community image derived from HAOS One, not an official Home Assistant image.",
"This is not a simple Home Assistant container: the outer OCI runs systemd, Docker, Supervisor, Core and add-ons.",
"The direct Proxmox OCI path removes one extra Docker layer compared with the upstream-tested Proxmox LXC plus Docker plus haos-one deployment.",
"Proxmox privileged OCI import failed with setgid(0) Invalid argument; the validated profile is unprivileged and functional.",
"The direct Proxmox OCI path removes one extra Docker layer compared with a Proxmox LXC plus Docker plus this image deployment.",
"The profile is offered unprivileged only: Supervisor, Core, add-ons and USB devices work without privileges.",
"Proxmox must use ostype=unmanaged because ID=haos is not recognized by guest distribution detection.",
"The official /mnt/data path is a Proxmox-managed volume with backup=1, not a shared host bind.",
"A clean first boot used approximately 8.6G under /mnt/data after Core and Matter installation; 32G is the recommended starting size.",
"Home Assistant Core 2026.9.1 listens on port 80 in this image. Port 8123 was present only during the landing-page stage and must not be hard-coded.",
"A clean first boot used approximately 4.7G under /mnt/data; 32G is the recommended starting size.",
"The image sets SETUP_PORT=8123: Home Assistant Core keeps port 8123 after onboarding and port 80 stays closed.",
"Supervisor reports healthy=true and supported=true even though several host-oriented HAOS units cannot run inside LXC.",
"AppArmor profile loading inside HAOS fails in the current unprivileged LXC; this is a security limitation and must remain visible to users.",
"The current tag 18 works because it includes the upstream Docker API create-request rewrite; tag 18.1-amd64 does not.",
"Images are tagged by Home Assistant OS version, such as 18.3; latest is the newest build the project published. Builds are started by hand, not on every Home Assistant OS release.",
"Mutable tags must be resolved to an immutable architecture-specific digest before installation.",
"Restart persistence is validated. Full vzdump restore, outer OCI image replacement, USB passthrough and real multicast discovery remain pending."
"Restart persistence, USB passthrough, rebuilding the container with its data kept and recovery from its backup are validated. The update to a newer image and real multicast discovery remain pending."
],
"references": {
"project": "https://github.com/qweritos/haos-one",
"docker_image": "https://hub.docker.com/r/qweritos/haos-one",
"dockerfile": "https://github.com/qweritos/haos-one/blob/master/Dockerfile",
"compatibility_documentation": "https://github.com/qweritos/haos-one/blob/master/docs/haos-one-compat.md",
"project": "https://github.com/sevenrats/docker-haos",
"docker_image": "https://github.com/sevenrats/docker-haos/pkgs/container/haos",
"dockerfile": "https://github.com/sevenrats/docker-haos/blob/master/Dockerfile",
"compatibility_documentation": "https://github.com/sevenrats/docker-haos/blob/master/docs/haos-compat.md",
"home_assistant_os_releases": "https://github.com/home-assistant/operating-system/releases",
"proxmox_pct_manual": "https://pve.proxmox.com/pve-docs/pct.1.html"
"proxmox_pct_manual": "https://pve.proxmox.com/pve-docs/pct.1.html",
"derived_from": "https://github.com/qweritos/haos-one"
}
},
"installer_profile": {
@@ -634,22 +656,25 @@
"volumes"
],
"untranslated_blockers": [],
"policy": "Native laboratory adaptations implemented. Rolling latest first boot, backup restore and outer-image upgrades still require runtime validation."
"policy": "Native laboratory adaptations implemented. Installation, modification with the data kept and recovery from the backup are validated; the update to a newer image waits for the first one the project publishes."
},
"validation": {
"schema": "passed-at-generation",
"validated_architecture": "amd64",
"arm64": "supported-upstream-not-yet-validated-in-proxmenux-laboratory",
"arm64": "published-upstream-not-yet-validated-in-proxmenux-laboratory",
"validated_profile": {
"id": "pve55-haos-one-native-oci",
"validated_on": "2026-09-08",
"validation_status": "passed-functional-restart-persistence-with-known-nonblocking-issues",
"proxmox_version": "9.2.11",
"container_id": 128,
"haos_version": "18.2",
"home_assistant_core": "2026.9.1",
"supervisor": "2026.09.0",
"matter_server": "9.2.0",
"id": "pve55-haos-native-oci",
"validated_on": "2026-10-07",
"validation_status": "passed-install-restart-persistence-with-known-nonblocking-issues",
"proxmox_version": "9.2.20",
"kernel": "7.0.14-19-pve",
"container_id": 121,
"image_reference": "ghcr.io/sevenrats/haos:latest",
"image_digest": "sha256:5b499b4782e7547fdce746ebd9c37b0de802626f4db7b80b24660da2f3442ba7",
"image_created": "2026-09-23",
"haos_version": "18.3",
"home_assistant_core": "2026.9.4",
"supervisor": "2026.09.3",
"resources": {
"cores": 4,
"memory_mb": 4096,
@@ -658,29 +683,33 @@
"data": "local-lvm:32G,mp=/mnt/data,backup=1"
},
"observed_after_first_boot": {
"data_used": "approximately-8.6G",
"memory_used_bytes": 1368289280,
"rootfs_used_bytes": 624910336
"data_used": "approximately-4.7G",
"first_boot_seconds": 90
},
"validation": {
"installer_default_mode": "passed",
"oci_import": "passed-with-ostype-unmanaged",
"image_entrypoint": "passed-/entrypoint.sh-/sbin/init",
"halt_signal": "passed-SIGRTMIN+3",
"nested_docker": "passed-overlayfs",
"compatibility_proxy": "passed",
"home_assistant_web": "passed-http-200-port-80",
"unprivileged": "passed",
"home_assistant_web": "passed-http-port-8123",
"port_80": "closed",
"observer": "passed-http-200-port-4357",
"supervisor_healthy": true,
"supervisor_supported": true,
"core_running": true,
"plugins_running": true,
"matter_addon_running": true,
"restart_persistence": "passed",
"notes_link_port": 8123,
"reboot_from_home_assistant": "passed-container-back-with-its-dhcp-address",
"shutdown_from_home_assistant": "passed-started-again-by-the-watchdog",
"shutdown_from_proxmox": "passed-25-seconds-left-stopped-by-the-watchdog",
"restart_persistence": "passed-marker-in-/mnt/data-unchanged",
"usb_passthrough": "passed-maintainer-test-unprivileged",
"native_volume_backup_flag": "passed-mp0-backup-1",
"full_vzdump_restore": "pending",
"outer_image_upgrade": "pending",
"usb_passthrough": "pending",
"multicast_discovery": "pending-functional-device-test"
"full_vzdump_restore": "passed-recovery-of-an-interrupted-modification",
"outer_image_upgrade": "pending-no-newer-image-published",
"multicast_discovery": "pending-functional-device-test",
"modification": "passed-cores-changed-data-kept-watchdog-kept"
},
"known_nonblocking_resolution_issues": [
"haos-mglru.service-failed",
@@ -690,43 +719,10 @@
"dummy-networkmanager-may-report-ipv4-or-dns-diagnostics"
]
},
"source_profile": "haos-one-oci.json",
"service_health": "passed-observed-2026-09-14",
"restart_persistence": "passed-2026-09-14",
"backup_restore": "pending",
"update_preserves_data": "pending",
"latest_runtime_observation": {
"date": "2026-09-14",
"vmid": 100,
"architecture": "amd64",
"proxmox": "9.2.18",
"kernel": "7.0.14-16-pve",
"core_version": "2026.9.2",
"supervisor_version": "2026.09.0",
"image_reference": "qweritos/haos-one:latest",
"outer_image_digest_verified": false,
"supervisor_healthy": true,
"supervisor_supported": true,
"required_internal_containers_running": true,
"matter_server_healthy_after_restart": true,
"restart_method": "pct shutdown --timeout 90; pct start",
"managed_data_volume_unchanged": true,
"data_volume_gb": 32,
"data_volume_backup_flag": true,
"configuration_yaml_sha256_unchanged": true,
"temporary_marker_sha256_unchanged": true,
"temporary_marker_removed": true,
"core_http_port": 80,
"observer_http_port": 4357,
"lan_address_unchanged": true,
"limitations": [
"Supervisor AppArmor profile could not be loaded inside this LXC.",
"Host kernel config/debug mounts, auditd and MGLRU units fail in this LXC.",
"PulseAudio warning appeared during boot; hassio_audio subsequently running, audio functionality not tested.",
"Core translation-domain warnings observed; no functionality test of associated integrations.",
"Full vzdump restore and outer OCI image replacement remain untested."
]
}
"service_health": "passed-observed-2026-10-07",
"restart_persistence": "passed-2026-10-07",
"backup_restore": "passed-2026-10-07",
"update_preserves_data": "passed-by-modification-2026-10-07"
},
"lifecycle": {
"update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/mnt/data",
@@ -741,14 +737,14 @@
"validated_workflows": {
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
"outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data",
"outer_oci_update_validation": "pending",
"outer_oci_update_validation": "rebuild-and-recovery-passed-newer-image-pending",
"required_before_update": [
"create-and-download-a-full-home-assistant-backup",
"create-a-native-proxmox-backup-including-mp0",
"record-current-image-digest",
"verify-new-image-contains-required-compatibility-rules"
],
"warning": "Do not claim in-place OCI image updates are validated until rootfs replacement and rollback have been tested without losing the managed /mnt/data volume."
"warning": "Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed."
}
}
}
+18 -2
View File
@@ -382,7 +382,10 @@
"scheme": "http",
"port": 5055,
"path": "/api/v1/settings/public",
"timeout_seconds": 360
"timeout_seconds": 360,
"request_timeout_seconds": 5,
"verify_tls": false,
"stability_seconds": 0
},
"security": {
"options": {
@@ -391,7 +394,20 @@
}
},
"adaptations": [
"Compose cap_drop ALL -> lxc.cap.drop reset and lxc.cap.keep none; no-new-privileges -> lxc.no_new_privs 1."
"Compose cap_drop ALL -> lxc.cap.drop reset and lxc.cap.keep none; no-new-privileges -> lxc.no_new_privs in the include of the container.",
"The configuration volume is given to the user the image runs as, uid 1000."
],
"volume_owner": {
"uid": 1000,
"gid": 1000
},
"volume_preparations": [
{
"container_path": "/app/config",
"remove_lost_found": true,
"owner_strategy": "mapped-application-user",
"only_when_mount_type": "managed-volume"
}
]
}
},
+138 -142
View File
@@ -11,12 +11,12 @@
"en_US": "Community single-container Home Assistant OS image"
},
"description": {
"en_US": "Community HAOS One image adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data."
"en_US": "Community Home Assistant OS image, derived from HAOS One, adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data."
},
"category": "smarthome",
"category_label": "IoT & Smart Home",
"author": "qweritos",
"developer": "qweritos",
"author": "sevenrats",
"developer": "sevenrats",
"icon": null,
"thumbnail": null,
"screenshots": [],
@@ -26,42 +26,42 @@
],
"launch": {
"scheme": "http",
"port": 80,
"port": 8123,
"path": "/"
},
"website": "https://github.com/qweritos/haos-one",
"documentation": "https://github.com/qweritos/haos-one#readme",
"repository": "https://github.com/qweritos/haos-one",
"website": "https://github.com/sevenrats/docker-haos",
"documentation": "https://github.com/sevenrats/docker-haos#readme",
"repository": "https://github.com/sevenrats/docker-haos",
"tips": [
"This is a third-party community image and is not affiliated with Home Assistant.",
"The validated profile uses a managed /mnt/data volume; preserve it during every image replacement.",
"Port 80 is used by current Core releases in the validated profile; port 8123 may appear during setup or on older releases.",
"Home Assistant answers on port 8123, during the first setup and afterwards.",
"Experimental unprivileged profile with nesting and keyctl. Internal AppArmor profiles may not be available.",
"The first start downloads internal images. Success is confirmed only with Supervisor healthy/supported and a real Core, internal services and Observer running.",
"The web port is detected between 80 and 8123. If the check fails, the CT, data and log are kept for diagnosis; success is not confirmed.",
"/mnt/data: Proxmox volume of 32 GB by default, minimum 16 GB, included in backup. Full restore and external image update pending testing."
"If the check of the first start fails, the CT, data and log are kept for diagnosis; success is not confirmed.",
"/mnt/data: Proxmox volume of 32 GB by default, minimum 16 GB, included in backup and kept when the application is updated or modified."
],
"mini_changelog": [],
"display_version": null,
"updated_at": "2026-08-20"
"updated_at": "2026-09-23"
},
"source": {
"provider": "qweritos",
"repository": "https://github.com/qweritos/haos-one",
"revision": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede",
"image_repository_url": "https://hub.docker.com/r/qweritos/haos-one",
"readme_pushed_at": "2026-08-20T12:29:50Z",
"compose_sha256": "b6dd721c4bf06e6f90ee1f8099a82b892e18af64a63015fe5fdfbfd02d538ede",
"generated_at": "2026-09-12T15:54:10+00:00",
"provider": "sevenrats",
"repository": "https://github.com/sevenrats/docker-haos",
"revision": "e7fe7f6728b84763b76658ab5a9669f50cb029b0",
"image_repository_url": "https://github.com/sevenrats/docker-haos/pkgs/container/haos",
"readme_pushed_at": "2026-09-23T15:30:10Z",
"compose_sha256": "4301a2454ca1a5ff58d596dc1f2f99eba52ab77a307f394d81e6d5f66f824111",
"generated_at": "2026-10-07T00:00:00+00:00",
"default_branch": "master"
},
"container_contract": {
"service_name": "haos-one",
"container_name": "haos-one",
"image": {
"reference": "qweritos/haos-one:latest",
"registry": "docker.io",
"repository": "qweritos/haos-one",
"reference": "ghcr.io/sevenrats/haos:latest",
"registry": "ghcr.io",
"repository": "sevenrats/haos",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
@@ -72,7 +72,7 @@
"example": "1",
"required": true,
"sensitive": false,
"source": "docker-compose",
"source": "image-default",
"prompt_user": false
},
{
@@ -80,16 +80,31 @@
"example": "auto",
"required": true,
"sensitive": false,
"source": "docker-compose",
"source": "image-default",
"prompt_user": false
},
{
"name": "DEV",
"example": "0",
"name": "SETUP_PORT",
"example": "8123",
"required": true,
"sensitive": false,
"source": "docker-compose",
"source": "image-default",
"prompt_user": false
},
{
"name": "ENABLE_IPV6",
"example": "1",
"required": true,
"sensitive": false,
"source": "image-default",
"prompt_user": false
},
{
"name": "TZ",
"example": "Europe/Madrid",
"required": true,
"sensitive": false,
"source": "upstream-compose-environment"
}
],
"volumes": [
@@ -111,8 +126,8 @@
],
"ports": [
{
"container_port": 80,
"published_example": 80,
"container_port": 8123,
"published_example": 8123,
"protocol": "tcp",
"required": true,
"proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
@@ -126,9 +141,9 @@
}
],
"related_services": [],
"restart": null,
"stop_grace_period": null,
"original_compose": "name: haos-one\nservices:\n haos-one:\n image: qweritos/haos-one:latest\n privileged: true\n environment:\n USE_DUMMY_NETWORKMANAGER: '1'\n USE_UDEV_SHIM: auto\n DEV: '0'\n ports:\n - 80:80\n - 4357:4357\n volumes:\n - haos-data:/mnt/data\n stop_signal: SIGRTMIN+3\nvolumes:\n haos-data: {}\n"
"restart": "unless-stopped",
"stop_grace_period": "2m",
"original_compose": "name: haos-one\nservices:\n haos-one:\n image: ghcr.io/sevenrats/haos:latest\n privileged: true\n restart: unless-stopped\n stop_grace_period: 2m\n environment:\n USE_DUMMY_NETWORKMANAGER: '1'\n USE_UDEV_SHIM: auto\n SETUP_PORT: '8123'\n ENABLE_IPV6: '1'\n TZ: Europe/Madrid\n ports:\n - 8123:8123\n - 4357:4357\n volumes:\n - haos-data:/mnt/data\n stop_signal: SIGRTMIN+3\nvolumes:\n haos-data: {}\n"
},
"compose_stack": {
"project_name": "haos-one",
@@ -139,7 +154,7 @@
"services": [
{
"name": "haos-one",
"image": "qweritos/haos-one:latest",
"image": "ghcr.io/sevenrats/haos:latest",
"is_main": true,
"role": "frontend",
"vmid_offset": 0,
@@ -147,15 +162,19 @@
"frontend_network": true,
"private_network": false,
"compose": {
"image": "qweritos/haos-one:latest",
"image": "ghcr.io/sevenrats/haos:latest",
"privileged": true,
"restart": "unless-stopped",
"stop_grace_period": "2m",
"environment": {
"USE_DUMMY_NETWORKMANAGER": "1",
"USE_UDEV_SHIM": "auto",
"DEV": "0"
"SETUP_PORT": "8123",
"ENABLE_IPV6": "1",
"TZ": "Europe/Madrid"
},
"ports": [
"80:80",
"8123:8123",
"4357:4357"
],
"volumes": [
@@ -230,9 +249,9 @@
{
"label": "Home Assistant",
"scheme": "http",
"port": 80,
"port": 8123,
"path": "/",
"source": "validated-current-core-profile"
"source": "image-setup-port"
},
{
"label": "Home Assistant Observer",
@@ -269,7 +288,7 @@
"nesting=1",
"keyctl=1"
],
"shutdown_timeout_seconds": 60
"shutdown_timeout_seconds": 120
},
"image_metadata_policy": {
"entrypoint": "import-from-oci-image",
@@ -290,7 +309,7 @@
},
{
"id": "nested-runtime-features",
"upstream_behavior": "haos-one starts systemd, Docker, Supervisor and nested Home Assistant containers.",
"upstream_behavior": "The image starts systemd, Docker, Supervisor and nested Home Assistant containers.",
"native_lxc_behavior": "Use an unprivileged LXC with nesting=1 and keyctl=1.",
"reason": "The inner Docker and containerd runtime require nested namespaces and keyring support.",
"behavioral_impact": "The LXC remains unprivileged; no AppArmor unconfined override was required.",
@@ -310,13 +329,21 @@
"native_lxc_behavior": "Use an upstream image digest that contains rewrite_create_request_payload; do not patch files locally.",
"reason": "Older builds fail to start Core and plug-ins with kernel.domainname permission denied.",
"behavioral_impact": "Uses the project-provided compatibility behavior unchanged.",
"validation": "passed-source-revision-5bffb27-and-runtime"
"validation": "passed-source-revision-e7fe7f6-and-runtime"
},
{
"id": "shutdown-grace",
"upstream_behavior": "The Compose file gives systemd two minutes to stop Home Assistant and its Docker daemon.",
"native_lxc_behavior": "Shut the LXC down with a timeout of 120 seconds.",
"reason": "A shorter wait ends the nested containers before they have written their state.",
"behavioral_impact": "No application behavior is changed.",
"validation": "passed-clean-shutdown-and-start"
}
],
"laboratory_contract": {
"requirements": {
"proxmox_min_version": "9.2",
"validated_proxmox_version": "9.2.11",
"validated_proxmox_version": "9.2.20",
"commands": [
"pct",
"pvesm",
@@ -358,7 +385,9 @@
"environment_defaults": {
"USE_DUMMY_NETWORKMANAGER": "1",
"USE_UDEV_SHIM": "auto",
"SETUP_PORT": null,
"SETUP_PORT": "8123",
"ENABLE_IPV6": "1",
"TZ": "UTC",
"DEV": "0"
},
"preserve_without_override": [
@@ -428,7 +457,7 @@
"type": "device-list",
"required": false,
"default": [],
"description": "Optional passthrough of Zigbee, Z-Wave, Bluetooth or other coordinators; not validated in this profile."
"description": "Optional passthrough of Zigbee, Z-Wave, Bluetooth or other coordinators."
}
},
"deployment": {
@@ -457,9 +486,9 @@
],
"ports": [
{
"port": 80,
"port": 8123,
"protocol": "tcp",
"purpose": "validated-home-assistant-web-for-core-2026.9.1"
"purpose": "home-assistant-web-fixed-by-SETUP_PORT"
},
{
"port": 4357,
@@ -469,10 +498,10 @@
],
"conditional_ports": [
{
"port": 8123,
"port": 80,
"protocol": "tcp",
"condition": "older-core-or-landing-page-stage-or-explicit-SETUP_PORT",
"warning": "Do not assume port 8123 remains active after onboarding on Home Assistant Core 2026.8 or newer."
"condition": "SETUP_PORT-emptied-on-core-2026.8-or-newer",
"warning": "The profile sets SETUP_PORT=8123. Port 80 is only used when that variable is emptied."
}
]
},
@@ -495,16 +524,16 @@
"data_role": "persistent-user-and-supervisor-state",
"restart_test": {
"status": "passed",
"method": "write-marker-shutdown-start-verify-hash-and-services",
"marker_sha256": "6c3762cede4f86dd5eeae16eff1067e188e9b47646f48ccc268c077711bffbd0",
"volume_before": "local-lvm:vm-128-disk-1",
"volume_after": "local-lvm:vm-128-disk-1",
"home_assistant_after_restart": "passed-http-200",
"method": "write-marker-reboot-shutdown-start-verify-hash-and-services",
"marker_sha256": "ddcc5557ff607ffc6ef0cdc54484f213c28f65b5ebde1c02cd8eefd2fa6b7c5e",
"volume_before": "local-lvm:vm-121-disk-1",
"volume_after": "local-lvm:vm-121-disk-1",
"home_assistant_after_restart": "passed-http-port-8123",
"inner_containers_after_restart": "passed-all-running"
},
"native_backup": {
"expected": "included-by-mp0-backup-1",
"full-vzdump-restore-test": "pending"
"full-vzdump-restore-test": "passed"
}
},
"installation_steps": [
@@ -516,7 +545,7 @@
"Attach a host-managed network interface to the selected bridge.",
"Preserve the imported /entrypoint.sh /sbin/init command and SIGRTMIN+3 stop signal.",
"Start the LXC and allow several minutes for the first pull of Supervisor, Core and plug-ins.",
"Discover the assigned address and probe both port 80 and port 8123.",
"Discover the assigned address and probe port 8123, then port 80.",
"Require Supervisor healthy=true and supported=true before reporting success.",
"Verify Core, CLI, DNS, audio, multicast and observer containers are running.",
"Report non-blocking HAOS resolution issues separately."
@@ -526,8 +555,8 @@
"name": "home-assistant-web",
"type": "http",
"candidate_urls": [
"http://${container_ip}/",
"http://${container_ip}:8123/"
"http://${container_ip}:8123/",
"http://${container_ip}/"
],
"expected_status": 200,
"startup_grace_seconds": 300
@@ -551,14 +580,14 @@
"update_strategy": {
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
"outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data",
"outer_oci_update_validation": "pending",
"outer_oci_update_validation": "rebuild-and-recovery-passed-newer-image-pending",
"required_before_update": [
"create-and-download-a-full-home-assistant-backup",
"create-a-native-proxmox-backup-including-mp0",
"record-current-image-digest",
"verify-new-image-contains-required-compatibility-rules"
],
"warning": "Do not claim in-place OCI image updates are validated until rootfs replacement and rollback have been tested without losing the managed /mnt/data volume."
"warning": "Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed."
},
"security": {
"image_is_official_home_assistant": false,
@@ -574,37 +603,30 @@
"Do not expose Home Assistant or Observer directly to the Internet without an authenticated reverse proxy and normal Home Assistant hardening."
]
},
"incompatible_builds": [
{
"reference": "docker.io/qweritos/haos-one:18.1-amd64",
"digest": "sha256:164d579522da0875c3eaa64283c5f7b875afae2013f1b878c86f80c1eac286af",
"status": "failed-native-unprivileged-oci",
"reason": "The bundled compatibility proxy does not rewrite nested container create requests, causing kernel.domainname permission denied for Core and plug-ins.",
"do_not_use_for_this_profile": true
}
],
"incompatible_builds": [],
"notes": [
"HAOS One is a community image, not an official Home Assistant image.",
"This is a community image derived from HAOS One, not an official Home Assistant image.",
"This is not a simple Home Assistant container: the outer OCI runs systemd, Docker, Supervisor, Core and add-ons.",
"The direct Proxmox OCI path removes one extra Docker layer compared with the upstream-tested Proxmox LXC plus Docker plus haos-one deployment.",
"Proxmox privileged OCI import failed with setgid(0) Invalid argument; the validated profile is unprivileged and functional.",
"The direct Proxmox OCI path removes one extra Docker layer compared with a Proxmox LXC plus Docker plus this image deployment.",
"The profile is offered unprivileged only: Supervisor, Core, add-ons and USB devices work without privileges.",
"Proxmox must use ostype=unmanaged because ID=haos is not recognized by guest distribution detection.",
"The official /mnt/data path is a Proxmox-managed volume with backup=1, not a shared host bind.",
"A clean first boot used approximately 8.6G under /mnt/data after Core and Matter installation; 32G is the recommended starting size.",
"Home Assistant Core 2026.9.1 listens on port 80 in this image. Port 8123 was present only during the landing-page stage and must not be hard-coded.",
"A clean first boot used approximately 4.7G under /mnt/data; 32G is the recommended starting size.",
"The image sets SETUP_PORT=8123: Home Assistant Core keeps port 8123 after onboarding and port 80 stays closed.",
"Supervisor reports healthy=true and supported=true even though several host-oriented HAOS units cannot run inside LXC.",
"AppArmor profile loading inside HAOS fails in the current unprivileged LXC; this is a security limitation and must remain visible to users.",
"The current tag 18 works because it includes the upstream Docker API create-request rewrite; tag 18.1-amd64 does not.",
"Images are tagged by Home Assistant OS version, such as 18.3; latest is the newest build the project published. Builds are started by hand, not on every Home Assistant OS release.",
"Mutable tags must be resolved to an immutable architecture-specific digest before installation.",
"Restart persistence is validated. Full vzdump restore, outer OCI image replacement, USB passthrough and real multicast discovery remain pending."
"Restart persistence, USB passthrough, rebuilding the container with its data kept and recovery from its backup are validated. The update to a newer image and real multicast discovery remain pending."
],
"references": {
"project": "https://github.com/qweritos/haos-one",
"docker_image": "https://hub.docker.com/r/qweritos/haos-one",
"dockerfile": "https://github.com/qweritos/haos-one/blob/master/Dockerfile",
"compatibility_documentation": "https://github.com/qweritos/haos-one/blob/master/docs/haos-one-compat.md",
"project": "https://github.com/sevenrats/docker-haos",
"docker_image": "https://github.com/sevenrats/docker-haos/pkgs/container/haos",
"dockerfile": "https://github.com/sevenrats/docker-haos/blob/master/Dockerfile",
"compatibility_documentation": "https://github.com/sevenrats/docker-haos/blob/master/docs/haos-compat.md",
"home_assistant_os_releases": "https://github.com/home-assistant/operating-system/releases",
"proxmox_pct_manual": "https://pve.proxmox.com/pve-docs/pct.1.html"
"proxmox_pct_manual": "https://pve.proxmox.com/pve-docs/pct.1.html",
"derived_from": "https://github.com/qweritos/haos-one"
}
},
"installer_profile": {
@@ -634,22 +656,25 @@
"volumes"
],
"untranslated_blockers": [],
"policy": "Native laboratory adaptations implemented. Rolling latest first boot, backup restore and outer-image upgrades still require runtime validation."
"policy": "Native laboratory adaptations implemented. Installation, modification with the data kept and recovery from the backup are validated; the update to a newer image waits for the first one the project publishes."
},
"validation": {
"schema": "passed-at-generation",
"validated_architecture": "amd64",
"arm64": "supported-upstream-not-yet-validated-in-proxmenux-laboratory",
"arm64": "published-upstream-not-yet-validated-in-proxmenux-laboratory",
"validated_profile": {
"id": "pve55-haos-one-native-oci",
"validated_on": "2026-09-08",
"validation_status": "passed-functional-restart-persistence-with-known-nonblocking-issues",
"proxmox_version": "9.2.11",
"container_id": 128,
"haos_version": "18.2",
"home_assistant_core": "2026.9.1",
"supervisor": "2026.09.0",
"matter_server": "9.2.0",
"id": "pve55-haos-native-oci",
"validated_on": "2026-10-07",
"validation_status": "passed-install-restart-persistence-with-known-nonblocking-issues",
"proxmox_version": "9.2.20",
"kernel": "7.0.14-19-pve",
"container_id": 121,
"image_reference": "ghcr.io/sevenrats/haos:latest",
"image_digest": "sha256:5b499b4782e7547fdce746ebd9c37b0de802626f4db7b80b24660da2f3442ba7",
"image_created": "2026-09-23",
"haos_version": "18.3",
"home_assistant_core": "2026.9.4",
"supervisor": "2026.09.3",
"resources": {
"cores": 4,
"memory_mb": 4096,
@@ -658,29 +683,33 @@
"data": "local-lvm:32G,mp=/mnt/data,backup=1"
},
"observed_after_first_boot": {
"data_used": "approximately-8.6G",
"memory_used_bytes": 1368289280,
"rootfs_used_bytes": 624910336
"data_used": "approximately-4.7G",
"first_boot_seconds": 90
},
"validation": {
"installer_default_mode": "passed",
"oci_import": "passed-with-ostype-unmanaged",
"image_entrypoint": "passed-/entrypoint.sh-/sbin/init",
"halt_signal": "passed-SIGRTMIN+3",
"nested_docker": "passed-overlayfs",
"compatibility_proxy": "passed",
"home_assistant_web": "passed-http-200-port-80",
"unprivileged": "passed",
"home_assistant_web": "passed-http-port-8123",
"port_80": "closed",
"observer": "passed-http-200-port-4357",
"supervisor_healthy": true,
"supervisor_supported": true,
"core_running": true,
"plugins_running": true,
"matter_addon_running": true,
"restart_persistence": "passed",
"notes_link_port": 8123,
"reboot_from_home_assistant": "passed-container-back-with-its-dhcp-address",
"shutdown_from_home_assistant": "passed-started-again-by-the-watchdog",
"shutdown_from_proxmox": "passed-25-seconds-left-stopped-by-the-watchdog",
"restart_persistence": "passed-marker-in-/mnt/data-unchanged",
"usb_passthrough": "passed-maintainer-test-unprivileged",
"native_volume_backup_flag": "passed-mp0-backup-1",
"full_vzdump_restore": "pending",
"outer_image_upgrade": "pending",
"usb_passthrough": "pending",
"multicast_discovery": "pending-functional-device-test"
"full_vzdump_restore": "passed-recovery-of-an-interrupted-modification",
"outer_image_upgrade": "pending-no-newer-image-published",
"multicast_discovery": "pending-functional-device-test",
"modification": "passed-cores-changed-data-kept-watchdog-kept"
},
"known_nonblocking_resolution_issues": [
"haos-mglru.service-failed",
@@ -690,43 +719,10 @@
"dummy-networkmanager-may-report-ipv4-or-dns-diagnostics"
]
},
"source_profile": "haos-one-oci.json",
"service_health": "passed-observed-2026-09-14",
"restart_persistence": "passed-2026-09-14",
"backup_restore": "pending",
"update_preserves_data": "pending",
"latest_runtime_observation": {
"date": "2026-09-14",
"vmid": 100,
"architecture": "amd64",
"proxmox": "9.2.18",
"kernel": "7.0.14-16-pve",
"core_version": "2026.9.2",
"supervisor_version": "2026.09.0",
"image_reference": "qweritos/haos-one:latest",
"outer_image_digest_verified": false,
"supervisor_healthy": true,
"supervisor_supported": true,
"required_internal_containers_running": true,
"matter_server_healthy_after_restart": true,
"restart_method": "pct shutdown --timeout 90; pct start",
"managed_data_volume_unchanged": true,
"data_volume_gb": 32,
"data_volume_backup_flag": true,
"configuration_yaml_sha256_unchanged": true,
"temporary_marker_sha256_unchanged": true,
"temporary_marker_removed": true,
"core_http_port": 80,
"observer_http_port": 4357,
"lan_address_unchanged": true,
"limitations": [
"Supervisor AppArmor profile could not be loaded inside this LXC.",
"Host kernel config/debug mounts, auditd and MGLRU units fail in this LXC.",
"PulseAudio warning appeared during boot; hassio_audio subsequently running, audio functionality not tested.",
"Core translation-domain warnings observed; no functionality test of associated integrations.",
"Full vzdump restore and outer OCI image replacement remain untested."
]
}
"service_health": "passed-observed-2026-10-07",
"restart_persistence": "passed-2026-10-07",
"backup_restore": "passed-2026-10-07",
"update_preserves_data": "passed-by-modification-2026-10-07"
},
"lifecycle": {
"update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/mnt/data",
@@ -741,14 +737,14 @@
"validated_workflows": {
"core_supervisor_and_addons": "managed-by-home-assistant-supervisor",
"outer_oci_image": "resolve-new-image-and-replace-rootfs-while-preserving-/mnt/data",
"outer_oci_update_validation": "pending",
"outer_oci_update_validation": "rebuild-and-recovery-passed-newer-image-pending",
"required_before_update": [
"create-and-download-a-full-home-assistant-backup",
"create-a-native-proxmox-backup-including-mp0",
"record-current-image-digest",
"verify-new-image-contains-required-compatibility-rules"
],
"warning": "Do not claim in-place OCI image updates are validated until rootfs replacement and rollback have been tested without losing the managed /mnt/data volume."
"warning": "Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed."
}
}
}
+18 -2
View File
@@ -382,7 +382,10 @@
"scheme": "http",
"port": 5055,
"path": "/api/v1/settings/public",
"timeout_seconds": 360
"timeout_seconds": 360,
"request_timeout_seconds": 5,
"verify_tls": false,
"stability_seconds": 0
},
"security": {
"options": {
@@ -391,7 +394,20 @@
}
},
"adaptations": [
"Compose cap_drop ALL -> lxc.cap.drop reset and lxc.cap.keep none; no-new-privileges -> lxc.no_new_privs 1."
"Compose cap_drop ALL -> lxc.cap.drop reset and lxc.cap.keep none; no-new-privileges -> lxc.no_new_privs in the include of the container.",
"The configuration volume is given to the user the image runs as, uid 1000."
],
"volume_owner": {
"uid": 1000,
"gid": 1000
},
"volume_preparations": [
{
"container_path": "/app/config",
"remove_lost_found": true,
"owner_strategy": "mapped-application-user",
"only_when_mount_type": "managed-volume"
}
]
}
},
+6 -6
View File
@@ -3927,19 +3927,19 @@
},
{
"id": "haos-one",
"provider": "qweritos",
"provider": "sevenrats",
"template_family": "curated-profile",
"title": "HAOS One",
"repository": "https://github.com/qweritos/haos-one",
"description": "Community HAOS One image adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data.",
"website": "https://github.com/qweritos/haos-one",
"repository": "https://github.com/sevenrats/docker-haos",
"description": "Community Home Assistant OS image, derived from HAOS One, adapted as a native Proxmox OCI LXC with persistent Supervisor, Core, add-ons and backups under /mnt/data.",
"website": "https://github.com/sevenrats/docker-haos",
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/home-assistant.webp",
"architectures": [
"amd64",
"arm64"
],
"updated_at": "2026-08-20",
"main_image": "qweritos/haos-one:latest",
"updated_at": "2026-09-23",
"main_image": "ghcr.io/sevenrats/haos:latest",
"category": "smarthome",
"category_label": "IoT & Smart Home",
"replaces_discovered_ids": [],
+4 -1
View File
@@ -27,6 +27,9 @@ def note(text):
"""Detail for the run log; without one, for stderr."""
try:
if OCI_LOG:
if not sys.stderr.isatty():
# The progress line goes to a file, which may be this log: end it first.
print(file=sys.stderr, flush=True)
log(OCI_LOG, text)
return
except OSError:
@@ -110,7 +113,7 @@ def probe(vmid, ip, remaining):
raise Pending(translate('Supervisor does not confirm healthy and supported yet'))
if not http_ready(f'http://{ip}:4357/', max(0.1, min(5, remaining()))):
raise Pending(translate('Observer is not responding on port 4357'))
for port in (80, 8123):
for port in (8123, 80):
if http_ready(f'http://{ip}:{port}/', max(0.1, min(5, remaining()))):
return [{'label': 'Home Assistant', 'url': f'http://{ip}:{port}/'},
{'label': 'Home Assistant Observer', 'url': f'http://{ip}:4357/'}]
+20
View File
@@ -103,6 +103,24 @@ def persist_instances(deployment, services, primary_id=None):
oci_instances.publish_stack(oci_instances.ROOT, None, template, deployment, services)
def refresh_notes(services):
"""The notes of each container were written when it was created, before
it had its leg on the local network: write them again with the address
the application is reached at."""
from oci_description import render
from oci_native_stack import access_address as reachable_address
for service in services:
vmid = int(service['vmid'])
try:
record = oci_instances.read(oci_instances.ROOT, vmid)
running = 'running' in run('pct', 'status', vmid)
description = render(record['template'], '', record['installation_id'],
reachable_address(vmid, wait=20 if running else 0))
run('pct', 'set', vmid, '--description', description)
except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
log(LOG, f'Notes of CT {vmid} not refreshed: {error}')
def run(*args, capture=True, timeout=180):
argv = list(map(str,args))
if capture:
@@ -811,6 +829,7 @@ def main():
fcntl.flock(lock,fcntl.LOCK_UN)
result = finish_independent_suite(services,subnet)
persist_instances(deployment, services)
refresh_notes(services)
result.update(completion_notes=list(deployment.get('completion_notes',[])), log=LOG)
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
return
@@ -852,6 +871,7 @@ def main():
msg_ok(translate('LAN address applied to the application URLs'))
result=results[primary['name']]
persist_instances(deployment, services, primary_id)
refresh_notes(services)
# The credentials of the main service come from its own installation.
result.update(vmid=primary_id,ip=lan,stack_vmids={s['name']:s['vmid'] for s in services},
urls=[{'label':'Web UI','url':public_url}],
+28 -12
View File
@@ -745,6 +745,21 @@ apply_post_start_configuration() {
esac
}
# The first address of the container on an interface Proxmox gave it. A
# container that runs Docker inside also lists the ones of its inner bridges.
# Fails when the interfaces cannot be read, so the caller takes what is listed.
own_ipv4() {
local name listed found own="" looked=0
listed=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -E '^[0-9]+\.') || return 1
for name in $(pct config "$VMID" 2>/dev/null | sed -n 's/^net[0-9]*: .*name=\([A-Za-z0-9_.-]*\).*/\1/p'); do
found=$(lxc-attach -n "$VMID" -s NETWORK -- ip -4 -o addr show dev "$name" scope global 2>/dev/null) || continue
looked=1
own+=$(sed -n 's/.* inet \([0-9.]*\)\/.*/\1/p' <<<"$found")$'\n'
done
(( looked == 1 )) || return 1
grep -m1 -Fx -f <(sed '/^$/d' <<<"$own") <<<"$listed" || true
}
# $1: optional result label shown before the address.
detect_container_ipv4() {
local attempt addresses label=${1:-}
@@ -755,10 +770,12 @@ detect_container_ipv4() {
fi
IP=""
for attempt in $(seq 1 15); do
IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
if [[ -z $IP ]]; then
addresses=$(pct exec "$VMID" -- hostname -I 2>/dev/null || true)
IP=$(tr ' ' '\n' <<<"$addresses" | grep -m1 -E '^[0-9]+\.' || true)
if ! IP=$(own_ipv4); then
IP=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
if [[ -z $IP ]]; then
addresses=$(pct exec "$VMID" -- hostname -I 2>/dev/null || true)
IP=$(tr ' ' '\n' <<<"$addresses" | grep -m1 -E '^[0-9]+\.' || true)
fi
fi
[[ -n $IP ]] && break
msg_progress "$(translate "Waiting for the network address...") $((attempt * 2))/30 s"
@@ -1705,7 +1722,8 @@ else
fi
SYSCTL_COUNT=$(jq '.security.sysctls? // [] | length' "$DEPLOYMENT_FILE")
if (( SYSCTL_COUNT > 0 )); then
NO_NEW_PRIVILEGES=$(jq -r '.security.options.no_new_privileges? // false' "$DEPLOYMENT_FILE")
if (( SYSCTL_COUNT > 0 )) || [[ $NO_NEW_PRIVILEGES == true ]]; then
SYSCTL_INCLUDE="/etc/pve/proxmenux/${VMID}.sysctls"
mkdir -p /etc/pve/proxmenux || die "$(translate "Could not create the ProxMenux folder in /etc/pve")"
SYSCTL_TEMP=$(mktemp)
@@ -1718,12 +1736,14 @@ if (( SYSCTL_COUNT > 0 )); then
printf 'lxc.sysctl.%s = %s\n' "$SYSCTL_NAME" "$SYSCTL_VALUE" >>"$SYSCTL_TEMP"
oci_log "Network sysctl prepared: ${SYSCTL_NAME}=${SYSCTL_VALUE}"
done < <(jq -r '.security.sysctls[]? | [.name,.value] | @tsv' "$DEPLOYMENT_FILE")
# Proxmox does not take this setting in the configuration of a container.
[[ $NO_NEW_PRIVILEGES != true ]] || printf 'lxc.no_new_privs = 1\n' >>"$SYSCTL_TEMP"
[[ ! -L $SYSCTL_INCLUDE ]] || die "$(translate "The sysctl include is a link:") $SYSCTL_INCLUDE"
# pmxcfs assigns its own permissions and rejects chmod.
cat "$SYSCTL_TEMP" >"$SYSCTL_INCLUDE"
rm -f "$SYSCTL_TEMP"
set_lxc_directive "lxc.include" "$SYSCTL_INCLUDE"
msg_ok "$(translate "Network sysctls prepared:") $SYSCTL_COUNT"
(( SYSCTL_COUNT == 0 )) || msg_ok "$(translate "Network sysctls prepared:") $SYSCTL_COUNT"
fi
REQUIRED_CAPABILITIES=$(jq -r '.security.required_capabilities? // [] | join(",")' "$DEPLOYMENT_FILE")
@@ -1731,13 +1751,9 @@ if [[ -n $REQUIRED_CAPABILITIES ]]; then
msg_ok "$(translate "Compose capabilities validated in the LXC user namespace:") $REQUIRED_CAPABILITIES"
fi
NO_NEW_PRIVILEGES=$(jq -r '.security.options.no_new_privileges? // false' "$DEPLOYMENT_FILE")
APPARMOR_PROFILE=$(jq -r '.security.options.apparmor_profile? // empty' "$DEPLOYMENT_FILE")
SECCOMP_PROFILE=$(jq -r '.security.options.seccomp_profile? // empty' "$DEPLOYMENT_FILE")
SELINUX_LABEL_DISABLED=$(jq -r '.security.options.selinux_label_disabled? // false' "$DEPLOYMENT_FILE")
if [[ $NO_NEW_PRIVILEGES == true ]]; then
set_lxc_directive "lxc.no_new_privs" "1"
fi
if [[ $(jq -r '.security.options.drop_all_capabilities? // false' "$DEPLOYMENT_FILE") == true ]]; then
[[ -z $REQUIRED_CAPABILITIES ]] || die "$(translate "Capabilities cannot be kept and all dropped at the same time")"
set_lxc_directive "lxc.cap.drop" ""
@@ -2057,9 +2073,9 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
HC_PORT=$(jq -er '.port' <<<"$STARTUP_HEALTHCHECK")
HC_PATH=$(jq -er '.path' <<<"$STARTUP_HEALTHCHECK")
HC_TIMEOUT=$(jq -er '.timeout_seconds' <<<"$STARTUP_HEALTHCHECK")
HC_REQUEST_TIMEOUT=$(jq -er '.request_timeout_seconds' <<<"$STARTUP_HEALTHCHECK")
HC_REQUEST_TIMEOUT=$(jq -r '.request_timeout_seconds // 5' <<<"$STARTUP_HEALTHCHECK")
HC_STABILITY=$(jq -r '.stability_seconds // 0' <<<"$STARTUP_HEALTHCHECK")
HC_VERIFY_TLS=$(jq -r '.verify_tls' <<<"$STARTUP_HEALTHCHECK")
HC_VERIFY_TLS=$(jq -r '.verify_tls // false' <<<"$STARTUP_HEALTHCHECK")
[[ $HC_SCHEME == http || $HC_SCHEME == https ]] || die "$(translate "Invalid healthcheck scheme")"
[[ $HC_PORT =~ ^[0-9]+$ && $HC_PORT -ge 1 && $HC_PORT -le 65535 ]] \
|| die "$(translate "Invalid healthcheck port")"
+7 -2
View File
@@ -1,9 +1,14 @@
#!/bin/sh
# Marks each start of an OCI container in its console log, so the log can be
# read from the last start. Run by the container's lxc.hook.pre-start.
# Run by the lxc.hook.pre-start of an OCI container. Marks each start in its
# console log, so the log can be read from the last start, and queues the
# check of its DHCP address for when it is up.
case "$1" in
''|*[!0-9]*) exit 0 ;;
esac
printf '\n=== ProxMenux: container started %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
>> "/var/log/proxmenux/oci/$1.console.log" 2>/dev/null
lease="${0%/*}/oci_dhcp_lease.py"
if [ -r "$lease" ] && command -v systemd-run >/dev/null 2>&1; then
systemd-run --quiet --collect --on-active=20 /usr/bin/python3 "$lease" "$1" >/dev/null 2>&1
fi
exit 0
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Give its DHCP address back to an OCI container that restarted itself.
Proxmox runs the DHCP client of a host-managed interface from the host and
starts it only when it starts the container. A container that reboots from
inside, as Home Assistant OS does from its settings, is started again by
Proxmox without that client and comes back with no address. The start hook of
every OCI container queues this check: once the container is up, the client
that is missing is started the way Proxmox starts it.
The client lives where the container was started from: one started from the
Monitor ends when the Monitor is restarted, and the address would be lost
when its lease runs out. The Monitor asks for every container when it starts.
"""
from __future__ import annotations
import fcntl
from pathlib import Path
import re
import subprocess
import sys
import time
CONFIGS = Path('/etc/pve/lxc')
HOOKS = Path('/var/lib/lxc')
PROC = Path('/proc')
LOCKS = Path('/run/proxmenux')
# Proxmox starts its own client right after the container; one that just
# started is left to it, and to the check its start hook queued.
SETTLE = 30
NAME_RE = re.compile(r'[A-Za-z0-9_.-]{1,15}')
START = 'PVE::LXC::manage_dhclient("start", $ARGV[0], $ARGV[1], $ARGV[2], "/proc/$ARGV[3]/root")'
def managed(config):
"""The host-managed interfaces of a container configuration that take
their address by DHCP: [(name, ip version)]."""
found = []
for line in config.split('\n[', 1)[0].splitlines():
match = re.match(r'net\d+:\s*(.*)', line)
if not match:
continue
options = dict(part.split('=', 1) for part in match.group(1).split(',') if '=' in part)
name = options.get('name', '')
if options.get('host-managed') != '1' or options.get('link_down') == '1' or not NAME_RE.fullmatch(name):
continue
found += [(name, version) for key, version in (('ip', 4), ('ip6', 6)) if options.get(key) == 'dhcp']
return found
def client_running(vmid, name, version):
"""Whether a DHCP client of the interface runs, with or without a lease yet."""
mark = f'{HOOKS}/{vmid}/hook/dhclient{version}-{name}.pid'.encode()
for entry in PROC.glob('[0-9]*/cmdline'):
try:
if mark in entry.read_bytes().split(b'\0'):
return True
except OSError:
continue
return False
def init_pid(vmid):
result = subprocess.run(['lxc-info', '-n', str(vmid), '-pH'], capture_output=True, text=True, check=False, timeout=30)
pid = result.stdout.strip()
return int(pid) if result.returncode == 0 and pid.isdigit() and int(pid) > 0 else None
def running_for(pid):
"""Seconds since the init of the container started."""
try:
return time.time() - (PROC / str(pid)).stat().st_ctime
except OSError:
return 0.0
def containers():
"""The containers of this node with a host-managed interface on DHCP."""
found = []
for path in sorted(CONFIGS.glob('*.conf')):
try:
if path.stem.isdigit() and managed(path.read_text(errors='replace')):
found.append(int(path.stem))
except OSError:
continue
return found
def start_client(vmid, name, version, pid):
# In a scope of its own: the client stays when this check ends.
result = subprocess.run(['systemd-run', '--scope', '--quiet', '--collect', 'perl', '-MPVE::LXC', '-e', START,
str(vmid), str(version), name, str(pid)],
capture_output=True, text=True, check=False, timeout=180)
return result.returncode == 0
def repair(vmid, settle=0):
"""Start the DHCP clients a running container is missing. Returns the
interfaces that got one: [(name, ip version)]."""
try:
config = (CONFIGS / f'{vmid}.conf').read_text(errors='replace')
except OSError:
return []
wanted = managed(config)
if not wanted:
return []
pid = init_pid(vmid)
if pid is None or running_for(pid) < settle:
return []
started = []
for name, version in wanted:
if not client_running(vmid, name, version) and start_client(vmid, name, version, pid):
started.append((name, version))
return started
def check(vmid, settle=0):
with (LOCKS / f'oci-dhcp-lease-{vmid}.lock').open('w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
for name, version in repair(vmid, settle):
print(f'CT {vmid}: DHCP client of {name} (IPv{version}) started again', flush=True)
def main():
if len(sys.argv) != 2 or not (sys.argv[1].isdigit() or sys.argv[1] == '--all'):
print('usage: oci_dhcp_lease.py <vmid> | --all', file=sys.stderr)
return 2
LOCKS.mkdir(parents=True, exist_ok=True)
if sys.argv[1] == '--all':
for vmid in containers():
check(vmid, SETTLE)
else:
check(int(sys.argv[1]))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+7 -2
View File
@@ -34,9 +34,13 @@ def snapshot(source, allow_missing=False):
'mode': stat.S_IMODE(info.st_mode)}
def validate_source(source, allow_missing=False):
def validate_source(source, allow_missing=False, declared=False):
"""`declared` is a source the recipe of the application names itself, as a
host monitor does with /proc and /sys: it is not one the user typed."""
valid_path(source)
value = snapshot(source, allow_missing)
if declared:
return dict(value, declared=True)
protected = ('/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', '/dev', '/proc', '/sys', '/run')
protected += tuple(str(Path(p).resolve()) for p in protected)
resolved = value['resolved_path']
@@ -52,7 +56,8 @@ def same_source(a, b):
def verify_sources(expected):
for source, previous in expected.items():
current = validate_source(source, allow_missing=not previous['exists'])
current = validate_source(source, allow_missing=not previous['exists'],
declared=previous.get('declared', False))
if not same_source(previous, current):
raise ValueError(f"{translate('The operation was stopped because a shared directory changed its identity:')} {source}")
+1
View File
@@ -195,6 +195,7 @@ def resolve_candidate(reference, architecture):
# version inside it moved.
return {'manifest_digest': digest, 'registry_digest': registry_digest,
'layers': [layer['digest'] for layer in json.loads(raw).get('layers', [])],
'size': sum(int(layer.get('size') or 0) for layer in json.loads(raw).get('layers', [])),
'defaults': defaults, 'version': version, 'created': config.get('created')}
+147 -41
View File
@@ -33,6 +33,7 @@ import oci_host_mounts as host_mounts
import oci_accelerators as gpu_devices
import oci_runtime_settings as runtime_settings
import oci_image_cache as image_cache
import oci_work_backup as work_backup
import oci_ui
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2
@@ -48,7 +49,9 @@ BASIC = {'arch', 'cmode', 'console', 'tty', 'cores', 'cpulimit', 'cpuunits', 'de
# The hook that marks each start in that log, set the same way.
'lxc.hook.pre-start'}
# Their output is data (and may hold saved secrets); it is never logged.
DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get')}
DATA_COMMANDS = {('pct', 'config'), ('pvesh', 'get'), ('pct', 'df')}
# Kept across a modification: the watchdog and where the backups are made.
HOST_PREFERENCES = ('watchdog', 'work_storage')
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]')
SPINNER_FRAME = re.compile('^ ?[' + ''.join(oci_ui.FRAMES) + ']')
@@ -316,13 +319,49 @@ def mounts(config):
return result
def is_file_bind(mount, files=()):
return mount['type'] == 'host-bind' and (mount['source'] in files or Path(mount['source']).is_file())
def declared_sources(contract):
"""The host paths the recipe of a host monitor mounts on its own."""
if not contract['deployment'].get('host_monitor'):
return set()
profile = contract['template'].get('proxmox', {}).get('installer_profile', {})
return {item['source'] for item in profile.get('host_monitor_mounts', [])}
def profile_keys(deployment):
"""The raw LXC settings the installer writes for what the application
declares, and writes again on the rebuilt container."""
keys = set()
security = deployment.get('security', {})
options = security.get('options') or {}
if options.get('apparmor_profile'):
keys.add('lxc.apparmor.profile')
if options.get('seccomp_profile'):
keys.add('lxc.seccomp.profile')
if options.get('no_new_privileges'):
keys.add('lxc.include')
if options.get('drop_all_capabilities'):
keys.update(('lxc.cap.drop', 'lxc.cap.keep'))
keys.update(f"lxc.prlimit.{limit['name']}" for limit in deployment.get('resources', {}).get('rlimits') or [])
if deployment.get('host_monitor'):
keys.update(('lxc.include', 'lxc.net.0.type', 'lxc.hook.mount'))
return keys
def effective_healthcheck(template):
"""The template's own health check or, when it has none, one derived from
its web address: any HTTP answer below 500 means the application is up."""
profile = template.get('proxmox', {}).get('installer_profile', {})
check = profile.get('startup_healthcheck')
if check or profile.get('haos_healthcheck') or profile.get('host_monitor'):
if check:
return check
if profile.get('haos_healthcheck'):
# Home Assistant OS has a check of its own: Supervisor, Core and the
# services it runs inside. The installer runs it on the new container.
return {'type': 'haos', 'timeout_seconds': int(profile['haos_healthcheck'].get('timeout_seconds', 1200))}
for endpoint in template.get('first_run', {}).get('endpoints', []):
path = str(endpoint.get('path') or '/')
if (endpoint.get('scheme') in ('http', 'https') and str(endpoint.get('port', '')).isdigit()
@@ -336,7 +375,7 @@ def effective_healthcheck(template):
def with_default_healthcheck(contract):
check = effective_healthcheck(contract['template'])
if check:
if check and check.get('type') != 'haos':
contract['template'].setdefault('proxmox', {}).setdefault('installer_profile', {})['startup_healthcheck'] = check
return contract
@@ -365,6 +404,13 @@ def candidate_contract(record, operation, proposal=None):
result = copy.deepcopy(record)
result.update(template=copy.deepcopy(candidate['template']),
deployment=copy.deepcopy(candidate['deployment']))
# What ProxMenux was told about how to treat the application is not part
# of what is being edited: it stays as the record has it now.
for key in HOST_PREFERENCES:
if key in record['deployment']:
result['deployment'][key] = record['deployment'][key]
else:
result['deployment'].pop(key, None)
return with_default_healthcheck(result)
@@ -435,23 +481,32 @@ def preflight(record, candidate, config, coordinated=None):
runtime_keys.add('lxc.mount.entry')
if deployment.get('security', {}).get('sysctls'):
runtime_keys.add('lxc.include')
runtime_keys |= profile_keys(deployment)
files = runtime_settings.file_binds(config)
if files:
runtime_keys.add('lxc.mount.entry')
runtime_settings.check(config, deployment, record['vmid'])
coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS)
if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k)}:
raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile'))
for plan in (deployment, desired):
security = plan.get('security', {})
if (security.get('unprivileged') is not True
or security.get('options') or plan.get('host_monitor')
or plan.get('resources', {}).get('rlimits')):
raise ValueError(translate('Updates are not available yet in this beta for applications that use '
'a privileged container or advanced LXC settings'))
unprivileged = deployment.get('security', {}).get('unprivileged', True) is not False
if (desired.get('security', {}).get('unprivileged', True) is not False) != unprivileged \
or desired.get('host_monitor') != deployment.get('host_monitor'):
# The owners of the data belong to one mode, and a monitor of the
# host has no network of its own to keep.
raise ValueError(translate('Changing between a privileged and an unprivileged container, or the host monitor mode, requires installing the application again'))
for template, plan in ((record['template'], deployment), (candidate['template'], desired)):
runtime_settings.sysctl_content(plan)
runtime_settings.tmpfs_lines(plan)
gpu_devices.planned(plan)
paths = []
# What the recipe of a host monitor mounts of the host, such as /sys
# and a folder inside it, is read only and laid out by the recipe.
declared = declared_sources({'template': template, 'deployment': plan})
for mount in plan.get('mounts', []):
target = host_mounts.valid_path(mount['container_path'])
if mount['type'] == 'host-bind' and mount['source'] in declared and mount.get('read_only'):
continue
if (any(target == p or target.startswith(p.rstrip('/') + '/')
or p.startswith(target.rstrip('/') + '/') for p in paths)):
raise ValueError(translate('Mount paths must not overlap'))
@@ -465,15 +520,18 @@ def preflight(record, candidate, config, coordinated=None):
else:
raise ValueError(translate('Unsupported mount type'))
paths.append(target)
if cfg.get('unprivileged') != '1' or ':' not in cfg.get('rootfs', ''):
raise ValueError(translate('A managed rootfs and an unprivileged container are required'))
if cfg.get('unprivileged', '0') != ('1' if unprivileged else '0') or ':' not in cfg.get('rootfs', ''):
raise ValueError(translate('The container disks do not match the saved record'))
if desired['rootfs'] != deployment['rootfs']:
raise ValueError(translate('Changing the rootfs or its storage requires a separate migration'))
actual = mounts(config)
gpu_devices.check(config, deployment)
gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment)
old = {m['container_path']: m for m in deployment.get('mounts', [])}
new = {m['container_path']: m for m in desired.get('mounts', [])}
recorded_files = {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)}
if files != recorded_files:
raise ValueError(translate('A container mount has a source, backup or permission different from the saved record'))
old = {m['container_path']: m for m in deployment.get('mounts', []) if not is_file_bind(m, files)}
new = {m['container_path']: m for m in desired.get('mounts', []) if not is_file_bind(m, files)}
if set(actual) != set(old):
raise ValueError(translate('The container disks do not match the saved record'))
for target, mount in actual.items():
@@ -500,24 +558,31 @@ def preflight(record, candidate, config, coordinated=None):
check = effective_healthcheck(candidate['template'])
if not check and not coordinated:
raise ValueError(translate('Updates are not available yet for this application in this beta'))
mac = next((item[7:] for item in cfg['net0'].split(',') if item.startswith('hwaddr=')), None)
if deployment.get('host_monitor') and 'net0' not in cfg:
return cfg, actual, None
mac = next((item[7:] for item in cfg.get('net0', '').split(',') if item.startswith('hwaddr=')), None)
if not mac:
raise ValueError(translate('The MAC address of the container cannot be kept'))
return cfg, actual, mac
def freeze_host_sources(record, candidate, acknowledge_external_data):
old = {m['source'] for m in record['deployment'].get('mounts', []) if m['type'] == 'host-bind'}
wanted = {m['source']: m for m in candidate['deployment'].get('mounts', []) if m['type'] == 'host-bind'}
if (old or wanted) and not acknowledge_external_data:
def freeze_host_sources(record, candidate, acknowledge_external_data, files=()):
# A single file mounted from the host holds no data of the application:
# the installer mounts it again, and nothing of it is pinned.
old = {m['source'] for m in record['deployment'].get('mounts', [])
if m['type'] == 'host-bind' and not is_file_bind(m, files)}
wanted = {m['source']: m for m in candidate['deployment'].get('mounts', [])
if m['type'] == 'host-bind' and not is_file_bind(m, files)}
declared = declared_sources(record) | declared_sources(candidate)
if (old | set(wanted)) - declared and not acknowledge_external_data:
raise ValueError(translate('Host data is not restored by the backup; confirm it with --acknowledge-external-data'))
baseline = record['observed'].get('host_bind_sources', {})
original = {p: host_mounts.validate_source(p) for p in old}
original = {p: host_mounts.validate_source(p, declared=p in declared) for p in old}
for source, previous in baseline.items():
if source in original and not host_mounts.same_source(previous, original[source]):
raise ValueError(translate('A shared source does not match its recorded identity'))
desired = {p: original[p] if p in original else
host_mounts.validate_source(p, allow_missing=m.get('create_if_missing') is True)
host_mounts.validate_source(p, allow_missing=m.get('create_if_missing') is True, declared=p in declared)
for p, m in wanted.items()}
if old or wanted:
log('host directories: not included in the backup and not reverted by a recovery')
@@ -529,7 +594,10 @@ def freeze_host_sources(record, candidate, acknowledge_external_data):
def check_runtime_mounts(config, deployment):
runtime_settings.check(config, deployment, deployment['vmid'])
actual = mounts(config)
declared = {m['container_path']: m for m in deployment.get('mounts', [])}
files = runtime_settings.file_binds(config)
if files != {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)}:
raise ValueError(translate('The mounts of the new container do not match the proposal'))
declared = {m['container_path']: m for m in deployment.get('mounts', []) if not is_file_bind(m, files)}
if actual.keys() != declared.keys():
raise ValueError(translate('The mounts of the new container do not match the proposal'))
for target, value in actual.items():
@@ -548,7 +616,7 @@ def pin_host_source(root, vmid, journal, source):
or record['installation_id'] != state['record']['installation_id'] or record['status'] != 'updating'):
raise ValueError(translate('Mount not authorized by the operation'))
expected = state['desired_host_sources'][source]
current = host_mounts.validate_source(source)
current = host_mounts.validate_source(source, declared=expected.get('declared', False))
if ((expected['exists'] and not host_mounts.same_source(expected, current))
or current['resolved_path'] != expected['resolved_path']):
raise ValueError(translate('The host directory changed before it was mounted'))
@@ -582,10 +650,48 @@ def stop(vmid):
raise ValueError(translate('The container did not stop; its disks are not touched'))
def own_addresses(vmid):
"""The IPv4 addresses of the interfaces Proxmox gave the container. One
that runs Docker inside also has the addresses of its inner bridges."""
names = re.findall(r'^net[0-9]+: (?:.*,)?name=([A-Za-z0-9_.-]+)', run('pct', 'config', str(vmid)).decode(), re.M)
found = []
for name in names:
try:
listed = run('lxc-attach', '-n', str(vmid), '-s', 'NETWORK', '--',
'ip', '-4', '-o', 'addr', 'show', 'dev', name, 'scope', 'global').decode()
except RuntimeError:
continue
found += re.findall(r'\binet ([0-9.]+)/', listed)
return found
def haos_healthcheck(vmid, timeout):
import haos_healthcheck as haos
haos.note = log
started, ip = time.monotonic(), None
while ip is None and time.monotonic() - started < 60:
if run('pct', 'status', str(vmid)).strip() != b'status: running':
raise ValueError(translate('The restored service stopped; the recovery is not confirmed'))
ip = next(iter(own_addresses(vmid)), None)
if ip is None:
time.sleep(2)
try:
if ip is None:
raise RuntimeError('no address')
url = haos.wait_ready(vmid, ip, timeout)[0]['url']
except RuntimeError as error:
log(f'restored Home Assistant OS not confirmed: {error}')
raise ValueError(translate('The restored service did not pass its health check'))
log(f'restored service responding: {url}')
return url
def healthcheck(vmid, template):
check = effective_healthcheck(template)
if not check:
return None
if check.get('type') == 'haos':
return haos_healthcheck(vmid, int(check['timeout_seconds']))
if check.get('type') == 'running':
stability = int(check.get('stability_seconds', 20))
started = time.monotonic()
@@ -603,10 +709,12 @@ def healthcheck(vmid, template):
or not 0 <= stability < timeout <= 3600):
raise ValueError(translate('Invalid health check'))
started, stable_since = time.monotonic(), None
# A monitor of the host answers on the network of the host itself.
shared = parse_config(run('pct', 'config', str(vmid))).get('lxc.net.0.type') == 'none'
while time.monotonic() - started < timeout:
if run('pct', 'status', str(vmid)).strip() != b'status: running':
raise ValueError(translate('The restored service stopped; the recovery is not confirmed'))
addresses = run('lxc-info', '-n', str(vmid), '-iH').decode().splitlines()
addresses = ['127.0.0.1'] if shared else run('lxc-info', '-n', str(vmid), '-iH').decode().splitlines()
ip = next((a for a in addresses if re.fullmatch(r'[0-9]+(?:\.[0-9]+){3}', a)), None)
ok = False
if ip:
@@ -820,16 +928,6 @@ def backup_size(vmid):
return total
def require_backup_space(directory, vmids):
"""The data in use on the backed-up volumes, plus a margin, must fit in
`directory`; data that is already compressed does not shrink."""
needed = int(sum(backup_size(vmid) for vmid in vmids) * 1.1) + 1024**3
free = shutil.disk_usage(directory).free
if free < needed:
raise ValueError(f"{translate('Not enough free space for the backup')} "
f"({translate('needed')}: {gib(needed)}, {translate('free')}: {gib(free)}, {directory})")
def prune_backups(root, vmid):
"""The backups of closed operations are removed once the container works
with its new image; their journal and log stay."""
@@ -843,9 +941,7 @@ def prune_backups(root, vmid):
continue
if phase not in ('committed', 'rolled-back'):
continue
for backup in (directory / 'backup').glob('vzdump-lxc-*'):
if backup.is_file() and not backup.is_symlink():
backup.unlink()
work_backup.clear(directory)
def check_archive(archive):
@@ -892,7 +988,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
before = run('pct', 'config', str(vmid))
cfg, actual, mac = preflight(record, candidate, before, coordinated)
description = original_description({'record': record, 'before_config': before.decode()})
original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data)
original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data,
runtime_settings.file_binds(before))
original_gpu = gpu_devices.planned(record['deployment'])
desired_gpu = gpu_devices.planned(candidate['deployment'])
resources = json.loads(run('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json'))
@@ -912,10 +1009,17 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
for p in set(image['defaults'].get('Volumes') or {}) - non_persistent_image_volumes(candidate['template'])):
raise ValueError(translate('The new image requires additional persistent paths; use Modify'))
directory = instances.location(root, vmid).parent / 'transactions' / uuid.uuid4().hex
# Where the backup goes is settled, and checked for room, before anything
# is stopped.
work = directory if coordinated else work_backup.locate(directory, [vmid], record, vmid)
private_directory(directory)
open_log(directory)
if not coordinated:
require_backup_space(directory, [vmid])
if work != directory:
work_backup.prepare(work)
work_backup.remember(directory, work)
if show:
msg_info2(f"{translate('This backup does not fit on the system disk of the host; it is made on the storage:')} "
f"{record['deployment'].get(work_backup.KEY)}")
journal = directory / 'transaction.json'
runtime_deployment = copy.deepcopy(candidate['deployment'])
runtime_deployment['network']['mac_address'] = mac
@@ -970,7 +1074,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
run('zstd', '-t', backup['archive'])
state.update(backup=backup['archive'], backup_sha256=backup['sha256'])
else:
backup_dir = directory / 'backup'
backup_dir = work / 'backup'
private_directory(backup_dir)
backup = verified_backup(vmid, backup_dir, backup_compression,
translate('The backup could not be identified; the image is not replaced'), show)
@@ -1195,6 +1299,8 @@ def recover(root, journal):
if show:
msg_ok(translate('Recovery completed. The container had not been modified yet.'))
return
if not Path(backup).is_file():
raise ValueError(f"{translate('The backup of this operation cannot be read. If it is on another storage, make it available and recover the operation again:')} {backup}")
if filehash(backup) != state['backup_sha256']:
raise ValueError(translate('The backup was altered; recovery blocked'))
run('gzip' if state.get('backup_compression') == 'gzip' else 'zstd', '-t', backup)
+9 -4
View File
@@ -17,6 +17,7 @@ import subprocess
import sys
import oci_image_cache as image_cache
import oci_work_backup as work_backup
import oci_instances as instances
from oci_installation_state import parse_config
import oci_console
@@ -119,9 +120,11 @@ def _unlink(path):
def remove_host_state(vmid):
"""What the installation kept on the host for a container that is gone:
its sysctl include, the Rclone mount hookscript and the views it
published, and its registration in the App tab of ProxMenux Monitor."""
for include in (runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid)):
its sysctl include, its seccomp profile, the Rclone mount hookscript and
the views it published, and its registration in the App tab of ProxMenux
Monitor."""
for include in (runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid),
runtime_settings.seccomp_path(vmid)):
_unlink(include)
hook = SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh'
if hook.is_file() and not hook.is_symlink():
@@ -179,7 +182,7 @@ def release_shared_host_files(hookscripts):
def _leftovers(vmid):
"""Whether anything of the container is still on the host."""
paths = [runtime_settings.include_path(vmid), runtime_settings.legacy_include_path(vmid),
SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh', MONITOR_APPS / f'{int(vmid)}.json',
runtime_settings.seccomp_path(vmid), SNIPPETS / f'proxmenux-rclone-{int(vmid)}-fuse-hook.sh', MONITOR_APPS / f'{int(vmid)}.json',
CLUSTER_RECORDS / f'{int(vmid)}.json',
*oci_console.LOG_DIR.glob(f'{int(vmid)}.console.log*')]
return any(path.exists() for path in paths)
@@ -193,6 +196,7 @@ def sweep_orphans(root):
found = {int(d.name) for d in root.iterdir() if d.name.isdecimal()} if root.is_dir() else set()
for directory, pattern in ((runtime_settings.include_path(0).parent, r'([0-9]+)\.sysctls'),
(runtime_settings.legacy_include_path(0).parent, r'([0-9]+)\.proxmenux-sysctls'),
(runtime_settings.seccomp_path(0).parent, r'([0-9]+)\.proxmenux-seccomp'),
(oci_console.LOG_DIR, r'([0-9]+)\.console\.log.*'),
(SNIPPETS, r'proxmenux-rclone-([0-9]+)-fuse-hook\.sh'),
(CLUSTER_RECORDS, r'([0-9]+)\.json')):
@@ -347,6 +351,7 @@ def remove(root, vmid):
lifecycle.unlink()
for member in members:
directory = instances.location(root, member).parent
work_backup.forget(root, member)
if directory.is_dir() and not directory.is_symlink():
shutil.rmtree(directory)
msg_ok(translate('Saved record removed'))
+55 -2
View File
@@ -18,6 +18,9 @@ def sysctl_content(deployment):
raise ValueError(translate('Invalid or duplicated network sysctl'))
seen.add(name)
result.append(f'lxc.sysctl.{name} = {value}\n')
# Proxmox does not take this setting in the configuration of a container.
if (deployment.get('security', {}).get('options') or {}).get('no_new_privileges'):
result.append('lxc.no_new_privs = 1\n')
return ''.join(result)
@@ -58,6 +61,28 @@ def legacy_include_path(vmid):
return Path(f'/etc/pve/lxc/{int(vmid)}.proxmenux-sysctls')
# A host monitor shares the process and network namespaces of the host. Proxmox
# takes those two settings from an include, the same for every monitor.
HOST_MONITOR_INCLUDES = (CLUSTER_DIR / 'host-monitor', Path('/etc/pve/lxc/proxmenux-host-monitor'))
HOST_MONITOR_CONTENT = 'lxc.namespace.share.pid = 1\nlxc.namespace.share.net = 1\n'
def host_monitor_include(includes):
"""Whether the includes are the one of a host monitor, unchanged."""
if len(includes) != 1 or Path(includes[0]) not in HOST_MONITOR_INCLUDES:
return False
try:
return Path(includes[0]).read_text() == HOST_MONITOR_CONTENT
except OSError:
return False
def seccomp_path(vmid):
"""The seccomp profile the installer writes for a container that asks for
it relaxed."""
return Path(f'/etc/pve/lxc/{int(vmid)}.proxmenux-seccomp')
def check(config, deployment, vmid):
expected = tmpfs_lines(deployment)
lines = config.decode().splitlines()
@@ -65,6 +90,10 @@ def check(config, deployment, vmid):
if sorted(actual) != sorted(expected):
raise ValueError(translate('The tmpfs mounts of the container differ from the saved record'))
includes = [line.split(': ',1)[1] for line in lines if line.startswith('lxc.include: ')]
if deployment.get('host_monitor'):
if not host_monitor_include(includes):
raise ValueError(translate('The sysctl include is unknown or differs from the saved record'))
return
content = sysctl_content(deployment)
allowed = [[str(include_path(vmid))], [str(legacy_include_path(vmid))]] if content else [[]]
if includes not in allowed:
@@ -78,12 +107,34 @@ def check(config, deployment, vmid):
raise ValueError(translate('The sysctl content was modified outside the saved record'))
FILE_BIND = re.compile(r'(/\S+) \S+ none bind,create=file(,ro)? 0 0')
def file_binds(config):
"""The host files mounted in the container: {source: read only}. Proxmox
takes a directory as a mount point and a single file as a raw LXC entry,
so these are not among the mount points."""
found = {}
for line in config.decode().splitlines():
match = FILE_BIND.fullmatch(line.split(': ', 1)[1]) if line.startswith('lxc.mount.entry: ') else None
if match:
found[match.group(1)] = bool(match.group(2))
return found
def filter_config(config, deployment):
"""The configuration without the entries of the mounts the deployment
declares itself: what is left belongs to the acceleration profile."""
declared = set(tmpfs_lines(deployment))
files = {m['source'] for m in deployment.get('mounts', []) if m.get('type') == 'host-bind'}
def own(line):
entry = line.decode().strip().split(': ', 1)[1]
match = FILE_BIND.fullmatch(entry)
return entry in declared or bool(match and match.group(1) in files)
return b''.join(line for line in config.splitlines(keepends=True)
if not line.startswith(b'lxc.include: ') and not (
line.startswith(b'lxc.mount.entry: ')
and line.decode().strip().split(': ',1)[1] in declared))
line.startswith(b'lxc.mount.entry: ') and own(line)))
def check_recovery(config, state):
@@ -94,6 +145,8 @@ def check_recovery(config, state):
raise ValueError(translate('A tmpfs mount is not part of the journal; recovery blocked'))
if line.startswith('lxc.include: '):
path = Path(line.split(': ', 1)[1])
if any(plan.get('host_monitor') for plan in plans) and host_monitor_include([str(path)]):
continue
if path not in (include_path(state['vmid']), legacy_include_path(state['vmid'])):
raise ValueError(translate('An include is not part of the journal; recovery blocked'))
contents = {sysctl_content(plan) for plan in plans} - {''}
+23 -6
View File
@@ -17,6 +17,7 @@ import oci_instance_transaction as member_tx
import oci_stack_plan
import oci_stack_transaction as stack_tx
import oci_stack_replay as replay
import oci_work_backup as work_backup
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
from oci_update_current import resolve_archive
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
@@ -251,7 +252,17 @@ class NativeAdapter:
for s in self.services.values() if s['vmid'] != primary_id and not s.get('deferred_setup')]
if spec.get('schema') != 1 or spec.get('dependencies') != expected:
raise ValueError(translate('The dependency hook and the stack recipe differ'))
member_tx.require_backup_space(self.journal.parent, list(self.records))
self.work_base()
def work_base(self):
"""Where the backups of this operation go. It is decided, and checked
for room, until the first backup exists; afterwards it is read back,
so the space the backups themselves take is not asked for again."""
directory = self.journal.parent
if (directory / work_backup.MARK).exists() or any(directory.glob('backup-*')):
return work_backup.recall(directory)
primary = self.plan['primary_vmid']
return work_backup.locate(directory, list(self.records), self.records[primary], primary)
def is_running(self, vmid):
return instances.command('pct', 'status', str(vmid)).strip() == b'status: running'
@@ -331,7 +342,13 @@ class NativeAdapter:
def backup(self, vmid, identity):
self.validate(self.plan)
directory = self.journal.parent / ('backup-%s' % vmid)
base = self.work_base()
if base != self.journal.parent and not (self.journal.parent / work_backup.MARK).exists():
work_backup.prepare(base)
work_backup.remember(self.journal.parent, base)
msg_warn(f"{translate('This backup does not fit on the system disk of the host; it is made on the storage:')} "
f"{self.records[self.plan['primary_vmid']]['deployment'].get(work_backup.KEY)}")
directory = base / ('backup-%s' % vmid)
private_directory(directory)
archive = member_tx.verified_backup(vmid, directory, 'zstd',
translate('The backup of a member could not be identified'))
@@ -339,6 +356,8 @@ class NativeAdapter:
def verify_backups(self, backups):
for backup in backups.values():
if not Path(backup['archive']).is_file():
raise ValueError(f"{translate('The backup of this operation cannot be read. If it is on another storage, make it available and recover the operation again:')} {backup['archive']}")
if member_tx.filehash(backup['archive']) != backup['sha256']:
raise ValueError(translate('A backup was modified'))
member_tx.run('zstd', '-t', backup['archive'])
@@ -579,7 +598,7 @@ class NativeAdapter:
self.release_stages(recovered=state['phase'] == 'rolled-back')
if self.keep_backup and state['phase'] == 'committed':
import oci_keep_backup
for backup in sorted(self.journal.parent.glob('backup-*/vzdump-lxc-*.tar.zst')):
for backup in sorted(work_backup.recall(self.journal.parent).glob('backup-*/vzdump-lxc-*.tar.zst')):
kept = oci_keep_backup.keep(backup, self.keep_backup)
if kept:
msg_ok(f"{translate('Backup kept in')} {self.keep_backup}: {Path(kept).name}")
@@ -621,9 +640,7 @@ class NativeAdapter:
except (OSError, ValueError):
continue
if phase in stack_tx.TERMINAL:
for backup in directory.glob('backup-*/vzdump-lxc-*'):
if backup.is_file() and not backup.is_symlink():
backup.unlink()
work_backup.clear(directory)
# The stack journal of this run, for the summary after a failure.
+9 -2
View File
@@ -12,6 +12,7 @@ import tempfile
import oci_instances as instances
import oci_instance_transaction as transaction
import oci_work_backup as work_backup
from oci_installation_state import image_from_archive, same_image
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2
@@ -102,6 +103,12 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N
return archive, digest
msg_warn(translate('The cached image is damaged; it will be downloaded again.'))
archive.unlink()
# The compressed layers are what the download writes; nothing was changed yet.
size, free = int(candidate.get('size') or 0), work_backup.available(archive.parent)
if size and free < int(size * 1.1) + 256 * 1024**2:
raise ValueError(f"{translate('The new image does not fit on the storage where images are downloaded:')} {storage}. "
f"{translate('Needed:')} {work_backup.gib(int(size * 1.1) + 256 * 1024**2)}. "
f"{translate('Free:')} {work_backup.gib(free)}. {translate('The application was not modified.')}")
# A download can come back complete yet damaged when the connection drops
# and the transfer resumes; the integrity check catches it, and a second
# download is what repairs it.
@@ -170,8 +177,8 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
transaction.preflight(record, desired, config)
msg_ok(translate('Container checked'))
current = record['observed']['image'] if operation == 'update' else None
archive, digest = resolve_archive(desired, config, current, lambda: transaction.require_backup_space(
instances.location(instances.ROOT, vmid).parent, [vmid]))
archive, digest = resolve_archive(desired, config, current, lambda: work_backup.locate(
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid))
if archive is None:
msg_ok(translate('The image is already up to date; nothing was changed.'))
return
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Where the backup taken before changing an OCI application is written.
An update, a modification or a recreation stops the application and takes a
verified backup first, to go back to if the new container does not work; it
is deleted when the operation ends. That backup goes next to the record of
the application, on the system disk of the host. An application with a lot
of data may not fit there: the user can then choose a file storage of the
host that accepts backups, and the choice is kept in the record so later
operations, the scheduled ones included, use it without asking.
"""
from __future__ import annotations
import json
from pathlib import Path
import re
import shutil
import socket
import subprocess
import oci_instances as instances
from oci_ui import translate
FOLDER = 'proxmenux-oci-work'
KEY = 'work_storage'
MARK = 'backup-location'
STORAGE_RE = re.compile(r'[A-Za-z0-9._-]{1,64}')
def gib(size):
return f'{size / 1024**3:.1f} GB'
def required(vmids):
"""The data in use on the backed-up volumes plus a margin: data that is
already compressed does not shrink."""
import oci_instance_transaction as transaction
return int(sum(transaction.backup_size(vmid) for vmid in vmids) * 1.1) + 1024**3
def available(path):
"""Free bytes where `path` is or will be created."""
path = Path(path)
while not path.exists() and path != path.parent:
path = path.parent
return shutil.disk_usage(path).free
def file_storages():
"""The storages of this node that accept backups and are a directory of
the host, with their free space: [{'storage', 'path', 'free'}]."""
node = socket.gethostname().split('.', 1)[0]
listed = subprocess.run(['pvesh', 'get', f'/nodes/{node}/storage', '--content', 'backup', '--enabled', '1',
'--output-format', 'json'], capture_output=True, text=True, timeout=60)
if listed.returncode != 0:
return []
found = []
for entry in json.loads(listed.stdout):
name = str(entry.get('storage') or '')
if not entry.get('active') or not STORAGE_RE.fullmatch(name):
continue
path = storage_path(name)
if path is not None and path.is_dir():
found.append({'storage': name, 'path': path, 'free': available(path)})
return found
def storage_path(storage):
"""The directory of a file storage that accepts backups, or None."""
if not STORAGE_RE.fullmatch(storage or ''):
return None
result = subprocess.run(['pvesh', 'get', f'/storage/{storage}', '--output-format', 'json'],
capture_output=True, text=True, timeout=30)
if result.returncode != 0:
return None
info = json.loads(result.stdout)
if 'backup' not in str(info.get('content', '')).split(',') or not info.get('path'):
return None
return Path(info['path'])
def explain(needed, free, storage=None, storage_free=None):
text = (f"{translate('The backup made before changing the application does not fit on the system disk of the host.')} "
f"{translate('Needed:')} {gib(needed)}. {translate('Free:')} {gib(free)}.")
if storage:
text += (f" {translate('The storage chosen for this backup has no room either:')} {storage}"
+ (f" ({gib(storage_free)})." if storage_free is not None else '.'))
return (f"{text} {translate('The application was not modified.')} "
f"{translate('Free space on the host, or choose another storage for this backup from Manage installed OCI applications.')}")
def locate(default, vmids, record, owner):
"""The directory the backups of one operation are written under: `default`
when they fit there, otherwise a folder of its own on the storage chosen
for the application. Nothing is created. Raises ValueError with what is
needed when there is no room."""
default = Path(default)
needed = required(vmids)
free = available(default)
if free >= needed:
return default
storage = (record.get('deployment') or {}).get(KEY)
path = storage_path(storage) if storage else None
if path is None or not path.is_dir():
raise ValueError(explain(needed, free))
storage_free = available(path)
if storage_free < needed:
raise ValueError(explain(needed, free, storage, storage_free))
return path / FOLDER / str(int(owner)) / default.name
def prepare(base):
"""Create the folder of an operation on another storage and make sure it
can be written, before anything is stopped."""
base = Path(base)
try:
base.mkdir(parents=True, exist_ok=True, mode=0o700)
probe = base / '.write-test'
probe.write_text('')
probe.unlink()
except OSError as error:
raise ValueError(f"{translate('The storage chosen for this backup cannot be written:')} {base} ({error.strerror or error})")
def remember(directory, base):
"""Leave in the folder of the operation where its backups are, when they
are not in that folder: a recovery or a cleanup reads it from there."""
directory, base = Path(directory), Path(base)
if base != directory:
(directory / MARK).write_text(str(base) + '\n')
def recall(directory):
"""Where the backups of the operation kept in `directory` are."""
directory = Path(directory)
try:
text = (directory / MARK).read_text().strip()
except OSError:
return directory
base = Path(text)
return base if base.is_absolute() and FOLDER in base.parts else directory
def clear(directory):
"""Delete the backups of a closed operation, and its folder on another
storage once it is empty. The journal and the log of the operation stay."""
directory = Path(directory)
base = recall(directory)
if base != directory and not base.exists():
return
for backup in list(base.glob('backup/vzdump-lxc-*')) + list(base.glob('backup-*/vzdump-lxc-*')):
if backup.is_file() and not backup.is_symlink():
backup.unlink()
if base == directory:
return
for folder in sorted(base.glob('backup*'), reverse=True):
_remove_empty(folder)
# The folder of the operation and the one of the application, never the
# one shared by every application nor the storage itself.
for folder in (base, base.parent):
if folder.parent.name == FOLDER or folder.parent.parent.name == FOLDER:
_remove_empty(folder)
def _remove_empty(folder):
try:
if folder.is_dir() and not folder.is_symlink():
folder.rmdir()
except OSError:
pass
def forget(root, vmid):
"""Before the record of an application is deleted: remove what its
operations left on another storage."""
transactions = instances.location(root, vmid).parent / 'transactions'
if not transactions.is_dir():
return
for directory in transactions.iterdir():
base = recall(directory)
if base != directory and base.exists() and FOLDER in base.parts:
shutil.rmtree(base, ignore_errors=True)
_remove_empty(base.parent)
def application(root, vmid):
"""The containers of the application `vmid` belongs to."""
record = instances.read(root, vmid)
primary_id = int((record.get('stack_member') or {}).get('primary_vmid') or vmid)
primary = record if primary_id == vmid else instances.read(root, primary_id)
members = [int(member['vmid']) for member in (primary.get('stack') or {}).get('members') or []]
return primary_id, members or [vmid]
def status(root, vmid):
"""What the menu needs to know before an operation: whether the backup
fits on the system disk, the storage chosen earlier and the ones that
have room."""
primary_id, vmids = application(root, vmid)
record = instances.read(root, primary_id)
default = instances.location(root, primary_id).parent
needed, free = required(vmids), available(default)
saved = (record.get('deployment') or {}).get(KEY)
path = storage_path(saved) if saved else None
saved_free = available(path) if path is not None and path.is_dir() else None
return {'needed': needed, 'free': free, 'fits': free >= needed, 'saved': saved,
'saved_fits': saved_free is not None and saved_free >= needed,
'candidates': [{'storage': item['storage'], 'free': item['free']}
for item in file_storages() if item['free'] >= needed]}
def choose(root, vmid, storage):
"""Keep the storage chosen for the backups of an application in the
record of each of its containers. None forgets it."""
import oci_carried_record
if storage is not None and storage_path(storage) is None:
raise ValueError(f"{translate('The storage does not accept backups:')} {storage}")
with instances.locked(root):
_, vmids = application(root, vmid)
for member in vmids:
record = instances.read(root, member)
deployment = record.setdefault('deployment', {})
if storage is None:
deployment.pop(KEY, None)
else:
deployment[KEY] = storage
instances.write(instances.location(root, member), record)
oci_carried_record.carry(root, member, mount_stopped=False)
return vmids
+43
View File
@@ -286,6 +286,44 @@ def set_watchdog(project, vmids, enabled):
return True
def _work_backup_ready(project, ui, vmid):
"""Before an operation that backs the application up: when the backup
does not fit on the system disk of the host, the user chooses the storage
where it is made. False when there is nowhere to make it."""
if getattr(ui, 'unattended', False):
# The engine uses the storage chosen earlier, or says why it stops.
return True
sys.path.insert(0, str(project / 'remote'))
import oci_instances as instances
import oci_work_backup
try:
state = oci_work_backup.status(instances.ROOT, vmid)
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
return True
if state['fits'] or state['saved_fits']:
return True
lacking = (f"{translate('The backup made before changing the application does not fit on the system disk of the host.')} "
f"{translate('Needed:')} {oci_work_backup.gib(state['needed'])}. {translate('Free:')} {oci_work_backup.gib(state['free'])}.")
candidates = sorted(state['candidates'], key=lambda item: item['free'], reverse=True)
if not candidates:
ui.message(f"{lacking}\n\n{translate('No other storage of this host that accepts backups has that much free space. Free space and try again; the application was not modified.')}",
translate('OCI management'))
return False
ui.message(f"{lacking}\n\n{translate('It can be made on another storage of this host. This backup is temporary: it is deleted when the operation ends. The choice is remembered for the next operations of this application.')}",
translate('OCI management'))
options = [(item['storage'], f"{item['storage']} - {oci_work_backup.gib(item['free'])} {translate('free')}") for item in candidates]
selected = ui.choose(translate('Storage for the backup of this operation'), options, options[0][0])
if selected is None:
return False
try:
oci_work_backup.choose(instances.ROOT, vmid, selected)
except (OSError, ValueError, KeyError):
ui.message(translate('Another OCI operation is using the instance registry. Wait for it to finish and open this menu again; no container is modified.'),
translate('OCI management'))
return False
return True
def _toggle_watchdog(project, ui, vmid, enabled):
"""Ask and change the watchdog of an application from its menu."""
question = (translate('This application is under watchdog: it is restarted automatically when it crashes. Turn the watchdog off?')
@@ -342,6 +380,9 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()):
return _toggle_watchdog(project, ui, row['vmid'], watched)
if action == 'remove':
return _remove(project, ui, row['vmid'])
if not _work_backup_ready(project, ui, row['vmid']):
return False
record = instances.read(instances.ROOT, row['vmid'])
import oci_instance_reconcile as reconcile
try:
current_config = instances.command('pct', 'config', str(row['vmid']))
@@ -565,6 +606,8 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
return False
import json
members = json.loads(Path(pending).read_text())['plan']['members']
if not pending and not _work_backup_ready(project, ui, primary_id):
return False
command = [sys.executable, str(project / 'remote/oci_stack_native.py'), str(primary_id)]
if pending:
command.append('--recover')
+89
View File
@@ -0,0 +1,89 @@
"""The DHCP client of a container that restarted itself is started again."""
import sys
import tempfile
from pathlib import Path
from unittest import TestCase
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
import oci_dhcp_lease as lease
NET = 'name=eth0,bridge=vmbr0,firewall=1,host-managed=1,hwaddr=BC:24:11:D5:0F:FD,type=veth'
class ManagedInterfaceTests(TestCase):
def test_only_host_managed_interfaces_on_dhcp(self):
config = (f'net0: {NET},ip=dhcp\n'
'net1: name=eth1,bridge=vmbr10,host-managed=1,ip=10.77.1.30/24,type=veth\n'
'net2: name=eth2,bridge=vmbr0,ip=dhcp,type=veth\n'
'net3: name=eth3,bridge=vmbr0,host-managed=1,ip=dhcp,ip6=dhcp,type=veth\n'
'net4: name=eth4,bridge=vmbr0,host-managed=1,ip=dhcp,link_down=1,type=veth\n')
self.assertEqual(lease.managed(config), [('eth0', 4), ('eth3', 4), ('eth3', 6)])
def test_snapshots_and_odd_names_are_ignored(self):
config = ('net0: name=eth0;reboot,bridge=vmbr0,host-managed=1,ip=dhcp\n'
f'[before-update]\nnet1: {NET},ip=dhcp\n')
self.assertEqual(lease.managed(config), [])
class RepairTests(TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
(self.root / 'conf').mkdir()
(self.root / 'proc').mkdir()
(self.root / 'conf/121.conf').write_text(f'net0: {NET},ip=dhcp\n')
for item in (patch.object(lease, 'CONFIGS', self.root / 'conf'), patch.object(lease, 'PROC', self.root / 'proc'),
patch.object(lease, 'init_pid', return_value=53014)):
item.start()
self.addCleanup(item.stop)
def client(self, pid, vmid):
(self.root / f'proc/{pid}').mkdir()
(self.root / f'proc/{pid}/cmdline').write_bytes(
f'/sbin/dhclient\0-1\0-4\0-pf\0/var/lib/lxc/{vmid}/hook/dhclient4-eth0.pid\0eth0\0'.encode())
def test_a_missing_client_is_started(self):
self.client(1661, 115)
with patch.object(lease, 'start_client', return_value=True) as start:
self.assertEqual(lease.repair(121), [('eth0', 4)])
start.assert_called_once_with(121, 'eth0', 4, 53014)
def test_a_running_client_is_left_alone(self):
self.client(42092, 121)
with patch.object(lease, 'start_client') as start:
self.assertEqual(lease.repair(121), [])
start.assert_not_called()
def test_a_stopped_or_unknown_container_is_left_alone(self):
with patch.object(lease, 'start_client') as start:
with patch.object(lease, 'init_pid', return_value=None):
self.assertEqual(lease.repair(121), [])
self.assertEqual(lease.repair(999), [])
start.assert_not_called()
def test_a_container_that_just_started_is_left_to_proxmox(self):
with patch.object(lease, 'start_client', return_value=True) as start:
with patch.object(lease, 'running_for', return_value=5.0):
self.assertEqual(lease.repair(121, settle=lease.SETTLE), [])
with patch.object(lease, 'running_for', return_value=3600.0):
self.assertEqual(lease.repair(121, settle=lease.SETTLE), [('eth0', 4)])
start.assert_called_once()
def test_every_container_with_a_managed_interface_is_listed(self):
(self.root / 'conf/109.conf').write_text('net0: name=eth0,bridge=vmbr0,ip=dhcp,type=veth\n')
(self.root / 'conf/100.conf').write_text(f'net0: {NET},ip=dhcp\n')
self.assertEqual(lease.containers(), [100, 121])
def test_the_start_hook_queues_the_check(self):
hook = (ROOT / 'remote/oci_console_mark.sh').read_text()
self.assertIn('lease="${0%/*}/oci_dhcp_lease.py"', hook)
self.assertIn('systemd-run --quiet --collect --on-active=20 /usr/bin/python3 "$lease" "$1"', hook)
self.assertTrue(hook.rstrip().endswith('exit 0'))
if __name__ == '__main__':
import unittest
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""The notes of a container of a suite or stack carry the address it is reached at."""
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import install_generic_stack as generic
import oci_native_stack
RECORD = {"installation_id": "22d65764-750c-4aba-b0ef-55f5eb4dda0d",
"template": {"id": "linuxserver-prowlarr", "catalog_ui": {"title": {"en_US": "Prowlarr"},
"launch": {"scheme": "http", "port": 9696, "path": "/"}},
"container_contract": {"image": {"reference": "lscr.io/linuxserver/prowlarr:latest"}}}}
class GenericStackNotes(unittest.TestCase):
def refresh(self, listed, status="status: running\n"):
calls = []
def run(*args, **_):
calls.append([str(arg) for arg in args])
return status if args[:2] == ("pct", "status") else ""
def info(command, **_):
if command[0] == "lxc-info":
return subprocess.CompletedProcess(command, 0, stdout=listed, stderr="")
return subprocess.CompletedProcess(command, 0, stdout="net0: name=eth0,bridge=vmbr11,ip=10.77.1.30/24,type=veth\n"
"net1: name=eth1,bridge=vmbr0,ip=dhcp,type=veth\n", stderr="")
with patch.object(generic, "run", side_effect=run), patch.object(generic.oci_instances, "read", return_value=RECORD), \
patch.object(oci_native_stack.subprocess, "run", side_effect=info), \
patch.object(oci_native_stack.time, "sleep"):
generic.refresh_notes([{"vmid": 180}])
written = [call for call in calls if call[:2] == ["pct", "set"]]
self.assertEqual(len(written), 1)
self.assertEqual(written[0][2:4], ["180", "--description"])
return written[0][4]
def test_the_local_network_address_is_written_not_the_one_of_the_private_network(self):
notes = self.refresh("10.77.1.30\n192.168.0.78\n")
self.assertIn("http://192.168.0.78:9696/", notes)
self.assertNotIn("10.77.1.30", notes)
def test_it_does_not_depend_on_the_range_of_the_local_network(self):
notes = self.refresh("10.77.1.30\n10.0.1.141\n")
self.assertIn("http://10.0.1.141:9696/", notes)
self.assertNotIn("10.77.1.30", notes)
def test_a_stopped_container_gets_its_notes_without_an_address(self):
notes = self.refresh("", status="status: stopped\n")
self.assertNotIn("http://10.", notes)
self.assertIn("Prowlarr OCI", notes)
self.assertIn("proxmenux-instance=22d65764-750c-4aba-b0ef-55f5eb4dda0d", notes)
def test_a_container_whose_notes_cannot_be_written_does_not_stop_the_installation(self):
with patch.object(generic, "run", side_effect=subprocess.CalledProcessError(1, "pct")), \
patch.object(generic.oci_instances, "read", return_value=RECORD), patch.object(generic, "log") as log:
generic.refresh_notes([{"vmid": 180}])
self.assertIn("CT 180", log.call_args.args[1])
if __name__ == "__main__":
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
"""Home Assistant OS is updated and modified like any other application."""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest import TestCase
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
import oci_instance_transaction as transaction
HAOS = json.loads((ROOT / 'catalog/curated/haos-one.json').read_text())
CONFIG = (b'net0: name=eth0,bridge=vmbr0,firewall=1,host-managed=1,hwaddr=BC:24:11:D5:0F:FD,ip=dhcp,type=veth\n'
b'ostype: unmanaged\n')
ETH0 = b'2: eth0 inet 192.168.0.100/24 brd 192.168.0.255 scope global eth0\\ valid_lft forever\n'
class HaosCheckTests(TestCase):
def test_the_recipe_has_a_check_so_the_operation_is_not_refused(self):
check = transaction.effective_healthcheck(HAOS)
self.assertEqual(check, {'type': 'haos', 'timeout_seconds': 1200})
def test_the_check_is_left_to_the_installer_profile(self):
contract = transaction.with_default_healthcheck({'template': json.loads(json.dumps(HAOS))})
profile = contract['template']['proxmox']['installer_profile']
self.assertNotIn('startup_healthcheck', profile)
self.assertEqual(profile['haos_healthcheck'], {'timeout_seconds': 1200})
def run_commands(self, *args):
if args[:2] == ('pct', 'config'):
return CONFIG
if args[:2] == ('pct', 'status'):
return b'status: running\n'
if args[0] == 'lxc-attach':
return ETH0
raise AssertionError(args)
def test_the_container_answers_on_its_own_interface(self):
with patch.object(transaction, 'run', side_effect=self.run_commands):
self.assertEqual(transaction.own_addresses(121), ['192.168.0.100'])
def test_a_restored_container_passes_the_check_of_home_assistant(self):
import haos_healthcheck
urls = [{'label': 'Home Assistant', 'url': 'http://192.168.0.100:8123/'}]
with patch.object(transaction, 'run', side_effect=self.run_commands), \
patch.object(transaction, 'log'), \
patch.object(haos_healthcheck, 'wait_ready', return_value=urls) as wait:
self.assertEqual(transaction.healthcheck(121, HAOS), 'http://192.168.0.100:8123/')
wait.assert_called_once_with(121, '192.168.0.100', 1200)
def test_a_restored_container_that_does_not_pass_is_not_confirmed(self):
import haos_healthcheck
with patch.object(transaction, 'run', side_effect=self.run_commands), \
patch.object(transaction, 'log'), \
patch.object(haos_healthcheck, 'wait_ready', side_effect=RuntimeError('time is up')):
with self.assertRaises(ValueError):
transaction.healthcheck(121, HAOS)
class InstallerAddressTests(TestCase):
"""The installer takes the address of an interface of the container, not
the one of a bridge Docker created inside it."""
def address(self, listed, attach):
script = (ROOT / 'remote/install_oci.sh').read_text()
start = script.index('own_ipv4() {')
function = script[start:script.index('\n}\n', start) + 3]
with tempfile.TemporaryDirectory() as tmp:
for name, body in (('lxc-info', f'printf "{listed}"'),
('pct', 'echo "net0: name=eth0,bridge=vmbr0,ip=dhcp,type=veth"'),
('lxc-attach', attach)):
path = Path(tmp) / name
path.write_text(f'#!/bin/bash\n{body}\n')
path.chmod(0o755)
result = subprocess.run(['bash', '-c', f'PATH={tmp}:$PATH; VMID=121; {function}\nown_ipv4; echo "rc=$?"'],
capture_output=True, text=True)
return result.stdout.split()
def test_the_inner_bridges_are_skipped(self):
attach = 'echo "2: eth0 inet 192.168.0.100/24 brd 192.168.0.255 scope global eth0"'
self.assertEqual(self.address('172.30.232.1\\n172.30.32.1\\n192.168.0.100\\n', attach), ['192.168.0.100', 'rc=0'])
def test_no_address_yet_is_waited_for(self):
self.assertEqual(self.address('172.30.232.1\\n', 'true'), ['rc=0'])
def test_interfaces_that_cannot_be_read_leave_it_to_the_caller(self):
self.assertEqual(self.address('192.168.0.100\\n', 'exit 1'), ['rc=1'])
self.assertEqual(self.address('', 'true'), ['rc=1'])
if __name__ == '__main__':
import unittest
unittest.main()
+177
View File
@@ -0,0 +1,177 @@
"""A privileged container, relaxed confinement, process limits, a monitor of
the host and a single mounted file are rebuilt like any other application."""
import json
import sys
import tempfile
from pathlib import Path
from unittest import TestCase
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
import oci_host_mounts as host_mounts
import oci_instance_transaction as transaction
import oci_runtime_settings as runtime_settings
INSTALLER = (ROOT / 'remote/install_oci.sh').read_text()
GLANCES = json.loads((ROOT / 'catalog/apps/glances.json').read_text())
MONITOR_CONFIG = (b'unprivileged: 0\n'
b'lxc.apparmor.profile: unconfined\n'
b'lxc.mount.entry: /etc/os-release etc/os-release none bind,create=file,ro 0 0\n'
b'lxc.mount.entry: /srv/app.conf etc/app.conf none bind,create=file 0 0\n'
b'lxc.mount.entry: tmpfs run/app tmpfs rw,size=64M,create=dir 0 0\n'
b'lxc.include: /etc/pve/proxmenux/host-monitor\n'
b'lxc.net.0.type: none\n')
def bind(source, target, read_only=True):
return {'type': 'host-bind', 'container_path': target, 'source': source, 'backup': False, 'read_only': read_only}
class ProfileKeyTests(TestCase):
def test_each_declared_setting_names_the_entries_the_installer_writes(self):
deployment = {'security': {'options': {'apparmor_profile': 'unconfined', 'seccomp_profile': 'unconfined',
'drop_all_capabilities': True, 'no_new_privileges': True}},
'resources': {'rlimits': [{'name': 'nofile', 'soft': '65536', 'hard': '65536'}]},
'host_monitor': 'glances'}
self.assertEqual(transaction.profile_keys(deployment), {
'lxc.apparmor.profile', 'lxc.seccomp.profile', 'lxc.cap.drop', 'lxc.cap.keep', 'lxc.include',
'lxc.prlimit.nofile', 'lxc.net.0.type', 'lxc.hook.mount'})
def test_a_plain_application_adds_none(self):
self.assertEqual(transaction.profile_keys({'security': {'unprivileged': True, 'options': {}},
'resources': {'rlimits': []}}), set())
def test_a_host_monitor_is_checked_like_any_other_application(self):
self.assertEqual(transaction.effective_healthcheck(GLANCES)['port'], 61208)
template = {'proxmox': {'installer_profile': {'host_monitor': 'netdata'}},
'first_run': {'endpoints': [{'scheme': 'http', 'port': 19999, 'path': '/'}]}}
self.assertEqual(transaction.effective_healthcheck(template)['port'], 19999)
class FileBindTests(TestCase):
def test_single_files_are_read_from_the_raw_entries(self):
self.assertEqual(runtime_settings.file_binds(MONITOR_CONFIG), {'/etc/os-release': True, '/srv/app.conf': False})
def test_declared_files_are_not_left_to_the_acceleration_check(self):
deployment = {'mounts': [bind('/etc/os-release', '/etc/os-release')],
'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 64, 'mount_options': ['rw']}]}
left = runtime_settings.filter_config(MONITOR_CONFIG, deployment).decode()
self.assertNotIn('/etc/os-release', left)
self.assertNotIn('tmpfs', left)
self.assertNotIn('lxc.include', left)
# One the deployment does not declare stays, and is refused there.
self.assertIn('/srv/app.conf', left)
def test_a_file_is_told_apart_from_a_directory(self):
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / 'os-release'
file.write_text('x')
self.assertTrue(transaction.is_file_bind(bind(str(file), '/etc/os-release')))
self.assertFalse(transaction.is_file_bind(bind(tmp, '/data')))
self.assertTrue(transaction.is_file_bind(bind('/gone/file', '/x'), {'/gone/file': True}))
self.assertFalse(transaction.is_file_bind({'type': 'managed-volume', 'source': 'local-lvm'}))
def test_files_are_not_pinned_nor_ask_for_the_host_data_notice(self):
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / 'os-release'
file.write_text('x')
record = {'template': {}, 'observed': {},
'deployment': {'mounts': [bind(str(file), '/etc/os-release')]}}
with patch.object(transaction, 'log'):
self.assertEqual(transaction.freeze_host_sources(record, record, False), ({}, {}))
class DeclaredSourceTests(TestCase):
def test_only_a_host_monitor_declares_sources(self):
contract = {'template': GLANCES, 'deployment': {'host_monitor': 'glances'}}
self.assertEqual(transaction.declared_sources(contract), {'/etc/os-release'})
self.assertEqual(transaction.declared_sources({'template': GLANCES, 'deployment': {'host_monitor': None}}), set())
def test_a_declared_system_directory_is_accepted_and_stays_declared(self):
with self.assertRaises(ValueError):
host_mounts.validate_source('/usr')
value = host_mounts.validate_source('/usr', declared=True)
self.assertTrue(value['declared'])
host_mounts.verify_sources({'/usr': value})
with self.assertRaises(ValueError):
host_mounts.verify_sources({'/usr': {k: v for k, v in value.items() if k != 'declared'}})
def test_the_system_mounts_of_a_monitor_need_no_notice_about_host_data(self):
record = {'template': {'proxmox': {'installer_profile': {'host_monitor_mounts': [{'source': '/usr', 'target': '/host/usr'}]}}},
'observed': {}, 'deployment': {'host_monitor': 'netdata', 'mounts': [bind('/usr', '/host/usr')]}}
with patch.object(transaction, 'log'):
original, desired = transaction.freeze_host_sources(record, record, False)
self.assertTrue(original['/usr']['declared'] and desired['/usr']['declared'])
class IncludeTests(TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.include = Path(tmp.name) / 'host-monitor'
patcher = patch.object(runtime_settings, 'HOST_MONITOR_INCLUDES', (self.include,))
patcher.start()
self.addCleanup(patcher.stop)
def config(self):
return f'lxc.include: {self.include}\n'.encode()
def test_the_include_of_a_host_monitor_is_accepted_unchanged(self):
self.include.write_text(runtime_settings.HOST_MONITOR_CONTENT)
runtime_settings.check(self.config(), {'host_monitor': 'glances'}, 122)
runtime_settings.check_recovery(self.config(), {'vmid': 122, 'record': {'deployment': {'host_monitor': 'glances'}}})
def test_a_changed_include_or_one_nobody_declared_is_refused(self):
self.include.write_text(runtime_settings.HOST_MONITOR_CONTENT + 'lxc.cap.drop =\n')
with self.assertRaises(ValueError):
runtime_settings.check(self.config(), {'host_monitor': 'glances'}, 122)
self.include.write_text(runtime_settings.HOST_MONITOR_CONTENT)
with self.assertRaises(ValueError):
runtime_settings.check(self.config(), {}, 122)
def test_no_new_privileges_goes_in_the_include_of_the_container(self):
deployment = {'security': {'options': {'no_new_privileges': True},
'sysctls': [{'name': 'net.ipv4.ip_forward', 'value': '1'}]}}
self.assertEqual(runtime_settings.sysctl_content(deployment),
'lxc.sysctl.net.ipv4.ip_forward = 1\nlxc.no_new_privs = 1\n')
self.assertEqual(runtime_settings.sysctl_content({'security': {'options': {}}}), '')
class RemovalTests(TestCase):
def test_the_seccomp_profile_of_a_removed_container_is_deleted(self):
import oci_remove
with tempfile.TemporaryDirectory() as tmp:
profile = Path(tmp) / '126.proxmenux-seccomp'
profile.write_text('2\ndenylist\n[all]\n')
with patch.object(runtime_settings, 'seccomp_path', return_value=profile), \
patch.object(runtime_settings, 'include_path', return_value=Path(tmp) / 'none'), \
patch.object(runtime_settings, 'legacy_include_path', return_value=Path(tmp) / 'none'), \
patch.object(oci_remove, 'SNIPPETS', Path(tmp) / 'snippets'), \
patch.object(oci_remove, 'MONITOR_APPS', Path(tmp) / 'apps'), \
patch.object(oci_remove, 'CLUSTER_RECORDS', Path(tmp) / 'records'):
self.assertTrue(oci_remove._leftovers(126))
oci_remove.remove_host_state(126)
self.assertFalse(profile.exists())
class InstallerTests(TestCase):
def test_no_new_privileges_is_not_written_as_a_container_setting(self):
self.assertNotIn('set_lxc_directive "lxc.no_new_privs"', INSTALLER)
self.assertIn("printf 'lxc.no_new_privs = 1\\n' >>\"$SYSCTL_TEMP\"", INSTALLER)
def test_a_startup_check_without_its_optional_fields_does_not_end_the_installation(self):
self.assertIn(".request_timeout_seconds // 5", INSTALLER)
self.assertIn(".verify_tls // false", INSTALLER)
def test_seerr_can_write_its_configuration(self):
for name in ('curated', 'apps'):
profile = json.loads((ROOT / f'catalog/{name}/seerr.json').read_text())['proxmox']['installer_profile']
self.assertEqual(profile['volume_owner'], {'uid': 1000, 'gid': 1000})
self.assertEqual(profile['volume_preparations'][0]['container_path'], '/app/config')
self.assertEqual(profile['startup_healthcheck']['request_timeout_seconds'], 5)
if __name__ == '__main__':
import unittest
unittest.main()
+258
View File
@@ -0,0 +1,258 @@
"""Where the backup taken before changing an application is written."""
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_work_backup as work
GIB = 1024 ** 3
class Location(unittest.TestCase):
def locate(self, free, needed=10 * GIB, record=None, storage_free=None, storage_dir=None):
def available(path):
return storage_free if storage_dir is not None and Path(path) == storage_dir else free
with patch.object(work, "required", return_value=needed), patch.object(work, "available", side_effect=available), \
patch.object(work, "storage_path", return_value=storage_dir):
return work.locate(Path("/registry/104/transactions/abc"), [104], record or {}, 104)
def test_a_backup_that_fits_stays_next_to_the_record(self):
self.assertEqual(self.locate(free=11 * GIB), Path("/registry/104/transactions/abc"))
def test_without_room_and_without_a_chosen_storage_it_says_what_is_needed(self):
with self.assertRaises(ValueError) as failure:
self.locate(free=2 * GIB)
text = str(failure.exception)
for expected in ("does not fit on the system disk", "10.0 GB", "2.0 GB", "The application was not modified."):
self.assertIn(expected, text)
self.assertNotIn("/registry", text)
def test_the_chosen_storage_is_used_when_the_system_disk_has_no_room(self):
with tempfile.TemporaryDirectory() as folder:
base = self.locate(free=2 * GIB, record={"deployment": {"work_storage": "nas"}},
storage_free=50 * GIB, storage_dir=Path(folder))
self.assertEqual(base, Path(folder) / "proxmenux-oci-work" / "104" / "abc")
self.assertFalse((Path(folder) / "proxmenux-oci-work").exists())
def test_a_chosen_storage_without_room_is_named(self):
with tempfile.TemporaryDirectory() as folder, self.assertRaises(ValueError) as failure:
self.locate(free=2 * GIB, record={"deployment": {"work_storage": "nas"}},
storage_free=3 * GIB, storage_dir=Path(folder))
self.assertIn("nas", str(failure.exception))
self.assertIn("3.0 GB", str(failure.exception))
def test_free_space_is_read_where_the_folder_will_be_created(self):
with tempfile.TemporaryDirectory() as folder:
self.assertGreater(work.available(Path(folder) / "not" / "yet" / "there"), 0)
class Remembering(unittest.TestCase):
def test_the_folder_of_the_operation_says_where_its_backups_are(self):
with tempfile.TemporaryDirectory() as folder:
operation = Path(folder) / "registry" / "abc"
operation.mkdir(parents=True)
self.assertEqual(work.recall(operation), operation)
work.remember(operation, operation)
self.assertFalse((operation / work.MARK).exists())
outside = Path(folder) / "nas" / work.FOLDER / "104" / "abc"
work.remember(operation, outside)
self.assertEqual(work.recall(operation), outside)
def test_a_location_that_is_not_one_of_ours_is_never_followed(self):
with tempfile.TemporaryDirectory() as folder:
operation = Path(folder)
(operation / work.MARK).write_text("/etc\n")
self.assertEqual(work.recall(operation), operation)
(operation / work.MARK).write_text("relative/proxmenux-oci-work/1/x\n")
self.assertEqual(work.recall(operation), operation)
class Cleaning(unittest.TestCase):
def test_the_backups_next_to_the_record_are_deleted_and_the_journal_stays(self):
with tempfile.TemporaryDirectory() as folder:
operation = Path(folder)
(operation / "backup").mkdir()
(operation / "backup-105").mkdir()
for name in ("backup/vzdump-lxc-104-x.tar.zst", "backup-105/vzdump-lxc-105-x.tar.zst", "transaction.json"):
(operation / name).write_text("x")
work.clear(operation)
self.assertEqual(sorted(path.name for path in operation.rglob("*") if path.is_file()), ["transaction.json"])
def test_the_folder_on_another_storage_goes_away_and_the_storage_is_left_alone(self):
with tempfile.TemporaryDirectory() as folder:
operation = Path(folder) / "registry" / "abc"
operation.mkdir(parents=True)
storage = Path(folder) / "nas"
base = storage / work.FOLDER / "104" / "abc"
(base / "backup").mkdir(parents=True)
(base / "backup" / "vzdump-lxc-104-x.tar.zst").write_text("x")
(storage / "dump").mkdir()
(storage / "dump" / "vzdump-lxc-104-kept.tar.zst").write_text("a backup of the user")
other = storage / work.FOLDER / "200" / "zzz"
other.mkdir(parents=True)
work.remember(operation, base)
work.clear(operation)
self.assertFalse((storage / work.FOLDER / "104").exists())
self.assertTrue(other.is_dir())
self.assertTrue((storage / "dump" / "vzdump-lxc-104-kept.tar.zst").is_file())
def test_removing_an_application_removes_what_its_operations_left_on_another_storage(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder) / "registry"
operation = root / "104" / "transactions" / "abc"
operation.mkdir(parents=True)
base = Path(folder) / "nas" / work.FOLDER / "104" / "abc"
(base / "backup").mkdir(parents=True)
(base / "backup" / "vzdump-lxc-104-x.tar.zst").write_text("x")
work.remember(operation, base)
work.forget(root, 104)
self.assertFalse(base.exists())
self.assertTrue((Path(folder) / "nas").is_dir())
class Choice(unittest.TestCase):
def test_the_choice_is_kept_in_every_container_of_the_application(self):
import oci_carried_record
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
ids = {104: "11111111-1111-4111-8111-111111111111", 105: "22222222-2222-4222-8222-222222222222"}
for vmid, extra in ((104, {"stack": {"members": [{"vmid": 104}, {"vmid": 105}]}, "stack_member": {"primary_vmid": 104}}),
(105, {"stack_member": {"primary_vmid": 104}})):
(root / str(vmid)).mkdir()
(root / str(vmid) / "oci-compose.json").write_text(json.dumps({
"schema_version": 1, "vmid": vmid, "installation_id": ids[vmid], "status": "installed",
"deployment": {"onboot": True}, **extra}))
saved = lambda vmid: json.loads((root / str(vmid) / "oci-compose.json").read_text())["deployment"]
with patch.object(work, "storage_path", return_value=Path(folder)), \
patch.object(oci_carried_record, "carry", return_value="carried"):
self.assertEqual(work.choose(root, 105, "nas"), [104, 105])
self.assertEqual((saved(104)["work_storage"], saved(105)["work_storage"]), ("nas", "nas"))
work.choose(root, 104, None)
self.assertNotIn("work_storage", saved(104))
self.assertEqual(saved(105), {"onboot": True})
def test_a_storage_that_does_not_take_backups_is_not_accepted(self):
with patch.object(work, "storage_path", return_value=None), self.assertRaises(ValueError):
work.choose(Path("/registry"), 104, "isos")
class Engines(unittest.TestCase):
def test_a_modification_keeps_what_the_user_chose_for_the_application(self):
import oci_instance_transaction as transaction
record = {"status": "installed", "vmid": 109, "installation_id": "x", "template": {"id": "adguard"},
"observed": {"config_sha256": "c"},
"deployment": {"vmid": 109, "cores": 1, "watchdog": True, "work_storage": "nas"}}
edited = {"vmid": 109, "installation_id": "x", "template": {"id": "adguard"},
"deployment": {"vmid": 109, "cores": 4, "watchdog": False}}
with patch.object(transaction, "with_default_healthcheck", side_effect=lambda value: value):
result = transaction.candidate_contract(record, "recreate", {"operation": "recreate", "candidate": edited})
self.assertEqual(result["deployment"], {"vmid": 109, "cores": 4, "watchdog": True, "work_storage": "nas"})
record["deployment"] = {"vmid": 109, "cores": 1}
edited["deployment"]["work_storage"] = "stale"
result = transaction.candidate_contract(record, "recreate", {"operation": "recreate", "candidate": edited})
self.assertEqual(result["deployment"], {"vmid": 109, "cores": 4})
def test_a_stack_asks_for_room_only_until_its_first_backup_exists(self):
import oci_stack_native as native
with tempfile.TemporaryDirectory() as folder:
operation = Path(folder) / "abc"
operation.mkdir()
adapter = native.NativeAdapter.__new__(native.NativeAdapter)
adapter.journal = operation / "transaction.json"
adapter.plan = {"primary_vmid": 104}
adapter.records = {104: {"deployment": {}}, 105: {"deployment": {}}}
with patch.object(native.work_backup, "locate", return_value=operation) as locate:
self.assertEqual(adapter.work_base(), operation)
(operation / "backup-105").mkdir()
self.assertEqual(adapter.work_base(), operation)
self.assertEqual(locate.call_count, 1)
self.assertEqual(locate.call_args.args[1:], ([104, 105], {"deployment": {}}, 104))
def test_an_image_that_does_not_fit_is_refused_before_it_is_downloaded(self):
import oci_update_current as update
desired = {"template": {"container_contract": {"image": {"reference": "docker.io/library/app:latest"}}},
"deployment": {"template_storage": "local"}}
candidate = {"manifest_digest": "sha256:" + "a" * 64, "registry_digest": None, "version": "1", "size": 3 * GIB}
called = []
def quiet(command, failure, capture=False):
called.append(command[0])
return json.dumps(candidate)
with tempfile.TemporaryDirectory() as folder, \
patch.object(update, "run_quiet", side_effect=quiet), \
patch.object(update.transaction, "parse_config", return_value={"arch": "amd64"}), \
patch.object(update.instances, "command", return_value=(folder + "/image.tar").encode()), \
patch.object(update.work_backup, "available", return_value=GIB), \
patch.object(update, "msg_info"), patch.object(update, "msg_ok"), patch.object(update.transaction, "log"):
with self.assertRaises(ValueError) as failure:
update.resolve_archive(desired, b"arch: amd64")
text = str(failure.exception)
for expected in ("does not fit on the storage where images are downloaded", "local", "1.0 GB", "The application was not modified."):
self.assertIn(expected, text)
self.assertNotIn("skopeo", called)
class Menu(unittest.TestCase):
def ready(self, state, pick=None, unattended=False):
sys.path.insert(0, str(ROOT / "src"))
from proxmenux_oci import management
shown = []
class UI:
def message(self, text, title=None):
shown.append(text)
def choose(self, title, options, default=None):
shown.append(options)
return pick
ui = UI()
if unattended:
ui.unattended = True
with patch.object(work, "status", return_value=state) as status, patch.object(work, "choose") as choose, \
patch.object(management, "translate", side_effect=lambda text: text):
result = management._work_backup_ready(ROOT, ui, 109)
return result, shown, status, choose
STATE = {"needed": 10 * GIB, "free": 2 * GIB, "fits": False, "saved": None, "saved_fits": False,
"candidates": [{"storage": "small", "free": 12 * GIB}, {"storage": "nas", "free": 900 * GIB}]}
def test_nothing_is_asked_when_the_backup_fits_or_a_storage_with_room_was_chosen(self):
for state in ({**self.STATE, "fits": True}, {**self.STATE, "saved": "nas", "saved_fits": True}):
result, shown, _, choose = self.ready(state)
self.assertTrue(result)
self.assertEqual(shown, [])
choose.assert_not_called()
def test_the_storages_with_room_are_offered_with_the_roomiest_first_and_the_choice_is_saved(self):
result, shown, _, choose = self.ready(self.STATE, pick="nas")
self.assertTrue(result)
self.assertIn("10.0 GB", shown[0])
self.assertIn("2.0 GB", shown[0])
self.assertEqual([tag for tag, _ in shown[1]], ["nas", "small"])
self.assertEqual(choose.call_args.args[1:], (109, "nas"))
def test_leaving_the_list_or_having_no_storage_with_room_does_not_start_the_operation(self):
result, _, _, choose = self.ready(self.STATE, pick=None)
self.assertFalse(result)
choose.assert_not_called()
result, shown, _, _ = self.ready({**self.STATE, "candidates": []})
self.assertFalse(result)
self.assertEqual(len(shown), 1)
self.assertIn("No other storage", shown[0])
def test_a_scheduled_run_asks_nothing_and_lets_the_engine_decide(self):
result, shown, status, _ = self.ready(self.STATE, unattended=True)
self.assertTrue(result)
self.assertEqual(shown, [])
status.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -125,6 +125,9 @@
}
]
}
},
{
"p": "Before anything is stopped, the operation checks that the backup fits: the data in use on the volumes that are backed up, plus a margin. It is written next to the record of the application, on the system disk of the host. When it does not fit there, the menu asks for another storage of the host that accepts backups: the backup is made there, it is deleted when the operation ends, and the choice is remembered for that application, scheduled updates included. With no storage that has room, the operation is refused and the application stays as it was. The new image is checked the same way before it is downloaded."
}
]
},
@@ -125,6 +125,9 @@
}
]
}
},
{
"p": "Antes de detener nada, la operación comprueba que el backup cabe: los datos en uso de los volúmenes que entran en el backup, más un margen. Se escribe junto al registro de la aplicación, en el disco del sistema del host. Cuando no cabe ahí, el menú pide otro almacenamiento del host que acepte backups: el backup se hace allí, se borra cuando termina la operación y la elección se recuerda para esa aplicación, también en las actualizaciones programadas. Si ningún almacenamiento tiene espacio, la operación se rechaza y la aplicación queda como estaba. La imagen nueva se comprueba igual antes de descargarla."
}
]
},