Skip to content

Commit a433a16

Browse files
authored
Migrate unique ID of Portainer integration (home-assistant#165123)
1 parent 7fd8614 commit a433a16

8 files changed

Lines changed: 128 additions & 42 deletions

File tree

homeassistant/components/portainer/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66

77
from pyportainer import Portainer
8+
from pyportainer.exceptions import PortainerError
89

910
from homeassistant.config_entries import ConfigEntry
1011
from homeassistant.const import (
@@ -138,6 +139,26 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry)
138139

139140
hass.config_entries.async_update_entry(entry=entry, version=4)
140141

142+
if entry.version < 5:
143+
client = Portainer(
144+
api_url=entry.data[CONF_URL],
145+
api_key=entry.data[CONF_API_TOKEN],
146+
session=async_create_clientsession(
147+
hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL]
148+
),
149+
)
150+
try:
151+
system_status = await client.portainer_system_status()
152+
except PortainerError:
153+
_LOGGER.exception("Failed to fetch instance ID during migration")
154+
return False
155+
156+
hass.config_entries.async_update_entry(
157+
entry=entry,
158+
unique_id=system_status.instance_id,
159+
version=5,
160+
)
161+
141162
return True
142163

143164

homeassistant/components/portainer/config_flow.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
PortainerConnectionError,
1313
PortainerTimeoutError,
1414
)
15+
from pyportainer.models.portainer import PortainerSystemStatus
1516
import voluptuous as vol
1617

1718
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -32,7 +33,9 @@
3233
)
3334

3435

35-
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
36+
async def _validate_input(
37+
hass: HomeAssistant, data: dict[str, Any]
38+
) -> PortainerSystemStatus:
3639
"""Validate the user input allows us to connect."""
3740

3841
client = Portainer(
@@ -41,7 +44,7 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
4144
session=async_get_clientsession(hass=hass, verify_ssl=data[CONF_VERIFY_SSL]),
4245
)
4346
try:
44-
await client.get_endpoints()
47+
system_status = await client.portainer_system_status()
4548
except PortainerAuthenticationError:
4649
raise InvalidAuth from None
4750
except PortainerConnectionError as err:
@@ -50,22 +53,22 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
5053
raise PortainerTimeout from err
5154

5255
_LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL])
56+
return system_status
5357

5458

5559
class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
5660
"""Handle a config flow for Portainer."""
5761

58-
VERSION = 4
62+
VERSION = 5
5963

