Handle orphaned ignored config entries (#153093)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Erwin Douna
2026-02-16 10:43:28 +01:00
committed by GitHub
parent 206c4e38be
commit 6a49a25799
5 changed files with 376 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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