Create repair asking user to remove duplicate config entries (#127948)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Erik Montnemery 2024-10-29 13:10:56 +01:00 committed by GitHub
parent 1649368cee
commit da11a72b4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 247 additions and 1 deletions

View file

@ -57,6 +57,14 @@
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Reauthentication is needed"
},
"config_entry_unique_id_collision": {
"title": "Multiple {domain} config entries with same unique ID",
"description": "There are multiple {domain} config entries with the same unique ID.\nThe config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates."
},
"config_entry_unique_id_collision_many": {
"title": "[%key:component::homeassistant::issues::config_entry_unique_id_collision::title%]",
"description": "There are multiple ({number_of_entries}) {domain} config entries with the same unique ID.\nThe first {title_limit} config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates."
},
"integration_not_found": {
"title": "Integration {domain} not found",
"fix_flow": {

View file

@ -123,6 +123,9 @@ SAVE_DELAY = 1
DISCOVERY_COOLDOWN = 1
ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision"
UNIQUE_ID_COLLISION_TITLE_LIMIT = 5
_DataT = TypeVar("_DataT", default=Any)
@ -1850,6 +1853,7 @@ class ConfigEntries:
)
self._entries[entry.entry_id] = entry
self.async_update_issues()
self._async_dispatch(ConfigEntryChange.ADDED, entry)
await self.async_setup(entry.entry_id)
self._async_schedule_save()
@ -1868,6 +1872,7 @@ class ConfigEntries:
await entry.async_remove(self.hass)
del self._entries[entry.entry_id]
self.async_update_issues()
self._async_schedule_save()
dev_reg = device_registry.async_get(self.hass)
@ -1942,6 +1947,7 @@ class ConfigEntries:
entries[entry_id] = config_entry
self._entries = entries
self.async_update_issues()
async def async_setup(self, entry_id: str, _lock: bool = True) -> bool:
"""Set up a config entry.
@ -2130,6 +2136,7 @@ class ConfigEntries:
)
# Reindex the entry if the unique_id has changed
self._entries.update_unique_id(entry, unique_id)
self.async_update_issues()
changed = True
for attr, value in (
@ -2372,6 +2379,67 @@ class ConfigEntries:
return False
return entry.state is ConfigEntryState.LOADED
@callback
def async_update_issues(self) -> None:
"""Update unique id collision issues."""
issue_registry = ir.async_get(self.hass)
issues: set[str] = set()
for issue in issue_registry.issues.values():
if (
issue.domain != HOMEASSISTANT_DOMAIN
or not (issue_data := issue.data)
or issue_data.get("issue_type") != ISSUE_UNIQUE_ID_COLLISION
):
continue
issues.add(issue.issue_id)
for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001
for unique_id, entries in unique_ids.items():
if len(entries) < 2:
continue
issue_id = f"{ISSUE_UNIQUE_ID_COLLISION}_{domain}_{unique_id}"
issues.discard(issue_id)
titles = [f"'{entry.title}'" for entry in entries]
translation_placeholders = {
"domain": domain,
"configure_url": f"/config/integrations/integration/{domain}",
"unique_id": str(unique_id),
}
if len(titles) <= UNIQUE_ID_COLLISION_TITLE_LIMIT:
translation_key = "config_entry_unique_id_collision"
translation_placeholders["titles"] = ", ".join(titles)
else:
translation_key = "config_entry_unique_id_collision_many"
translation_placeholders["number_of_entries"] = str(len(titles))
translation_placeholders["titles"] = ", ".join(
titles[:UNIQUE_ID_COLLISION_TITLE_LIMIT]
)
translation_placeholders["title_limit"] = str(
UNIQUE_ID_COLLISION_TITLE_LIMIT
)
ir.async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
issue_id,
breaks_in_ha_version="2025.11.0",
data={
"issue_type": ISSUE_UNIQUE_ID_COLLISION,
"unique_id": unique_id,
},
is_fixable=False,
issue_domain=domain,
severity=ir.IssueSeverity.ERROR,
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)
break # Only create one issue per domain
for issue_id in issues:
ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
@callback
def _async_abort_entries_match(

View file

@ -21,3 +21,83 @@
'version': 1,
})
# ---
# name: test_unique_id_collision_issues
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2025.11.0',
'created': <ANY>,
'data': dict({
'issue_type': 'config_entry_unique_id_collision',
'unique_id': 'group_1',
}),
'dismissed_version': None,
'domain': 'homeassistant',
'is_fixable': False,
'is_persistent': False,
'issue_domain': 'test2',
'issue_id': 'config_entry_unique_id_collision_test2_group_1',
'learn_more_url': None,
'severity': <IssueSeverity.ERROR: 'error'>,
'translation_key': 'config_entry_unique_id_collision',
'translation_placeholders': dict({
'configure_url': '/config/integrations/integration/test2',
'domain': 'test2',
'titles': "'Mock Title', 'Mock Title', 'Mock Title'",
'unique_id': 'group_1',
}),
})
# ---
# name: test_unique_id_collision_issues.1
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2025.11.0',
'created': <ANY>,
'data': dict({
'issue_type': 'config_entry_unique_id_collision',
'unique_id': 'not_unique',
}),
'dismissed_version': None,
'domain': 'homeassistant',
'is_fixable': False,
'is_persistent': False,
'issue_domain': 'test3',
'issue_id': 'config_entry_unique_id_collision_test3_not_unique',
'learn_more_url': None,
'severity': <IssueSeverity.ERROR: 'error'>,
'translation_key': 'config_entry_unique_id_collision_many',
'translation_placeholders': dict({
'configure_url': '/config/integrations/integration/test3',
'domain': 'test3',
'number_of_entries': '6',
'title_limit': '5',
'titles': "'Mock Title', 'Mock Title', 'Mock Title', 'Mock Title', 'Mock Title'",
'unique_id': 'not_unique',
}),
})
# ---
# name: test_unique_id_collision_issues.2
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2025.11.0',
'created': <ANY>,
'data': dict({
'issue_type': 'config_entry_unique_id_collision',
'unique_id': 'not_unique',
}),
'dismissed_version': None,
'domain': 'homeassistant',
'is_fixable': False,
'is_persistent': False,
'issue_domain': 'test3',
'issue_id': 'config_entry_unique_id_collision_test3_not_unique',
'learn_more_url': None,
'severity': <IssueSeverity.ERROR: 'error'>,
'translation_key': 'config_entry_unique_id_collision',
'translation_placeholders': dict({
'configure_url': '/config/integrations/integration/test3',
'domain': 'test3',
'titles': "'Mock Title', 'Mock Title', 'Mock Title', 'Mock Title', 'Mock Title'",
'unique_id': 'not_unique',
}),
})
# ---

View file

@ -6915,8 +6915,13 @@ async def test_async_update_entry_unique_id_collision(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
caplog: pytest.LogCaptureFixture,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test we warn when async_update_entry creates a unique_id collision."""
"""Test we warn when async_update_entry creates a unique_id collision.
Also test an issue registry issue is created.
"""
assert len(issue_registry.issues) == 0
entry1 = MockConfigEntry(domain="test", unique_id=None)
entry2 = MockConfigEntry(domain="test", unique_id="not none")
@ -6928,9 +6933,11 @@ async def test_async_update_entry_unique_id_collision(
entry4.add_to_manager(manager)
manager.async_update_entry(entry2, unique_id=None)
assert len(issue_registry.issues) == 0
assert len(caplog.record_tuples) == 0
manager.async_update_entry(entry4, unique_id="very unique")
assert len(issue_registry.issues) == 1
assert len(caplog.record_tuples) == 1
assert (
@ -6938,6 +6945,89 @@ async def test_async_update_entry_unique_id_collision(
"'very unique' which is already in use"
) in caplog.text
issue_id = "config_entry_unique_id_collision_test_very unique"
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id)
async def test_unique_id_collision_issues(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
caplog: pytest.LogCaptureFixture,
issue_registry: ir.IssueRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test issue registry issues are created and remove on unique id collision."""
assert len(issue_registry.issues) == 0
mock_setup_entry = AsyncMock(return_value=True)
for i in range(3):
mock_integration(
hass, MockModule(f"test{i+1}", async_setup_entry=mock_setup_entry)
)
mock_platform(hass, f"test{i+1}.config_flow", None)
test2_group_1: list[MockConfigEntry] = []
test2_group_2: list[MockConfigEntry] = []
test3: list[MockConfigEntry] = []
for _ in range(3):
await manager.async_add(MockConfigEntry(domain="test1", unique_id=None))
test2_group_1.append(MockConfigEntry(domain="test2", unique_id="group_1"))
test2_group_2.append(MockConfigEntry(domain="test2", unique_id="group_2"))
await manager.async_add(test2_group_1[-1])
await manager.async_add(test2_group_2[-1])
for _ in range(6):
test3.append(MockConfigEntry(domain="test3", unique_id="not_unique"))
await manager.async_add(test3[-1])
# Check we get one issue for domain test2 and one issue for domain test3
assert len(issue_registry.issues) == 2
issue_id = "config_entry_unique_id_collision_test2_group_1"
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) == snapshot
issue_id = "config_entry_unique_id_collision_test3_not_unique"
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) == snapshot
# Remove one config entry for domain test3, the translations should be updated
await manager.async_remove(test3[0].entry_id)
assert set(issue_registry.issues) == {
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_1"),
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test3_not_unique"),
}
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) == snapshot
# Remove all but two config entries for domain test 3
for i in range(3):
await manager.async_remove(test3[1 + i].entry_id)
assert set(issue_registry.issues) == {
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_1"),
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test3_not_unique"),
}
# Remove the last test3 duplicate, the issue is cleared
await manager.async_remove(test3[-1].entry_id)
assert set(issue_registry.issues) == {
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_1"),
}
await manager.async_remove(test2_group_1[0].entry_id)
assert set(issue_registry.issues) == {
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_1"),
}
# Remove the last test2 group1 duplicate, a new issue is created
await manager.async_remove(test2_group_1[1].entry_id)
assert set(issue_registry.issues) == {
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_2"),
}
await manager.async_remove(test2_group_2[0].entry_id)
assert set(issue_registry.issues) == {
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_2"),
}
# Remove the last test2 group2 duplicate, a new issue is created
await manager.async_remove(test2_group_2[1].entry_id)
assert not issue_registry.issues
async def test_context_no_leak(hass: HomeAssistant) -> None:
"""Test ensure that config entry context does not leak.