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:
@@ -143,5 +143,76 @@ class TestWriteEnvFile(unittest.TestCase):
|
||||
self.assertTrue(val.strip())
|
||||
|
||||
|
||||
class TestPortDefaults(unittest.TestCase):
|
||||
def test_port_defaults_exist(self):
|
||||
self.assertIsInstance(ip_utils.PORT_DEFAULTS, dict)
|
||||
for key in ('dns_port', 'mail_smtp_port', 'wg_port', 'radicale_port',
|
||||
'webdav_port', 'api_port', 'webui_port', 'rainloop_port', 'filegator_port'):
|
||||
self.assertIn(key, ip_utils.PORT_DEFAULTS)
|
||||
|
||||
def test_port_env_var_names_cover_all_defaults(self):
|
||||
self.assertEqual(set(ip_utils.PORT_DEFAULTS.keys()), set(ip_utils.PORT_ENV_VAR_NAMES.keys()))
|
||||
|
||||
def test_port_to_containers_covers_all_defaults(self):
|
||||
self.assertEqual(set(ip_utils.PORT_DEFAULTS.keys()), set(ip_utils.PORT_TO_CONTAINERS.keys()))
|
||||
|
||||
def test_default_values(self):
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['dns_port'], 53)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['mail_smtp_port'], 25)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['mail_submission_port'], 587)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['mail_imap_port'], 993)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['radicale_port'], 5232)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['webdav_port'], 8080)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['wg_port'], 51820)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['api_port'], 3000)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['webui_port'], 8081)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['rainloop_port'], 8888)
|
||||
self.assertEqual(ip_utils.PORT_DEFAULTS['filegator_port'], 8082)
|
||||
|
||||
|
||||
class TestWriteEnvFilePorts(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.env_path = os.path.join(self.tmp, '.env')
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.tmp)
|
||||
|
||||
def test_contains_default_ports(self):
|
||||
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
|
||||
content = open(self.env_path).read()
|
||||
self.assertIn('DNS_PORT=53', content)
|
||||
self.assertIn('WG_PORT=51820', content)
|
||||
self.assertIn('MAIL_SMTP_PORT=25', content)
|
||||
self.assertIn('MAIL_SUBMISSION_PORT=587', content)
|
||||
self.assertIn('MAIL_IMAP_PORT=993', content)
|
||||
self.assertIn('RADICALE_PORT=5232', content)
|
||||
self.assertIn('WEBDAV_PORT=8080', content)
|
||||
self.assertIn('API_PORT=3000', content)
|
||||
self.assertIn('WEBUI_PORT=8081', content)
|
||||
self.assertIn('RAINLOOP_PORT=8888', content)
|
||||
self.assertIn('FILEGATOR_PORT=8082', content)
|
||||
|
||||
def test_custom_ports_override_defaults(self):
|
||||
ip_utils.write_env_file('172.20.0.0/16', self.env_path, ports={'wg_port': 12345, 'api_port': 4000})
|
||||
content = open(self.env_path).read()
|
||||
self.assertIn('WG_PORT=12345', content)
|
||||
self.assertIn('API_PORT=4000', content)
|
||||
self.assertIn('DNS_PORT=53', content) # unchanged default
|
||||
|
||||
def test_custom_ports_do_not_leak_default(self):
|
||||
ip_utils.write_env_file('172.20.0.0/16', self.env_path, ports={'wg_port': 12345})
|
||||
content = open(self.env_path).read()
|
||||
self.assertNotIn('WG_PORT=51820', content)
|
||||
self.assertIn('WG_PORT=12345', content)
|
||||
|
||||
def test_all_port_env_vars_present(self):
|
||||
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
|
||||
content = open(self.env_path).read()
|
||||
for var in ip_utils.PORT_ENV_VAR_NAMES.values():
|
||||
self.assertIn(var + '=', content, f'{var} missing from .env')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user