From beee1298c5facb93529a582805f2752ab1b6c245 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 18 Feb 2020 11:52:38 -0800 Subject: [PATCH] Extend safe mode (#31927) * Extend safe mode * Add safe mode boolean to config JSON output and default Lovelace * Add safe mode to frontend * Update accent color --- homeassistant/bootstrap.py | 32 +++++++++++++++--- homeassistant/components/frontend/__init__.py | 17 ++++++++++ homeassistant/components/lovelace/__init__.py | 3 ++ homeassistant/components/zeroconf/__init__.py | 1 - homeassistant/core.py | 4 +++ homeassistant/loader.py | 15 +++++++-- homeassistant/util/logging.py | 19 ++++++----- tests/components/frontend/test_init.py | 10 ++++++ tests/components/lovelace/test_init.py | 7 ++++ tests/test_bootstrap.py | 33 ++++++++++++++++++- tests/test_core.py | 1 + tests/test_loader.py | 6 ++++ 12 files changed, 131 insertions(+), 17 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 250b95eafda..723e3c512e2 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -1,5 +1,6 @@ """Provide methods to bootstrap a Home Assistant instance.""" import asyncio +import contextlib import logging import logging.handlers import os @@ -7,12 +8,14 @@ import sys from time import monotonic from typing import Any, Dict, Optional, Set +from async_timeout import timeout import voluptuous as vol from homeassistant import config as conf_util, config_entries, core, loader from homeassistant.components import http from homeassistant.const import ( EVENT_HOMEASSISTANT_CLOSE, + EVENT_HOMEASSISTANT_STOP, REQUIRED_NEXT_PYTHON_DATE, REQUIRED_NEXT_PYTHON_VER, ) @@ -80,8 +83,7 @@ async def async_setup_hass( config_dict = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: _LOGGER.error( - "Failed to parse configuration.yaml: %s. Falling back to safe mode", - err, + "Failed to parse configuration.yaml: %s. Activating safe mode", err, ) else: if not is_virtual_env(): @@ -93,8 +95,30 @@ async def async_setup_hass( finally: clear_secret_cache() - if safe_mode or config_dict is None or not basic_setup_success: + if config_dict is None: + safe_mode = True + + elif not basic_setup_success: + _LOGGER.warning("Unable to set up core integrations. Activating safe mode") + safe_mode = True + + elif "frontend" not in hass.config.components: + _LOGGER.warning("Detected that frontend did not load. Activating safe mode") + # Ask integrations to shut down. It's messy but we can't + # do a clean stop without knowing what is broken + hass.async_track_tasks() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP, {}) + with contextlib.suppress(asyncio.TimeoutError): + async with timeout(10): + await hass.async_block_till_done() + + safe_mode = True + hass = core.HomeAssistant() + hass.config.config_dir = config_dir + + if safe_mode: _LOGGER.info("Starting in safe mode") + hass.config.safe_mode = True http_conf = (await http.async_get_last_config(hass)) or {} @@ -283,7 +307,7 @@ def _get_domains(hass: core.HomeAssistant, config: Dict[str, Any]) -> Set[str]: domains = set(key.split(" ")[0] for key in config.keys() if key != core.DOMAIN) # Add config entry domains - if "safe_mode" not in config: + if not hass.config.safe_mode: domains.update(hass.config_entries.async_domains()) # Make sure the Hass.io component is loaded diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6521901d692..a6f531b6dd5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -508,6 +508,23 @@ def websocket_get_themes(hass, connection, msg): Async friendly. """ + if hass.config.safe_mode: + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "themes": { + "safe_mode": { + "primary-color": "#db4437", + "accent-color": "#eeee02", + } + }, + "default_theme": "safe_mode", + }, + ) + ) + return + connection.send_message( websocket_api.result_message( msg["id"], diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 6c99356907f..fc8cb67894b 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -89,6 +89,9 @@ class LovelaceStorage: async def async_load(self, force): """Load config.""" + if self._hass.config.safe_mode: + raise ConfigNotFound + if self._data is None: await self._load() diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b4dbbda51f1..206f529344f 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -108,7 +108,6 @@ def setup(hass, config): def stop_zeroconf(_): """Stop Zeroconf.""" - zeroconf.unregister_service(info) zeroconf.close() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf) diff --git a/homeassistant/core.py b/homeassistant/core.py index e819a32b7c7..c17c1f698ce 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1288,6 +1288,9 @@ class Config: # List of allowed external dirs to access self.whitelist_external_dirs: Set[str] = set() + # If Home Assistant is running in safe mode + self.safe_mode: bool = False + def distance(self, lat: float, lon: float) -> Optional[float]: """Calculate distance from Home Assistant. @@ -1350,6 +1353,7 @@ class Config: "whitelist_external_dirs": self.whitelist_external_dirs, "version": __version__, "config_source": self.config_source, + "safe_mode": self.safe_mode, } def set_time_zone(self, time_zone_str: str) -> None: diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 9033202e652..4c46d437760 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -41,7 +41,6 @@ DATA_INTEGRATIONS = "integrations" DATA_CUSTOM_COMPONENTS = "custom_components" PACKAGE_CUSTOM_COMPONENTS = "custom_components" PACKAGE_BUILTIN = "homeassistant.components" -LOOKUP_PATHS = [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] CUSTOM_WARNING = ( "You are using a custom integration for %s which has not " "been tested by Home Assistant. This component might " @@ -67,6 +66,9 @@ async def _async_get_custom_components( hass: "HomeAssistant", ) -> Dict[str, "Integration"]: """Return list of custom integrations.""" + if hass.config.safe_mode: + return {} + try: import custom_components except ImportError: @@ -178,7 +180,7 @@ class Integration: Will create a stub manifest. """ - comp = _load_file(hass, domain, LOOKUP_PATHS) + comp = _load_file(hass, domain, _lookup_path(hass)) if comp is None: return None @@ -464,7 +466,7 @@ class Components: component: Optional[ModuleType] = integration.get_component() else: # Fallback to importing old-school - component = _load_file(self._hass, comp_name, LOOKUP_PATHS) + component = _load_file(self._hass, comp_name, _lookup_path(self._hass)) if component is None: raise ImportError(f"Unable to load {comp_name}") @@ -546,3 +548,10 @@ def _async_mount_config_dir(hass: "HomeAssistant") -> bool: if hass.config.config_dir not in sys.path: sys.path.insert(0, hass.config.config_dir) return True + + +def _lookup_path(hass: "HomeAssistant") -> List[str]: + """Return the lookup paths for legacy lookups.""" + if hass.config.safe_mode: + return [PACKAGE_BUILTIN] + return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index de04f23d9dd..1a46a34c1a8 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -80,16 +80,19 @@ class AsyncHandler: def _process(self) -> None: """Process log in a thread.""" - while True: - record = asyncio.run_coroutine_threadsafe( - self._queue.get(), self.loop - ).result() + try: + while True: + record = asyncio.run_coroutine_threadsafe( + self._queue.get(), self.loop + ).result() - if record is None: - self.handler.close() - return + if record is None: + self.handler.close() + return - self.handler.emit(record) + self.handler.emit(record) + except asyncio.CancelledError: + self.handler.close() def createLock(self) -> None: """Ignore lock stuff.""" diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index f9f25192211..627bf23341d 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -126,6 +126,16 @@ async def test_themes_api(hass, hass_ws_client): assert msg["result"]["default_theme"] == "default" assert msg["result"]["themes"] == {"happy": {"primary-color": "red"}} + # safe mode + hass.config.safe_mode = True + await client.send_json({"id": 6, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_theme"] == "safe_mode" + assert msg["result"]["themes"] == { + "safe_mode": {"primary-color": "#db4437", "accent-color": "#eeee02"} + } + async def test_themes_set_theme(hass, hass_ws_client): """Test frontend.set_theme service.""" diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 9f1c62a8b13..82e7b3bc2ac 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -38,6 +38,13 @@ async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): assert response["result"] == {"yo": "hello"} + # Test with safe mode + hass.config.safe_mode = True + await client.send_json({"id": 8, "type": "lovelace/config"}) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "config_not_found" + async def test_lovelace_from_storage_save_before_load( hass, hass_ws_client, hass_storage diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 7f653f18f0e..dac32b4728d 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -250,7 +250,8 @@ async def test_setup_hass( log_no_color = Mock() with patch( - "homeassistant.config.async_hass_config_yaml", return_value={"browser": {}} + "homeassistant.config.async_hass_config_yaml", + return_value={"browser": {}, "frontend": {}}, ): hass = await bootstrap.async_setup_hass( config_dir=get_test_config_dir(), @@ -263,6 +264,7 @@ async def test_setup_hass( ) assert "browser" in hass.config.components + assert "safe_mode" not in hass.config.components assert len(mock_enable_logging.mock_calls) == 1 assert mock_enable_logging.mock_calls[0][1] == ( @@ -382,3 +384,32 @@ async def test_setup_hass_invalid_core_config( ) assert "safe_mode" in hass.config.components + + +async def test_setup_safe_mode_if_no_frontend( + mock_enable_logging, + mock_is_virtual_env, + mock_mount_local_lib_path, + mock_ensure_config_exists, + mock_process_ha_config_upgrade, +): + """Test we setup safe mode if frontend didn't load.""" + verbose = Mock() + log_rotate_days = Mock() + log_file = Mock() + log_no_color = Mock() + + with patch( + "homeassistant.config.async_hass_config_yaml", return_value={"browser": {}} + ): + hass = await bootstrap.async_setup_hass( + config_dir=get_test_config_dir(), + verbose=verbose, + log_rotate_days=log_rotate_days, + log_file=log_file, + log_no_color=log_no_color, + skip_pip=True, + safe_mode=False, + ) + + assert "safe_mode" in hass.config.components diff --git a/tests/test_core.py b/tests/test_core.py index 657bbeda7c6..0c7acfbba0e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -904,6 +904,7 @@ class TestConfig(unittest.TestCase): "whitelist_external_dirs": set(), "version": __version__, "config_source": "default", + "safe_mode": False, } assert expected == self.config.as_dict() diff --git a/tests/test_loader.py b/tests/test_loader.py index 47d9e4e23fa..745bb9c8c2c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -236,3 +236,9 @@ async def test_get_config_flows(hass): flows = await loader.async_get_config_flows(hass) assert "test_2" in flows assert "test_1" not in flows + + +async def test_get_custom_components_safe_mode(hass): + """Test that we get empty custom components in safe mode.""" + hass.config.safe_mode = True + assert await loader.async_get_custom_components(hass) == {}