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:
+152
-1
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user