mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
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:
+25
-15
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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/'}]
|
||||
|
||||
@@ -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
@@ -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")"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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} - {''}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user