Ensure frontend is available if integrations fail to start - Part 1 of 2 (#36093)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
J. Nick Koston 2020-05-28 23:09:07 -05:00 committed by GitHub
parent 9c45115468
commit fbe7b4ddfa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 173 additions and 63 deletions

View file

@ -29,6 +29,9 @@ _LOGGER = logging.getLogger(__name__)
ERROR_LOG_FILENAME = "home-assistant.log" ERROR_LOG_FILENAME = "home-assistant.log"
# How long to wait until things that run on bootstrap have to finish.
TIMEOUT_EVENT_BOOTSTRAP = 15
# hass.data key for logging information. # hass.data key for logging information.
DATA_LOGGING = "logging" DATA_LOGGING = "logging"
@ -44,6 +47,13 @@ STAGE_1_INTEGRATIONS = {
"mqtt_eventstream", "mqtt_eventstream",
# To provide account link implementations # To provide account link implementations
"cloud", "cloud",
# Ensure supervisor is available
"hassio",
# Get the frontend up and running as soon
# as possible so problem integrations can
# be removed
"frontend",
"config",
} }
@ -399,6 +409,8 @@ async def _async_set_up_integrations(
) )
if stage_1_domains: if stage_1_domains:
_LOGGER.info("Setting up %s", stage_1_domains)
await async_setup_multi_components(stage_1_domains) await async_setup_multi_components(stage_1_domains)
# Load all integrations # Load all integrations
@ -442,4 +454,11 @@ async def _async_set_up_integrations(
# Wrap up startup # Wrap up startup
_LOGGER.debug("Waiting for startup to wrap up") _LOGGER.debug("Waiting for startup to wrap up")
try:
async with timeout(TIMEOUT_EVENT_BOOTSTRAP):
await hass.async_block_till_done() await hass.async_block_till_done()
except asyncio.TimeoutError:
_LOGGER.warning(
"Something is blocking Home Assistant from wrapping up the "
"bootstrap phase. We're going to continue anyway."
)

View file

@ -4,7 +4,7 @@ import logging
import os import os
import ssl import ssl
from traceback import extract_stack from traceback import extract_stack
from typing import Optional, cast from typing import Dict, Optional, cast
from aiohttp import web from aiohttp import web
from aiohttp.web_exceptions import HTTPMovedPermanently from aiohttp.web_exceptions import HTTPMovedPermanently
@ -15,7 +15,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
SERVER_PORT, SERVER_PORT,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import storage from homeassistant.helpers import storage
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
@ -216,29 +216,25 @@ async def async_setup(hass, config):
ssl_profile=ssl_profile, ssl_profile=ssl_profile,
) )
async def stop_server(event): startup_listeners = []
async def stop_server(event: Event) -> None:
"""Stop the server.""" """Stop the server."""
await server.stop() await server.stop()
async def start_server(event): async def start_server(event: Event) -> None:
"""Start the server.""" """Start the server."""
for listener in startup_listeners:
listener()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
await server.start()
# If we are set up successful, we store the HTTP settings for safe mode. await start_http_server_and_save_config(hass, dict(conf), server)
store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY)
if CONF_TRUSTED_PROXIES in conf: startup_listeners.append(
conf_to_save = dict(conf) hass.bus.async_listen(EVENT_HOMEASSISTANT_START, start_server)
conf_to_save[CONF_TRUSTED_PROXIES] = [ )
str(ip.network_address) for ip in conf_to_save[CONF_TRUSTED_PROXIES]
]
else:
conf_to_save = conf
await store.async_save(conf_to_save)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server)
hass.http = server hass.http = server
@ -418,3 +414,20 @@ class HomeAssistantHTTP:
"""Stop the aiohttp server.""" """Stop the aiohttp server."""
await self.site.stop() await self.site.stop()
await self.runner.cleanup() await self.runner.cleanup()
async def start_http_server_and_save_config(
hass: HomeAssistant, conf: Dict, server: HomeAssistantHTTP
) -> None:
"""Startup the http server and save the config."""
await server.start() # type: ignore
# If we are set up successful, we store the HTTP settings for safe mode.
store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY)
if CONF_TRUSTED_PROXIES in conf:
conf[CONF_TRUSTED_PROXIES] = [
str(ip.network_address) for ip in conf[CONF_TRUSTED_PROXIES]
]
await store.async_save(conf)

View file

