1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Re-implement Cloudflare using coordinator (#156817)

Signed-off-by: David Rapan <david@rapan.cz>
This commit is contained in:
David Rapan
2026-02-13 00:33:48 +01:00
committed by GitHub
parent df7c3d787d
commit cce5358901
4 changed files with 215 additions and 223 deletions

View File

@@ -2,86 +2,23 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
import socket
import pycfdns
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, CONF_ZONE
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.location import async_detect_location_info
from homeassistant.util.network import is_ipv4_address
from .const import CONF_RECORDS, DEFAULT_UPDATE_INTERVAL, DOMAIN, SERVICE_UPDATE_RECORDS from .const import DOMAIN, SERVICE_UPDATE_RECORDS
from .coordinator import CloudflareConfigEntry, CloudflareCoordinator
_LOGGER = logging.getLogger(__name__)
type CloudflareConfigEntry = ConfigEntry[CloudflareRuntimeData]
@dataclass
class CloudflareRuntimeData:
"""Runtime data for Cloudflare config entry."""
client: pycfdns.Client
dns_zone: pycfdns.ZoneModel
async def async_setup_entry(hass: HomeAssistant, entry: CloudflareConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: CloudflareConfigEntry) -> bool:
"""Set up Cloudflare from a config entry.""" """Set up Cloudflare from a config entry."""
session = async_get_clientsession(hass) entry.runtime_data = CloudflareCoordinator(hass, entry)
client = pycfdns.Client( await entry.runtime_data.async_config_entry_first_refresh()
api_token=entry.data[CONF_API_TOKEN],
client_session=session,
)
try: # Since we are not using coordinator for data reads, we need to add dummy listener
dns_zones = await client.list_zones() entry.async_on_unload(entry.runtime_data.async_add_listener(lambda: None))
dns_zone = next(
zone for zone in dns_zones if zone["name"] == entry.data[CONF_ZONE]
)
except pycfdns.AuthenticationException as error:
raise ConfigEntryAuthFailed from error
except pycfdns.ComunicationException as error:
raise ConfigEntryNotReady from error
entry.runtime_data = CloudflareRuntimeData(client, dns_zone) async def update_records_service(_: ServiceCall) -> None:
async def update_records(now: datetime) -> None:
"""Set up recurring update."""
try:
await _async_update_cloudflare(hass, entry)
except (
pycfdns.AuthenticationException,
pycfdns.ComunicationException,
) as error:
_LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error)
async def update_records_service(call: ServiceCall) -> None:
"""Set up service for manual trigger.""" """Set up service for manual trigger."""
try: await entry.runtime_data.async_request_refresh()
await _async_update_cloudflare(hass, entry)
except (
pycfdns.AuthenticationException,
pycfdns.ComunicationException,
) as error:
_LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error)
update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
entry.async_on_unload(
async_track_time_interval(hass, update_records, update_interval)
)
hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service) hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service)
@@ -92,49 +29,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: CloudflareConfigEntry)
"""Unload Cloudflare config entry.""" """Unload Cloudflare config entry."""
return True return True
async def _async_update_cloudflare(
hass: HomeAssistant,
entry: CloudflareConfigEntry,
) -> None:
client = entry.runtime_data.client
dns_zone = entry.runtime_data.dns_zone
target_records: list[str] = entry.data[CONF_RECORDS]
_LOGGER.debug("Starting update for zone %s", dns_zone["name"])
records = await client.list_dns_records(zone_id=dns_zone["id"], type="A")
_LOGGER.debug("Records: %s", records)
session = async_get_clientsession(hass, family=socket.AF_INET)
location_info = await async_detect_location_info(session)
if not location_info or not is_ipv4_address(location_info.ip):
raise HomeAssistantError("Could not get external IPv4 address")
filtered_records = [
record
for record in records
if record["name"] in target_records and record["content"] != location_info.ip
]
if len(filtered_records) == 0:
_LOGGER.debug("All target records are up to date")
return
await asyncio.gather(
*[
client.update_dns_record(
zone_id=dns_zone["id"],
record_id=record["id"],
record_content=location_info.ip,
record_name=record["name"],
record_type=record["type"],
record_proxied=record["proxied"],
)
for record in filtered_records
]
)
_LOGGER.debug("Update for zone %s is complete", dns_zone["name"])

