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
+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()