@ -14,7 +14,7 @@ from aiohttp.web_exceptions import (
import voluptuous as vol import voluptuous as vol
from homeassistant import exceptions from homeassistant import exceptions
from homeassistant.const import CONTENT_TYPE_JSON, HTTP_OK from homeassistant.const import CONTENT_TYPE_JSON, HTTP_OK, HTTP_SERVICE_UNAVAILABLE
from homeassistant.core import Context, is_callback from homeassistant.core import Context, is_callback
from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.json import JSONEncoder
@ -107,8 +107,8 @@ def request_handler_factory(view: HomeAssistantView, handler: Callable) -> Calla
async def handle(request: web.Request) -> web.StreamResponse: async def handle(request: web.Request) -> web.StreamResponse:
"""Handle incoming request.""" """Handle incoming request."""
if not request.app[KEY_HASS].is_running: if request.app[KEY_HASS].is_stopping:
return web.Response(status=503) return web.Response(status=HTTP_SERVICE_UNAVAILABLE)
authenticated = request.get(KEY_AUTHENTICATED, False) authenticated = request.get(KEY_AUTHENTICATED, False)

View file

@ -31,7 +31,9 @@ def async_response(
@wraps(func) @wraps(func)
def schedule_handler(hass, connection, msg): def schedule_handler(hass, connection, msg):
"""Schedule the handler.""" """Schedule the handler."""
hass.async_create_task(_handle_async_response(func, hass, connection, msg)) # As the webserver is now started before the start
# event we do not want to block for websocket responders
hass.loop.create_task(_handle_async_response(func, hass, connection, msg))
return schedule_handler return schedule_handler

View file

@ -165,7 +165,9 @@ class WebSocketHandler:
EVENT_HOMEASSISTANT_STOP, handle_hass_stop EVENT_HOMEASSISTANT_STOP, handle_hass_stop
) )
self._writer_task = self.hass.async_create_task(self._writer()) # As the webserver is now started before the start
# event we do not want to block for websocket responses
self._writer_task = self.hass.loop.create_task(self._writer())
auth = AuthPhase(self._logger, self.hass, self._send_message, request) auth = AuthPhase(self._logger, self.hass, self._send_message, request)
connection = None connection = None

View file

@ -209,6 +209,11 @@ class HomeAssistant:
"""Return if Home Assistant is running.""" """Return if Home Assistant is running."""
return self.state in (CoreState.starting, CoreState.running) return self.state in (CoreState.starting, CoreState.running)
@property
def is_stopping(self) -> bool:
"""Return if Home Assistant is stopping."""
return self.state in (CoreState.stopping, CoreState.final_write)
def start(self) -> int: def start(self) -> int:
"""Start Home Assistant. """Start Home Assistant.
@ -260,6 +265,7 @@ class HomeAssistant:
setattr(self.loop, "_thread_ident", threading.get_ident()) setattr(self.loop, "_thread_ident", threading.get_ident())
self.bus.async_fire(EVENT_HOMEASSISTANT_START) self.bus.async_fire(EVENT_HOMEASSISTANT_START)
self.bus.async_fire(EVENT_CORE_CONFIG_UPDATE)
try: try:
# Only block for EVENT_HOMEASSISTANT_START listener # Only block for EVENT_HOMEASSISTANT_START listener
@ -1391,6 +1397,7 @@ class Config:
"version": __version__, "version": __version__,
"config_source": self.config_source, "config_source": self.config_source,
"safe_mode": self.safe_mode, "safe_mode": self.safe_mode,
"state": self.hass.state.value,
"external_url": self.external_url, "external_url": self.external_url,
"internal_url": self.internal_url, "internal_url": self.internal_url,
} }

View file

