fix: port changes now propagate to containers via env file in-place writes
Root cause: write_env_file used os.replace() which creates a new inode. Docker file bind-mounts track the original inode at mount time, so the container's /app/.env.compose never saw updates — docker compose always read the stale port value and skipped container recreation. Fixes: - ip_utils.write_env_file: write in-place (open 'w') instead of os.replace() so Docker bind-mounted files see the update immediately - apply_pending_config: add --force-recreate to docker compose up for specific-container restarts, bypassing config-hash comparison as a belt-and-suspenders measure Tests added: - TestWriteEnvFileInPlace: verifies inode is preserved across writes - TestApplyPendingConfigForceRecreate: verifies --force-recreate is in the docker compose command for specific-container restarts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -288,5 +288,97 @@ class TestWireGuardPortPropagation(unittest.TestCase):
|
||||
mock_wg.apply_config.assert_not_called()
|
||||
|
||||
|
||||
class TestApplyPendingConfigForceRecreate(unittest.TestCase):
|
||||
"""
|
||||
POST /api/config/apply for specific containers (not '*') must pass
|
||||
--force-recreate to docker compose so that port-binding changes actually
|
||||
take effect even if Docker's config-hash comparison misses them.
|
||||
|
||||
The config-hash issue arises from Docker file bind-mounts: the env file
|
||||
inside the container is mounted to a specific inode; if the host file was
|
||||
ever replaced (new inode), the container's bind-mount stays on the old
|
||||
inode and docker compose sees stale values. --force-recreate bypasses
|
||||
the hash comparison entirely.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch('app._clear_pending_restart')
|
||||
@patch('app.config_manager')
|
||||
def test_apply_pending_uses_force_recreate(self, mock_cm, mock_clear):
|
||||
"""apply_pending_config for specific containers must include --force-recreate."""
|
||||
mock_cm.configs = {
|
||||
'_pending_restart': {
|
||||
'needs_restart': True,
|
||||
'containers': ['wireguard'],
|
||||
'network_recreate': False,
|
||||
}
|
||||
}
|
||||
|
||||
captured_target = {}
|
||||
|
||||
def patched_thread(target=None, daemon=False, **kw):
|
||||
captured_target['fn'] = target
|
||||
t = MagicMock()
|
||||
t.start = lambda: None
|
||||
return t
|
||||
|
||||
with patch('app.threading.Thread', side_effect=patched_thread):
|
||||
r = self.client.post('/api/config/apply')
|
||||
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn('fn', captured_target)
|
||||
|
||||
# Execute the captured _do_apply and verify subprocess call includes --force-recreate
|
||||
with patch('subprocess.run') as mock_run, \
|
||||
patch('time.sleep'):
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||
captured_target['fn']()
|
||||
|
||||
call_args = mock_run.call_args
|
||||
self.assertIsNotNone(call_args, 'subprocess.run was not called in _do_apply')
|
||||
cmd = call_args[0][0]
|
||||
self.assertIn('--force-recreate', cmd,
|
||||
f'--force-recreate missing from docker compose command: {cmd}')
|
||||
self.assertIn('wireguard', cmd)
|
||||
|
||||
@patch('app._clear_pending_restart')
|
||||
@patch('app.config_manager')
|
||||
def test_apply_pending_all_services_no_force_recreate(self, mock_cm, mock_clear):
|
||||
"""All-services restart ('*') uses a helper container (Popen), not subprocess.run."""
|
||||
mock_cm.configs = {
|
||||
'_pending_restart': {
|
||||
'needs_restart': True,
|
||||
'containers': ['*'],
|
||||
'network_recreate': False,
|
||||
}
|
||||
}
|
||||
|
||||
captured_target = {}
|
||||
|
||||
def patched_thread(target=None, daemon=False, **kw):
|
||||
captured_target['fn'] = target
|
||||
t = MagicMock()
|
||||
t.start = lambda: None
|
||||
return t
|
||||
|
||||
with patch('app.threading.Thread', side_effect=patched_thread):
|
||||
r = self.client.post('/api/config/apply')
|
||||
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn('fn', captured_target)
|
||||
|
||||
# For '*', _do_apply spawns a helper container via Popen, not subprocess.run
|
||||
with patch('subprocess.Popen') as mock_popen, \
|
||||
patch('subprocess.run') as mock_run:
|
||||
mock_popen.return_value = MagicMock()
|
||||
captured_target['fn']()
|
||||
|
||||
mock_run.assert_not_called()
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user