Files
supervisor/supervisor/plugins/base.py
Stefan Agner 1448a33dbf Remove Codenotary integrity check (#6236)
* Formally deprecate CodeNotary build config

* Remove CodeNotary specific integrity checking

The current code is specific to how CodeNotary was doing integrity
checking. A future integrity checking mechanism likely will work
differently (e.g. through EROFS based containers). Remove the current
code to make way for a future implementation.

* Drop CodeNotary integrity fixups

* Drop unused tests

* Fix pytest

* Fix pytest

* Remove CodeNotary related exceptions and handling

Remove CodeNotary related exceptions and handling from the Docker
interface.

* Drop unnecessary comment

* Remove Codenotary specific IssueType/SuggestionType

* Drop Codenotary specific environment and secret reference

* Remove unused constants

* Introduce APIGone exception for removed APIs

Introduce a new exception class APIGone to indicate that certain API
features have been removed and are no longer available. Update the
security integrity check endpoint to raise this new exception instead
of a generic APIError, providing clearer communication to clients that
the feature has been intentionally removed.

* Drop content trust

A cosign based signature verification will likely be named differently
to avoid confusion with existing implementations. For now, remove the
content trust option entirely.

* Drop code sign test

* Remove source_mods/content_trust evaluations

* Remove content_trust reference in bootstrap.py

* Fix security tests

* Drop unused tests

* Drop codenotary from schema

Since we have "remove extra" in voluptuous, we can remove the
codenotary field from the addon schema.

* Remove content_trust from tests

* Remove content_trust unsupported reason

* Remove unnecessary comment

* Remove unrelated pytest

* Remove unrelated fixtures
2025-11-03 20:13:15 +01:00

244 lines
7.8 KiB
Python

"""Supervisor plugins base class."""
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable
from contextlib import suppress
import logging
from awesomeversion import AwesomeVersion, AwesomeVersionException
from ..const import ATTR_IMAGE, ATTR_VERSION, BusEvent
from ..coresys import CoreSysAttributes
from ..docker.const import ContainerState
from ..docker.interface import DockerInterface
from ..docker.monitor import DockerContainerStateEvent
from ..exceptions import DockerError, PluginError
from ..utils.common import FileConfiguration
from ..utils.sentry import async_capture_exception
from .const import WATCHDOG_MAX_ATTEMPTS, WATCHDOG_RETRY_SECONDS
_LOGGER: logging.Logger = logging.getLogger(__name__)
class PluginBase(ABC, FileConfiguration, CoreSysAttributes):
"""Base class for plugins."""
slug: str
instance: DockerInterface
@property
def version(self) -> AwesomeVersion | None:
"""Return current version of the plugin."""
return self._data.get(ATTR_VERSION)
@version.setter
def version(self, value: AwesomeVersion) -> None:
"""Set current version of the plugin."""
self._data[ATTR_VERSION] = value
@property
def default_image(self) -> str:
"""Return default image for plugin."""
return f"ghcr.io/home-assistant/{self.sys_arch.supervisor}-hassio-{self.slug}"
@property
def image(self) -> str:
"""Return current image of plugin."""
if self._data.get(ATTR_IMAGE):
return self._data[ATTR_IMAGE]
return self.default_image
@image.setter
def image(self, value: str) -> None:
"""Return current image of the plugin."""
self._data[ATTR_IMAGE] = value
@property
@abstractmethod
def latest_version(self) -> AwesomeVersion | None:
"""Return latest version of the plugin."""
@property
def need_update(self) -> bool:
"""Return True if an update is available."""
try:
return (
self.version is not None
and self.latest_version is not None
and self.version < self.latest_version
)
except (AwesomeVersionException, TypeError):
return False
@property
def in_progress(self) -> bool:
"""Return True if a task is in progress."""
return self.instance.in_progress
def logs(self) -> Awaitable[bytes]:
"""Get docker plugin logs.
Return Coroutine.
"""
return self.instance.logs()
def is_running(self) -> Awaitable[bool]:
"""Return True if Docker container is running.
Return a coroutine.
"""
return self.instance.is_running()
def is_failed(self) -> Awaitable[bool]:
"""Return True if a Docker container is failed state.
Return a coroutine.
"""
return self.instance.is_failed()
def start_watchdog(self) -> None:
"""Register docker container listener for plugin."""
self.sys_bus.register_event(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.watchdog_container
)
async def watchdog_container(self, event: DockerContainerStateEvent) -> None:
"""Process state changes in plugin container and restart if necessary."""
if event.name != self.instance.name:
return
if event.state in {ContainerState.FAILED, ContainerState.UNHEALTHY}:
await self._restart_after_problem(event.state)
async def _restart_after_problem(self, state: ContainerState):
"""Restart unhealthy or failed plugin."""
attempts = 0
while await self.instance.current_state() == state:
if not self.in_progress:
_LOGGER.warning(
"Watchdog found %s plugin %s, restarting...",
self.slug,
state,
)
try:
await self.rebuild()
except PluginError as err:
attempts = attempts + 1
_LOGGER.error("Watchdog restart of %s plugin failed!", self.slug)
await async_capture_exception(err)
else:
break
if attempts >= WATCHDOG_MAX_ATTEMPTS:
_LOGGER.critical(
"Watchdog cannot restart %s plugin, failed all %s attempts",
self.slug,
attempts,
)
break
await asyncio.sleep(WATCHDOG_RETRY_SECONDS)
async def rebuild(self) -> None:
"""Rebuild system plugin."""
with suppress(DockerError):
await self.instance.stop()
await self.start()
@abstractmethod
async def start(self) -> None:
"""Start system plugin."""
@abstractmethod
async def stop(self) -> None:
"""Stop system plugin."""
async def load(self) -> None:
"""Load system plugin."""
self.start_watchdog()
# Check plugin state
try:
# Evaluate Version if we lost this information
if self.version:
version = self.version
else:
self.version = version = await self.instance.get_latest_version()
await self.instance.attach(version=version, skip_state_event_if_down=True)
await self.instance.check_image(version, self.default_image)
except DockerError:
_LOGGER.info(
"No %s plugin Docker image %s found.", self.slug, self.instance.image
)
# Install plugin
with suppress(PluginError):
await self.install()
else:
self.version = self.instance.version or version
self.image = self.default_image
await self.save_data()
# Run plugin
with suppress(PluginError):
if not await self.instance.is_running():
await self.start()
async def install(self) -> None:
"""Install system plugin."""
_LOGGER.info("Setup %s plugin", self.slug)
while True:
# read plugin tag and install it
if not self.latest_version:
await self.sys_updater.reload()
if to_version := self.latest_version:
with suppress(DockerError):
await self.instance.install(to_version, image=self.default_image)
self.version = self.instance.version or to_version
break
_LOGGER.warning(
"Error on installing %s plugin, retrying in 30sec", self.slug
)
await asyncio.sleep(30)
_LOGGER.info("%s plugin now installed", self.slug)
self.image = self.default_image
await self.save_data()
async def update(self, version: str | None = None) -> None:
"""Update system plugin."""
to_version = AwesomeVersion(version) if version else self.latest_version
if not to_version:
raise PluginError(
f"Cannot determine latest version of plugin {self.slug} for update",
_LOGGER.error,
)
old_image = self.image
if to_version == self.version:
_LOGGER.warning(
"Version %s is already installed for %s", to_version, self.slug
)
return
await self.instance.update(to_version, image=self.default_image)
self.version = self.instance.version or to_version
self.image = self.default_image
await self.save_data()
# Cleanup
with suppress(DockerError):
await self.instance.cleanup(old_image=old_image)
# Start plugin
await self.start()
@abstractmethod
async def repair(self) -> None:
"""Repair system plugin."""