@ -60,6 +60,9 @@ async def test_hassio_discovery_startup(hass, aioclient_mock, hassio_client):
async def test_hassio_discovery_startup_done(hass, aioclient_mock, hassio_client): async def test_hassio_discovery_startup_done(hass, aioclient_mock, hassio_client):
"""Test startup and discovery with hass discovery.""" """Test startup and discovery with hass discovery."""
aioclient_mock.post(
"http://127.0.0.1/supervisor/options", json={"result": "ok", "data": {}},
)
aioclient_mock.get( aioclient_mock.get(
"http://127.0.0.1/discovery", "http://127.0.0.1/discovery",
json={ json={
@ -101,7 +104,7 @@ async def test_hassio_discovery_startup_done(hass, aioclient_mock, hassio_client
await async_setup_component(hass, "hassio", {}) await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done() await hass.async_block_till_done()
assert aioclient_mock.call_count == 2 assert aioclient_mock.call_count == 3
assert mock_mqtt.called assert mock_mqtt.called
mock_mqtt.assert_called_with( mock_mqtt.assert_called_with(
{ {

View file

@ -11,7 +11,7 @@ from tests.async_mock import Mock
async def get_client(aiohttp_client, validator): async def get_client(aiohttp_client, validator):
"""Generate a client that hits a view decorated with validator.""" """Generate a client that hits a view decorated with validator."""
app = web.Application() app = web.Application()
app["hass"] = Mock(is_running=True) app["hass"] = Mock(is_stopping=False)
class TestView(HomeAssistantView): class TestView(HomeAssistantView):
url = "/" url = "/"

View file

@ -19,7 +19,13 @@ from tests.async_mock import AsyncMock, Mock
@pytest.fixture @pytest.fixture
def mock_request(): def mock_request():
"""Mock a request.""" """Mock a request."""
return Mock(app={"hass": Mock(is_running=True)}, match_info={}) return Mock(app={"hass": Mock(is_stopping=False)}, match_info={})
@pytest.fixture
def mock_request_with_stopping():
"""Mock a request."""
return Mock(app={"hass": Mock(is_stopping=True)}, match_info={})
async def test_invalid_json(caplog): async def test_invalid_json(caplog):
@ -55,3 +61,11 @@ async def test_handling_service_not_found(mock_request):
Mock(requires_auth=False), Mock(requires_auth=False),
AsyncMock(side_effect=ServiceNotFound("test", "test")), AsyncMock(side_effect=ServiceNotFound("test", "test")),
)(mock_request) )(mock_request)
async def test_not_running(mock_request_with_stopping):
"""Test we get a 503 when not running."""
response = await request_handler_factory(
Mock(requires_auth=False), AsyncMock(side_effect=Unauthorized)
)(mock_request_with_stopping)
assert response.status == 503

View file

@ -43,6 +43,7 @@ class TestComponentLogbook(unittest.TestCase):
"""Set up things to be run when tests are started.""" """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant() self.hass = get_test_home_assistant()
init_recorder_component(self.hass) # Force an in memory DB init_recorder_component(self.hass) # Force an in memory DB
with patch("homeassistant.components.http.start_http_server_and_save_config"):
assert setup_component(self.hass, logbook.DOMAIN, self.EMPTY_CONFIG) assert setup_component(self.hass, logbook.DOMAIN, self.EMPTY_CONFIG)
def tearDown(self): def tearDown(self):

View file

@ -4,6 +4,7 @@ import unittest
from homeassistant import setup from homeassistant import setup
from homeassistant.components import frontend from homeassistant.components import frontend
from tests.async_mock import patch
from tests.common import get_test_home_assistant from tests.common import get_test_home_assistant
@ -26,12 +27,16 @@ class TestPanelIframe(unittest.TestCase):
] ]
for conf in to_try: for conf in to_try:
with patch(
"homeassistant.components.http.start_http_server_and_save_config"
):
assert not setup.setup_component( assert not setup.setup_component(
self.hass, "panel_iframe", {"panel_iframe": conf} self.hass, "panel_iframe", {"panel_iframe": conf}
) )
def test_correct_config(self): def test_correct_config(self):
"""Test correct config.""" """Test correct config."""
with patch("homeassistant.components.http.start_http_server_and_save_config"):
assert setup.setup_component( assert setup.setup_component(
self.hass, self.hass,
"panel_iframe", "panel_iframe",

View file

@ -201,6 +201,43 @@ async def test_setup_after_deps_not_present(hass, caplog):
assert order == ["root", "second_dep"] assert order == ["root", "second_dep"]
async def test_setup_continues_if_blocked(hass, caplog):
"""Test we continue after timeout if blocked."""
caplog.set_level(logging.DEBUG)
order = []
def gen_domain_setup(domain):
async def async_setup(hass, config):
order.append(domain)
return True
return async_setup
mock_integration(
hass, MockModule(domain="root", async_setup=gen_domain_setup("root"))
)
mock_integration(
hass,
MockModule(
domain="second_dep",
async_setup=gen_domain_setup("second_dep"),
partial_manifest={"after_dependencies": ["first_dep"]},
),
)
with patch.object(bootstrap, "TIMEOUT_EVENT_BOOTSTRAP", 0):
hass.async_create_task(asyncio.sleep(2))
await bootstrap._async_set_up_integrations(
hass, {"root": {}, "first_dep": {}, "second_dep": {}}
)
assert "root" in hass.config.components
assert "first_dep" not in hass.config.components
assert "second_dep" in hass.config.components
assert order == ["root", "second_dep"]
assert "blocking Home Assistant from wrapping up" in caplog.text
@pytest.fixture @pytest.fixture
def mock_is_virtual_env(): def mock_is_virtual_env():
"""Mock enable logging.""" """Mock enable logging."""
@ -261,7 +298,9 @@ async def test_setup_hass(
with patch( with patch(
"homeassistant.config.async_hass_config_yaml", "homeassistant.config.async_hass_config_yaml",
return_value={"browser": {}, "frontend": {}}, return_value={"browser": {}, "frontend": {}},
), patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 5000): ), patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 5000), patch(
"homeassistant.components.http.start_http_server_and_save_config"
):
hass = await bootstrap.async_setup_hass( hass = await bootstrap.async_setup_hass(
config_dir=get_test_config_dir(), config_dir=get_test_config_dir(),
verbose=verbose, verbose=verbose,
@ -338,7 +377,7 @@ async def test_setup_hass_invalid_yaml(
"""Test it works.""" """Test it works."""
with patch( with patch(
"homeassistant.config.async_hass_config_yaml", side_effect=HomeAssistantError "homeassistant.config.async_hass_config_yaml", side_effect=HomeAssistantError
): ), patch("homeassistant.components.http.start_http_server_and_save_config"):
hass = await bootstrap.async_setup_hass( hass = await bootstrap.async_setup_hass(
config_dir=get_test_config_dir(), config_dir=get_test_config_dir(),
verbose=False, verbose=False,
@ -391,7 +430,9 @@ async def test_setup_hass_safe_mode(
hass.config_entries._async_schedule_save() hass.config_entries._async_schedule_save()
await flush_store(hass.config_entries._store) await flush_store(hass.config_entries._store)
with patch("homeassistant.components.browser.setup") as browser_setup: with patch("homeassistant.components.browser.setup") as browser_setup, patch(
"homeassistant.components.http.start_http_server_and_save_config"
):
hass = await bootstrap.async_setup_hass( hass = await bootstrap.async_setup_hass(
config_dir=get_test_config_dir(), config_dir=get_test_config_dir(),
verbose=False, verbose=False,
@ -421,7 +462,7 @@ async def test_setup_hass_invalid_core_config(
with patch( with patch(
"homeassistant.config.async_hass_config_yaml", "homeassistant.config.async_hass_config_yaml",
return_value={"homeassistant": {"non-existing": 1}}, return_value={"homeassistant": {"non-existing": 1}},
): ), patch("homeassistant.components.http.start_http_server_and_save_config"):
hass = await bootstrap.async_setup_hass( hass = await bootstrap.async_setup_hass(
config_dir=get_test_config_dir(), config_dir=get_test_config_dir(),
verbose=False, verbose=False,
@ -451,7 +492,7 @@ async def test_setup_safe_mode_if_no_frontend(
with patch( with patch(
"homeassistant.config.async_hass_config_yaml", "homeassistant.config.async_hass_config_yaml",
return_value={"map": {}, "person": {"invalid": True}}, return_value={"map": {}, "person": {"invalid": True}},
): ), patch("homeassistant.components.http.start_http_server_and_save_config"):
hass = await bootstrap.async_setup_hass( hass = await bootstrap.async_setup_hass(
config_dir=get_test_config_dir(), config_dir=get_test_config_dir(),
verbose=verbose, verbose=verbose,

View file

@ -35,7 +35,7 @@ from homeassistant.exceptions import InvalidEntityFormatError, InvalidStateError
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.util.unit_system import METRIC_SYSTEM
from tests.async_mock import MagicMock, Mock, patch from tests.async_mock import MagicMock, Mock, PropertyMock, patch
from tests.common import async_mock_service, get_test_home_assistant from tests.common import async_mock_service, get_test_home_assistant
PST = pytz.timezone("America/Los_Angeles") PST = pytz.timezone("America/Los_Angeles")
@ -901,6 +901,8 @@ class TestConfig(unittest.TestCase):
def test_as_dict(self): def test_as_dict(self):
"""Test as dict.""" """Test as dict."""
self.config.config_dir = "/test/ha-config" self.config.config_dir = "/test/ha-config"
self.config.hass = MagicMock()
type(self.config.hass.state).value = PropertyMock(return_value="RUNNING")
expected = { expected = {
"latitude": 0, "latitude": 0,
"longitude": 0, "longitude": 0,
@ -914,6 +916,7 @@ class TestConfig(unittest.TestCase):
"version": __version__, "version": __version__,
"config_source": "default", "config_source": "default",
"safe_mode": False, "safe_mode": False,
"state": "RUNNING",
"external_url": None, "external_url": None,
"internal_url": None, "internal_url": None,
} }