From 6a49a2579947ca7d31e2903b18ade120b34b4810 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 16 Feb 2026 10:43:28 +0100 Subject: [PATCH] Handle orphaned ignored config entries (#153093) Co-authored-by: Martin Hjelmare --- .../components/homeassistant/repairs.py | 41 ++++ .../components/homeassistant/strings.json | 18 ++ homeassistant/config_entries.py | 55 ++++- .../components/homeassistant/test_repairs.py | 207 ++++++++++++++++++ tests/test_config_entries.py | 56 +++++ 5 files changed, 376 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/repairs.py b/homeassistant/components/homeassistant/repairs.py index cff123da17a..d631c13b569 100644 --- a/homeassistant/components/homeassistant/repairs.py +++ b/homeassistant/components/homeassistant/repairs.py @@ -50,6 +50,44 @@ class IntegrationNotFoundFlow(RepairsFlow): ) +class OrphanedConfigEntryFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self.entry_id = data["entry_id"] + self.description_placeholders = data + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_menu( + step_id="init", + menu_options=["confirm", "ignore"], + description_placeholders=self.description_placeholders, + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + await self.hass.config_entries.async_remove(self.entry_id) + return self.async_create_entry(data={}) + + async def async_step_ignore( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the ignore step of a fix flow.""" + ir.async_get(self.hass).async_ignore( + DOMAIN, f"orphaned_ignored_entry.{self.entry_id}", True + ) + return self.async_abort( + reason="issue_ignored", + description_placeholders=self.description_placeholders, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -58,4 +96,7 @@ async def async_create_fix_flow( if issue_id.split(".", maxsplit=1)[0] == "integration_not_found": assert data return IntegrationNotFoundFlow(data) + if issue_id.split(".", maxsplit=1)[0] == "orphaned_ignored_entry": + assert data + return OrphanedConfigEntryFlow(data) return ConfirmRepairFlow() diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index e23a165005d..ccd44c0be5e 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -162,6 +162,24 @@ "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurrences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}", "title": "Unused YAML configuration for the {platform} integration" }, + "orphaned_ignored_config_entry": { + "fix_flow": { + "abort": { + "issue_ignored": "Non-existent ignored integration {domain} ignored." + }, + "step": { + "init": { + "description": "There is an ignored orphaned config entry for the `{domain}` integration. This can happen when an integration is removed, but the config entry is still present in Home Assistant.\n\nTo resolve this, press **Remove** to clean up the orphaned entry.", + "menu_options": { + "confirm": "Remove", + "ignore": "Ignore" + }, + "title": "[%key:component::homeassistant::issues::orphaned_ignored_config_entry::title%]" + } + } + }, + "title": "Orphaned ignored config entry for {domain}" + }, "platform_only": { "description": "The {domain} integration does not support configuration under its own key, it must be configured under its supported platforms.\n\nTo resolve this:\n\n1. Remove `{domain}:` from your YAML configuration file.\n\n2. Restart Home Assistant.", "title": "The {domain} integration does not support YAML configuration under its own key" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index dc69d669582..a89c5869a2f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -69,7 +69,13 @@ from .helpers.event import ( ) from .helpers.frame import ReportBehavior, report_usage from .helpers.json import json_bytes, json_bytes_sorted, json_fragment -from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType +from .helpers.typing import ( + UNDEFINED, + ConfigType, + DiscoveryInfoType, + NoEventData, + UndefinedType, +) from .loader import async_suggest_report_issue from .setup import ( SetupPhases, @@ -2237,6 +2243,53 @@ class ConfigEntries: self._entries = entries self.async_update_issues() + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries + ) + + async def _async_scan_orphan_ignored_entries( + self, event: Event[NoEventData] + ) -> None: + """Scan for ignored entries that can be removed. + + Orphaned ignored entries are entries that are in ignored state + for integrations that are no longer available. + """ + remove_candidates = [ + entry + for entry in self.async_entries( + include_ignore=True, + include_disabled=False, + ) + if entry.source == SOURCE_IGNORE + ] + + if not remove_candidates: + return + + for entry in remove_candidates: + try: + await loader.async_get_integration(self.hass, entry.domain) + except loader.IntegrationNotFound: + _LOGGER.info( + "Integration for ignored config entry %s not found. Creating repair issue", + entry, + ) + ir.async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + issue_id=f"orphaned_ignored_entry.{entry.entry_id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="orphaned_ignored_config_entry", + translation_placeholders={"domain": entry.domain}, + data={ + "domain": entry.domain, + "entry_id": entry.entry_id, + }, + ) + async def async_setup(self, entry_id: str, _lock: bool = True) -> bool: """Set up a config entry. diff --git a/tests/components/homeassistant/test_repairs.py b/tests/components/homeassistant/test_repairs.py index d9329744694..76035e60022 100644 --- a/tests/components/homeassistant/test_repairs.py +++ b/tests/components/homeassistant/test_repairs.py @@ -1,6 +1,10 @@ """Test the Homeassistant repairs module.""" +from typing import Any + +from homeassistant import config_entries from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -117,3 +121,206 @@ async def test_integration_not_found_ignore_step( issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) assert issue is not None assert issue.dismissed_version is not None + + +async def test_orphaned_config_entry_confirm_step( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """Test the orphaned_config_entry issue confirm step.""" + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await hass.async_block_till_done() + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await hass.async_block_till_done() + + await async_process_repairs_platforms(hass) + http_client = await hass_client() + + entry = MockConfigEntry(domain="test_issued", source=config_entries.SOURCE_IGNORE) + entry_valid = MockConfigEntry(domain="test_valid") + issue_id = f"orphaned_ignored_entry.{entry.entry_id}" + + hass_storage[config_entries.STORAGE_KEY] = { + "version": 1, + "minor_version": 5, + "data": { + "entries": [ + { + "created_at": entry.created_at.isoformat(), + "data": {}, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test_issued", + "entry_id": entry.entry_id, + "minor_version": 1, + "modified_at": entry.modified_at.isoformat(), + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "ignore", + "subentries": [], + "title": "Title probably no-one will read", + "unique_id": None, + "version": 1, + }, + { + "created_at": entry_valid.created_at.isoformat(), + "data": {}, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test_valid", + "entry_id": entry_valid.entry_id, + "minor_version": 1, + "modified_at": entry_valid.modified_at.isoformat(), + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Title probably no-one will read", + "unique_id": None, + "version": 1, + }, + ] + }, + } + + await hass.config_entries.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + assert issue is not None + assert issue.translation_placeholders == {"domain": "test_issued"} + + data = await start_repair_fix_flow(http_client, HOMEASSISTANT_DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["step_id"] == "init" + assert data["description_placeholders"] == { + "entry_id": entry.entry_id, + "domain": "test_issued", + } + + data = await process_repair_fix_flow(http_client, flow_id) + + assert data["type"] == "menu" + + # Apply fix + data = await process_repair_fix_flow( + http_client, flow_id, json={"next_step_id": "confirm"} + ) + + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + assert hass.config_entries.async_get_entry(entry.entry_id) is None + assert hass.config_entries.async_get_entry(entry_valid.entry_id) is not None + + assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + + +async def test_orphaned_config_entry_ignore_step( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """Test the orphaned_config_entry issue ignore step.""" + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await hass.async_block_till_done() + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await hass.async_block_till_done() + + await async_process_repairs_platforms(hass) + http_client = await hass_client() + + entry = MockConfigEntry(domain="test_issued", source=config_entries.SOURCE_IGNORE) + entry_valid = MockConfigEntry(domain="test_valid") + issue_id = f"orphaned_ignored_entry.{entry.entry_id}" + + hass_storage[config_entries.STORAGE_KEY] = { + "version": 1, + "minor_version": 5, + "data": { + "entries": [ + { + "created_at": entry.created_at.isoformat(), + "data": {}, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test_issued", + "entry_id": entry.entry_id, + "minor_version": 1, + "modified_at": entry.modified_at.isoformat(), + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "ignore", + "subentries": [], + "title": "Title probably no-one will read", + "unique_id": None, + "version": 1, + }, + { + "created_at": entry_valid.created_at.isoformat(), + "data": {}, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test_valid", + "entry_id": entry_valid.entry_id, + "minor_version": 1, + "modified_at": entry_valid.modified_at.isoformat(), + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Title probably no-one will read", + "unique_id": None, + "version": 1, + }, + ] + }, + } + + await hass.config_entries.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + assert issue is not None + assert issue.translation_placeholders == {"domain": "test_issued"} + + data = await start_repair_fix_flow(http_client, HOMEASSISTANT_DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["step_id"] == "init" + assert data["description_placeholders"] == { + "entry_id": entry.entry_id, + "domain": "test_issued", + } + + data = await process_repair_fix_flow(http_client, flow_id) + + assert data["type"] == "menu" + + # Apply fix + data = await process_repair_fix_flow( + http_client, flow_id, json={"next_step_id": "ignore"} + ) + + assert data["type"] == "abort" + assert data["reason"] == "issue_ignored" + + await hass.async_block_till_done() + + assert hass.config_entries.async_get_entry(entry.entry_id) + + # Assert the issue is resolved + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + assert issue is not None + assert issue.dismissed_version is not None diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index c1f570e3c29..4269c75232e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -9875,3 +9875,59 @@ async def test_user_flow_not_dismiss_protected_on_configure( # User flows should not be marked as dismiss protected context = _get_flow_context(manager, result["flow_id"]) assert "dismiss_protected" not in context + + +async def test_orphaned_ignored_entries( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Test orphaned ignored entries scanner creates a repair issue for missing (custom) integration.""" + await hass.config_entries.async_initialize() + entry = MockConfigEntry( + domain="ghost_orphan_domain", source=config_entries.SOURCE_IGNORE + ) + entry.add_to_hass(hass) + + # Not sure if this is the best way. Let's wait a review + async def _raise(hass_param: HomeAssistant, domain: str) -> None: + raise loader.IntegrationNotFound(domain) + + with patch( + "homeassistant.loader.async_get_integration", new=AsyncMock(side_effect=_raise) + ): + await hass.config_entries._async_scan_orphan_ignored_entries(None) + + # If all went according to plan, an issue has been created. + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"orphaned_ignored_entry.{entry.entry_id}" + ) + + assert issue is not None, "Expected repair issue for orphaned ignored entry" + assert issue.is_fixable + assert issue.is_persistent + assert issue.translation_key == "orphaned_ignored_config_entry" + assert issue.data == { + "domain": "ghost_orphan_domain", + "entry_id": entry.entry_id, + } + + +async def test_orphaned_ignored_entries_existing_integration( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Just to be very thorough: test no repair issue is created for ignored entry with existing (custom) integration.""" + await hass.config_entries.async_initialize() + + # This time we mock we have an actual existing integration + mock_integration(hass, MockModule(domain="existing_domain")) + + entry = MockConfigEntry( + domain="existing_domain", source=config_entries.SOURCE_IGNORE + ) + entry.add_to_hass(hass) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done()