From 7dcf5ba6311f6a72fac373dfa725a090930f5371 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 29 Jul 2025 15:59:03 +0200 Subject: [PATCH] Enable IPv6 for containers on new installations (#6029) * Enable IPv6 by default for new installations Enable IPv6 by default for new Supervisor installations. Let's also make the `enable_ipv6` attribute nullable, so we can distinguish between "not set" and "set to false". * Add pytest * Add log message that system restart is required for IPv6 changes * Fix API pytest * Create resolution center issue when reboot is required * Order log after actual setter call --- supervisor/api/docker.py | 15 +++++++++++++-- supervisor/docker/manager.py | 6 +++--- supervisor/docker/network.py | 21 +++++++++++++++++---- supervisor/validate.py | 2 +- tests/api/test_docker.py | 2 +- tests/docker/test_network.py | 36 ++++++++++++++++++++++++++++++++++++ 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/supervisor/api/docker.py b/supervisor/api/docker.py index 3af203ebf..e751cc006 100644 --- a/supervisor/api/docker.py +++ b/supervisor/api/docker.py @@ -6,6 +6,8 @@ from typing import Any from aiohttp import web import voluptuous as vol +from supervisor.resolution.const import ContextType, IssueType, SuggestionType + from ..const import ( ATTR_ENABLE_IPV6, ATTR_HOSTNAME, @@ -32,7 +34,7 @@ SCHEMA_DOCKER_REGISTRY = vol.Schema( ) # pylint: disable=no-value-for-parameter -SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_ENABLE_IPV6): vol.Boolean()}) +SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_ENABLE_IPV6): vol.Maybe(vol.Boolean())}) class APIDocker(CoreSysAttributes): @@ -59,8 +61,17 @@ class APIDocker(CoreSysAttributes): """Set docker options.""" body = await api_validate(SCHEMA_OPTIONS, request) - if ATTR_ENABLE_IPV6 in body: + if ( + ATTR_ENABLE_IPV6 in body + and self.sys_docker.config.enable_ipv6 != body[ATTR_ENABLE_IPV6] + ): self.sys_docker.config.enable_ipv6 = body[ATTR_ENABLE_IPV6] + _LOGGER.info("Host system reboot required to apply new IPv6 configuration") + self.sys_resolution.create_issue( + IssueType.REBOOT_REQUIRED, + ContextType.SYSTEM, + suggestions=[SuggestionType.EXECUTE_REBOOT], + ) await self.sys_docker.config.save_data() diff --git a/supervisor/docker/manager.py b/supervisor/docker/manager.py index e350f19d6..5bf678f4f 100644 --- a/supervisor/docker/manager.py +++ b/supervisor/docker/manager.py @@ -95,12 +95,12 @@ class DockerConfig(FileConfiguration): super().__init__(FILE_HASSIO_DOCKER, SCHEMA_DOCKER_CONFIG) @property - def enable_ipv6(self) -> bool: + def enable_ipv6(self) -> bool | None: """Return IPv6 configuration for docker network.""" - return self._data.get(ATTR_ENABLE_IPV6, False) + return self._data.get(ATTR_ENABLE_IPV6, None) @enable_ipv6.setter - def enable_ipv6(self, value: bool) -> None: + def enable_ipv6(self, value: bool | None) -> None: """Set IPv6 configuration for docker network.""" self._data[ATTR_ENABLE_IPV6] = value diff --git a/supervisor/docker/network.py b/supervisor/docker/network.py index d95137611..e4e8c22a9 100644 --- a/supervisor/docker/network.py +++ b/supervisor/docker/network.py @@ -47,6 +47,8 @@ DOCKER_NETWORK_PARAMS = { "options": {"com.docker.network.bridge.name": DOCKER_NETWORK}, } +DOCKER_ENABLE_IPV6_DEFAULT = True + class DockerNetwork: """Internal Supervisor Network. @@ -59,7 +61,7 @@ class DockerNetwork: self.docker: docker.DockerClient = docker_client self._network: docker.models.networks.Network - async def post_init(self, enable_ipv6: bool = False) -> Self: + async def post_init(self, enable_ipv6: bool | None = None) -> Self: """Post init actions that must be done in event loop.""" self._network = await asyncio.get_running_loop().run_in_executor( None, self._get_network, enable_ipv6 @@ -111,16 +113,24 @@ class DockerNetwork: """Return observer of the network.""" return DOCKER_IPV4_NETWORK_MASK[6] - def _get_network(self, enable_ipv6: bool = False) -> docker.models.networks.Network: + def _get_network( + self, enable_ipv6: bool | None = None + ) -> docker.models.networks.Network: """Get supervisor network.""" try: if network := self.docker.networks.get(DOCKER_NETWORK): - if network.attrs.get(DOCKER_ENABLEIPV6) == enable_ipv6: + current_ipv6 = network.attrs.get(DOCKER_ENABLEIPV6, False) + # If the network exists and we don't have an explicit setting, + # simply stick with what we have. + if enable_ipv6 is None or current_ipv6 == enable_ipv6: return network + + # We have an explicit setting which differs from the current state. _LOGGER.info( "Migrating Supervisor network to %s", "IPv4/IPv6 Dual-Stack" if enable_ipv6 else "IPv4-Only", ) + if (containers := network.containers) and ( containers_all := all( container.name in (OBSERVER_DOCKER_NAME, SUPERVISOR_DOCKER_NAME) @@ -134,6 +144,7 @@ class DockerNetwork: requests.RequestException, ): network.disconnect(container, force=True) + if not containers or containers_all: try: network.remove() @@ -151,7 +162,9 @@ class DockerNetwork: _LOGGER.info("Can't find Supervisor network, creating a new network") network_params = DOCKER_NETWORK_PARAMS.copy() - network_params[ATTR_ENABLE_IPV6] = enable_ipv6 + network_params[ATTR_ENABLE_IPV6] = ( + DOCKER_ENABLE_IPV6_DEFAULT if enable_ipv6 is None else enable_ipv6 + ) try: self._network = self.docker.networks.create(**network_params) # type: ignore diff --git a/supervisor/validate.py b/supervisor/validate.py index c6a8e6202..47f62f4a1 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -182,7 +182,7 @@ SCHEMA_DOCKER_CONFIG = vol.Schema( } } ), - vol.Optional(ATTR_ENABLE_IPV6): vol.Boolean(), + vol.Optional(ATTR_ENABLE_IPV6, default=None): vol.Maybe(vol.Boolean()), } ) diff --git a/tests/api/test_docker.py b/tests/api/test_docker.py index e282332ee..81d183d31 100644 --- a/tests/api/test_docker.py +++ b/tests/api/test_docker.py @@ -19,7 +19,7 @@ async def test_api_docker_info(api_client: TestClient): async def test_api_network_enable_ipv6(coresys: CoreSys, api_client: TestClient): """Test setting docker network for enabled IPv6.""" - assert coresys.docker.config.enable_ipv6 is False + assert coresys.docker.config.enable_ipv6 is None resp = await api_client.post("/docker/options", json={"enable_ipv6": True}) assert resp.status == 200 diff --git a/tests/docker/test_network.py b/tests/docker/test_network.py index 3d89415fb..7da89bf2c 100644 --- a/tests/docker/test_network.py +++ b/tests/docker/test_network.py @@ -111,3 +111,39 @@ async def test_network_recreation( network_params[ATTR_ENABLE_IPV6] = new_enable_ipv6 mock_create.assert_called_with(**network_params) + + +async def test_network_default_ipv6_for_new_installations(): + """Test that IPv6 is enabled by default when no user setting is provided (None).""" + with ( + patch( + "supervisor.docker.network.DockerNetwork.docker", + new_callable=PropertyMock, + return_value=MagicMock(), + create=True, + ), + patch( + "supervisor.docker.network.DockerNetwork.docker.networks", + new_callable=PropertyMock, + return_value=MagicMock(), + create=True, + ), + patch( + "supervisor.docker.network.DockerNetwork.docker.networks.get", + side_effect=docker.errors.NotFound("Network not found"), + ), + patch( + "supervisor.docker.network.DockerNetwork.docker.networks.create", + return_value=MockNetwork(False, None, True), + ) as mock_create, + ): + # Pass None as enable_ipv6 to simulate no user setting + network = (await DockerNetwork(MagicMock()).post_init(None)).network + + assert network is not None + assert network.attrs.get(DOCKER_ENABLEIPV6) is True + + # Verify that create was called with IPv6 enabled by default + expected_params = DOCKER_NETWORK_PARAMS.copy() + expected_params[ATTR_ENABLE_IPV6] = True + mock_create.assert_called_with(**expected_params)