From 97cc05d0b4900f87cdebdb57b003dd16113aac49 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 14:47:58 +0200 Subject: [PATCH] Make it possible to restart core in safe mode (#102606) --- homeassistant/__main__.py | 5 +- homeassistant/bootstrap.py | 3 + homeassistant/components/frontend/__init__.py | 14 +++- .../components/homeassistant/__init__.py | 11 +++- .../components/homeassistant/strings.json | 8 ++- homeassistant/config.py | 23 +++++++ homeassistant/core.py | 4 ++ homeassistant/loader.py | 4 +- homeassistant/runner.py | 2 + tests/components/frontend/test_init.py | 14 +++- tests/components/homeassistant/test_init.py | 13 +++- tests/test_bootstrap.py | 66 +++++++++++++++++++ tests/test_config.py | 13 ++++ tests/test_core.py | 1 + 14 files changed, 170 insertions(+), 11 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 9acf46dbac6..4ea324878ec 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -185,7 +185,9 @@ def main() -> int: ensure_config_path(config_dir) # pylint: disable-next=import-outside-toplevel - from . import runner + from . import config, runner + + safe_mode = config.safe_mode_enabled(config_dir) runtime_conf = runner.RuntimeConfig( config_dir=config_dir, @@ -198,6 +200,7 @@ def main() -> int: recovery_mode=args.recovery_mode, debug=args.debug, open_ui=args.open_ui, + safe_mode=safe_mode, ) fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 89aa5c05d0d..098f970d55f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -120,6 +120,7 @@ async def async_setup_hass( runtime_config.log_no_color, ) + hass.config.safe_mode = runtime_config.safe_mode hass.config.skip_pip = runtime_config.skip_pip hass.config.skip_pip_packages = runtime_config.skip_pip_packages if runtime_config.skip_pip or runtime_config.skip_pip_packages: @@ -197,6 +198,8 @@ async def async_setup_hass( {"recovery_mode": {}, "http": http_conf}, hass, ) + elif hass.config.safe_mode: + _LOGGER.info("Starting in safe mode") if runtime_config.open_ui: hass.add_job(open_hass_ui, hass) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index e8a71d23adf..0225d723d20 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -593,7 +593,7 @@ class IndexView(web_urldispatcher.AbstractResource): async def get(self, request: web.Request) -> web.Response: """Serve the index page for panel pages.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] if not onboarding.async_is_onboarded(hass): return web.Response(status=302, headers={"location": "/onboarding.html"}) @@ -602,12 +602,20 @@ class IndexView(web_urldispatcher.AbstractResource): self.get_template ) + extra_modules: frozenset[str] + extra_js_es5: frozenset[str] + if hass.config.safe_mode: + extra_modules = frozenset() + extra_js_es5 = frozenset() + else: + extra_modules = hass.data[DATA_EXTRA_MODULE_URL].urls + extra_js_es5 = hass.data[DATA_EXTRA_JS_URL_ES5].urls return web.Response( text=_async_render_index_cached( template, theme_color=MANIFEST_JSON["theme_color"], - extra_modules=hass.data[DATA_EXTRA_MODULE_URL].urls, - extra_js_es5=hass.data[DATA_EXTRA_JS_URL_ES5].urls, + extra_modules=extra_modules, + extra_js_es5=extra_js_es5, ), content_type="text/html", ) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 5b26cb29ded..c978a7d4320 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -44,6 +44,7 @@ from .const import ( from .exposed_entities import ExposedEntities ATTR_ENTRY_ID = "entry_id" +ATTR_SAFE_MODE = "safe_mode" _LOGGER = logging.getLogger(__name__) SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" @@ -63,7 +64,7 @@ SCHEMA_RELOAD_CONFIG_ENTRY = vol.All( ), cv.has_at_least_one_key(ATTR_ENTRY_ID, *cv.ENTITY_SERVICE_FIELDS), ) - +SCHEMA_RESTART = vol.Schema({vol.Optional(ATTR_SAFE_MODE, default=False): bool}) SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) @@ -193,6 +194,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no ) if call.service == SERVICE_HOMEASSISTANT_RESTART: + if call.data[ATTR_SAFE_MODE]: + await conf_util.async_enable_safe_mode(hass) stop_handler = hass.data[DATA_STOP_HANDLER] await stop_handler(hass, True) @@ -228,7 +231,11 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass, ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service ) async_register_admin_service( - hass, ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service + hass, + ha.DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, + async_handle_core_service, + SCHEMA_RESTART, ) async_register_admin_service( hass, ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index a3435a8d1f5..26871522819 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -68,7 +68,13 @@ }, "restart": { "name": "[%key:common::action::restart%]", - "description": "Restarts Home Assistant." + "description": "Restarts Home Assistant.", + "fields": { + "safe_mode": { + "name": "Safe mode", + "description": "Disable custom integrations and custom cards." + } + } }, "set_location": { "name": "Set location", diff --git a/homeassistant/config.py b/homeassistant/config.py index 8d316eb773b..1b7e90996dc 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -88,6 +88,8 @@ INTEGRATION_LOAD_EXCEPTIONS = ( *LOAD_EXCEPTIONS, ) +SAFE_MODE_FILENAME = "safe-mode" + DEFAULT_CONFIG = f""" # Loads default set of integrations. Do not remove. default_config: @@ -1007,3 +1009,24 @@ def async_notify_setup_error( persistent_notification.async_create( hass, message, "Invalid config", "invalid_config" ) + + +def safe_mode_enabled(config_dir: str) -> bool: + """Return if safe mode is enabled. + + If safe mode is enabled, the safe mode file will be removed. + """ + safe_mode_path = os.path.join(config_dir, SAFE_MODE_FILENAME) + safe_mode = os.path.exists(safe_mode_path) + if safe_mode: + os.remove(safe_mode_path) + return safe_mode + + +async def async_enable_safe_mode(hass: HomeAssistant) -> None: + """Enable safe mode.""" + + def _enable_safe_mode() -> None: + Path(hass.config.path(SAFE_MODE_FILENAME)).touch() + + await hass.async_add_executor_job(_enable_safe_mode) diff --git a/homeassistant/core.py b/homeassistant/core.py index e495973440e..2025d813be4 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2135,6 +2135,9 @@ class Config: # Use legacy template behavior self.legacy_templates: bool = False + # If Home Assistant is running in safe mode + self.safe_mode: bool = False + def distance(self, lat: float, lon: float) -> float | None: """Calculate distance from Home Assistant. @@ -2215,6 +2218,7 @@ class Config: "currency": self.currency, "country": self.country, "language": self.language, + "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 e4f36f11a36..39564846de3 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -188,7 +188,7 @@ async def _async_get_custom_components( hass: HomeAssistant, ) -> dict[str, Integration]: """Return list of custom integrations.""" - if hass.config.recovery_mode: + if hass.config.recovery_mode or hass.config.safe_mode: return {} try: @@ -1179,7 +1179,7 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: def _lookup_path(hass: HomeAssistant) -> list[str]: """Return the lookup paths for legacy lookups.""" - if hass.config.recovery_mode: + if hass.config.recovery_mode or hass.config.safe_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] diff --git a/homeassistant/runner.py b/homeassistant/runner.py index ca658c154a2..622e69ecf8c 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -54,6 +54,8 @@ class RuntimeConfig: debug: bool = False open_ui: bool = False + safe_mode: bool = False + def can_use_pidfd() -> bool: """Check if pidfd_open is available. diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 0c6b893b4b9..4f75bc2e790 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -374,7 +374,9 @@ async def test_missing_themes(hass: HomeAssistant, ws_client) -> None: assert msg["result"]["themes"] == {} -async def test_extra_js(mock_http_client_with_extra_js, mock_onboarded): +async def test_extra_js( + hass: HomeAssistant, mock_http_client_with_extra_js, mock_onboarded +): """Test that extra javascript is loaded.""" resp = await mock_http_client_with_extra_js.get("") assert resp.status == 200 @@ -384,6 +386,16 @@ async def test_extra_js(mock_http_client_with_extra_js, mock_onboarded): assert '"/local/my_module.js"' in text assert '"/local/my_es5.js"' in text + # safe mode + hass.config.safe_mode = True + resp = await mock_http_client_with_extra_js.get("") + assert resp.status == 200 + assert "cache-control" not in resp.headers + + text = await resp.text() + assert '"/local/my_module.js"' not in text + assert '"/local/my_es5.js"' not in text + async def test_get_panels( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_http_client diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 9048e03ea70..22b380a3249 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -11,6 +11,7 @@ from homeassistant import config import homeassistant.components as comps from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, + ATTR_SAFE_MODE, SERVICE_CHECK_CONFIG, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, @@ -536,22 +537,32 @@ async def test_raises_when_config_is_invalid( assert mock_async_check_ha_config_file.called -async def test_restart_homeassistant(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("service_data", "safe_mode_enabled"), + [({}, False), ({ATTR_SAFE_MODE: False}, False), ({ATTR_SAFE_MODE: True}, True)], +) +async def test_restart_homeassistant( + hass: HomeAssistant, service_data: dict, safe_mode_enabled: bool +) -> None: """Test we can restart when there is no configuration error.""" await async_setup_component(hass, "homeassistant", {}) with patch( "homeassistant.config.async_check_ha_config_file", return_value=None ) as mock_check, patch( + "homeassistant.config.async_enable_safe_mode" + ) as mock_safe_mode, patch( "homeassistant.core.HomeAssistant.async_stop", return_value=None ) as mock_restart: await hass.services.async_call( "homeassistant", SERVICE_HOMEASSISTANT_RESTART, + service_data, blocking=True, ) assert mock_check.called await hass.async_block_till_done() assert mock_restart.called + assert mock_safe_mode.called == safe_mode_enabled async def test_stop_homeassistant(hass: HomeAssistant) -> None: diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index d7901b0566e..555bcbdf6b2 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -642,6 +642,72 @@ async def test_setup_hass_recovery_mode( assert len(browser_setup.mock_calls) == 0 +async def test_setup_hass_safe_mode( + mock_hass_config: None, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + caplog: pytest.LogCaptureFixture, + event_loop: asyncio.AbstractEventLoop, +) -> None: + """Test it works.""" + with patch("homeassistant.components.browser.setup"), patch( + "homeassistant.config_entries.ConfigEntries.async_domains", + return_value=["browser"], + ): + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=False, + safe_mode=True, + ), + ) + + assert "recovery_mode" not in hass.config.components + assert "Starting in recovery mode" not in caplog.text + assert "Starting in safe mode" in caplog.text + + +async def test_setup_hass_recovery_mode_and_safe_mode( + mock_hass_config: None, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + caplog: pytest.LogCaptureFixture, + event_loop: asyncio.AbstractEventLoop, +) -> None: + """Test it works.""" + with patch("homeassistant.components.browser.setup"), patch( + "homeassistant.config_entries.ConfigEntries.async_domains", + return_value=["browser"], + ): + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=True, + safe_mode=True, + ), + ) + + assert "recovery_mode" in hass.config.components + assert "Starting in recovery mode" in caplog.text + assert "Starting in safe mode" not in caplog.text + + @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) async def test_setup_hass_invalid_core_config( mock_hass_config: None, diff --git a/tests/test_config.py b/tests/test_config.py index aeb25313302..d5181bbe115 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,6 +49,7 @@ VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, config_util.AUTOMATION_CONFIG_PATH) SCRIPTS_PATH = os.path.join(CONFIG_DIR, config_util.SCRIPT_CONFIG_PATH) SCENES_PATH = os.path.join(CONFIG_DIR, config_util.SCENE_CONFIG_PATH) +SAFE_MODE_PATH = os.path.join(CONFIG_DIR, config_util.SAFE_MODE_FILENAME) def create_file(path): @@ -80,6 +81,9 @@ def teardown(): if os.path.isfile(SCENES_PATH): os.remove(SCENES_PATH) + if os.path.isfile(SAFE_MODE_PATH): + os.remove(SAFE_MODE_PATH) + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" @@ -1386,3 +1390,12 @@ async def test_core_store_no_country( await hass.config.async_update(**{"country": "SE"}) issue = issue_registry.async_get_issue("homeassistant", issue_id) assert not issue + + +async def test_safe_mode(hass: HomeAssistant) -> None: + """Test safe mode.""" + assert config_util.safe_mode_enabled(hass.config.config_dir) is False + assert config_util.safe_mode_enabled(hass.config.config_dir) is False + await config_util.async_enable_safe_mode(hass) + assert config_util.safe_mode_enabled(hass.config.config_dir) is True + assert config_util.safe_mode_enabled(hass.config.config_dir) is False diff --git a/tests/test_core.py b/tests/test_core.py index cd855ab2c73..957da634dce 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1494,6 +1494,7 @@ async def test_config_as_dict() -> None: "currency": "EUR", "country": None, "language": "en", + "safe_mode": False, } assert expected == config.as_dict()