Restart HA on saving new http conf and also return it on http/config (#173103)

This commit is contained in:
Robert Resch
2026-07-02 09:29:28 +00:00
parent 08094cb8b8
commit 1e8e23d70a
2 changed files with 55 additions and 16 deletions
+23 -7
View File
@@ -5,6 +5,10 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.homeassistant import (
DOMAIN as HASS_DOMAIN,
SERVICE_HOMEASSISTANT_RESTART,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -28,9 +32,16 @@ async def websocket_get_config(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the active HTTP configuration (the confirmed-working ``stable`` slot)."""
"""Return the HTTP configuration.
``stable`` is the confirmed-working config
``pending`` is an unconfirmed config awaiting promotion, or ``None``.
"""
store = await async_get_and_load_store(hass)
connection.send_result(msg["id"], store.stable)
connection.send_result(
msg["id"],
{"stable": store.stable, "pending": store.pending},
)
@websocket_api.require_admin
@@ -46,15 +57,20 @@ async def websocket_set_config(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Store a new pending HTTP configuration.
"""Store a new pending HTTP configuration and restart to apply it.
The new config is not applied until Home Assistant is restarted
and the user promotes it via ``http/config/promote``. Until then
the existing ``stable`` config remains the recovery fallback.
Restart whenever the pending slot changes, so the runtime config is
refreshed. The result reports whether a restart was triggered via
``{"restart": bool}``.
"""
store = await async_get_and_load_store(hass)
previous_pending = store.pending
await store.async_set_pending(msg[ATTR_CONFIG])
connection.send_result(msg["id"])
restart = store.pending != previous_pending
connection.send_result(msg["id"], {"restart": restart})
if restart:
await hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART)
@websocket_api.require_admin
+32 -9
View File
@@ -24,7 +24,7 @@ from homeassistant.helpers.network import NoURLAvailableError
from homeassistant.setup import async_setup_component
from homeassistant.util.ssl import server_context_intermediate, server_context_modern
from tests.common import async_call_logger_set_level
from tests.common import async_call_logger_set_level, async_mock_service
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@@ -1205,12 +1205,15 @@ async def test_websocket_http_config(
ws_client = await hass_ws_client(hass)
# On a fresh setup the stable slot is seeded with the schema defaults.
# Staging a new config triggers a restart so the pending config is applied.
restart_calls = async_mock_service(hass, "homeassistant", "restart")
# On a fresh setup the stable slot is seeded with the schema defaults and
# there is no pending config.
await ws_client.send_json_auto_id({"type": "http/config"})
response = await ws_client.receive_json()
assert response["success"]
assert response["result"]["server_port"] == 8123
assert response["result"]["ip_ban_enabled"] is True
assert response["result"] == {"stable": _DEFAULT_CONFIG, "pending": None}
new_config = {
"server_port": 9123,
@@ -1227,15 +1230,19 @@ async def test_websocket_http_config(
)
response = await ws_client.receive_json()
assert response["success"]
# Configure is an ack; verify pending state via storage.
assert response["result"] == {"restart": True}
pending = hass_storage["http"]["data"]["pending"]
assert pending["server_port"] == 9123
assert pending["trusted_proxies"] == ["127.0.0.0/8"]
# Stable is unchanged until the user promotes.
await hass.async_block_till_done()
assert len(restart_calls) == 1
# Stable is unchanged until the user promotes, but the pending config is
# now 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"]["server_port"] == 8123
assert response["result"] == {"stable": _DEFAULT_CONFIG, "pending": new_config}
# Promote: pending becomes stable, pending is cleared.
await ws_client.send_json_auto_id({"type": "http/config/promote"})
@@ -1247,7 +1254,7 @@ 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"]["server_port"] == 9123
assert response["result"] == {"stable": new_config, "pending": None}
# Promoting again with no pending is rejected.
await ws_client.send_json_auto_id({"type": "http/config/promote"})
@@ -1255,19 +1262,35 @@ async def test_websocket_http_config(
assert not response["success"]
assert response["error"]["code"] == "not_allowed"
# Clearing pending leaves stable untouched.
# Staging a different config again changes the pending slot -> restart.
await ws_client.send_json_auto_id(
{"type": "http/config/configure", "config": {"server_port": 7000}}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == {"restart": True}
assert hass_storage["http"]["data"]["pending"]["server_port"] == 7000
await hass.async_block_till_done()
assert len(restart_calls) == 2
# Clearing a previously staged config also changes the active config back
# to stable, so it must trigger a restart too.
await ws_client.send_json_auto_id({"type": "http/config/configure", "config": None})
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == {"restart": True}
assert hass_storage["http"]["data"]["pending"] is None
assert hass_storage["http"]["data"]["stable"]["server_port"] == 9123
await hass.async_block_till_done()
assert len(restart_calls) == 3
# Clearing again when there is no pending config is a no-op -> no restart.
await ws_client.send_json_auto_id({"type": "http/config/configure", "config": None})
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == {"restart": False}
await hass.async_block_till_done()
assert len(restart_calls) == 3
@pytest.mark.parametrize(