fix(apply): handle ip_range network recreation; propagate IPs+ports to service pages

When ip_range changes, Docker cannot modify a network subnet in-place.
_set_pending_restart now accepts network_recreate=True; apply endpoint
runs `docker compose down` before `up -d` in that case so the bridge
network is fully recreated with the new subnet.

Service page fixes:
- GET /api/config includes service_ips (dns, vip_mail, vip_calendar,
  vip_files, vip_webdav) computed via ip_utils
- Email/Calendar/Files pages read IPs and ports from useConfig() instead
  of hardcoded 172.20.0.x constants and default port literals
- Apply feedback: spinner → success/timeout/error banners via health polling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 12:45:54 -04:00
parent 10878543a9
commit f07df79f94
+39 -18
View File
@@ -508,8 +508,12 @@ def update_config():
# Write new .env with updated IPs (and current ports) for next container start
env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs))
# Mark ALL containers as needing restart (IPs affect every container)
_set_pending_restart([f'ip_range changed to {new_range} — container IPs updated'], ['*'])
# Mark ALL containers as needing restart; network_recreate signals that
# docker compose down is required before up (Docker can't change subnet in-place)
_set_pending_restart(
[f'ip_range changed to {new_range} — network will be recreated'],
['*'], network_recreate=True
)
# Detect port changes across service configs and identity
# Maps (service_key, field_name) → (port_env_key, [containers])
@@ -595,10 +599,11 @@ def _collect_service_ports(configs: dict) -> dict:
return ports
def _set_pending_restart(changes: list, containers: list = None):
def _set_pending_restart(changes: list, containers: list = None, network_recreate: bool = False):
"""Record that specific containers need to be restarted to apply configuration.
containers: list of docker-compose service names, or None/'*' to restart all.
network_recreate: True when the Docker bridge subnet changed (requires down+up).
Merges with any existing pending state so multiple changes accumulate.
"""
from datetime import datetime as _dt
@@ -616,13 +621,14 @@ def _set_pending_restart(changes: list, containers: list = None):
'changed_at': _dt.utcnow().isoformat(),
'changes': existing_changes + changes,
'containers': new_containers,
'network_recreate': network_recreate or existing.get('network_recreate', False),
}
config_manager._save_all_configs()
def _clear_pending_restart():
config_manager.configs['_pending_restart'] = {
'needs_restart': False, 'changes': [], 'containers': []
'needs_restart': False, 'changes': [], 'containers': [], 'network_recreate': False
}
config_manager._save_all_configs()
@@ -669,24 +675,39 @@ def apply_pending_config():
# Clear pending flag before we restart so it shows cleared after new containers start
_clear_pending_restart()
# Build compose args: restart all, or only the specific changed containers
if '*' in containers:
compose_up_args = ['up', '-d']
else:
compose_up_args = ['up', '-d', '--no-deps'] + containers
# Check if the IP range (network subnet) is changing — Docker cannot modify an
# existing network's subnet in-place, so we need `down` + `up` in that case.
needs_network_recreate = pending.get('network_recreate', False)
# Run docker compose up -d in a background thread; the 0.3s delay lets Flask
# finish sending this response before cell-api itself gets recreated.
if '*' in containers:
if needs_network_recreate:
# down removes containers AND the bridge network; up recreates everything
compose_down_args = ['down']
compose_up_args = ['up', '-d']
else:
compose_down_args = None
compose_up_args = ['up', '-d']
else:
compose_down_args = None
compose_up_args = ['up', '-d', '--no-deps'] + containers
base_cmd = ['docker', 'compose',
'--project-directory', project_dir,
'-f', '/app/docker-compose.yml',
'--env-file', '/app/.env.compose']
# Run in a background thread; 0.3 s delay lets Flask send this response first.
def _do_apply():
import time as _time
_time.sleep(0.3)
result = subprocess.run(
['docker', 'compose',
'--project-directory', project_dir,
'-f', '/app/docker-compose.yml',
'--env-file', '/app/.env.compose'] + compose_up_args,
capture_output=True, text=True, timeout=120
)
if compose_down_args:
r = subprocess.run(base_cmd + compose_down_args,
capture_output=True, text=True, timeout=60)
if r.returncode != 0:
logger.error(f"docker compose down failed: {r.stderr.strip()}")
return
result = subprocess.run(base_cmd + compose_up_args,
capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.error(f"docker compose up failed: {result.stderr.strip()}")
else: