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
+152 -1
View File
@@ -114,10 +114,17 @@ class ServiceComposer:
# ── Template rendering ────────────────────────────────────────────────
def render_template(self, service_id: str, manifest: Dict,
template_content: str) -> str:
template_content: str,
instance_vars: Optional[Dict[str, str]] = None) -> str:
"""
Substitute all PIC_* variables in a compose-template.yml string.
Returns the rendered compose YAML.
instance_vars optionally supplies per-connection-instance values for
${INSTANCE_ID} and ${REDIRECT_PORT} so an instanceable connectivity
service can be rendered once per connection without collisions. They
are ignored for non-instanceable services (the placeholders simply
never appear in the template).
"""
schema = manifest.get('config_schema') or {}
saved = self.cm.configs.get(service_id, {})
@@ -141,6 +148,13 @@ class ServiceComposer:
result = result.replace('${PIC_SERVICE_ID}', service_id)
result = result.replace('${PIC_DATA_DIR}', str(Path(self.data_dir).resolve()))
if instance_vars:
for var in ('INSTANCE_ID', 'REDIRECT_PORT'):
if var in instance_vars and instance_vars[var] is not None:
safe = str(instance_vars[var]).replace('\n', '').replace(
'\r', '').replace('\t', ' ')
result = result.replace(f'${{{var}}}', safe)
# PIC_SECRET_* — generate on first use, reuse on reconfigure
for match in _SECRET_RE.finditer(template_content):
var_name = match.group(1)
@@ -294,6 +308,143 @@ class ServiceComposer:
pass
return result
# ── Connection-instance lifecycle (one container per connection) ──────
#
# An instanceable connectivity service (wireguard-ext / openvpn-client /
# sshuttle / proxy) backs MANY connections — one container per connection.
# The store service supplies the image + raw compose-template; each
# connection renders that template with its own ${INSTANCE_ID} (short id),
# ${REDIRECT_PORT} and a per-instance config dir, so two connections of the
# same type never collide on container name, config mount, or listen port.
#
# Layout (all under data/services/<service_id>/<instance_id>/):
# docker-compose.yml rendered per-instance compose
# config/ per-instance bind-mounted config dir
# Tor is single-instance and keeps using the plain store-service path.
@staticmethod
def instance_id_for(conn_id: str) -> str:
"""Derive a short, docker-safe INSTANCE_ID from a connection id."""
return conn_id.split('_')[-1][:12]
def _instance_dir(self, service_id: str, instance_id: str) -> str:
self._validate_service_id(service_id)
if not _SAFE_ID_RE.match(instance_id):
raise ValueError(f'invalid instance_id {instance_id!r}')
candidate = os.path.join(self._svc_dir(service_id), instance_id)
real_base = os.path.realpath(self._svc_dir(service_id))
real_cand = os.path.realpath(candidate)
if not real_cand.startswith(real_base + os.sep) and real_cand != real_base:
raise ValueError(f'instance_id {instance_id!r} escapes service directory')
return candidate
def _instance_compose_path(self, service_id: str, instance_id: str) -> str:
return os.path.join(self._instance_dir(service_id, instance_id),
'docker-compose.yml')
def instance_config_dir(self, service_id: str, instance_id: str) -> str:
"""Per-instance config dir that the compose template bind-mounts."""
return os.path.join(self._instance_dir(service_id, instance_id), 'config')
def has_instance_compose(self, service_id: str, instance_id: str) -> bool:
try:
return os.path.exists(self._instance_compose_path(service_id, instance_id))
except ValueError:
return False
def write_instance_compose(self, service_id: str, instance_id: str,
manifest: Dict, template_content: str,
redirect_port: Optional[int] = None) -> str:
"""Render + atomically write a per-instance compose file. Returns content."""
inst_dir = self._instance_dir(service_id, instance_id)
os.makedirs(os.path.join(inst_dir, 'config'), exist_ok=True)
instance_vars = {'INSTANCE_ID': instance_id}
if redirect_port is not None:
instance_vars['REDIRECT_PORT'] = str(redirect_port)
content = self.render_template(
service_id, manifest, template_content, instance_vars=instance_vars)
allow_host_network = bool(manifest.get('requires_host_network'))
ok, errs = validate_rendered_compose(
content,
allowed_data_dir=str(Path(self.data_dir).resolve()),
allow_host_network=allow_host_network,
)
if not ok:
raise ValueError(
f'Instance compose failed security validation: {"; ".join(errs)}')
path = self._instance_compose_path(service_id, instance_id)
tmp = path + '.tmp'
with open(tmp, 'w') as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
logger.info('ServiceComposer: wrote instance compose %s/%s',
service_id, instance_id)
return content
def _instance_cmd(self, service_id: str, instance_id: str, *args,
timeout: int = 120) -> Dict:
compose_file = self._instance_compose_path(service_id, instance_id)
if not os.path.exists(compose_file):
return {'ok': False,
'error': f'No compose file for instance {service_id}/{instance_id}'}
cmd = [
'docker', 'compose',
'-f', compose_file,
'--project-name', f'pic-conn-{instance_id}',
*args,
]
return self._run(cmd, timeout)
def up_instance(self, service_id: str, instance_id: str, manifest: Dict,
template_content: str,
redirect_port: Optional[int] = None) -> Dict:
"""Render + bring up the container for one connection instance."""
try:
self.write_instance_compose(service_id, instance_id, manifest,
template_content, redirect_port)
except ValueError as e:
return {'ok': False, 'error': str(e)}
return self._instance_cmd(service_id, instance_id, 'up', '-d',
'--remove-orphans', timeout=600)
def down_instance(self, service_id: str, instance_id: str,
purge_data: bool = False) -> Dict:
"""Stop the connection instance's container and remove its compose/dir."""
result = {'ok': True}
if self.has_instance_compose(service_id, instance_id):
args = ['down']
if purge_data:
args.append('--volumes')
result = self._instance_cmd(service_id, instance_id, *args)
try:
inst_dir = self._instance_dir(service_id, instance_id)
except ValueError as e:
logger.warning('down_instance: %s', e)
return result
if os.path.isdir(inst_dir):
real_inst = os.path.realpath(inst_dir)
real_base = os.path.realpath(self._svc_dir(service_id))
if not real_inst.startswith(real_base + os.sep):
logger.error('ServiceComposer: refusing rmtree outside service dir: %s',
inst_dir)
else:
try:
shutil.rmtree(inst_dir)
except OSError as e:
logger.warning('ServiceComposer: could not remove %s: %s',
inst_dir, e)
return result
def status_instance(self, service_id: str, instance_id: str) -> Dict:
result = self._instance_cmd(service_id, instance_id, 'ps', '--format', 'json')
result['containers'] = self._parse_ps_json(result.get('stdout', ''))
return result
# ── Dependency resolution ─────────────────────────────────────────────
def _resolve_requires(self, manifest: Dict, installed_services: Dict) -> Optional[str]: