feat: connectivity redesign phase 5 — one container per connection instance
Unit Tests / test (push) Successful in 13m5s
Unit Tests / test (push) Successful in 13m5s
instanceable rendering, per-instance up/down on create/delete, store-service-installed gate, per-instance health Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -100,6 +100,15 @@ class _Base(unittest.TestCase):
|
||||
# is exercised separately in TestMigration; here we want an empty slate
|
||||
# so the always-configured Tor default doesn't get auto-migrated in.
|
||||
self.cm.configs['connectivity'] = {'version': 2, 'connections': []}
|
||||
# P5 gate: a connection's backing store service must be installed before
|
||||
# it can be created. Mark all connectivity store services installed so
|
||||
# the CRUD tests below exercise allocation/persistence, not the gate
|
||||
# (the gate itself is tested explicitly in TestStoreServiceGate). The
|
||||
# per-instance container up/down is a no-op here: the manager has no
|
||||
# service_composer wired (service_composer=None), so up_connection_instance
|
||||
# short-circuits without touching Docker.
|
||||
for _svc in ('wireguard-ext', 'openvpn-client', 'tor', 'sshuttle', 'proxy'):
|
||||
self.cm.set_installed_service(_svc, {'id': _svc, 'manifest': {}})
|
||||
self.cm._save_all_configs()
|
||||
|
||||
def tearDown(self):
|
||||
@@ -469,5 +478,158 @@ class TestMigration(_Base):
|
||||
self.assertIsNotNone(c['redirect_port'])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# P5 — per-instance container lifecycle wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROXY_TEMPLATE = (
|
||||
'services:\n'
|
||||
' proxy:\n'
|
||||
' image: git.pic.ngo/roof/svc-redsocks:latest\n'
|
||||
' container_name: cell-proxy-${INSTANCE_ID}\n'
|
||||
' network_mode: host\n'
|
||||
' cap_add:\n'
|
||||
' - NET_ADMIN\n'
|
||||
' environment:\n'
|
||||
' - REDSOCKS_LOCAL_PORT=${REDIRECT_PORT}\n'
|
||||
' volumes:\n'
|
||||
' - ${PIC_DATA_DIR}/services/proxy/${INSTANCE_ID}/config:/config\n'
|
||||
)
|
||||
|
||||
_SSHUTTLE_TEMPLATE = (
|
||||
'services:\n'
|
||||
' sshuttle:\n'
|
||||
' image: git.pic.ngo/roof/svc-sshuttle:latest\n'
|
||||
' container_name: cell-sshuttle-${INSTANCE_ID}\n'
|
||||
' network_mode: host\n'
|
||||
' cap_add:\n'
|
||||
' - NET_ADMIN\n'
|
||||
' environment:\n'
|
||||
' - SSHUTTLE_LISTEN_PORT=${REDIRECT_PORT}\n'
|
||||
' volumes:\n'
|
||||
' - ${PIC_DATA_DIR}/services/sshuttle/${INSTANCE_ID}/config:/config\n'
|
||||
)
|
||||
|
||||
|
||||
class _ComposerBase(_Base):
|
||||
"""Base that wires a real ServiceComposer (subprocess mocked) + install records
|
||||
that carry a compose template, so create/delete drive per-instance up/down."""
|
||||
|
||||
TEMPLATES = {
|
||||
'proxy': _PROXY_TEMPLATE,
|
||||
'sshuttle': _SSHUTTLE_TEMPLATE,
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from service_composer import ServiceComposer
|
||||
cm = MagicMock()
|
||||
ident = {'cell_name': 'c', 'domain': 'cell.local', 'domain_mode': 'lan'}
|
||||
cm.get_identity.return_value = ident
|
||||
cm.get_effective_domain.return_value = 'cell.local'
|
||||
cm.configs = {}
|
||||
self.composer = ServiceComposer(config_manager=cm, data_dir=self.data_dir)
|
||||
self.mgr.service_composer = self.composer
|
||||
# Re-record install records with compose templates so up_connection_instance
|
||||
# finds an image + template (the _Base records had empty manifests).
|
||||
svc_template = {
|
||||
'wireguard-ext': _SSHUTTLE_TEMPLATE, # content irrelevant; render only
|
||||
'openvpn-client': _SSHUTTLE_TEMPLATE,
|
||||
'sshuttle': _SSHUTTLE_TEMPLATE,
|
||||
'proxy': _PROXY_TEMPLATE,
|
||||
'tor': None,
|
||||
}
|
||||
for svc, tmpl in svc_template.items():
|
||||
rec = {'id': svc, 'manifest': {'instanceable': bool(tmpl),
|
||||
'requires_host_network': True}}
|
||||
if tmpl:
|
||||
rec['compose_template'] = tmpl
|
||||
self.cm.set_installed_service(svc, rec)
|
||||
self.cm._save_all_configs()
|
||||
|
||||
|
||||
class TestCreateBringsUpInstance(_ComposerBase):
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_create_proxy_triggers_up_and_writes_compose(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
res = self.mgr.create_connection('proxy', 'Work proxy', _proxy_cfg())
|
||||
self.assertTrue(res['ok'], res)
|
||||
cid = res['connection']['id']
|
||||
inst = cid.split('_')[-1][:12]
|
||||
# up_instance ran docker compose up for the per-instance project.
|
||||
cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||
self.assertTrue(any('up' in cmd and f'pic-conn-{inst}' in cmd
|
||||
for cmd in cmds), cmds)
|
||||
# Per-instance compose written to its own dir, not the type-level path.
|
||||
self.assertTrue(self.composer.has_instance_compose('proxy', inst))
|
||||
compose_path = self.composer._instance_compose_path('proxy', inst)
|
||||
with open(compose_path) as f:
|
||||
content = f.read()
|
||||
self.assertIn(f'cell-proxy-{inst}', content)
|
||||
self.assertIn(str(res['connection']['redirect_port']), content)
|
||||
# redsocks.conf materialized in the per-instance config dir.
|
||||
self.assertTrue(os.path.exists(os.path.join(
|
||||
self.composer.instance_config_dir('proxy', inst), 'redsocks.conf')))
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_two_proxies_distinct_containers_and_ports(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
r1 = self.mgr.create_connection('proxy', 'p1', _proxy_cfg())
|
||||
r2 = self.mgr.create_connection('proxy', 'p2', _proxy_cfg())
|
||||
self.assertTrue(r1['ok'] and r2['ok'])
|
||||
n1 = self.mgr.instance_container_name(r1['connection'])
|
||||
n2 = self.mgr.instance_container_name(r2['connection'])
|
||||
self.assertNotEqual(n1, n2)
|
||||
self.assertNotEqual(r1['connection']['redirect_port'],
|
||||
r2['connection']['redirect_port'])
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_delete_brings_down_and_cleans_instance(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
res = self.mgr.create_connection('sshuttle', 'tun', _sshuttle_cfg(),
|
||||
secrets={'private_key': VALID_KEY})
|
||||
cid = res['connection']['id']
|
||||
inst = cid.split('_')[-1][:12]
|
||||
self.assertTrue(self.composer.has_instance_compose('sshuttle', inst))
|
||||
inst_dir = self.composer._instance_dir('sshuttle', inst)
|
||||
mock_run.reset_mock()
|
||||
d = self.mgr.delete_connection(cid)
|
||||
self.assertTrue(d['ok'], d)
|
||||
cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||
self.assertTrue(any('down' in cmd and f'pic-conn-{inst}' in cmd
|
||||
for cmd in cmds), cmds)
|
||||
self.assertFalse(os.path.exists(inst_dir))
|
||||
|
||||
|
||||
class TestStoreServiceGate(_Base):
|
||||
|
||||
def test_create_errors_when_store_service_not_installed(self):
|
||||
# _Base marks all installed; remove proxy to hit the gate.
|
||||
self.cm.remove_installed_service('proxy')
|
||||
res = self.mgr.create_connection('proxy', 'p', _proxy_cfg())
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('Service Store', res['error'])
|
||||
self.assertEqual(len(self.cm.list_connections()), 0)
|
||||
|
||||
|
||||
class TestTorSingleInstance(_ComposerBase):
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_tor_uses_fixed_container_and_no_instance_up(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
res = self.mgr.create_connection('tor', 'Tor')
|
||||
self.assertTrue(res['ok'], res)
|
||||
rec = res['connection']
|
||||
# Single fixed container name, not cell-tor-<id>.
|
||||
self.assertEqual(self.mgr.instance_container_name(rec), 'cell-tor')
|
||||
# No per-instance compose written for tor.
|
||||
inst = rec['id'].split('_')[-1][:12]
|
||||
self.assertFalse(self.composer.has_instance_compose('tor', inst))
|
||||
# A second tor is rejected (single instance).
|
||||
res2 = self.mgr.create_connection('tor', 'Tor2')
|
||||
self.assertFalse(res2['ok'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user