Skip to content

Commit

Permalink
Docker ipv6 support (#14977)
Browse files Browse the repository at this point in the history
  • Loading branch information
Qubad786 authored Nov 27, 2024
1 parent 22ef44f commit 3b9b1c7
Show file tree
Hide file tree
Showing 9 changed files with 132 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -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
24 changes: 22 additions & 2 deletions src/middlewared/middlewared/api/v25_04_0/docker.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,14 +10,23 @@

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 self.base.version == 4 and self.size > 32:
raise ValueError('Size must be <= 32 for IPv4.')
elif self.base.version == 6 and self.size > 128:
raise ValueError('Size must be <= 128 for IPv6.')
return self


class DockerEntry(BaseModel):
id: int
Expand All @@ -25,6 +35,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')
Expand Down
2 changes: 2 additions & 0 deletions src/middlewared/middlewared/etc_files/docker/daemon.json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
9 changes: 8 additions & 1 deletion src/middlewared/middlewared/plugins/apps/ix_apps/portals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down
19 changes: 13 additions & 6 deletions src/middlewared/middlewared/plugins/docker/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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):
Expand All @@ -51,6 +55,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']]):
Expand All @@ -64,11 +69,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')
Expand All @@ -93,10 +100,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:
Expand All @@ -107,7 +114,7 @@ async def do_update(self, job, data):
if old_config['nvidia'] != config['nvidia']:
await self.middleware.call('docker.configure_nvidia')

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', [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,53 @@
'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"
},

]


@pytest.mark.parametrize('user_specified_networks,error_msg', (
(
[],
'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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ def docker(pool: dict):
yield docker_config
finally:
docker_config = call(
'docker.update', {'pool': None, 'address_pools': [{'base': '172.17.0.0/12', 'size': 24}]}, job=True
'docker.update', {
'pool': None, 'address_pools': [
{'base': '172.17.0.0/12', 'size': 24}, {'base': 'fdd0::/48', 'size': 64}
]
}, job=True
)
assert docker_config['pool'] is None, docker_config
15 changes: 13 additions & 2 deletions tests/api2/test_docker_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,19 @@ def test_apps_are_running(docker_pool):


def test_apps_dataset_after_address_pool_update(docker_pool):
docker_config = call('docker.update', {'address_pools': [{'base': '172.17.0.0/12', 'size': 27}]}, job=True)
assert docker_config['address_pools'] == [{'base': '172.17.0.0/12', 'size': 27}]
docker_config = call('docker.update', {'address_pools': [
{'base': '172.17.0.0/12', 'size': 27}, {"base": '2024:db8::/48', 'size': 64}]
}, job=True)
assert docker_config['address_pools'] == [
{'base': '172.17.0.0/12', 'size': 27}, {"base": '2024:db8::/48', 'size': 64}
]
assert call('filesystem.statfs', IX_APPS_MOUNT_PATH)['source'] == docker_config['dataset']
assert call('docker.status')['status'] == 'RUNNING'


def test_apps_dataset_after_cidr_v6_update(docker_pool):
docker_config = call('docker.update', {'cidr_v6': 'fc98:dead:beef::/64'}, job=True)
assert docker_config['cidr_v6'] == 'fc98:dead:beef::/64'
assert call('filesystem.statfs', IX_APPS_MOUNT_PATH)['source'] == docker_config['dataset']
assert call('docker.status')['status'] == 'RUNNING'

Expand Down

0 comments on commit 3b9b1c7

Please sign in to comment.