6064
async def async_step_user(
6165
self, user_input: dict[str, Any] | None = None
6266
) -> ConfigFlowResult:
6367
"""Handle the initial step."""
6468
errors: dict[str, str] = {}
6569
if user_input is not None:
66-
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
6770
try:
68-
await _validate_input(self.hass, user_input)
71+
system_status = await _validate_input(self.hass, user_input)
6972
except CannotConnect:
7073
errors["base"] = "cannot_connect"
7174
except InvalidAuth:
@@ -76,7 +79,7 @@ async def async_step_user(
7679
_LOGGER.exception("Unexpected exception")
7780
errors["base"] = "unknown"
7881
else:
79-
await self.async_set_unique_id(user_input[CONF_API_TOKEN])
82+
await self.async_set_unique_id(system_status.instance_id)
8083
self._abort_if_unique_id_configured()
8184
return self.async_create_entry(
8285
title=user_input[CONF_URL], data=user_input
@@ -142,7 +145,7 @@ async def async_step_reconfigure(
142145

143146
if user_input:
144147
try:
145-
await _validate_input(
148+
system_status = await _validate_input(
146149
self.hass,
147150
data={
148151
**reconf_entry.data,
@@ -159,12 +162,8 @@ async def async_step_reconfigure(
159162
_LOGGER.exception("Unexpected exception")
160163
errors["base"] = "unknown"
161164
else:
162-
# Logic that can be reverted back once the new unique ID is in
163-
existing_entry = await self.async_set_unique_id(
164-
user_input[CONF_API_TOKEN]
165-
)
166-
if existing_entry and existing_entry.entry_id != reconf_entry.entry_id:
167-
return self.async_abort(reason="already_configured")
165+
await self.async_set_unique_id(system_status.instance_id)
166+
self._abort_if_unique_id_mismatch()
168167
return self.async_update_reload_and_abort(
169168
reconf_entry,
170169
data_updates={

homeassistant/components/portainer/strings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"abort": {
44
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
55
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
6-
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
6+
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
7+
"unique_id_mismatch": "The Portainer instance ID does not match the previously configured instance. This can occur if the device was reset or reconfigured outside of Home Assistant."
78
},
89
"error": {
910
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

tests/components/portainer/conftest.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
DockerSystemDF,
1010
)
1111
from pyportainer.models.docker_inspect import DockerInfo, DockerVersion
12-
from pyportainer.models.portainer import Endpoint
12+
from pyportainer.models.portainer import Endpoint, PortainerSystemStatus
1313
from pyportainer.models.stacks import Stack
1414
import pytest
1515

@@ -29,6 +29,7 @@
2929
}
3030

3131
TEST_ENTRY = "portainer_test_entry_123"
32+
TEST_INSTANCE_ID = "299ab403-70a8-4c05-92f7-bf7a994d50df"
3233

3334

3435
@pytest.fixture
@@ -77,6 +78,9 @@ def mock_portainer_client() -> Generator[AsyncMock]:
7778
Stack.from_dict(stack)
7879
for stack in load_json_array_fixture("stacks.json", DOMAIN)
7980
]
81+
client.portainer_system_status.return_value = PortainerSystemStatus.from_dict(
82+
load_json_value_fixture("portainer_system_status.json", DOMAIN)
83+
)
8084

8185
client.restart_container = AsyncMock(return_value=None)
8286
client.images_prune = AsyncMock(return_value=None)
@@ -95,7 +99,7 @@ def mock_config_entry() -> MockConfigEntry:
9599
domain=DOMAIN,
96100
title="Portainer test",
97101
data=MOCK_TEST_CONFIG,
98-
unique_id=MOCK_TEST_CONFIG[CONF_API_TOKEN],
102+
unique_id=TEST_INSTANCE_ID,
99103
entry_id=TEST_ENTRY,
100-
version=2,
104+
version=5,
101105
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"InstanceID": "299ab403-70a8-4c05-92f7-bf7a994d50df",
3+
"Version": "2.0.0"
4+
}

tests/components/portainer/snapshots/test_diagnostics.ambr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
'subentries': list([
2222
]),
2323
'title': 'Portainer test',
24-
'unique_id': 'test_api_token',
25-
'version': 4,
24+
'unique_id': '299ab403-70a8-4c05-92f7-bf7a994d50df',
25+
'version': 5,
2626
}),
2727
'coordinator': dict({
2828
'endpoints': list([

tests/components/portainer/test_config_flow.py

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
PortainerConnectionError,
88
PortainerTimeoutError,
99
)
10+
from pyportainer.models.portainer import PortainerSystemStatus
1011
import pytest
1112

1213
from homeassistant.components.portainer.const import DOMAIN
@@ -81,7 +82,7 @@ async def test_form_exceptions(
8182
reason: str,
8283
) -> None:
8384
"""Test we handle all exceptions."""
84-
mock_portainer_client.get_endpoints.side_effect = exception
85+
mock_portainer_client.portainer_system_status.side_effect = exception
8586

8687
result = await hass.config_entries.flow.async_init(
8788
DOMAIN, context={"source": SOURCE_USER}
@@ -98,7 +99,7 @@ async def test_form_exceptions(
9899
assert result["type"] is FlowResultType.FORM
99100
assert result["errors"] == {"base": reason}
100101

101-
mock_portainer_client.get_endpoints.side_effect = None
102+
mock_portainer_client.portainer_system_status.side_effect = None
102103

103104
result = await hass.config_entries.flow.async_configure(
104105
result["flow_id"],
@@ -199,7 +200,7 @@ async def test_reauth_flow_exceptions(
199200
"""Test we handle all exceptions in the reauth flow."""
200201
mock_config_entry.add_to_hass(hass)
201202

202-
mock_portainer_client.get_endpoints.side_effect = exception
203+
mock_portainer_client.portainer_system_status.side_effect = exception
203204

204205
result = await hass.config_entries.flow.async_init(
205206
DOMAIN, context={"source": SOURCE_USER}
@@ -216,7 +217,7 @@ async def test_reauth_flow_exceptions(
216217
assert result["errors"] == {"base": reason}
217218

218219
# Now test that we can recover from the error
219-
mock_portainer_client.get_endpoints.side_effect = None
220+
mock_portainer_client.portainer_system_status.side_effect = None
220221

221222
result = await hass.config_entries.flow.async_configure(
222223
result["flow_id"],
@@ -255,23 +256,17 @@ async def test_full_flow_reconfigure(
255256
assert len(mock_setup_entry.mock_calls) == 1
256257

257258

258-
async def test_full_flow_reconfigure_unique_id(
259+
async def test_full_flow_reconfigure_unique_id_mismatch(
259260
hass: HomeAssistant,
260261
mock_portainer_client: AsyncMock,
261262
mock_setup_entry: MagicMock,
262263
mock_config_entry: MockConfigEntry,
263264
) -> None:
264-
"""Test the full flow of the config flow, this time with a known unique ID."""
265+
"""Test reconfigure aborts when credentials point to a different Portainer instance."""
265266
mock_config_entry.add_to_hass(hass)
266-
267-
other_entry = MockConfigEntry(
268-
domain=DOMAIN,
269-
title="Portainer other",
270-
data=USER_INPUT_RECONFIGURE,
271-
unique_id=USER_INPUT_RECONFIGURE[CONF_API_TOKEN],
267+
mock_portainer_client.portainer_system_status.return_value = PortainerSystemStatus(
268+
instance_id="different-instance-id", version="2.0.0"
272269
)
273-
other_entry.add_to_hass(hass)
274-
275270
result = await mock_config_entry.start_reconfigure_flow(hass)
276271
assert result["type"] is FlowResultType.FORM
277272
assert result["step_id"] == "reconfigure"
@@ -282,7 +277,7 @@ async def test_full_flow_reconfigure_unique_id(
282277
)
283278

284279
assert result["type"] is FlowResultType.ABORT
285-
assert result["reason"] == "already_configured"
280+
assert result["reason"] == "unique_id_mismatch"
286281
assert mock_config_entry.data[CONF_API_TOKEN] == "test_api_token"
287282
assert mock_config_entry.data[CONF_URL] == "https://127.0.0.1:9000/"
288283
assert len(mock_setup_entry.mock_calls) == 0
@@ -323,7 +318,7 @@ async def test_full_flow_reconfigure_exceptions(
323318
assert result["type"] is FlowResultType.FORM
324319
assert result["step_id"] == "reconfigure"
325320

326-
mock_portainer_client.get_endpoints.side_effect = exception
321+
mock_portainer_client.portainer_system_status.side_effect = exception
327322
result = await hass.config_entries.flow.async_configure(
328323
result["flow_id"],
329324
user_input=USER_INPUT_RECONFIGURE,
@@ -332,7 +327,7 @@ async def test_full_flow_reconfigure_exceptions(
332327
assert result["type"] is FlowResultType.FORM
333328
assert result["errors"] == {"base": reason}
334329

335-
mock_portainer_client.get_endpoints.side_effect = None
330+
mock_portainer_client.portainer_system_status.side_effect = None
336331
result = await hass.config_entries.flow.async_configure(
337332
result["flow_id"],
338333
user_input=USER_INPUT_RECONFIGURE,

0 commit comments

Comments
 (0)