From 770da0afde9e136430c8eaee93702148b111f933 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 21 Aug 2026 21:52:10 +0200 Subject: [PATCH] Add OCI image labels to Portainer (#176678) --- homeassistant/components/portainer/icons.json | 6 + homeassistant/components/portainer/sensor.py | 39 +++- .../components/portainer/strings.json | 6 + .../portainer/fixtures/containers.json | 8 +- .../portainer/snapshots/test_sensor.ambr | 202 ++++++++++++++++++ 5 files changed, 257 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index c0907a461658..e78c26c996c6 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -60,12 +60,18 @@ "image": { "default": "mdi:docker" }, + "image_created": { + "default": "mdi:calendar-clock" + }, "image_disk_usage_reclaimable": { "default": "mdi:file-restore" }, "image_disk_usage_total_size": { "default": "mdi:harddisk" }, + "image_version": { + "default": "mdi:tag-outline" + }, "images_count": { "default": "mdi:image-multiple" }, diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 7062674e86f2..3c8d15f10024 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from itertools import chain from typing import TYPE_CHECKING, override @@ -19,6 +20,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfInformation, UnitOfRatio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import ( PortainerConfigEntry, @@ -42,7 +44,7 @@ PARALLEL_UPDATES = 0 class PortainerContainerSensorEntityDescription(SensorEntityDescription): """Class to hold Portainer container sensor description.""" - value_fn: Callable[[PortainerContainerData], StateType] + value_fn: Callable[[PortainerContainerData], StateType | datetime] supported_fn: Callable[[PortainerContainerData], bool] = lambda _: True @@ -80,6 +82,39 @@ CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = ( translation_key="image", value_fn=lambda data: data.container.image, ), + PortainerContainerSensorEntityDescription( + key="image_version", + translation_key="image_version", + supported_fn=lambda data: bool( + data.container.labels + and data.container.labels.get("org.opencontainers.image.version") + ), + value_fn=lambda data: ( + data.container.labels.get("org.opencontainers.image.version") + if data.container.labels + else None + ), + ), + PortainerContainerSensorEntityDescription( + key="image_created", + translation_key="image_created", + supported_fn=lambda data: bool( + data.container.labels + and data.container.labels.get("org.opencontainers.image.created") + ), + value_fn=lambda data: ( + parsed + if data.container.labels + and ( + created := data.container.labels.get("org.opencontainers.image.created") + ) + and (parsed := dt_util.parse_datetime(created)) is not None + and parsed.tzinfo is not None + else None + ), + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), PortainerContainerSensorEntityDescription( key="container_state", translation_key="container_state", @@ -488,7 +523,7 @@ class PortainerContainerSensor(PortainerContainerEntity, SensorEntity): @property @override - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.container_data) diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 53f7befbc440..6f748880a8ed 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -142,12 +142,18 @@ "image": { "name": "Image" }, + "image_created": { + "name": "Image created" + }, "image_disk_usage_reclaimable": { "name": "Image disk usage reclaimable" }, "image_disk_usage_total_size": { "name": "Image disk usage total size" }, + "image_version": { + "name": "Image version" + }, "images_count": { "name": "Image count" }, diff --git a/tests/components/portainer/fixtures/containers.json b/tests/components/portainer/fixtures/containers.json index 3728db9fbb04..51c2af256672 100644 --- a/tests/components/portainer/fixtures/containers.json +++ b/tests/components/portainer/fixtures/containers.json @@ -110,7 +110,9 @@ } ], "Labels": { - "com.docker.compose.project": "webstack" + "com.docker.compose.project": "webstack", + "org.opencontainers.image.version": "1.29.3", + "org.opencontainers.image.created": "2026-05-02T14:31:00Z" }, "State": "running", "Status": "Up 2 days" @@ -130,7 +132,9 @@ } ], "Labels": { - "com.docker.compose.project": "webstack" + "com.docker.compose.project": "webstack", + "org.opencontainers.image.version": "15.14", + "org.opencontainers.image.created": "not-a-timestamp" }, "State": "running", "Status": "Up 1 day" diff --git a/tests/components/portainer/snapshots/test_sensor.ambr b/tests/components/portainer/snapshots/test_sensor.ambr index 8336c03bfcfa..357a4042e3e0 100644 --- a/tests/components/portainer/snapshots/test_sensor.ambr +++ b/tests/components/portainer/snapshots/test_sensor.ambr @@ -3273,6 +3273,107 @@ 'state': 'docker.io/library/nginx:latest', }) # --- +# name: test_all_entities[sensor.serene_banach_image_created-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.serene_banach_image_created', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image created', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Image created', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_created', + 'unique_id': 'portainer_test_entry_123_serene_banach_image_created', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_image_created-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'serene_banach Image created', + }), + 'context': , + 'entity_id': 'sensor.serene_banach_image_created', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-05-02T14:31:00+00:00', + }) +# --- +# name: test_all_entities[sensor.serene_banach_image_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.serene_banach_image_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image version', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_version', + 'unique_id': 'portainer_test_entry_123_serene_banach_image_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_image_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'serene_banach Image version', + }), + 'context': , + 'entity_id': 'sensor.serene_banach_image_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.29.3', + }) +# --- # name: test_all_entities[sensor.serene_banach_memory_limit-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -3691,6 +3792,107 @@ 'state': 'docker.io/library/postgres:15', }) # --- +# name: test_all_entities[sensor.stoic_turing_image_created-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.stoic_turing_image_created', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image created', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Image created', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_created', + 'unique_id': 'portainer_test_entry_123_stoic_turing_image_created', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image_created-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'stoic_turing Image created', + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_image_created', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stoic_turing_image_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image version', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_version', + 'unique_id': 'portainer_test_entry_123_stoic_turing_image_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'stoic_turing Image version', + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_image_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.14', + }) +# --- # name: test_all_entities[sensor.stoic_turing_memory_limit-entry] EntityRegistryEntrySnapshot({ 'aliases': list([