Make it possible to restart core in safe mode (#102606)
parent
46322a0f59
commit
97cc05d0b4
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue