fix: port changes now correctly queue pending restart for all services

Two bugs fixed:

1. calendar_manager and wireguard_manager (port-only) called
   _restart_container immediately in apply_config, bypassing the pending
   restart banner and restarting the container before the docker port
   binding in .env was updated — leaving the service broken until the
   banner was applied manually. apply_config now only updates the config
   file (radicale.conf / wg0.conf); the docker compose restart happens
   via the banner as intended.

2. Port change detection in update_config used `if old_val is not None`
   to guard against triggering on unchanged values. When a service's port
   was never explicitly saved (first time), old_val was None, so the
   pending restart was never queued. Fix: fall back to PORT_DEFAULTS[key]
   so the comparison is always against the effective current value.

Add TestPortChangeDetection (5 tests) covering first-save and multi-service
accumulation cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 13:59:52 -04:00
parent 7a273ad43e
commit 255f9e2576
4 changed files with 79 additions and 9 deletions
+6 -4
View File
@@ -538,11 +538,13 @@ def update_config():
port_changed_containers = set() port_changed_containers = set()
port_change_messages = [] port_change_messages = []
import ip_utils as _ip_utils_pcd
for (svc_key, field), (_env_key, containers) in _PORT_CHANGE_MAP.items(): for (svc_key, field), (_env_key, containers) in _PORT_CHANGE_MAP.items():
if svc_key in data and field in data[svc_key]: if svc_key in data and field in data[svc_key]:
old_val = old_svc_configs.get(svc_key, {}).get(field) default_val = _ip_utils_pcd.PORT_DEFAULTS.get(_env_key)
old_val = old_svc_configs.get(svc_key, {}).get(field, default_val)
new_val = data[svc_key][field] new_val = data[svc_key][field]
if old_val is not None and old_val != new_val: if old_val != new_val:
port_changed_containers.update(containers) port_changed_containers.update(containers)
port_change_messages.append( port_change_messages.append(
f'{svc_key} {field}: {old_val}{new_val}' f'{svc_key} {field}: {old_val}{new_val}'
@@ -550,9 +552,9 @@ def update_config():
# wireguard_port in identity also drives WG_PORT env var; sync to service config # wireguard_port in identity also drives WG_PORT env var; sync to service config
if 'wireguard_port' in identity_updates: if 'wireguard_port' in identity_updates:
old_wg = old_identity.get('wireguard_port') old_wg = old_identity.get('wireguard_port', _ip_utils_pcd.PORT_DEFAULTS.get('wg_port', 51820))
new_wg = identity_updates['wireguard_port'] new_wg = identity_updates['wireguard_port']
if old_wg is not None and old_wg != new_wg: if old_wg != new_wg:
# Sync to wireguard service config and update wg0.conf # Sync to wireguard service config and update wg0.conf
_wg_svc = config_manager.configs.get('wireguard', {}) _wg_svc = config_manager.configs.get('wireguard', {})
_wg_svc['port'] = new_wg _wg_svc['port'] = new_wg
+3 -3
View File
@@ -478,7 +478,7 @@ class CalendarManager(BaseServiceManager):
f.write(config_content) f.write(config_content)
def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]: def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Update radicale config port and restart cell-radicale.""" """Update radicale config file. Port changes go through pending restart (docker binding)."""
restarted = [] restarted = []
warnings = [] warnings = []
if 'port' not in config: if 'port' not in config:
@@ -494,8 +494,8 @@ class CalendarManager(BaseServiceManager):
] ]
with open(radicale_conf, 'w') as f: with open(radicale_conf, 'w') as f:
f.writelines(lines) f.writelines(lines)
self._restart_container('cell-radicale') # No immediate restart — docker port binding must be updated first.
restarted.append('cell-radicale') # The pending restart banner will run docker compose up with updated .env.
except Exception as e: except Exception as e:
warnings.append(f"radicale config update failed: {e}") warnings.append(f"radicale config update failed: {e}")
return {'restarted': restarted, 'warnings': warnings} return {'restarted': restarted, 'warnings': warnings}
+6
View File
@@ -225,19 +225,25 @@ class WireGuardManager(BaseServiceManager):
return result return result
changed = False changed = False
port_only_change = True
if 'port' in config and config['port']: if 'port' in config and config['port']:
lines = _set_iface_field(lines, 'ListenPort', config['port']) lines = _set_iface_field(lines, 'ListenPort', config['port'])
changed = True changed = True
if 'address' in config and config['address']: if 'address' in config and config['address']:
lines = _set_iface_field(lines, 'Address', config['address']) lines = _set_iface_field(lines, 'Address', config['address'])
changed = True changed = True
port_only_change = False
if 'private_key' in config and config['private_key']: if 'private_key' in config and config['private_key']:
lines = _set_iface_field(lines, 'PrivateKey', config['private_key']) lines = _set_iface_field(lines, 'PrivateKey', config['private_key'])
changed = True changed = True
port_only_change = False
if changed: if changed:
with open(cf, 'w') as f: with open(cf, 'w') as f:
f.writelines(lines) f.writelines(lines)
# Port-only changes: docker binding must be updated first via pending restart.
# Non-port changes (address, private_key) can restart immediately.
if not port_only_change:
self._restart_container('cell-wireguard') self._restart_container('cell-wireguard')
restarted.append('cell-wireguard') restarted.append('cell-wireguard')
except Exception as e: except Exception as e:
+62
View File
@@ -209,5 +209,67 @@ class TestCancelPendingEndpoint(unittest.TestCase):
self.assertEqual(data['changes'], []) self.assertEqual(data['changes'], [])
class TestPortChangeDetection(unittest.TestCase):
"""Test that port changes always trigger pending restart, even on first save."""
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
_clear_pending_restart()
# Remove any stored service configs so we start clean
for key in ('calendar', 'files', 'wireguard', 'network', 'email'):
config_manager.configs.pop(key, None)
def tearDown(self):
_clear_pending_restart()
for key in ('calendar', 'files', 'wireguard', 'network', 'email'):
config_manager.configs.pop(key, None)
def _put_config(self, payload):
return self.client.put('/api/config',
data=json.dumps(payload),
content_type='application/json')
def test_calendar_port_first_save_marks_pending(self):
"""First-time calendar port save should still queue pending restart."""
r = self._put_config({'calendar': {'port': 5233}})
self.assertEqual(r.status_code, 200)
p = config_manager.configs.get('_pending_restart', {})
self.assertTrue(p.get('needs_restart'), 'pending restart not set on first calendar port save')
self.assertIn('radicale', p.get('containers', []))
def test_files_port_first_save_marks_pending(self):
"""First-time files (webdav) port save should queue pending restart."""
r = self._put_config({'files': {'port': 8181}})
self.assertEqual(r.status_code, 200)
p = config_manager.configs.get('_pending_restart', {})
self.assertTrue(p.get('needs_restart'))
self.assertIn('webdav', p.get('containers', []))
def test_files_manager_port_first_save_marks_pending(self):
r = self._put_config({'files': {'manager_port': 9090}})
self.assertEqual(r.status_code, 200)
p = config_manager.configs.get('_pending_restart', {})
self.assertTrue(p.get('needs_restart'))
self.assertIn('filegator', p.get('containers', []))
def test_multiple_service_port_changes_accumulate_containers(self):
"""Saving two services should accumulate both containers in pending."""
self._put_config({'calendar': {'port': 5233}})
self._put_config({'files': {'port': 8181}})
p = config_manager.configs.get('_pending_restart', {})
self.assertTrue(p.get('needs_restart'))
containers = p.get('containers', [])
self.assertIn('radicale', containers)
self.assertIn('webdav', containers)
def test_same_port_as_default_no_pending(self):
"""Saving the default port value should NOT trigger pending restart."""
r = self._put_config({'calendar': {'port': 5232}}) # 5232 is default
self.assertEqual(r.status_code, 200)
p = config_manager.configs.get('_pending_restart', {})
self.assertFalse(p.get('needs_restart', False))
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()