feat: connectivity redesign phase 5 — one container per connection instance
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:
2026-06-10 22:56:31 -04:00
parent d39c091cec
commit 603225694c
8 changed files with 688 additions and 8 deletions
+162
View File
@@ -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()