feat: connectivity redesign phase 1 — multi-instance connection data model
Unit Tests / test (push) Successful in 12m51s
Unit Tests / test (push) Successful in 12m51s
Migrate from the single-exit-per-type model (one wireguard_exit, one tor_exit, etc.) to N named connection instances, each carrying its own resource allocations and vault-backed secret refs. config_manager.py: - Connectivity v2 schema: top-level `connections` list, each entry has id, name, type, enabled, status, config, secret_ref, and allocated resources (mark, table, iface, redirect_port). - Helpers: get_connectivity / list_connections / get_connection / add_connection / update_connection / delete_connection / set_connection_status. - v1→v2 migration: promotes legacy wireguard_exit / tor fields into the new list on first load; idempotent on v2 configs. connectivity_manager.py: - Resource allocator: per-instance fwmark range 0x1000–0x1FFF, routing table range 1000+, interface names, and redirect ports 9100–9199; all tracked in config to survive restarts. - Connection CRUD: create / update / delete / list / get with vault secret refs for WireGuard private keys and Tor credentials. - Single-Tor enforcement: rejects a second tor/tor_bridge instance at creation time. - Per-instance config validation for each connection type. - apply_routes, peer wiring, and egress hookups are intentionally left unchanged in this phase; they land in later phases alongside UI. tests/test_connectivity_connections.py (new, 473 lines): - Allocator uniqueness, v1→v2 migration round-trip, CRUD lifecycle, single-Tor enforcement, and status transitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import fnmatch
|
||||
import yaml
|
||||
import shutil
|
||||
import hashlib
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
@@ -61,6 +62,13 @@ class ConfigManager:
|
||||
pass
|
||||
self.service_schemas = self._load_service_schemas()
|
||||
self.configs = self._load_all_configs()
|
||||
# Guards concurrent reads/writes of the connectivity v2 section.
|
||||
self._connectivity_lock = threading.RLock()
|
||||
# Optional callback invoked to migrate the legacy connectivity section
|
||||
# to v2 on first access. Wired by ConnectivityManager (which owns the
|
||||
# resource-allocation logic). Until set, get_connectivity() returns the
|
||||
# raw (possibly legacy) section without migrating.
|
||||
self._connectivity_migrator = None
|
||||
# Ensure _identity key always exists
|
||||
if '_identity' not in self.configs:
|
||||
self.configs['_identity'] = {}
|
||||
@@ -1037,6 +1045,130 @@ class ConfigManager:
|
||||
logger.error(f"set_connectivity_field({field}): {e}")
|
||||
return False
|
||||
|
||||
# ── Connectivity v2 — named connection instances ──────────────────────
|
||||
#
|
||||
# The legacy schema stored at most one exit per type under
|
||||
# `connectivity.exits` plus a `peer_exit_map`. v2 replaces this with a list
|
||||
# of named connection instances under `connectivity.connections`, each with
|
||||
# its own allocated routing resources (mark/table/iface/redirect_port) and
|
||||
# vault secret references. The legacy keys are kept readable so the one-time
|
||||
# migration can consume them; the new code path uses `connections`.
|
||||
|
||||
def register_connectivity_migrator(self, migrator) -> None:
|
||||
"""Register the v1→v2 migration callback (owned by ConnectivityManager).
|
||||
|
||||
`migrator(legacy_section) -> list[connection_record]` builds the v2
|
||||
connection records (allocating resources, repointing secrets) from the
|
||||
legacy section. Called at most once, lazily, on first get_connectivity().
|
||||
"""
|
||||
self._connectivity_migrator = migrator
|
||||
|
||||
def get_connectivity(self) -> Dict[str, Any]:
|
||||
"""Return the connectivity v2 dict, running v1→v2 migration if needed.
|
||||
|
||||
Idempotent: once `version` is 2 the stored section is returned as-is.
|
||||
When `version` < 2 and a migrator is registered, the legacy exits are
|
||||
converted to connection instances exactly once and the result persisted.
|
||||
"""
|
||||
with self._connectivity_lock:
|
||||
cfg = self.configs.get('connectivity')
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
if cfg.get('version') == 2 and isinstance(cfg.get('connections'), list):
|
||||
return self._copy_connectivity(cfg)
|
||||
|
||||
connections: List[Dict[str, Any]] = []
|
||||
if self._connectivity_migrator is not None:
|
||||
try:
|
||||
built = self._connectivity_migrator(dict(cfg))
|
||||
if isinstance(built, list):
|
||||
connections = built
|
||||
except Exception as e:
|
||||
logger.error(f"connectivity v1→v2 migration failed: {e}")
|
||||
raise
|
||||
|
||||
new_cfg = dict(cfg)
|
||||
new_cfg['version'] = 2
|
||||
new_cfg['connections'] = connections
|
||||
self.configs['connectivity'] = new_cfg
|
||||
self._save_all_configs()
|
||||
return self._copy_connectivity(new_cfg)
|
||||
|
||||
@staticmethod
|
||||
def _copy_connectivity(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Deep-ish copy of the connectivity section so callers can't mutate state."""
|
||||
out = dict(cfg)
|
||||
out['connections'] = [dict(c) for c in cfg.get('connections', [])]
|
||||
return out
|
||||
|
||||
def list_connections(self) -> List[Dict[str, Any]]:
|
||||
"""Return a copy of all v2 connection records."""
|
||||
with self._connectivity_lock:
|
||||
return self.get_connectivity().get('connections', [])
|
||||
|
||||
def get_connection(self, conn_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a copy of one connection record by id, or None."""
|
||||
with self._connectivity_lock:
|
||||
for conn in self.get_connectivity().get('connections', []):
|
||||
if conn.get('id') == conn_id:
|
||||
return dict(conn)
|
||||
return None
|
||||
|
||||
def add_connection(self, record: Dict[str, Any]) -> bool:
|
||||
"""Append a connection record and persist atomically."""
|
||||
with self._connectivity_lock:
|
||||
cfg = self.get_connectivity()
|
||||
conns = cfg.get('connections', [])
|
||||
conns.append(dict(record))
|
||||
self.configs['connectivity'] = {
|
||||
**self.configs.get('connectivity', {}),
|
||||
'version': 2,
|
||||
'connections': conns,
|
||||
}
|
||||
self._save_all_configs()
|
||||
return True
|
||||
|
||||
def update_connection(self, conn_id: str, fields: Dict[str, Any]) -> bool:
|
||||
"""Merge `fields` into the connection record with id `conn_id`."""
|
||||
with self._connectivity_lock:
|
||||
cfg = self.get_connectivity()
|
||||
conns = cfg.get('connections', [])
|
||||
found = False
|
||||
for conn in conns:
|
||||
if conn.get('id') == conn_id:
|
||||
conn.update(fields)
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return False
|
||||
self.configs['connectivity'] = {
|
||||
**self.configs.get('connectivity', {}),
|
||||
'version': 2,
|
||||
'connections': conns,
|
||||
}
|
||||
self._save_all_configs()
|
||||
return True
|
||||
|
||||
def delete_connection(self, conn_id: str) -> bool:
|
||||
"""Remove the connection record with id `conn_id`."""
|
||||
with self._connectivity_lock:
|
||||
cfg = self.get_connectivity()
|
||||
conns = cfg.get('connections', [])
|
||||
remaining = [c for c in conns if c.get('id') != conn_id]
|
||||
if len(remaining) == len(conns):
|
||||
return False
|
||||
self.configs['connectivity'] = {
|
||||
**self.configs.get('connectivity', {}),
|
||||
'version': 2,
|
||||
'connections': remaining,
|
||||
}
|
||||
self._save_all_configs()
|
||||
return True
|
||||
|
||||
def set_connection_status(self, conn_id: str, status: Dict[str, Any]) -> bool:
|
||||
"""Replace the `status` sub-dict of one connection record."""
|
||||
return self.update_connection(conn_id, {'status': dict(status)})
|
||||
|
||||
def get_all_configs(self) -> Dict[str, Dict]:
|
||||
"""Get all service configurations"""
|
||||
return self.configs.copy()
|
||||
|
||||
Reference in New Issue
Block a user