diff --git a/src/middlewared/middlewared/alembic/versions/25.04/2024-11-15_12-14_docker_ipv6_support.py b/src/middlewared/middlewared/alembic/versions/25.04/2024-11-15_12-14_docker_ipv6_support.py new file mode 100644 index 0000000000000..50c1afab5946b --- /dev/null +++ b/src/middlewared/middlewared/alembic/versions/25.04/2024-11-15_12-14_docker_ipv6_support.py @@ -0,0 +1,40 @@ +""" +Docker ipv6 support + +Revision ID: bb352e66987f +Revises: 2b59607575b8 +Create Date: 2024-11-15 12:14:35.553785+00:00 + +""" +import json + +from alembic import op +import sqlalchemy as sa + + +revision = 'bb352e66987f' +down_revision = '2b59607575b8' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + with op.batch_alter_table('services_docker', schema=None) as batch_op: + batch_op.add_column(sa.Column('cidr_v6', sa.String(), nullable=False, server_default='fdd0::/64')) + + if docker_config := list(map( + dict, conn.execute('SELECT * FROM services_docker').fetchall() + )): + docker_config = docker_config[0] + address_pool_config = json.loads(docker_config['address_pools']) + address_pool_config.append({'base': 'fdd0::/48', 'size': 64}) + + conn.execute("UPDATE services_docker SET address_pools = ? WHERE id = ?", [ + json.dumps(address_pool_config), + docker_config['id']] + ) + + +def downgrade(): + pass diff --git a/src/middlewared/middlewared/api/v25_04_0/docker.py b/src/middlewared/middlewared/api/v25_04_0/docker.py index 1bcd4d3e3ee5e..7e2cd2b67dd15 100644 --- a/src/middlewared/middlewared/api/v25_04_0/docker.py +++ b/src/middlewared/middlewared/api/v25_04_0/docker.py @@ -1,6 +1,7 @@ +from ipaddress import IPv4Interface, IPv6Interface from typing import Annotated, Literal -from pydantic import IPvAnyInterface, Field, field_validator +from pydantic import IPvAnyInterface, Field, field_validator, model_validator from middlewared.api.base import ( BaseModel, Excluded, excluded_field, ForUpdateMetaclass, NonEmptyString, single_argument_args, @@ -9,14 +10,25 @@ class AddressPool(BaseModel): base: IPvAnyInterface - size: Annotated[int, Field(ge=1, le=32)] + size: Annotated[int, Field(ge=1)] @field_validator('base') + @classmethod def check_prefixlen(cls, v): if v.network.prefixlen in (32, 128): raise ValueError('Prefix length of base network cannot be 32 or 128.') return v + @model_validator(mode='after') + def validate_attrs(self): + if isinstance(self.base, IPv4Interface): + if self.size > 32: + raise ValueError('Size must be <= 32 for IPv4.') + elif isinstance(self.base, IPv6Interface): + if self.size > 128: + raise ValueError('Size must be <= 128 for IPv6.') + return self + class DockerEntry(BaseModel): id: int @@ -25,6 +37,16 @@ class DockerEntry(BaseModel): pool: NonEmptyString | None nvidia: bool address_pools: list[AddressPool] + cidr_v6: IPvAnyInterface + + @field_validator('cidr_v6') + @classmethod + def validate_ipv6(cls, v): + if v.version != 6: + raise ValueError('cidr_v6 must be an IPv6 address.') + if v.network.prefixlen == 128: + raise ValueError('Prefix length of cidr_v6 network cannot be 128.') + return v @single_argument_args('docker_update') diff --git a/src/middlewared/middlewared/etc_files/docker/daemon.json.py b/src/middlewared/middlewared/etc_files/docker/daemon.json.py index e4e2d920ff371..1125035bdc173 100644 --- a/src/middlewared/middlewared/etc_files/docker/daemon.json.py +++ b/src/middlewared/middlewared/etc_files/docker/daemon.json.py @@ -21,7 +21,9 @@ def render(service, middleware): 'data-root': data_root, 'exec-opts': ['native.cgroupdriver=cgroupfs'], 'iptables': True, + 'ipv6': True, 'storage-driver': 'overlay2', + 'fixed-cidr-v6': config['cidr_v6'], 'default-address-pools': config['address_pools'], } isolated = middleware.call_sync('system.advanced.config')['isolated_gpu_pci_ids'] diff --git a/src/middlewared/middlewared/plugins/apps/ix_apps/portals.py b/src/middlewared/middlewared/plugins/apps/ix_apps/portals.py index 5a547ab9867b7..b6f87c3695825 100644 --- a/src/middlewared/middlewared/plugins/apps/ix_apps/portals.py +++ b/src/middlewared/middlewared/plugins/apps/ix_apps/portals.py @@ -7,6 +7,12 @@ def normalized_port_value(scheme: str, port: int) -> str: return '' if ((scheme == 'http' and port == 80) or (scheme == 'https' and port == 443)) else f':{port}' +def normalized_host_value(host: str) -> str: + if ':' in host: + return f'[{host}]' + return host + + def get_portals_and_app_notes(app_name: str, version: str) -> dict: rendered_config = get_rendered_template_config_of_app(app_name, version) portal_and_notes_config = { @@ -27,7 +33,8 @@ def get_portals_and_app_notes(app_name: str, version: str) -> dict: portals = {} for portal in portal_and_notes_config.get(IX_PORTAL_KEY, []): port_value = normalized_port_value(portal['scheme'], portal['port']) - portals[portal['name']] = f'{portal["scheme"]}://{portal["host"]}{port_value}{portal.get("path", "")}' + host_value = normalized_host_value(portal['host']) + portals[portal['name']] = f'{portal["scheme"]}://{host_value}{port_value}{portal.get("path", "")}' return { 'portals': portals, diff --git a/src/middlewared/middlewared/plugins/docker/update.py b/src/middlewared/middlewared/plugins/docker/update.py index 235b0a92322a6..c14bc839934b1 100644 --- a/src/middlewared/middlewared/plugins/docker/update.py +++ b/src/middlewared/middlewared/plugins/docker/update.py @@ -23,7 +23,11 @@ class DockerModel(sa.Model): pool = sa.Column(sa.String(255), default=None, nullable=True) enable_image_updates = sa.Column(sa.Boolean(), default=True) nvidia = sa.Column(sa.Boolean(), default=False) - address_pools = sa.Column(sa.JSON(list), default=[{'base': '172.17.0.0/12', 'size': 24}]) + cidr_v6 = sa.Column(sa.String(), default='fdd0::/64', nullable=False) + address_pools = sa.Column(sa.JSON(list), default=[ + {'base': '172.17.0.0/12', 'size': 24}, + {'base': 'fdd0::/48', 'size': 64}, + ]) class DockerService(ConfigService): @@ -50,6 +54,7 @@ async def do_update(self, job, data): old_config.pop('dataset') config = old_config.copy() config.update(data) + config['cidr_v6'] = str(config['cidr_v6']) verrors = ValidationErrors() if config['pool'] and not await self.middleware.run_in_thread(query_imported_fast_impl, [config['pool']]): @@ -63,11 +68,13 @@ async def do_update(self, job, data): ) if old_config != config: - if config['pool'] != old_config['pool']: + address_pools_changed = any(config[k] != old_config[k] for k in ('address_pools', 'cidr_v6')) + pool_changed = config['pool'] != old_config['pool'] + if pool_changed: # We want to clear upgrade alerts for apps at this point await self.middleware.call('app.clear_upgrade_alerts_for_all') - if any(config[k] != old_config[k] for k in ('pool', 'address_pools')): + if pool_changed or address_pools_changed: job.set_progress(20, 'Stopping Docker service') try: await self.middleware.call('service.stop', 'docker') @@ -92,10 +99,10 @@ async def do_update(self, job, data): await self.middleware.call('datastore.update', self._config.datastore, old_config['id'], config) - if config['pool'] != old_config['pool']: + if pool_changed: job.set_progress(60, 'Applying requested configuration') await self.middleware.call('docker.setup.status_change') - elif config['pool'] and config['address_pools'] != old_config['address_pools']: + elif config['pool'] and address_pools_changed: job.set_progress(60, 'Starting docker') catalog_sync_job = await self.middleware.call('docker.fs_manage.mount') if catalog_sync_job: @@ -114,7 +121,7 @@ async def do_update(self, job, data): ) ).wait(raise_error=True) - if config['pool'] and config['address_pools'] != old_config['address_pools']: + if config['pool'] and address_pools_changed: job.set_progress(95, 'Initiating redeployment of applications to apply new address pools changes') await self.middleware.call( 'core.bulk', 'app.redeploy', [ diff --git a/src/middlewared/middlewared/plugins/docker/validation_utils.py b/src/middlewared/middlewared/plugins/docker/validation_utils.py index d88052738fdac..7d063a6165b04 100644 --- a/src/middlewared/middlewared/plugins/docker/validation_utils.py +++ b/src/middlewared/middlewared/plugins/docker/validation_utils.py @@ -15,7 +15,7 @@ def validate_address_pools(system_ips: list[dict], user_specified_networks: list ]) seen_networks = set() for index, user_network in enumerate(user_specified_networks): - if isinstance(user_network['base'], ipaddress.IPv4Interface): + if isinstance(user_network['base'], (ipaddress.IPv4Interface, ipaddress.IPv6Interface)): base_network = user_network['base'].network user_network['base'] = str(user_network['base']) else: diff --git a/src/middlewared/middlewared/pytest/unit/plugins/docker/test_docker_address_pools_validtaion.py b/src/middlewared/middlewared/pytest/unit/plugins/docker/test_docker_address_pools_validtaion.py index 74ebdabbb0f93..ed8b9c122216a 100644 --- a/src/middlewared/middlewared/pytest/unit/plugins/docker/test_docker_address_pools_validtaion.py +++ b/src/middlewared/middlewared/pytest/unit/plugins/docker/test_docker_address_pools_validtaion.py @@ -10,7 +10,14 @@ 'address': '172.20.0.33', 'netmask': 16, 'broadcast': '172.20.0.63' - } + }, + { + "type": "INET6", + "address": "fe80::5054:ff:fe4f:bbbe", + "netmask": 64, + "broadcast": "fe80::ffff:ffff:ffff:ffff" + }, + ] @@ -18,20 +25,38 @@ ( [], 'At least one address pool must be specified'), + ( + [{'base': 'fe80::5054:ff:fe4f:bbbe/56', 'size': 64}], + 'Base network fe80::5054:ff:fe4f:bbbe/56 overlaps with an existing system network' + ), ( [{'base': '172.20.2.0/24', 'size': 27}], - 'Base network 172.20.2.0/24 overlaps with an existing system network'), + 'Base network 172.20.2.0/24 overlaps with an existing system network' + ), ( [{'base': '172.21.2.0/16', 'size': 10}], - 'Base network 172.21.2.0/16 cannot be smaller than the specified subnet size 10'), + 'Base network 172.21.2.0/16 cannot be smaller than the specified subnet size 10' + ), + ( + [{'base': 'fedd::5054:ff:fe4f:bbbe/56', 'size': 10}], + 'Base network fedd::5054:ff:fe4f:bbbe/56 cannot be smaller than the specified subnet size 10' + ), ( [{'base': '172.21.2.0/16', 'size': 27}, {'base': '172.21.2.0/16', 'size': 27}], 'Base network 172.21.2.0/16 is a duplicate of another specified network' ), + ( + [{'base': 'fedd::5054:ff:fe4f:bbbe/56', 'size': 64}, {'base': 'fedd::5054:ff:fe4f:bbbe/56', 'size': 64}], + 'Base network fedd::5054:ff:fe4f:bbbe/56 is a duplicate of another specified network' + ), ( [{'base': '172.21.0.0/16', 'size': 27}, {'base': '172.22.0.0/16', 'size': 27}], '' ), + ( + [{'base': 'fedd::5054:ff:fe4f:bbbe/56', 'size': 64}, {'base': 'fed0::5054:ff:fe4f:bbbe/56', 'size': 64}], + '' + ), )) @pytest.mark.asyncio async def test_address_pools_validation(user_specified_networks, error_msg):