Implement auto-revert for pending HTTP config after a delay and update WebSocket API to include revert deadline (#174428)

This commit is contained in:
Robert Resch
2026-07-03 14:46:20 +02:00
committed by GitHub
parent c1dd0065a3
commit 4b90541e93
3 changed files with 198 additions and 6 deletions
+69 -1
View File
@@ -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,
},
)
+122 -4
View File
@@ -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",
[