mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 00:03:16 +01:00
Handle orphaned ignored config entries (#153093)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user