Add initial update platform to Proxmox (#167620)

This commit is contained in:
Tom
2026-08-29 12:15:06 +02:00
committed by GitHub
parent 0f7daec37a
commit c93f3d3781
14 changed files with 490 additions and 8 deletions
@@ -20,6 +20,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
Platform.UPDATE,
]
@@ -42,5 +42,6 @@ class ProxmoxPermission(StrEnum):
POWER = "VM.PowerMgmt"
SNAPSHOT = "VM.Snapshot"
SYSAUDIT = "Sys.Audit"
SYSMOD = "Sys.Modify"
SYSPOWER = "Sys.PowerMgmt"
VMAUDIT = "VM.Audit"
@@ -35,7 +35,9 @@ from .const import (
DEFAULT_VERIFY_SSL,
DOMAIN,
NODE_ONLINE,
ProxmoxPermission,
)
from .helpers import is_granted
type ProxmoxConfigEntry = ConfigEntry[ProxmoxCoordinator]
@@ -52,6 +54,8 @@ class NodeResources:
containers: list[dict[str, Any]]
storages: list[dict[str, Any]]
backups: list[dict[str, Any]]
version: dict[str, Any]
update: list[dict[str, Any]] | bool
@dataclass(slots=True, kw_only=True)
@@ -63,6 +67,8 @@ class ProxmoxNodeData:
containers: dict[int, dict[str, Any]] = field(default_factory=dict)
storages: dict[str, dict[str, Any]] = field(default_factory=dict)
backups: list[dict[str, Any]] = field(default_factory=list)
version: dict[str, Any] = field(default_factory=dict)
update: list[dict[str, Any]] | bool = False
def proxmox_base_url(coordinator: ProxmoxCoordinator) -> URL:
@@ -212,6 +218,8 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
},
storages={s["storage"]: s for s in resources.storages},
backups=resources.backups,
version=resources.version,
update=resources.update,
)
self._async_add_remove_nodes(data)
@@ -258,7 +266,7 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
raise ProxmoxServerError from err
def _fetch_all_nodes(self) -> list[tuple[dict[str, Any], NodeResources]]:
"""Fetch all nodes with their VMs, containers, storages, and backups."""
"""Fetch all nodes with their VMs, containers, storages, etc."""
nodes = self.proxmox.nodes.get() or []
return [(node, self._get_node_data(node)) for node in nodes]
@@ -266,13 +274,20 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
self,
node: dict[str, Any],
) -> NodeResources:
"""Get vms, containers, storages, and backups for a node."""
"""Get vms, containers, storages, etc. for a node."""
if node.get("status") != NODE_ONLINE:
_LOGGER.debug(
"Node %s is offline, skipping VM/container/storage fetch",
node[CONF_NODE],
)
return NodeResources(vms=[], containers=[], storages=[], backups=[])
return NodeResources(
vms=[],
containers=[],
storages=[],
backups=[],
version={},
update=False,
)
vms = self.proxmox.nodes(node[CONF_NODE]).qemu.get() or []
containers = self.proxmox.nodes(node[CONF_NODE]).lxc.get() or []
@@ -281,9 +296,23 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
self.proxmox.nodes(node[CONF_NODE]).tasks.get(typefilter="vzdump", limit=1)
or []
)
version = self.proxmox.nodes(node[CONF_NODE]).version.get() or {}
update: list | bool = False
if is_granted(
self.permissions,
p_type="nodes",
p_id=node[CONF_NODE],
permission=ProxmoxPermission.SYSMOD,
):
update = self.proxmox.nodes(node[CONF_NODE]).apt.update.get() or []
return NodeResources(
vms=vms, containers=containers, storages=storages, backups=backups
vms=vms,
containers=containers,
storages=storages,
backups=backups,
version=version,
update=update,
)
def _async_add_remove_nodes(self, data: dict[str, ProxmoxNodeData]) -> None:
@@ -18,10 +18,14 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a Proxmox VE config entry."""
devices = {}
# De-noise updates list
for node, node_data in config_entry.runtime_data.data.items():
d = asdict(node_data)
d.pop("update", None)
devices[node] = d
return {
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
"devices": {
node: asdict(node_data)
for node, node_data in config_entry.runtime_data.data.items()
},
"devices": devices,
}
@@ -1,8 +1,24 @@
"""Helpers for Proxmox VE."""
from dataclasses import dataclass
from typing import Any
from packaging.version import parse as parse_version
from .const import ProxmoxPermission
@dataclass(frozen=True)
class ProxmoxUpdateInfo:
"""Describes Proxmox VE update information."""
latest_version: str | None = None
latest_version_id: str | None = None
total_updates: int = 0
proxmox_updates: int = 0
other_updates: int = 0
def is_granted(
permissions: dict[str, dict[str, int]],
p_type: str = "vms",
@@ -16,3 +32,47 @@ def is_granted(
if value is not None:
return value == 1
return False
def is_proxmox_package(update: dict[str, Any]) -> bool:
"""Indicate if the given update is related to Proxmox VE."""
package = update.get("Package", "")
origin = update.get("Origin", "")
title = update.get("Title", "")
return (
package.startswith(("pve-", "libpve-"))
or "proxmox" in origin.lower()
or "proxmox" in title.lower()
)
def latest_version(versions: list[str]) -> str:
"""Return the latest version from a list of version strings."""
# Fix proxmox -pve1 style suffixes
safe_versions = [v.split("-")[0] for v in versions]
return max(safe_versions, key=parse_version)
def update_version(
current_version: str,
updates: list[dict[str, Any]],
) -> ProxmoxUpdateInfo:
"""Return the updated version based on the current version and updates."""
count = len(updates)
pve_count = sum(is_proxmox_package(u) for u in updates)
other_count = len(updates) - pve_count
versions = [current_version] + [
u["Version"] for u in updates if is_proxmox_package(u)
]
latest = latest_version(versions) if pve_count else current_version
return ProxmoxUpdateInfo(
latest_version=latest if count else current_version,
latest_version_id=f"{latest}-p{pve_count}-d{other_count}"
if count
else current_version,
total_updates=count,
proxmox_updates=pve_count,
other_updates=other_count,
)
@@ -296,6 +296,11 @@
"vm_uptime": {
"name": "Uptime"
}
},
"update": {
"node_update": {
"name": "Software update"
}
}
},
"exceptions": {
@@ -0,0 +1,120 @@
"""Update platform for Proxmox VE."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from homeassistant.components.update import (
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData
from .entity import ProxmoxNodeEntity
from .helpers import ProxmoxUpdateInfo, update_version
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class ProxmoxNodeUpdateEntityDescription(UpdateEntityDescription):
"""Describes Proxmox node update entity."""
installed_version: Callable[[ProxmoxNodeData], str]
update_info: Callable[[ProxmoxNodeData], ProxmoxUpdateInfo | bool]
NODE_UPDATES: tuple[ProxmoxNodeUpdateEntityDescription, ...] = (
ProxmoxNodeUpdateEntityDescription(
key="node_update",
translation_key="node_update",
entity_category=EntityCategory.CONFIG,
installed_version=lambda node_data: node_data.version.get("version", "unknown"),
update_info=lambda node_data: update_version(
node_data.version.get("version", "unknown"),
node_data.update if isinstance(node_data.update, list) else [],
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ProxmoxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Proxmox VE update entities."""
coordinator = entry.runtime_data
def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None:
"""Add update entities for newly discovered nodes."""
async_add_entities(
ProxmoxNodeUpdateEntity(coordinator, entity_description, node)
for node in nodes
for entity_description in NODE_UPDATES
if node.update
is not False # Only create entities for nodes where update information is available
)
coordinator.new_nodes_callbacks.append(_async_add_new_nodes)
_async_add_new_nodes(
[
node_data
for node_data in coordinator.data.values()
if node_data.node["node"] in coordinator.known_nodes
]
)
class ProxmoxNodeUpdateEntity(ProxmoxNodeEntity, UpdateEntity):
"""Represents a Proxmox VE node software update."""
_attr_supported_features = UpdateEntityFeature.RELEASE_NOTES
entity_description: ProxmoxNodeUpdateEntityDescription
@property
@override
def installed_version(self) -> str | None:
"""Return installed version."""
return self.entity_description.installed_version(
self.coordinator.data[self.device_name]
)
@property
@override
def latest_version(self) -> str | None:
"""Return latest version."""
update_info = self._update_info()
return update_info.latest_version_id if update_info else None
@property
@override
def release_summary(self) -> str | None:
"""Return the release summary for the update."""
url = self.device_info.get("configuration_url") if self.device_info else None
update_info = self._update_info()
if update_info and update_info.total_updates > 0:
return f"A total of {update_info.total_updates} package update(s) are pending installation: of these {update_info.proxmox_updates} relate to Proxmox and {update_info.other_updates} to other updates. Please visit the [Proxmox VE node]({url}) for details on the pending updates and to upgrade to {update_info.latest_version}."
return None
@property
@override
def available(self) -> bool:
"""Return if the update platform is available."""
return self._update_info() is not None
@override
def release_notes(self) -> str | None:
"""Return the release notes for the update."""
return self.release_summary
def _update_info(self) -> ProxmoxUpdateInfo | None:
"""Return update info or None if unavailable."""
info = self.entity_description.update_info(
self.coordinator.data[self.device_name]
)
return info if isinstance(info, ProxmoxUpdateInfo) else None
+5
View File
@@ -43,16 +43,21 @@ SNAPSHOT_PERMISSIONS = {
"/vms": {"VM.Snapshot": 1},
"/vms/101": {"VM.Snapshot": 0},
}
SYSMOD_PERMISSIONS = {
"/": {"Sys.Modify": 1},
}
MERGED_PERMISSIONS = {
key: {
**AUDIT_PERMISSIONS.get(key, {}),
**POWER_PERMISSIONS.get(key, {}),
**SNAPSHOT_PERMISSIONS.get(key, {}),
**SYSMOD_PERMISSIONS.get(key, {}),
}
for key in set(AUDIT_PERMISSIONS)
| set(POWER_PERMISSIONS)
| set(SNAPSHOT_PERMISSIONS)
| set(SYSMOD_PERMISSIONS)
}
PVEVMUSER_PERMISSIONS = deepcopy(MERGED_PERMISSIONS)
+6
View File
@@ -132,6 +132,12 @@ def mock_proxmox_client():
not in mock_instance.access.permissions.get.return_value.get("/nodes", [])
else load_json_array_fixture("nodes/tasks.json", DOMAIN)
)
node_mock.version.get.return_value = load_json_object_fixture(
"nodes/version.json", DOMAIN
)
node_mock.apt.update.get.return_value = load_json_array_fixture(
"nodes/update.json", DOMAIN
)
qemu_by_vmid = {int(vm["vmid"]): vm for vm in qemu_list}
lxc_by_vmid = {int(vm["vmid"]): vm for vm in lxc_list}
@@ -0,0 +1,57 @@
[
{
"Arch": "amd64",
"Description": "This package provides the 'host' DNS lookup utility in the form that is bundled with the BIND 9 sources.",
"OldVersion": "1:9.20.18-1~deb13u1",
"Origin": "Debian",
"Package": "bind9-host",
"Priority": "standard",
"Section": "net",
"Title": "DNS Lookup Utility",
"Version": "1:9.20.21-1~deb13u1"
},
{
"Arch": "all",
"Description": "This package contains the Proxmox Virtual Environment management tools.",
"OldVersion": "9.1.6",
"Origin": "Proxmox",
"Package": "pve-manager",
"Priority": "optional",
"Section": "admin",
"Title": "Proxmox Virtual Environment Management Tools",
"Version": "9.1.7"
},
{
"Arch": "all",
"Description": "This package contains the base library used by other Proxmox VE components.",
"OldVersion": "9.1.7",
"Origin": "Proxmox",
"Package": "libpve-common-perl",
"Priority": "optional",
"Section": "perl",
"Title": "Proxmox VE base library",
"Version": "9.1.9"
},
{
"Arch": "amd64",
"Description": "The Corosync Cluster Engine is a Group Communication System with additional features for implementing high availability within applications. The project provides four C Application Programming Interface features: . * A closed process group communication model with virtual synchrony guarantees for creating replicated state machines. * A simple availability manager that restarts the application process when it has failed. * A configuration and statistics in-memory database that provide the ability to set, retrieve, and receive change notifications of information. * A quorum system that notifies applications when quorum is achieved or lost. . This package contains the Corosync daemon and some administration tools.",
"OldVersion": "3.1.10-pve1",
"Origin": "Proxmox",
"Package": "corosync",
"Priority": "optional",
"Section": "admin",
"Title": "cluster engine daemon and utilities",
"Version": "3.1.10-pve2"
},
{
"Arch": "all",
"Description": "This package contains the binary firmware for various modules used in the pve-kernel.",
"OldVersion": "3.18-1",
"Origin": "Proxmox",
"Package": "pve-firmware",
"Priority": "optional",
"Section": "misc",
"Title": "Binary firmware code for the pve-kernel",
"Version": "3.18-2"
}
]
@@ -0,0 +1,5 @@
{
"release": "9.1",
"repoid": "abcdefghijklmnop",
"version": "9.1.6"
}
@@ -129,6 +129,11 @@
'used_fraction': 0.73,
}),
}),
'version': dict({
'release': '9.1',
'repoid': 'abcdefghijklmnop',
'version': '9.1.6',
}),
'vms': dict({
'100': dict({
'cpu': 0.15,
@@ -0,0 +1,63 @@
# serializer version: 1
# name: test_all_entities[update.pve1_software_update-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'update.pve1_software_update',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Software update',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Software update',
'platform': 'proxmoxve',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <UpdateEntityFeature: 16>,
'translation_key': 'node_update',
'unique_id': '1234_node/pve1_node_update',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[update.pve1_software_update-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<UpdateEntityStateAttribute.AUTO_UPDATE: 'auto_update'>: False,
<UpdateEntityStateAttribute.DISPLAY_PRECISION: 'display_precision'>: 0,
<EntityStateAttribute.ENTITY_PICTURE: 'entity_picture'>: '/api/brands/integration/proxmoxve/icon.png',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'pve1 Software update',
<UpdateEntityStateAttribute.IN_PROGRESS: 'in_progress'>: False,
<UpdateEntityStateAttribute.INSTALLED_VERSION: 'installed_version'>: '9.1.6',
<UpdateEntityStateAttribute.LATEST_VERSION: 'latest_version'>: '9.1.9-p4-d1',
<UpdateEntityStateAttribute.RELEASE_SUMMARY: 'release_summary'>: 'A total of 5 package update(s) are pending installation: of these 4 relate to Proxmox and 1 to other updates. Please visit the [Proxmox VE node](https://127.0.0.1:8006/#v1:0:=node/pve1) for details on the pending updates and to upgrade to 9.1.9.',
<UpdateEntityStateAttribute.RELEASE_URL: 'release_url'>: None,
<UpdateEntityStateAttribute.SKIPPED_VERSION: 'skipped_version'>: None,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <UpdateEntityFeature: 16>,
<UpdateEntityStateAttribute.TITLE: 'title'>: None,
<UpdateEntityStateAttribute.UPDATE_PERCENTAGE: 'update_percentage'>: None,
}),
'context': <ANY>,
'entity_id': 'update.pve1_software_update',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
+121
View File
@@ -0,0 +1,121 @@
"""Tests for the Proxmox VE update platform."""
from unittest.mock import MagicMock, patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import AUDIT_PERMISSIONS, MERGED_PERMISSIONS, setup_integration
from tests.common import MockConfigEntry, snapshot_platform
from tests.typing import WebSocketGenerator
ENTITY_ID = "update.pve1_software_update"
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_proxmox_client: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
# Ensure Sys.Modify permissions to ensure update status can be determined
mock_proxmox_client.access.permissions.get.return_value = MERGED_PERMISSIONS
with patch(
"homeassistant.components.proxmoxve.PLATFORMS",
[Platform.UPDATE],
):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)
async def test_update_entities_ignored(
hass: HomeAssistant,
mock_proxmox_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that updates entities are not created with only auditor permissions."""
mock_proxmox_client.access.permissions.get.return_value = AUDIT_PERMISSIONS
with patch(
"homeassistant.components.proxmoxve.PLATFORMS",
[Platform.UPDATE],
):
await setup_integration(hass, mock_config_entry)
assert hass.states.get(ENTITY_ID) is None
async def test_update_unavailable_on_permission_change(
hass: HomeAssistant,
mock_proxmox_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that updates entities are unavailable with only auditor permissions."""
mock_proxmox_client.access.permissions.get.return_value = MERGED_PERMISSIONS
with patch(
"homeassistant.components.proxmoxve.PLATFORMS",
[Platform.UPDATE],
):
await setup_integration(hass, mock_config_entry)
assert hass.states.get(ENTITY_ID).state is not None
mock_proxmox_client.access.permissions.get.return_value = AUDIT_PERMISSIONS
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.state == STATE_UNAVAILABLE
async def test_update_up_to_date(
hass: HomeAssistant,
mock_proxmox_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that updates are up to date when no updates are pending."""
mock_proxmox_client.access.permissions.get.return_value = MERGED_PERMISSIONS
mock_proxmox_client.nodes.return_value.apt.update.get.return_value = []
with patch(
"homeassistant.components.proxmoxve.PLATFORMS",
[Platform.UPDATE],
):
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state.attributes.get("latest_version") == "9.1.6"
assert state.state == STATE_OFF
async def test_update_release_notes(
hass: HomeAssistant,
mock_proxmox_client: MagicMock,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that updates release notes are correctly set."""
mock_proxmox_client.access.permissions.get.return_value = MERGED_PERMISSIONS
with patch(
"homeassistant.components.proxmoxve.PLATFORMS",
[Platform.UPDATE],
):
await setup_integration(hass, mock_config_entry)
ws_client = await hass_ws_client(hass)
await ws_client.send_json(
{"id": 1, "type": "update/release_notes", "entity_id": ENTITY_ID}
)
result = await ws_client.receive_json()
assert "5 package" in result["result"]