View File

@@ -0,0 +1,116 @@
"""Contains the Coordinator for updating the IP addresses of your Cloudflare DNS records."""
from __future__ import annotations
import asyncio
from datetime import timedelta
from logging import getLogger
import socket
import pycfdns
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, CONF_ZONE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.location import async_detect_location_info
from homeassistant.util.network import is_ipv4_address
from .const import CONF_RECORDS, DEFAULT_UPDATE_INTERVAL
_LOGGER = getLogger(__name__)
type CloudflareConfigEntry = ConfigEntry[CloudflareCoordinator]
class CloudflareCoordinator(DataUpdateCoordinator[None]):
"""Coordinates records updates."""
config_entry: CloudflareConfigEntry
client: pycfdns.Client
zone: pycfdns.ZoneModel
def __init__(
self, hass: HomeAssistant, config_entry: CloudflareConfigEntry
) -> None:
"""Initialize an coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=config_entry.title,
update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL),
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
self.client = pycfdns.Client(
api_token=self.config_entry.data[CONF_API_TOKEN],
client_session=async_get_clientsession(self.hass),
)
try:
self.zone = next(
zone
for zone in await self.client.list_zones()
if zone["name"] == self.config_entry.data[CONF_ZONE]
)
except pycfdns.AuthenticationException as e:
raise ConfigEntryAuthFailed from e
except pycfdns.ComunicationException as e:
raise UpdateFailed("Error communicating with API") from e
async def _async_update_data(self) -> None:
"""Update records."""
_LOGGER.debug("Starting update for zone %s", self.zone["name"])
try:
records = await self.client.list_dns_records(
zone_id=self.zone["id"], type="A"
)
_LOGGER.debug("Records: %s", records)
target_records: list[str] = self.config_entry.data[CONF_RECORDS]
location_info = await async_detect_location_info(
async_get_clientsession(self.hass, family=socket.AF_INET)
)
if not location_info or not is_ipv4_address(location_info.ip):
raise UpdateFailed("Could not get external IPv4 address")
filtered_records = [
record
for record in records
if record["name"] in target_records
and record["content"] != location_info.ip
]
if len(filtered_records) == 0:
_LOGGER.debug("All target records are up to date")
return
await asyncio.gather(
*[
self.client.update_dns_record(
zone_id=self.zone["id"],
record_id=record["id"],
record_content=location_info.ip,
record_name=record["name"],
record_type=record["type"],
record_proxied=record["proxied"],
)
for record in filtered_records
]
)
_LOGGER.debug("Update for zone %s is complete", self.zone["name"])
except (
pycfdns.AuthenticationException,
pycfdns.ComunicationException,
) as e:
raise UpdateFailed(
f"Error updating zone {self.config_entry.data[CONF_ZONE]}"
) from e

View File

@@ -5,15 +5,21 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from homeassistant.util.location import LocationInfo
from . import get_mock_client from . import get_mock_client
LOCATION_PATCH_TARGET = (
"homeassistant.components.cloudflare.coordinator.async_detect_location_info"
)
@pytest.fixture @pytest.fixture
def cfupdate() -> Generator[MagicMock]: def cfupdate() -> Generator[MagicMock]:
"""Mock the CloudflareUpdater for easier testing.""" """Mock the CloudflareUpdater for easier testing."""
mock_cfupdate = get_mock_client() mock_cfupdate = get_mock_client()
with patch( with patch(
"homeassistant.components.cloudflare.pycfdns.Client", "homeassistant.components.cloudflare.coordinator.pycfdns.Client",
return_value=mock_cfupdate, return_value=mock_cfupdate,
) as mock_api: ) as mock_api:
yield mock_api yield mock_api
@@ -28,3 +34,25 @@ def cfupdate_flow() -> Generator[MagicMock]:
return_value=mock_cfupdate, return_value=mock_cfupdate,
) as mock_api: ) as mock_api:
yield mock_api yield mock_api
@pytest.fixture
def location_info() -> Generator[None]:
"""Mock the LocationInfo for easier testing."""
with patch(
LOCATION_PATCH_TARGET,
return_value=LocationInfo(
"0.0.0.0",
"US",
"USD",
"CA",
"California",
"San Diego",
"92122",
"America/Los_Angeles",
32.8594,
-117.2073,
True,
),
):
yield

View File

@@ -3,6 +3,7 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pycfdns import pycfdns
import pytest import pytest
@@ -14,29 +15,13 @@ from homeassistant.components.cloudflare.const import (
) )
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from homeassistant.util.location import LocationInfo
from . import ENTRY_CONFIG, init_integration from . import ENTRY_CONFIG, init_integration
from .conftest import LOCATION_PATCH_TARGET
from tests.common import MockConfigEntry, async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed
async def test_unload_entry(hass: HomeAssistant, cfupdate: MagicMock) -> None:
"""Test successful unload of entry."""
entry = await init_integration(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"side_effect", "side_effect",
[pycfdns.ComunicationException()], [pycfdns.ComunicationException()],
@@ -83,6 +68,21 @@ async def test_async_setup_raises_entry_auth_failed(
assert flow["context"]["entry_id"] == entry.entry_id assert flow["context"]["entry_id"] == entry.entry_id
@pytest.mark.usefixtures("location_info")
async def test_unload_entry(hass: HomeAssistant, cfupdate: MagicMock) -> None:
"""Test successful unload of entry."""
entry = await init_integration(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("location_info")
async def test_integration_services( async def test_integration_services(
hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -92,36 +92,24 @@ async def test_integration_services(
entry = await init_integration(hass) entry = await init_integration(hass)
assert entry.state is ConfigEntryState.LOADED assert entry.state is ConfigEntryState.LOADED
with patch( assert len(instance.update_dns_record.mock_calls) == 2
"homeassistant.components.cloudflare.async_detect_location_info", instance.update_dns_record.reset_mock()
return_value=LocationInfo(
"0.0.0.0", await hass.services.async_call(
"US", DOMAIN,
"USD", SERVICE_UPDATE_RECORDS,
"CA", {},
"California", blocking=True,
"San Diego", )
"92122", await hass.async_block_till_done()
"America/Los_Angeles",
32.8594,
-117.2073,
True,
),
):
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_RECORDS,
{},
blocking=True,
)
await hass.async_block_till_done()
assert len(instance.update_dns_record.mock_calls) == 2 assert len(instance.update_dns_record.mock_calls) == 2
assert "All target records are up to date" not in caplog.text assert "All target records are up to date" not in caplog.text
@pytest.mark.usefixtures("location_info")
async def test_integration_services_with_issue( async def test_integration_services_with_issue(
hass: HomeAssistant, cfupdate: MagicMock hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test integration services with issue.""" """Test integration services with issue."""
instance = cfupdate.return_value instance = cfupdate.return_value
@@ -129,13 +117,10 @@ async def test_integration_services_with_issue(
entry = await init_integration(hass) entry = await init_integration(hass)
assert entry.state is ConfigEntryState.LOADED assert entry.state is ConfigEntryState.LOADED
with ( assert len(instance.update_dns_record.mock_calls) == 2
patch( instance.update_dns_record.reset_mock()
"homeassistant.components.cloudflare.async_detect_location_info",
return_value=None, with patch(LOCATION_PATCH_TARGET, return_value=None):
),
pytest.raises(HomeAssistantError, match="Could not get external IPv4 address"),
):
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_UPDATE_RECORDS, SERVICE_UPDATE_RECORDS,
@@ -144,8 +129,10 @@ async def test_integration_services_with_issue(
) )
instance.update_dns_record.assert_not_called() instance.update_dns_record.assert_not_called()
assert "Could not get external IPv4 address" in caplog.text
@pytest.mark.usefixtures("location_info")
async def test_integration_services_with_nonexisting_record( async def test_integration_services_with_nonexisting_record(
hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
@@ -157,38 +144,24 @@ async def test_integration_services_with_nonexisting_record(
) )
assert entry.state is ConfigEntryState.LOADED assert entry.state is ConfigEntryState.LOADED
with patch( await hass.services.async_call(
"homeassistant.components.cloudflare.async_detect_location_info", DOMAIN,
return_value=LocationInfo( SERVICE_UPDATE_RECORDS,
"0.0.0.0", {},
"US", blocking=True,
"USD", )
"CA", await hass.async_block_till_done()
"California",
"San Diego",
"92122",
"America/Los_Angeles",
32.8594,
-117.2073,
True,
),
):
await hass.services.async_call(
DOMAIN,
SERVICE_UPDATE_RECORDS,
{},
blocking=True,
)
await hass.async_block_till_done()
instance.update_dns_record.assert_not_called() instance.update_dns_record.assert_not_called()
assert "All target records are up to date" in caplog.text assert "All target records are up to date" in caplog.text
@pytest.mark.usefixtures("location_info")
async def test_integration_update_interval( async def test_integration_update_interval(
hass: HomeAssistant, hass: HomeAssistant,
cfupdate: MagicMock, cfupdate: MagicMock,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test integration update interval.""" """Test integration update interval."""
instance = cfupdate.return_value instance = cfupdate.return_value
@@ -196,39 +169,23 @@ async def test_integration_update_interval(
entry = await init_integration(hass) entry = await init_integration(hass)
assert entry.state is ConfigEntryState.LOADED assert entry.state is ConfigEntryState.LOADED
with patch( freezer.tick(timedelta(minutes=DEFAULT_UPDATE_INTERVAL))
"homeassistant.components.cloudflare.async_detect_location_info", async_fire_time_changed(hass)
return_value=LocationInfo( await hass.async_block_till_done(wait_background_tasks=True)
"0.0.0.0", assert len(instance.list_dns_records.mock_calls) == 2
"US", assert len(instance.update_dns_record.mock_calls) == 4
"USD", assert "All target records are up to date" not in caplog.text
"CA",
"California",
"San Diego",
"92122",
"America/Los_Angeles",
32.8594,
-117.2073,
True,
),
):
async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
)
await hass.async_block_till_done(wait_background_tasks=True)
assert len(instance.update_dns_record.mock_calls) == 2
assert "All target records are up to date" not in caplog.text
instance.list_dns_records.side_effect = pycfdns.AuthenticationException() instance.list_dns_records.side_effect = pycfdns.AuthenticationException()
async_fire_time_changed( freezer.tick(timedelta(minutes=DEFAULT_UPDATE_INTERVAL))
hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) async_fire_time_changed(hass)
) await hass.async_block_till_done(wait_background_tasks=True)
await hass.async_block_till_done(wait_background_tasks=True) assert len(instance.list_dns_records.mock_calls) == 3
assert len(instance.update_dns_record.mock_calls) == 2 assert len(instance.update_dns_record.mock_calls) == 4
instance.list_dns_records.side_effect = pycfdns.ComunicationException() instance.list_dns_records.side_effect = pycfdns.ComunicationException()
async_fire_time_changed( freezer.tick(timedelta(minutes=DEFAULT_UPDATE_INTERVAL))
hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) async_fire_time_changed(hass)
) await hass.async_block_till_done(wait_background_tasks=True)
await hass.async_block_till_done(wait_background_tasks=True) assert len(instance.list_dns_records.mock_calls) == 4
assert len(instance.update_dns_record.mock_calls) == 2 assert len(instance.update_dns_record.mock_calls) == 4