Make it possible to restart core in safe mode (#102606)

pull/102558/head^2
Erik Montnemery 2023-10-24 14:47:58 +02:00 committed by GitHub
parent 46322a0f59
commit 97cc05d0b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 170 additions and 11 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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",
)

View File

@ -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

View File

@ -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",

View File

@ -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)

View File

@ -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:

View File

@ -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]

View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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,

View File

@ -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

View File

@ -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()