feat(service-ports): remove hardcoded ports from docker-compose, make all service ports configurable
All host port bindings in docker-compose.yml now use \${VAR:-default} substitution,
driven by the .env file generated by ip_utils.write_env_file(). Changing a port in
Settings triggers a per-container pending-restart banner so only the affected container
is restarted on Apply.
- ip_utils: add PORT_DEFAULTS, PORT_ENV_VAR_NAMES, PORT_TO_CONTAINERS; extend
write_env_file() to accept optional ports dict and write all port env vars
- docker-compose: convert all hardcoded port bindings to \${VAR:-default} form
- app.py: add _collect_service_ports helper; detect port changes in update_config,
write updated .env and call _set_pending_restart with specific container list;
update _set_pending_restart to merge/accumulate pending state with containers list;
update apply_pending_config to use --no-deps <service> for targeted restarts
- config_manager: add submission_port, webmail_port to email schema; add manager_port
to files schema
- Settings.jsx: make all email/files ports editable, add submission_port, webmail_port,
manager_port fields; update stale identity note
- tests: 8 new tests for PORT_DEFAULTS, PORT_ENV_VAR_NAMES, and port override in write_env_file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+110
-13
@@ -417,8 +417,12 @@ def update_config():
|
||||
# Handle identity fields (cell_name, domain, ip_range, wireguard_port)
|
||||
identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'}
|
||||
identity_updates = {k: v for k, v in data.items() if k in identity_keys}
|
||||
# Capture old identity BEFORE saving, for apply_cell_name comparison
|
||||
# Capture old identity and service configs BEFORE saving, for change detection
|
||||
old_identity = dict(config_manager.configs.get('_identity', {}))
|
||||
old_svc_configs = {
|
||||
svc: dict(config_manager.configs.get(svc, {}))
|
||||
for svc in data if svc in config_manager.service_schemas
|
||||
}
|
||||
if identity_updates:
|
||||
stored = config_manager.configs.get('_identity', {})
|
||||
stored.update(identity_updates)
|
||||
@@ -491,11 +495,57 @@ def update_config():
|
||||
# Update firewall virtual IPs (iptables) and Caddy virtual IPs immediately
|
||||
firewall_manager.update_service_ips(new_range)
|
||||
firewall_manager.ensure_caddy_virtual_ips()
|
||||
# Write new .env so docker-compose picks up new container IPs on next start
|
||||
# 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)
|
||||
# Mark containers as needing restart
|
||||
_set_pending_restart([f'ip_range changed to {new_range} — container IPs updated'])
|
||||
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'], ['*'])
|
||||
|
||||
# Detect port changes across service configs and identity
|
||||
# Maps (service_key, field_name) → (port_env_key, [containers])
|
||||
_PORT_CHANGE_MAP = {
|
||||
('network', 'dns_port'): ('dns_port', ['dns']),
|
||||
('wireguard','port'): ('wg_port', ['wireguard']),
|
||||
('email', 'smtp_port'): ('mail_smtp_port', ['mail']),
|
||||
('email', 'submission_port'): ('mail_submission_port', ['mail']),
|
||||
('email', 'imap_port'): ('mail_imap_port', ['mail']),
|
||||
('email', 'webmail_port'): ('rainloop_port', ['rainloop']),
|
||||
('calendar', 'port'): ('radicale_port', ['radicale']),
|
||||
('files', 'port'): ('webdav_port', ['webdav']),
|
||||
('files', 'manager_port'): ('filegator_port', ['filegator']),
|
||||
}
|
||||
|
||||
port_changed_containers = set()
|
||||
port_change_messages = []
|
||||
|
||||
for (svc_key, field), (_env_key, containers) in _PORT_CHANGE_MAP.items():
|
||||
if svc_key in data and field in data[svc_key]:
|
||||
old_val = old_svc_configs.get(svc_key, {}).get(field)
|
||||
new_val = data[svc_key][field]
|
||||
if old_val is not None and old_val != new_val:
|
||||
port_changed_containers.update(containers)
|
||||
port_change_messages.append(
|
||||
f'{svc_key} {field}: {old_val} → {new_val}'
|
||||
)
|
||||
|
||||
# wireguard_port in identity also drives WG_PORT env var
|
||||
if 'wireguard_port' in identity_updates:
|
||||
old_wg = old_identity.get('wireguard_port')
|
||||
new_wg = identity_updates['wireguard_port']
|
||||
if old_wg is not None and old_wg != new_wg:
|
||||
port_changed_containers.add('wireguard')
|
||||
port_change_messages.append(f'wireguard_port: {old_wg} → {new_wg}')
|
||||
|
||||
if port_changed_containers:
|
||||
import ip_utils as _ip_utils_ports
|
||||
_env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
|
||||
_ip_range = config_manager.configs.get('_identity', {}).get(
|
||||
'ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')
|
||||
)
|
||||
_ip_utils_ports.write_env_file(
|
||||
_ip_range, _env_file, _collect_service_ports(config_manager.configs)
|
||||
)
|
||||
_set_pending_restart(port_change_messages, list(port_changed_containers))
|
||||
|
||||
logger.info(f"Updated config, restarted: {all_restarted}")
|
||||
return jsonify({
|
||||
@@ -512,19 +562,58 @@ def update_config():
|
||||
# Pending-restart helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _set_pending_restart(changes: list):
|
||||
"""Record that containers need to be restarted to apply configuration."""
|
||||
def _collect_service_ports(configs: dict) -> dict:
|
||||
"""Extract current port values from service configs for .env generation."""
|
||||
ports = {}
|
||||
net = configs.get('network', {})
|
||||
wg = configs.get('wireguard', {})
|
||||
email = configs.get('email', {})
|
||||
cal = configs.get('calendar', {})
|
||||
files = configs.get('files', {})
|
||||
identity = configs.get('_identity', {})
|
||||
|
||||
if 'dns_port' in net: ports['dns_port'] = net['dns_port']
|
||||
if 'port' in wg: ports['wg_port'] = wg['port']
|
||||
elif 'wireguard_port' in identity: ports['wg_port'] = identity['wireguard_port']
|
||||
if 'smtp_port' in email: ports['mail_smtp_port'] = email['smtp_port']
|
||||
if 'submission_port' in email: ports['mail_submission_port'] = email['submission_port']
|
||||
if 'imap_port' in email: ports['mail_imap_port'] = email['imap_port']
|
||||
if 'webmail_port' in email: ports['rainloop_port'] = email['webmail_port']
|
||||
if 'port' in cal: ports['radicale_port'] = cal['port']
|
||||
if 'port' in files: ports['webdav_port'] = files['port']
|
||||
if 'manager_port' in files: ports['filegator_port'] = files['manager_port']
|
||||
return ports
|
||||
|
||||
|
||||
def _set_pending_restart(changes: list, containers: list = None):
|
||||
"""Record that specific containers need to be restarted to apply configuration.
|
||||
|
||||
containers: list of docker-compose service names, or None/'*' to restart all.
|
||||
Merges with any existing pending state so multiple changes accumulate.
|
||||
"""
|
||||
from datetime import datetime as _dt
|
||||
existing = config_manager.configs.get('_pending_restart', {})
|
||||
existing_changes = existing.get('changes', []) if existing.get('needs_restart') else []
|
||||
existing_containers = existing.get('containers', []) if existing.get('needs_restart') else []
|
||||
|
||||
if containers is None or '*' in (containers or []) or existing_containers == ['*']:
|
||||
new_containers = ['*']
|
||||
else:
|
||||
new_containers = list(set(existing_containers) | set(containers))
|
||||
|
||||
config_manager.configs['_pending_restart'] = {
|
||||
'needs_restart': True,
|
||||
'changed_at': _dt.utcnow().isoformat(),
|
||||
'changes': changes,
|
||||
'changes': existing_changes + changes,
|
||||
'containers': new_containers,
|
||||
}
|
||||
config_manager._save_all_configs()
|
||||
|
||||
|
||||
def _clear_pending_restart():
|
||||
config_manager.configs['_pending_restart'] = {'needs_restart': False, 'changes': []}
|
||||
config_manager.configs['_pending_restart'] = {
|
||||
'needs_restart': False, 'changes': [], 'containers': []
|
||||
}
|
||||
config_manager._save_all_configs()
|
||||
|
||||
|
||||
@@ -536,6 +625,7 @@ def get_pending_config():
|
||||
'needs_restart': pending.get('needs_restart', False),
|
||||
'changed_at': pending.get('changed_at'),
|
||||
'changes': pending.get('changes', []),
|
||||
'containers': pending.get('containers', ['*']),
|
||||
})
|
||||
|
||||
|
||||
@@ -557,9 +647,17 @@ def apply_pending_config():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear pending flag before we restart so it shows cleared after the new container starts
|
||||
containers = pending.get('containers', ['*'])
|
||||
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
def _do_apply():
|
||||
@@ -569,14 +667,13 @@ def apply_pending_config():
|
||||
['docker', 'compose',
|
||||
'--project-directory', project_dir,
|
||||
'-f', '/app/docker-compose.yml',
|
||||
'--env-file', '/app/.env.compose',
|
||||
'up', '-d'],
|
||||
'--env-file', '/app/.env.compose'] + 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:
|
||||
logger.info('docker compose up -d completed successfully')
|
||||
logger.info(f'docker compose {" ".join(compose_up_args)} completed successfully')
|
||||
|
||||
threading.Thread(target=_do_apply, daemon=False).start()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user