mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Implement auto-revert for pending HTTP config after a delay and update WebSocket API to include revert deadline (#174428)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""User-managed HTTP configuration store."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from ipaddress import IPv4Network, IPv6Network, ip_network
|
||||
import logging
|
||||
import os
|
||||
@@ -9,11 +10,13 @@ from typing import Any, Final, TypedDict, cast, override
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import SERVER_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
@@ -69,6 +72,8 @@ KEY_STABLE: Final = "stable"
|
||||
KEY_PENDING: Final = "pending"
|
||||
KEY_YAML_MIGRATION_DONE: Final = "yaml_migration_done"
|
||||
|
||||
AUTO_REVERT_DELAY: Final = timedelta(minutes=5)
|
||||
|
||||
DATA_STORE: HassKey[HTTPConfigStore] = HassKey(STORAGE_KEY)
|
||||
|
||||
|
||||
@@ -203,6 +208,7 @@ async def async_load_config(hass: HomeAssistant, config: ConfigType) -> ConfData
|
||||
|
||||
if store.pending is not None:
|
||||
_LOGGER.info("Using pending HTTP config")
|
||||
store.async_schedule_revert_to_stable()
|
||||
return store.pending
|
||||
|
||||
_LOGGER.info("Using stable HTTP config")
|
||||
@@ -243,6 +249,8 @@ class HTTPConfigStore:
|
||||
self._yaml_migration_done = False
|
||||
self._loaded = False
|
||||
self._load_lock = asyncio.Lock()
|
||||
self._revert_unsub: CALLBACK_TYPE | None = None
|
||||
self._revert_deadline: datetime | None = None
|
||||
|
||||
@property
|
||||
def stable(self) -> ConfData:
|
||||
@@ -254,6 +262,11 @@ class HTTPConfigStore:
|
||||
"""Return the unconfirmed config awaiting promotion, if any."""
|
||||
return self._pending
|
||||
|
||||
@property
|
||||
def revert_deadline(self) -> datetime | None:
|
||||
"""Return when the pending config auto-reverts to stable, if scheduled."""
|
||||
return self._revert_deadline
|
||||
|
||||
@property
|
||||
def yaml_migration_done(self) -> bool:
|
||||
"""Return whether the YAML migration has been completed."""
|
||||
@@ -294,8 +307,63 @@ class HTTPConfigStore:
|
||||
raise HomeAssistantError("No pending HTTP config to promote")
|
||||
self._stable = self._pending
|
||||
self._pending = None
|
||||
# The config is now confirmed; no need to revert it anymore.
|
||||
self._async_cancel_revert()
|
||||
await self._async_persist()
|
||||
|
||||
@callback
|
||||
def async_schedule_revert_to_stable(self) -> None:
|
||||
"""Schedule reverting the pending config back to stable.
|
||||
|
||||
Loading a pending config is a trial. If the user does not promote it
|
||||
within ``AUTO_REVERT_DELAY`` (e.g. because the new config made Home
|
||||
Assistant unreachable), automatically clear it and restart so the last
|
||||
known-good stable config is restored.
|
||||
"""
|
||||
self._async_cancel_revert()
|
||||
self._revert_deadline = dt_util.utcnow() + AUTO_REVERT_DELAY
|
||||
self._revert_unsub = async_call_later(
|
||||
self._hass,
|
||||
AUTO_REVERT_DELAY,
|
||||
HassJob(
|
||||
self._async_revert_to_stable,
|
||||
"http config auto-revert",
|
||||
cancel_on_shutdown=True,
|
||||
),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_cancel_revert(self) -> None:
|
||||
"""Cancel a scheduled revert, if any.
|
||||
|
||||
Also clears the deadline so ``revert_deadline`` no longer reports a
|
||||
revert that will not happen (e.g. after the config is promoted).
|
||||
"""
|
||||
if self._revert_unsub is not None:
|
||||
self._revert_unsub()
|
||||
self._revert_unsub = None
|
||||
self._revert_deadline = None
|
||||
|
||||
async def _async_revert_to_stable(self, _now: datetime) -> None:
|
||||
"""Clear the unconfirmed pending config and restart to apply stable."""
|
||||
self._async_cancel_revert()
|
||||
if self._pending is None:
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"Pending HTTP config was not confirmed within %s; reverting to the "
|
||||
"stable config and restarting",
|
||||
AUTO_REVERT_DELAY,
|
||||
)
|
||||
self._pending = None
|
||||
await self._async_persist()
|
||||
# Imported here to avoid a circular import at module load time.
|
||||
from homeassistant.components.homeassistant import ( # noqa: PLC0415
|
||||
DOMAIN as HASS_DOMAIN,
|
||||
SERVICE_HOMEASSISTANT_RESTART,
|
||||
)
|
||||
|
||||
await self._hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART)
|
||||
|
||||
async def async_migrate_yaml(self, config: ConfData) -> None:
|
||||
"""Migrate YAML config to storage as pending if not the same as the config used for recovery."""
|
||||
await self.async_load()
|
||||
|
||||
@@ -36,11 +36,17 @@ async def websocket_get_config(
|
||||
|
||||
``stable`` is the confirmed-working config
|
||||
``pending`` is an unconfirmed config awaiting promotion, or ``None``.
|
||||
``revert_at`` is when an unconfirmed pending config auto-reverts to
|
||||
stable, or ``None`` when no revert is scheduled.
|
||||
"""
|
||||
store = await async_get_and_load_store(hass)
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{"stable": store.stable, "pending": store.pending},
|
||||
{
|
||||
"stable": store.stable,
|
||||
"pending": store.pending,
|
||||
"revert_at": store.revert_deadline,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, Mock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.auth.providers.homeassistant import HassAuthProvider
|
||||
@@ -17,6 +18,7 @@ from homeassistant.components.cloud import CloudNotAvailable
|
||||
from homeassistant.components.http import DOMAIN
|
||||
from homeassistant.components.http.config import (
|
||||
_DEFAULT_CONFIG,
|
||||
AUTO_REVERT_DELAY,
|
||||
HTTP_STORAGE_SCHEMA,
|
||||
default_server_port,
|
||||
)
|
||||
@@ -27,9 +29,14 @@ from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.http import KEY_HASS
|
||||
from homeassistant.helpers.network import NoURLAvailableError
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.ssl import server_context_intermediate, server_context_modern
|
||||
|
||||
from tests.common import async_call_logger_set_level, async_mock_service
|
||||
from tests.common import (
|
||||
async_call_logger_set_level,
|
||||
async_fire_time_changed,
|
||||
async_mock_service,
|
||||
)
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
|
||||
@@ -1258,7 +1265,11 @@ async def test_websocket_http_config(
|
||||
await ws_client.send_json_auto_id({"type": "http/config"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"stable": _DEFAULT_CONFIG, "pending": None}
|
||||
assert response["result"] == {
|
||||
"stable": _DEFAULT_CONFIG,
|
||||
"pending": None,
|
||||
"revert_at": None,
|
||||
}
|
||||
|
||||
new_config = {
|
||||
"server_port": 9123,
|
||||
@@ -1287,7 +1298,11 @@ async def test_websocket_http_config(
|
||||
await ws_client.send_json_auto_id({"type": "http/config"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"stable": _DEFAULT_CONFIG, "pending": new_config}
|
||||
assert response["result"] == {
|
||||
"stable": _DEFAULT_CONFIG,
|
||||
"pending": new_config,
|
||||
"revert_at": None,
|
||||
}
|
||||
|
||||
# Promote: pending becomes stable, pending is cleared.
|
||||
await ws_client.send_json_auto_id({"type": "http/config/promote"})
|
||||
@@ -1299,7 +1314,11 @@ async def test_websocket_http_config(
|
||||
await ws_client.send_json_auto_id({"type": "http/config"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"stable": new_config, "pending": None}
|
||||
assert response["result"] == {
|
||||
"stable": new_config,
|
||||
"pending": None,
|
||||
"revert_at": None,
|
||||
}
|
||||
|
||||
# Promoting again with no pending is rejected.
|
||||
await ws_client.send_json_auto_id({"type": "http/config/promote"})
|
||||
@@ -1338,6 +1357,105 @@ async def test_websocket_http_config(
|
||||
assert len(restart_calls) == 3
|
||||
|
||||
|
||||
async def test_pending_config_auto_reverts_to_stable(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_storage: dict[str, Any],
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""A loaded pending config reverts to stable if it is not confirmed in time."""
|
||||
hass_storage["http"] = _stable_http_storage(
|
||||
{"server_port": 9876}, pending={"server_port": 9999}
|
||||
)
|
||||
|
||||
# A revert clears the pending config and restarts to apply stable.
|
||||
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
||||
|
||||
# The revert deadline is anchored to the (frozen) load time.
|
||||
revert_at = dt_util.utcnow() + AUTO_REVERT_DELAY
|
||||
|
||||
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
||||
assert await async_setup_component(hass, "http", {})
|
||||
await async_setup_component(hass, "websocket_api", {})
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
# While the unconfirmed pending config is active, a revert deadline is
|
||||
# returned alongside it.
|
||||
await ws_client.send_json_auto_id({"type": "http/config"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {
|
||||
"stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}),
|
||||
"pending": HTTP_STORAGE_SCHEMA({"server_port": 9999}),
|
||||
"revert_at": revert_at.isoformat(),
|
||||
}
|
||||
|
||||
# After the delay elapses without a promotion, pending is dropped and a
|
||||
# restart is requested so the stable config is applied.
|
||||
freezer.tick(AUTO_REVERT_DELAY)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass_storage["http"]["data"] == {
|
||||
"stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}),
|
||||
"pending": None,
|
||||
"yaml_migration_done": True,
|
||||
}
|
||||
assert len(restart_calls) == 1
|
||||
|
||||
|
||||
async def test_pending_config_promote_cancels_revert(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_storage: dict[str, Any],
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Promoting a pending config cancels the scheduled revert."""
|
||||
hass_storage["http"] = _stable_http_storage(
|
||||
{"server_port": 9876}, pending={"server_port": 9999}
|
||||
)
|
||||
|
||||
restart_calls = async_mock_service(hass, "homeassistant", "restart")
|
||||
|
||||
with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()):
|
||||
assert await async_setup_component(hass, "http", {})
|
||||
await async_setup_component(hass, "websocket_api", {})
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
# Confirm the pending config before the revert fires.
|
||||
await ws_client.send_json_auto_id({"type": "http/config/promote"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
# The deadline is cleared once the config is confirmed.
|
||||
await ws_client.send_json_auto_id({"type": "http/config"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {
|
||||
"stable": HTTP_STORAGE_SCHEMA({"server_port": 9999}),
|
||||
"pending": None,
|
||||
"revert_at": None,
|
||||
}
|
||||
|
||||
# The cancelled revert must not fire after the delay.
|
||||
freezer.tick(AUTO_REVERT_DELAY)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass_storage["http"]["data"] == {
|
||||
"stable": HTTP_STORAGE_SCHEMA({"server_port": 9999}),
|
||||
"pending": None,
|
||||
"yaml_migration_done": True,
|
||||
}
|
||||
assert len(restart_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user