feat: PIC Services Architecture Phase 1 — registry-driven services ecosystem
Unit Tests / test (push) Successful in 11m30s
Unit Tests / test (push) Successful in 11m30s
Implements the full Phase 1 services architecture: - ServiceRegistry: merges built-in + installed + runtime config; drives Caddy and CoreDNS instead of hardcoded service names - ServiceComposer: docker-compose lifecycle for third-party services - AccountManager: per-service credential provisioning and deprovisioning per peer - Built-in manifests (email, calendar, files) with subdomain, backup, and account hooks - Admin UI: Accounts tab on Email, Calendar, Files pages - Developer guide v1: manifest reference, compose variables, backup/egress integration - 158 new tests; 1762 total passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
AccountManager — per-service credential provisioning for PIC peers.
|
||||
|
||||
Responsibilities:
|
||||
- Dispatch account creation/deletion to each service's underlying manager
|
||||
- Store per-peer per-service credentials securely (0o600 file)
|
||||
- Provide credential retrieval for peer_config_template filling
|
||||
- Bulk-deprovision a peer from all services on peer deletion
|
||||
|
||||
Credentials file format (data/peer_service_credentials.json):
|
||||
{
|
||||
"<service_id>": {
|
||||
"<peer_username>": {"password": "..."}
|
||||
}
|
||||
}
|
||||
|
||||
Design note — plaintext passwords:
|
||||
Credentials are stored in plaintext so the peer endpoint can return them to
|
||||
the peer's device for one-time client configuration. The file is created with
|
||||
0o600 so it is only readable by the process owner (same pattern used for
|
||||
WireGuard keys and service_secrets.json).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets as _secrets_mod
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger('picell')
|
||||
|
||||
_DISPATCH_PROVISION = {
|
||||
'email_manager': '_provision_email',
|
||||
'calendar_manager': '_provision_calendar',
|
||||
'file_manager': '_provision_files',
|
||||
}
|
||||
_DISPATCH_DEPROVISION = {
|
||||
'email_manager': '_deprovision_email',
|
||||
'calendar_manager': '_deprovision_calendar',
|
||||
'file_manager': '_deprovision_files',
|
||||
}
|
||||
|
||||
|
||||
class AccountManager:
|
||||
|
||||
def __init__(self, service_registry, data_dir: str, **managers):
|
||||
"""
|
||||
service_registry — ServiceRegistry instance
|
||||
data_dir — host data directory (data/peer_service_credentials.json lives here)
|
||||
**managers — named manager instances: email_manager=..., calendar_manager=...,
|
||||
file_manager=...
|
||||
"""
|
||||
self._registry = service_registry
|
||||
self._creds_path = Path(data_dir) / 'peer_service_credentials.json'
|
||||
self._managers = managers
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── Credential storage (0o600) ────────────────────────────────────────
|
||||
|
||||
def _load_creds(self) -> Dict:
|
||||
if not self._creds_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(self._creds_path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning('AccountManager: failed to load credentials: %s', e)
|
||||
return {}
|
||||
|
||||
def _save_creds(self, creds: Dict) -> None:
|
||||
tmp = str(self._creds_path) + '.tmp'
|
||||
with open(tmp, 'w', opener=lambda path, flags: os.open(path, flags, 0o600)) as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, str(self._creds_path))
|
||||
|
||||
# ── Per-manager provision / deprovision ───────────────────────────────
|
||||
|
||||
def _provision_email(self, manager, svc: Dict, peer_username: str, password: str) -> bool:
|
||||
domain = (svc.get('config') or {}).get('domain', '')
|
||||
if not domain:
|
||||
raise ValueError("Email service has no 'domain' configured")
|
||||
return manager.create_email_user(peer_username, domain, password)
|
||||
|
||||
def _deprovision_email(self, manager, svc: Dict, peer_username: str) -> bool:
|
||||
domain = (svc.get('config') or {}).get('domain', '')
|
||||
return manager.delete_email_user(peer_username, domain)
|
||||
|
||||
@staticmethod
|
||||
def _provision_calendar(manager, _svc: Dict, peer_username: str, password: str) -> bool:
|
||||
return manager.create_calendar_user(peer_username, password)
|
||||
|
||||
@staticmethod
|
||||
def _deprovision_calendar(manager, _svc: Dict, peer_username: str) -> bool:
|
||||
return manager.delete_calendar_user(peer_username)
|
||||
|
||||
@staticmethod
|
||||
def _provision_files(manager, _svc: Dict, peer_username: str, password: str) -> bool:
|
||||
return manager.create_user(peer_username, password)
|
||||
|
||||
@staticmethod
|
||||
def _deprovision_files(manager, _svc: Dict, peer_username: str) -> bool:
|
||||
return manager.delete_user(peer_username)
|
||||
|
||||
# ── Service validation helper ─────────────────────────────────────────
|
||||
|
||||
def _resolve_service(self, service_id: str):
|
||||
"""Return (svc, manager_name, manager) or raise ValueError."""
|
||||
svc = self._registry.get(service_id)
|
||||
if svc is None:
|
||||
raise ValueError(f'Unknown service: {service_id!r}')
|
||||
accounts_cfg = svc.get('accounts') or {}
|
||||
manager_name = accounts_cfg.get('manager')
|
||||
if not manager_name:
|
||||
raise ValueError(f'Service {service_id!r} does not support accounts')
|
||||
manager = self._managers.get(manager_name)
|
||||
if manager is None:
|
||||
raise ValueError(f'Manager {manager_name!r} is not registered with AccountManager')
|
||||
return svc, manager_name, manager
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
def provision(self, service_id: str, peer_username: str,
|
||||
password: str = None) -> Dict:
|
||||
"""Create an account on the service for the peer; store and return credentials.
|
||||
|
||||
Raises ValueError if the service doesn't support accounts.
|
||||
Raises RuntimeError if the underlying manager fails.
|
||||
"""
|
||||
svc, manager_name, manager = self._resolve_service(service_id)
|
||||
|
||||
if password is None:
|
||||
password = _secrets_mod.token_urlsafe(16)
|
||||
|
||||
dispatch = _DISPATCH_PROVISION.get(manager_name)
|
||||
if dispatch is None:
|
||||
raise ValueError(f'No provision dispatch for manager: {manager_name!r}')
|
||||
fn = getattr(self, dispatch)
|
||||
|
||||
ok = fn(manager, svc, peer_username, password)
|
||||
if not ok:
|
||||
raise RuntimeError(
|
||||
f'Provision of {peer_username!r} on {service_id!r} returned False — '
|
||||
'check underlying service manager logs'
|
||||
)
|
||||
|
||||
cred = {'password': password}
|
||||
with self._lock:
|
||||
all_creds = self._load_creds()
|
||||
all_creds.setdefault(service_id, {})[peer_username] = cred
|
||||
self._save_creds(all_creds)
|
||||
|
||||
logger.info('AccountManager: provisioned %s on %s', peer_username, service_id)
|
||||
return cred
|
||||
|
||||
def deprovision(self, service_id: str, peer_username: str) -> bool:
|
||||
"""Delete the peer's account on the service and clear stored credentials."""
|
||||
svc, manager_name, manager = self._resolve_service(service_id)
|
||||
|
||||
dispatch = _DISPATCH_DEPROVISION.get(manager_name)
|
||||
if dispatch is None:
|
||||
raise ValueError(f'No deprovision dispatch for manager: {manager_name!r}')
|
||||
fn = getattr(self, dispatch)
|
||||
|
||||
ok = fn(manager, svc, peer_username)
|
||||
|
||||
with self._lock:
|
||||
all_creds = self._load_creds()
|
||||
svc_creds = all_creds.get(service_id, {})
|
||||
if peer_username in svc_creds:
|
||||
del svc_creds[peer_username]
|
||||
if not svc_creds:
|
||||
del all_creds[service_id]
|
||||
self._save_creds(all_creds)
|
||||
|
||||
logger.info('AccountManager: deprovisioned %s from %s', peer_username, service_id)
|
||||
return bool(ok)
|
||||
|
||||
def get_credentials(self, service_id: str, peer_username: str) -> Optional[Dict]:
|
||||
"""Return stored credentials for peer+service, or None if not provisioned."""
|
||||
with self._lock:
|
||||
return self._load_creds().get(service_id, {}).get(peer_username)
|
||||
|
||||
def list_accounts(self, service_id: str) -> List[str]:
|
||||
"""Return peer usernames provisioned on a service."""
|
||||
with self._lock:
|
||||
return list(self._load_creds().get(service_id, {}).keys())
|
||||
|
||||
def list_peer_services(self, peer_username: str) -> List[str]:
|
||||
"""Return service IDs where this peer has a provisioned account."""
|
||||
with self._lock:
|
||||
creds = self._load_creds()
|
||||
return [svc_id for svc_id, peers in creds.items() if peer_username in peers]
|
||||
|
||||
def is_provisioned(self, service_id: str, peer_username: str) -> bool:
|
||||
return self.get_credentials(service_id, peer_username) is not None
|
||||
|
||||
def deprovision_peer(self, peer_username: str) -> Dict[str, bool]:
|
||||
"""Remove a peer from every service they are provisioned on.
|
||||
|
||||
Called on peer deletion. Continues even if individual services fail.
|
||||
Returns {service_id: success} for each service attempted.
|
||||
"""
|
||||
results: Dict[str, bool] = {}
|
||||
for service_id in self.list_peer_services(peer_username):
|
||||
try:
|
||||
results[service_id] = self.deprovision(service_id, peer_username)
|
||||
except Exception as e:
|
||||
logger.warning('AccountManager: deprovision %s from %s failed: %s',
|
||||
peer_username, service_id, e)
|
||||
results[service_id] = False
|
||||
return results
|
||||
|
||||
def get_all_credentials(self, peer_username: str) -> Dict[str, Dict]:
|
||||
"""Return {service_id: {field: value}} for all services the peer is provisioned on."""
|
||||
with self._lock:
|
||||
creds = self._load_creds()
|
||||
return {
|
||||
svc_id: peers[peer_username]
|
||||
for svc_id, peers in creds.items()
|
||||
if peer_username in peers
|
||||
}
|
||||
|
||||
def store_credentials(self, service_id: str, peer_username: str,
|
||||
cred: Dict) -> None:
|
||||
"""Directly store credentials without calling the underlying manager.
|
||||
|
||||
Used when a peer was provisioned through the legacy peers-POST route
|
||||
so that their credentials become retrievable via AccountManager.
|
||||
"""
|
||||
with self._lock:
|
||||
all_creds = self._load_creds()
|
||||
all_creds.setdefault(service_id, {})[peer_username] = cred
|
||||
self._save_creds(all_creds)
|
||||
@@ -44,6 +44,9 @@ from managers import (
|
||||
caddy_manager,
|
||||
ddns_manager, service_store_manager,
|
||||
connectivity_manager,
|
||||
service_registry,
|
||||
service_composer,
|
||||
account_manager,
|
||||
firewall_manager, EventType,
|
||||
)
|
||||
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
|
||||
|
||||
+97
-13
@@ -52,11 +52,13 @@ class CaddyManager(BaseServiceManager):
|
||||
def __init__(self, config_manager=None,
|
||||
data_dir: str = '/app/data',
|
||||
config_dir: str = '/app/config',
|
||||
service_bus=None):
|
||||
service_bus=None,
|
||||
service_registry=None):
|
||||
super().__init__('caddy', data_dir, config_dir)
|
||||
self.config_manager = config_manager
|
||||
self.container_name = 'cell-caddy'
|
||||
self.caddyfile_path = LIVE_CADDYFILE
|
||||
self._service_registry = service_registry
|
||||
# Consecutive health-check failure counter (reset on success or when
|
||||
# the caller restarts the container).
|
||||
self._health_failures = 0
|
||||
@@ -187,6 +189,69 @@ class CaddyManager(BaseServiceManager):
|
||||
f" }}"
|
||||
)
|
||||
|
||||
def _build_registry_service_routes(self, domain: str) -> str:
|
||||
"""Build named-matcher + handle blocks from the service registry.
|
||||
|
||||
Falls back to the hardcoded ``_build_core_service_routes`` when no
|
||||
registry is wired or the registry returns nothing, so the method is
|
||||
always safe to call even in tests that don't supply a registry.
|
||||
"""
|
||||
routes: List[Dict] = []
|
||||
if self._service_registry is not None:
|
||||
try:
|
||||
routes = self._service_registry.get_caddy_routes()
|
||||
except Exception as exc:
|
||||
logger.warning('_build_registry_service_routes: registry error: %s', exc)
|
||||
|
||||
if not routes:
|
||||
return self._build_core_service_routes(domain)
|
||||
|
||||
# Pre-seed with reserved names so no registry entry can squat them.
|
||||
seen_matchers: set = {'api', 'webui'}
|
||||
|
||||
blocks: List[str] = []
|
||||
for route in routes:
|
||||
primary_sub = route['subdomain']
|
||||
backend = route['backend']
|
||||
extra_subs: List[str] = route.get('extra_subdomains') or []
|
||||
extra_backends: Dict[str, str] = route.get('extra_backends') or {}
|
||||
|
||||
if primary_sub in seen_matchers:
|
||||
logger.warning('Caddy: skipping duplicate/reserved matcher %r', primary_sub)
|
||||
continue
|
||||
seen_matchers.add(primary_sub)
|
||||
|
||||
# Subdomains that share the primary backend go in one matcher block.
|
||||
shared = [primary_sub] + [s for s in extra_subs if s not in extra_backends]
|
||||
host_list = ' '.join(f'{s}.{domain}' for s in shared)
|
||||
blocks.append(
|
||||
f' @{primary_sub} host {host_list}\n'
|
||||
f' handle @{primary_sub} {{\n'
|
||||
f' reverse_proxy {backend}\n'
|
||||
f' }}'
|
||||
)
|
||||
# Extra subdomains with their own backends each get their own block.
|
||||
for sub, sub_backend in extra_backends.items():
|
||||
if sub in seen_matchers:
|
||||
logger.warning('Caddy: skipping duplicate/reserved matcher %r', sub)
|
||||
continue
|
||||
seen_matchers.add(sub)
|
||||
blocks.append(
|
||||
f' @{sub} host {sub}.{domain}\n'
|
||||
f' handle @{sub} {{\n'
|
||||
f' reverse_proxy {sub_backend}\n'
|
||||
f' }}'
|
||||
)
|
||||
|
||||
# The api subdomain is always infrastructure — not delegated to the registry.
|
||||
blocks.append(
|
||||
f' @api host api.{domain}\n'
|
||||
f' handle @api {{\n'
|
||||
f' reverse_proxy cell-api:3000\n'
|
||||
f' }}'
|
||||
)
|
||||
return '\n'.join(blocks)
|
||||
|
||||
@staticmethod
|
||||
def _indent_routes(routes: str, spaces: int = 4) -> str:
|
||||
"""Indent a multi-line route block by ``spaces`` columns."""
|
||||
@@ -230,7 +295,7 @@ class CaddyManager(BaseServiceManager):
|
||||
service_routes: str, core_routes: str) -> str:
|
||||
"""pic_ngo mode: wildcard DNS-01 via the pic_ngo plugin."""
|
||||
domain = f"{cell_name}.pic.ngo"
|
||||
body = [self._build_core_service_routes(domain)]
|
||||
body = [self._build_registry_service_routes(domain)]
|
||||
if service_routes:
|
||||
body.append(self._indent_routes(service_routes))
|
||||
body.append(core_routes)
|
||||
@@ -253,7 +318,7 @@ class CaddyManager(BaseServiceManager):
|
||||
def _caddyfile_cloudflare(self, custom_domain: str,
|
||||
service_routes: str, core_routes: str) -> str:
|
||||
"""cloudflare mode: wildcard DNS-01 via the cloudflare plugin."""
|
||||
body = [self._build_core_service_routes(custom_domain)]
|
||||
body = [self._build_registry_service_routes(custom_domain)]
|
||||
if service_routes:
|
||||
body.append(self._indent_routes(service_routes))
|
||||
body.append(core_routes)
|
||||
@@ -273,7 +338,7 @@ class CaddyManager(BaseServiceManager):
|
||||
service_routes: str, core_routes: str) -> str:
|
||||
"""duckdns mode: DNS-01 via the duckdns plugin."""
|
||||
domain = f"{cell_name}.duckdns.org"
|
||||
body = [self._build_core_service_routes(domain)]
|
||||
body = [self._build_registry_service_routes(domain)]
|
||||
if service_routes:
|
||||
body.append(self._indent_routes(service_routes))
|
||||
body.append(core_routes)
|
||||
@@ -299,15 +364,8 @@ class CaddyManager(BaseServiceManager):
|
||||
out.append(core_routes)
|
||||
out.append("}")
|
||||
|
||||
# One block per core service subdomain.
|
||||
_core_services = [
|
||||
('calendar', 'cell-radicale:5232'),
|
||||
('mail', 'cell-rainloop:8888'),
|
||||
('webmail', 'cell-rainloop:8888'),
|
||||
('files', 'cell-filegator:8080'),
|
||||
('webdav', 'cell-webdav:80'),
|
||||
('api', 'cell-api:3000'),
|
||||
]
|
||||
# Build (subdomain, backend) pairs from registry when available.
|
||||
_core_services = self._http01_service_pairs()
|
||||
for subdomain, backend in _core_services:
|
||||
out.append("")
|
||||
out.append(f"{subdomain}.{host} {{")
|
||||
@@ -330,6 +388,32 @@ class CaddyManager(BaseServiceManager):
|
||||
out.append("}")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
def _http01_service_pairs(self) -> List[tuple]:
|
||||
"""Return (subdomain, backend) pairs for http01 per-host blocks."""
|
||||
pairs: List[tuple] = []
|
||||
if self._service_registry is not None:
|
||||
try:
|
||||
for route in self._service_registry.get_caddy_routes():
|
||||
pairs.append((route['subdomain'], route['backend']))
|
||||
extra_subs: List[str] = route.get('extra_subdomains') or []
|
||||
extra_backends: Dict[str, str] = route.get('extra_backends') or {}
|
||||
for sub in extra_subs:
|
||||
backend = extra_backends.get(sub, route['backend'])
|
||||
pairs.append((sub, backend))
|
||||
except Exception as exc:
|
||||
logger.warning('_http01_service_pairs: registry error: %s', exc)
|
||||
pairs = []
|
||||
if not pairs:
|
||||
pairs = [
|
||||
('calendar', 'cell-radicale:5232'),
|
||||
('mail', 'cell-rainloop:8888'),
|
||||
('webmail', 'cell-rainloop:8888'),
|
||||
('files', 'cell-filegator:8080'),
|
||||
('webdav', 'cell-webdav:80'),
|
||||
]
|
||||
pairs.append(('api', 'cell-api:3000'))
|
||||
return pairs
|
||||
|
||||
# ── filesystem + admin-API operations ─────────────────────────────────
|
||||
|
||||
def write_caddyfile(self, caddyfile_content: str) -> bool:
|
||||
|
||||
+139
-4
@@ -6,6 +6,8 @@ Centralized configuration management for all services
|
||||
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import yaml
|
||||
import shutil
|
||||
import hashlib
|
||||
@@ -14,6 +16,9 @@ from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
_SAFE_CONTAINER_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$')
|
||||
_SAFE_VOL_NAME_RE = re.compile(r'^[a-zA-Z0-9_.-]{1,64}$')
|
||||
|
||||
# The Caddyfile lives on a separate volume mount from the rest of config
|
||||
LIVE_CADDYFILE = os.environ.get('CADDYFILE_PATH', '/app/config-caddy/Caddyfile')
|
||||
|
||||
@@ -228,8 +233,128 @@ class ConfigManager:
|
||||
"warnings": warnings
|
||||
}
|
||||
|
||||
def backup_config(self) -> str:
|
||||
"""Create a backup of cell_config.json, secrets, Caddyfile, .env, Corefile, and DNS zones."""
|
||||
@staticmethod
|
||||
def _validate_vol_entry(service_id: str, vol: dict) -> bool:
|
||||
"""Return True if a backup volume entry is safe to use; log and return False otherwise."""
|
||||
container = vol.get('container', '')
|
||||
path = vol.get('path', '')
|
||||
name = vol.get('name', '')
|
||||
if not _SAFE_CONTAINER_RE.match(container):
|
||||
logger.warning('Backup: unsafe container name %r for %s — skipping', container, service_id)
|
||||
return False
|
||||
if not path.startswith('/') or '..' in path.split('/') or '\x00' in path:
|
||||
logger.warning('Backup: unsafe volume path %r for %s — skipping', path, service_id)
|
||||
return False
|
||||
if not _SAFE_VOL_NAME_RE.match(name):
|
||||
logger.warning('Backup: unsafe volume name %r for %s — skipping', name, service_id)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _backup_service_volumes(self, backup_path: Path, service_registry) -> None:
|
||||
"""Stream service data out of each container via 'docker exec tar'.
|
||||
|
||||
Archives are relative (created with -C <path> .) so they can be safely
|
||||
restored with -C <path> without risk of path traversal outside the volume.
|
||||
Writes to a .partial temp file then renames atomically on success.
|
||||
"""
|
||||
try:
|
||||
plan = service_registry.get_backup_plan()
|
||||
except Exception as e:
|
||||
logger.warning('_backup_service_volumes: could not get backup plan: %s', e)
|
||||
return
|
||||
for entry in plan:
|
||||
service_id = entry['service_id']
|
||||
volumes = entry.get('volumes') or []
|
||||
if not volumes:
|
||||
continue
|
||||
svc_dir = backup_path / 'service_data' / service_id
|
||||
svc_dir.mkdir(parents=True, exist_ok=True)
|
||||
for vol in volumes:
|
||||
if not self._validate_vol_entry(service_id, vol):
|
||||
continue
|
||||
container = vol['container']
|
||||
path = vol['path']
|
||||
name = vol['name']
|
||||
archive_path = svc_dir / f'{name}.tar.gz'
|
||||
tmp_path = svc_dir / f'{name}.tar.gz.partial'
|
||||
try:
|
||||
with open(tmp_path, 'wb') as af:
|
||||
result = subprocess.run(
|
||||
# -C path; then '.' archives the whole dir with relative entries.
|
||||
# '--' prevents path/container from being parsed as options.
|
||||
['docker', 'exec', '--', container,
|
||||
'tar', '-C', path, '-czf', '-', '.'],
|
||||
stdout=af,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
'Backup: docker exec tar failed for %s/%s: %s',
|
||||
service_id, name, result.stderr.decode(errors='replace'),
|
||||
)
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
else:
|
||||
os.replace(tmp_path, archive_path)
|
||||
logger.info('Backup: archived %s/%s', service_id, name)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning('Backup: timed out streaming %s/%s', service_id, name)
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.warning('Backup: failed to archive %s/%s: %s', service_id, name, e)
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
def _restore_service_volumes(self, backup_path: Path, service_registry) -> None:
|
||||
"""Pipe archived service data back into containers via 'docker exec -i tar'.
|
||||
|
||||
Extracts with -C <path>, matching how archives were created (relative paths).
|
||||
This bounds extraction to within the declared volume directory.
|
||||
"""
|
||||
svc_data_dir = backup_path / 'service_data'
|
||||
if not svc_data_dir.is_dir():
|
||||
return
|
||||
for svc_dir in svc_data_dir.iterdir():
|
||||
if not svc_dir.is_dir():
|
||||
continue
|
||||
service_id = svc_dir.name
|
||||
svc = service_registry.get(service_id)
|
||||
if not svc:
|
||||
logger.warning('Restore: unknown service %s in backup, skipping', service_id)
|
||||
continue
|
||||
volumes = (svc.get('backup') or {}).get('volumes') or []
|
||||
for vol in volumes:
|
||||
if not self._validate_vol_entry(service_id, vol):
|
||||
continue
|
||||
container = vol['container']
|
||||
path = vol['path']
|
||||
name = vol['name']
|
||||
archive_path = svc_dir / f'{name}.tar.gz'
|
||||
if not archive_path.exists():
|
||||
continue
|
||||
try:
|
||||
with open(archive_path, 'rb') as af:
|
||||
result = subprocess.run(
|
||||
['docker', 'exec', '-i', '--', container,
|
||||
'tar', '-C', path, '-xzf', '-'],
|
||||
stdin=af,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
'Restore: docker exec tar failed for %s/%s: %s',
|
||||
service_id, name, result.stderr.decode(errors='replace'),
|
||||
)
|
||||
else:
|
||||
logger.info('Restore: restored %s/%s', service_id, name)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning('Restore: timed out restoring %s/%s', service_id, name)
|
||||
except Exception as e:
|
||||
logger.warning('Restore: failed to restore %s/%s: %s', service_id, name, e)
|
||||
|
||||
def backup_config(self, service_registry=None) -> str:
|
||||
"""Create a backup of cell_config.json, secrets, Caddyfile, .env, Corefile, DNS zones,
|
||||
and (when service_registry is provided) live service data volumes."""
|
||||
try:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_id = f"backup_{timestamp}"
|
||||
@@ -278,12 +403,17 @@ class ConfigManager:
|
||||
except (PermissionError, OSError) as e:
|
||||
logger.warning(f"Could not back up {src.name}: {e} (skipping)")
|
||||
|
||||
# Live service data volumes (streamed via docker exec)
|
||||
if service_registry is not None:
|
||||
self._backup_service_volumes(backup_path, service_registry)
|
||||
|
||||
services = ['identity'] + list(self.service_schemas.keys())
|
||||
manifest = {
|
||||
"backup_id": backup_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"services": services,
|
||||
"files": [f.name for f in backup_path.iterdir()],
|
||||
"includes_service_data": service_registry is not None,
|
||||
}
|
||||
with open(backup_path / 'manifest.json', 'w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
@@ -294,8 +424,9 @@ class ConfigManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating backup: {e}")
|
||||
raise
|
||||
|
||||
def restore_config(self, backup_id: str, services: list = None) -> bool:
|
||||
|
||||
def restore_config(self, backup_id: str, services: list = None,
|
||||
service_registry=None) -> bool:
|
||||
"""Restore from backup. If services list given, only restore those service configs (selective)."""
|
||||
try:
|
||||
backup_path = self.backup_dir / backup_id
|
||||
@@ -373,6 +504,10 @@ class ConfigManager:
|
||||
except (PermissionError, OSError) as e:
|
||||
logger.warning(f"Could not restore {dest.name}: {e} (skipping)")
|
||||
|
||||
# Live service data volumes
|
||||
if service_registry is not None:
|
||||
self._restore_service_volumes(backup_path, service_registry)
|
||||
|
||||
self.configs = self._load_all_configs()
|
||||
logger.info(f"Restored configuration from backup: {backup_id}")
|
||||
return True
|
||||
|
||||
+22
-2
@@ -31,6 +31,9 @@ from setup_manager import SetupManager
|
||||
from caddy_manager import CaddyManager
|
||||
from ddns_manager import DDNSManager
|
||||
from connectivity_manager import ConnectivityManager
|
||||
from service_registry import ServiceRegistry
|
||||
from service_composer import ServiceComposer
|
||||
from account_manager import AccountManager
|
||||
|
||||
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
|
||||
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
|
||||
@@ -42,7 +45,13 @@ config_manager = ConfigManager(
|
||||
service_bus = ServiceBus()
|
||||
log_manager = LogManager(log_dir='./data/logs')
|
||||
|
||||
network_manager = NetworkManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
# ServiceRegistry depends only on config_manager; create it early so
|
||||
# NetworkManager and CaddyManager can derive subdomains from manifests
|
||||
# instead of hardcoding service names.
|
||||
service_registry = ServiceRegistry(config_manager=config_manager)
|
||||
|
||||
network_manager = NetworkManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR,
|
||||
service_registry=service_registry)
|
||||
wireguard_manager = WireGuardManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
peer_registry = PeerRegistry(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
email_manager = EmailManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR, service_bus=service_bus)
|
||||
@@ -58,7 +67,8 @@ cell_link_manager = CellLinkManager(
|
||||
)
|
||||
auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager)
|
||||
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR, service_bus=service_bus)
|
||||
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR,
|
||||
service_bus=service_bus, service_registry=service_registry)
|
||||
ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
connectivity_manager = ConnectivityManager(
|
||||
config_manager=config_manager,
|
||||
@@ -67,6 +77,15 @@ connectivity_manager = ConnectivityManager(
|
||||
config_dir=CONFIG_DIR,
|
||||
)
|
||||
|
||||
service_composer = ServiceComposer(config_manager=config_manager, data_dir=DATA_DIR)
|
||||
account_manager = AccountManager(
|
||||
service_registry=service_registry,
|
||||
data_dir=DATA_DIR,
|
||||
email_manager=email_manager,
|
||||
calendar_manager=calendar_manager,
|
||||
file_manager=file_manager,
|
||||
)
|
||||
|
||||
from service_store_manager import ServiceStoreManager
|
||||
service_store_manager = ServiceStoreManager(
|
||||
config_manager=config_manager,
|
||||
@@ -110,6 +129,7 @@ __all__ = [
|
||||
'routing_manager', 'vault_manager', 'container_manager',
|
||||
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
|
||||
'ddns_manager', 'service_store_manager', 'connectivity_manager',
|
||||
'service_registry', 'service_composer', 'account_manager',
|
||||
'firewall_manager', 'EventType',
|
||||
'DATA_DIR', 'CONFIG_DIR',
|
||||
]
|
||||
|
||||
+32
-13
@@ -18,11 +18,13 @@ logger = logging.getLogger(__name__)
|
||||
class NetworkManager(BaseServiceManager):
|
||||
"""Manages network services (DNS, DHCP, NTP)"""
|
||||
|
||||
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config'):
|
||||
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config',
|
||||
service_registry=None):
|
||||
super().__init__('network', data_dir, config_dir)
|
||||
self.dns_zones_dir = os.path.join(data_dir, 'dns')
|
||||
self.dhcp_leases_file = os.path.join(data_dir, 'dhcp', 'leases')
|
||||
|
||||
self._service_registry = service_registry
|
||||
|
||||
# Ensure directories exist
|
||||
self.safe_makedirs(self.dns_zones_dir)
|
||||
self.safe_makedirs(os.path.dirname(self.dhcp_leases_file))
|
||||
@@ -201,7 +203,7 @@ class NetworkManager(BaseServiceManager):
|
||||
# domain (e.g. primary_domain='pic.ngo', effective_domain='pic2.pic.ngo'),
|
||||
# bootstrap service records like 'api', 'calendar' etc. would pollute the
|
||||
# zone display and shadow the public domain. Remove them.
|
||||
_stale = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
|
||||
_stale = {'api', 'webui'} | set(self._get_service_subdomains())
|
||||
if effective_domain.endswith('.' + primary_domain):
|
||||
existing = self._load_dns_records(primary_domain)
|
||||
cleaned = [r for r in existing if r.get('name', '') not in _stale]
|
||||
@@ -249,6 +251,25 @@ class NetworkManager(BaseServiceManager):
|
||||
pass
|
||||
return '10.0.0.1'
|
||||
|
||||
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
|
||||
def _get_service_subdomains(self) -> List[str]:
|
||||
"""Return all service subdomains from the registry, or a hardcoded fallback."""
|
||||
registry = getattr(self, "_service_registry", None)
|
||||
if registry is not None:
|
||||
try:
|
||||
subs: List[str] = []
|
||||
for route in registry.get_caddy_routes():
|
||||
for sub in [route['subdomain']] + list(route.get('extra_subdomains') or []):
|
||||
if self._SUBDOMAIN_RE.match(sub):
|
||||
subs.append(sub)
|
||||
else:
|
||||
logger.warning('_get_service_subdomains: skipping invalid subdomain %r', sub)
|
||||
return subs
|
||||
except Exception as exc:
|
||||
logger.warning('_get_service_subdomains: registry error: %s', exc)
|
||||
return ['calendar', 'files', 'mail', 'webmail', 'webdav']
|
||||
|
||||
def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
|
||||
"""Build the standard set of DNS A records.
|
||||
|
||||
@@ -258,16 +279,14 @@ class NetworkManager(BaseServiceManager):
|
||||
routes requests to the correct backend by Host header.
|
||||
"""
|
||||
wg_ip = self._get_wg_server_ip()
|
||||
return [
|
||||
{'name': cell_name, 'type': 'A', 'value': wg_ip},
|
||||
{'name': 'api', 'type': 'A', 'value': wg_ip},
|
||||
{'name': 'webui', 'type': 'A', 'value': wg_ip},
|
||||
{'name': 'calendar', 'type': 'A', 'value': wg_ip},
|
||||
{'name': 'files', 'type': 'A', 'value': wg_ip},
|
||||
{'name': 'mail', 'type': 'A', 'value': wg_ip},
|
||||
{'name': 'webmail', 'type': 'A', 'value': wg_ip},
|
||||
{'name': 'webdav', 'type': 'A', 'value': wg_ip},
|
||||
records = [
|
||||
{'name': cell_name, 'type': 'A', 'value': wg_ip},
|
||||
{'name': 'api', 'type': 'A', 'value': wg_ip},
|
||||
{'name': 'webui', 'type': 'A', 'value': wg_ip},
|
||||
]
|
||||
for sub in self._get_service_subdomains():
|
||||
records.append({'name': sub, 'type': 'A', 'value': wg_ip})
|
||||
return records
|
||||
|
||||
def get_dns_records(self, zone: str = 'cell') -> List[Dict]:
|
||||
"""Get all DNS records across all zones"""
|
||||
@@ -595,7 +614,7 @@ class NetworkManager(BaseServiceManager):
|
||||
if not new_name:
|
||||
return {'restarted': restarted, 'warnings': warnings}
|
||||
# Exclude service names, wildcard, and apex from cell-hostname detection.
|
||||
_service_names = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
|
||||
_service_names = {'api', 'webui'} | set(self._get_service_subdomains())
|
||||
_reserved = _service_names | {'@', '*'}
|
||||
changed = False
|
||||
try:
|
||||
|
||||
@@ -784,8 +784,8 @@ def apply_pending_config():
|
||||
@bp.route('/api/config/backup', methods=['POST'])
|
||||
def create_config_backup():
|
||||
try:
|
||||
from app import config_manager, service_bus, EventType
|
||||
backup_id = config_manager.backup_config()
|
||||
from app import config_manager, service_bus, service_registry, EventType
|
||||
backup_id = config_manager.backup_config(service_registry=service_registry)
|
||||
service_bus.publish_event(EventType.BACKUP_CREATED, 'api', {
|
||||
'backup_id': backup_id,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
@@ -809,9 +809,14 @@ def list_config_backups():
|
||||
@bp.route('/api/config/restore/<backup_id>', methods=['POST'])
|
||||
def restore_config(backup_id):
|
||||
try:
|
||||
from app import config_manager, service_bus, EventType
|
||||
from app import config_manager, service_bus, service_registry, EventType
|
||||
data = request.get_json(silent=True) or {}
|
||||
success = config_manager.restore_config(backup_id, services=data.get('services'))
|
||||
services = data.get('services')
|
||||
success = config_manager.restore_config(
|
||||
backup_id,
|
||||
services=services,
|
||||
service_registry=service_registry if services is None else None,
|
||||
)
|
||||
if success:
|
||||
service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', {
|
||||
'backup_id': backup_id,
|
||||
|
||||
@@ -125,6 +125,17 @@ def add_peer():
|
||||
return jsonify({"error": f"Peer {peer_name} already exists"}), 400
|
||||
peer_added_to_registry = True
|
||||
|
||||
# Store credentials only after the peer is committed — avoids orphaned
|
||||
# credential entries if peer_registry.add_peer rejects a duplicate name.
|
||||
try:
|
||||
from app import account_manager
|
||||
_svc_names = {'email', 'calendar', 'files'}
|
||||
for svc in provisioned:
|
||||
if svc in _svc_names:
|
||||
account_manager.store_credentials(svc, peer_name, {'password': password})
|
||||
except Exception as _am_err:
|
||||
logger.warning(f"Peer {peer_name}: credential storage failed (non-fatal): {_am_err}")
|
||||
|
||||
firewall_manager.apply_peer_rules(peer_info['ip'], peer_info,
|
||||
wg_subnet=_wg_subnet, cell_subnets=_cell_subnets)
|
||||
firewall_applied = True
|
||||
@@ -320,12 +331,46 @@ def remove_peer(peer_name):
|
||||
_cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from app import account_manager
|
||||
account_manager.deprovision_peer(peer_name)
|
||||
except Exception as _am_err:
|
||||
logger.warning(f"Peer {peer_name}: account_manager cleanup failed (non-fatal): {_am_err}")
|
||||
return jsonify({"message": f"Peer {peer_name} removed successfully"})
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing peer: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/peers/<peer_name>/service-credentials', methods=['GET'])
|
||||
def get_peer_service_credentials(peer_name: str):
|
||||
"""Return service credentials for a peer across all provisioned services (admin only).
|
||||
|
||||
Returns filled peer_config_template values for each service the peer is provisioned on.
|
||||
Intended for an admin to view or copy credentials to share with the peer during
|
||||
device setup. The global enforce_auth gate already restricts this to admin sessions.
|
||||
|
||||
Phase 2 note: a peer-self-service variant should live at /api/peer/service-credentials
|
||||
(no path arg) and restrict to session['username'] to prevent cross-peer enumeration.
|
||||
"""
|
||||
try:
|
||||
from app import peer_registry, account_manager, service_registry, config_manager
|
||||
peer = peer_registry.get_peer(peer_name)
|
||||
if not peer:
|
||||
return jsonify({'error': f'Peer {peer_name!r} not found'}), 404
|
||||
raw_creds = account_manager.get_all_credentials(peer_name)
|
||||
identity = config_manager.get_identity()
|
||||
domain = config_manager.get_effective_domain() or identity.get('domain', '')
|
||||
result = {}
|
||||
for service_id, cred in raw_creds.items():
|
||||
svc_info = service_registry.get_peer_service_info(service_id, peer_name, domain, cred)
|
||||
result[service_id] = svc_info if svc_info is not None else cred
|
||||
return jsonify({'peer': peer_name, 'services': result})
|
||||
except Exception as e:
|
||||
logger.error('get_peer_service_credentials(%s): %s', peer_name, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/peers/register', methods=['POST'])
|
||||
def register_peer():
|
||||
try:
|
||||
|
||||
@@ -6,6 +6,174 @@ from flask import Blueprint, request, jsonify
|
||||
logger = logging.getLogger('picell')
|
||||
bp = Blueprint('services', __name__)
|
||||
|
||||
@bp.route('/api/services/catalog', methods=['GET'])
|
||||
def get_services_catalog():
|
||||
"""
|
||||
Return all services (builtins + installed store packages) with merged config.
|
||||
Used by the frontend to build navigation and service pages dynamically.
|
||||
"""
|
||||
try:
|
||||
from app import service_registry
|
||||
return jsonify({'services': service_registry.list_all()})
|
||||
except Exception as e:
|
||||
logger.error('get_services_catalog: %s', e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>', methods=['GET'])
|
||||
def get_service_catalog_entry(service_id: str):
|
||||
"""Return a single service manifest+config, or 404 if unknown."""
|
||||
try:
|
||||
from app import service_registry
|
||||
svc = service_registry.get(service_id)
|
||||
if svc is None:
|
||||
return jsonify({'error': f'Service {service_id!r} not found'}), 404
|
||||
return jsonify(svc)
|
||||
except Exception as e:
|
||||
logger.error('get_service_catalog_entry(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/status', methods=['GET'])
|
||||
def get_service_container_status(service_id: str):
|
||||
"""
|
||||
Return container status for a service.
|
||||
Builtins query the main compose stack; store services query their own compose project.
|
||||
"""
|
||||
try:
|
||||
from app import service_registry, service_composer
|
||||
svc = service_registry.get(service_id)
|
||||
if svc is None:
|
||||
return jsonify({'error': f'Service {service_id!r} not found'}), 404
|
||||
result = service_composer.status_service(service_id, svc)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error('get_service_container_status(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/restart', methods=['POST'])
|
||||
def restart_service_containers(service_id: str):
|
||||
"""
|
||||
Restart containers for a service.
|
||||
Builtins restart via the main compose stack; store services via their own compose project.
|
||||
"""
|
||||
try:
|
||||
from app import service_registry, service_composer
|
||||
svc = service_registry.get(service_id)
|
||||
if svc is None:
|
||||
return jsonify({'error': f'Service {service_id!r} not found'}), 404
|
||||
result = service_composer.restart_service(service_id, svc)
|
||||
if result['ok']:
|
||||
return jsonify({'message': f'Service {service_id!r} restarted', **result})
|
||||
return jsonify({'error': result.get('stderr') or result.get('error', 'restart failed')}), 500
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error('restart_service_containers(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/reconfigure', methods=['POST'])
|
||||
def reconfigure_service(service_id: str):
|
||||
"""
|
||||
Re-apply the stored compose file for a store service (rolling `up -d`).
|
||||
The compose template must already exist on disk from the original install —
|
||||
accepting templates from the request body is deliberately not supported
|
||||
(arbitrary compose files can mount host paths or request privileged mode).
|
||||
"""
|
||||
try:
|
||||
from app import service_registry, service_composer
|
||||
svc = service_registry.get(service_id)
|
||||
if svc is None:
|
||||
return jsonify({'error': f'Service {service_id!r} not found'}), 404
|
||||
if svc.get('kind') == 'builtin':
|
||||
return jsonify({'error': 'Builtins are reconfigured via their settings routes'}), 400
|
||||
if not service_composer.has_compose_file(service_id):
|
||||
return jsonify({'error': f'No compose file for {service_id!r} — install it first'}), 400
|
||||
|
||||
result = service_composer.up(service_id)
|
||||
if result['ok']:
|
||||
return jsonify({'message': f'Service {service_id!r} reconfigured', **result})
|
||||
return jsonify({'error': result.get('stderr') or result.get('error', 'reconfigure failed')}), 500
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error('reconfigure_service(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/accounts', methods=['GET'])
|
||||
def list_service_accounts(service_id: str):
|
||||
"""Return peer usernames provisioned on a service."""
|
||||
try:
|
||||
from app import account_manager
|
||||
accounts = account_manager.list_accounts(service_id)
|
||||
return jsonify({'service_id': service_id, 'accounts': accounts})
|
||||
except Exception as e:
|
||||
logger.error('list_service_accounts(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/accounts', methods=['POST'])
|
||||
def provision_service_account(service_id: str):
|
||||
"""Provision a peer account on a service. Generates a password if none is given.
|
||||
|
||||
The generated or provided password is NOT echoed in this response — retrieve it
|
||||
separately via GET /api/services/catalog/<id>/accounts/<username>/credentials.
|
||||
This keeps passwords out of HTTP logs and browser network panels.
|
||||
"""
|
||||
try:
|
||||
from app import account_manager
|
||||
data = request.get_json(silent=True) or {}
|
||||
peer_username = data.get('username')
|
||||
if not peer_username:
|
||||
return jsonify({'error': 'username is required'}), 400
|
||||
account_manager.provision(service_id, peer_username,
|
||||
password=data.get('password'))
|
||||
return jsonify({'service_id': service_id, 'username': peer_username,
|
||||
'provisioned': True}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except RuntimeError as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
except Exception as e:
|
||||
logger.error('provision_service_account(%s): %s', service_id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/accounts/<username>', methods=['DELETE'])
|
||||
def deprovision_service_account(service_id: str, username: str):
|
||||
"""Remove a peer's account from a service."""
|
||||
try:
|
||||
from app import account_manager
|
||||
ok = account_manager.deprovision(service_id, username)
|
||||
if ok:
|
||||
return jsonify({'message': f'{username!r} deprovisioned from {service_id!r}'})
|
||||
return jsonify({'error': 'deprovision failed'}), 500
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error('deprovision_service_account(%s, %s): %s', service_id, username, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/catalog/<service_id>/accounts/<username>/credentials', methods=['GET'])
|
||||
def get_service_account_credentials(service_id: str, username: str):
|
||||
"""Return stored credentials for a peer on a service."""
|
||||
try:
|
||||
from app import account_manager
|
||||
creds = account_manager.get_credentials(service_id, username)
|
||||
if creds is None:
|
||||
return jsonify({'error': f'{username!r} not provisioned on {service_id!r}'}), 404
|
||||
return jsonify({'service_id': service_id, 'username': username, **creds})
|
||||
except Exception as e:
|
||||
logger.error('get_service_account_credentials(%s, %s): %s', service_id, username, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/services/bus/status', methods=['GET'])
|
||||
def get_service_bus_status():
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
ServiceComposer — docker-compose generation and container lifecycle for PIC services.
|
||||
|
||||
Responsibilities:
|
||||
- Render compose-template.yml → per-service docker-compose.yml with PIC_* substitution
|
||||
- Manage store-service container lifecycle (up / down / restart / status / reconfigure)
|
||||
- Manage builtin-service restarts and status via the main compose stack
|
||||
- Generate and persist PIC_SECRET_* variables in a dedicated secrets file
|
||||
|
||||
Template variable reference (for compose-template.yml authors):
|
||||
${PIC_CFG_<KEY>} — value from manifest config_schema, uppercased
|
||||
${PIC_SECRET_<NAME>} — auto-generated random secret, persisted across reconfigures
|
||||
${PIC_DOMAIN} — effective domain (e.g. cell.pic.ngo)
|
||||
${PIC_CELL_NAME} — cell name (e.g. mycell)
|
||||
${PIC_SERVICE_ID} — service identifier (e.g. nextcloud)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets as _secrets_lib
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger('picell')
|
||||
|
||||
_SECRET_RE = re.compile(r'\$\{(PIC_SECRET_\w+)\}')
|
||||
_SAFE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
|
||||
|
||||
|
||||
class ServiceComposer:
|
||||
|
||||
def __init__(self, config_manager, data_dir: str):
|
||||
self.cm = config_manager
|
||||
self.data_dir = data_dir
|
||||
self._services_dir = os.path.join(data_dir, 'services')
|
||||
self._secrets_path = os.path.join(data_dir, 'service_secrets.json')
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── Path helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _validate_service_id(service_id: str) -> None:
|
||||
"""Raise ValueError if service_id could be used for path traversal."""
|
||||
if not _SAFE_ID_RE.match(service_id):
|
||||
raise ValueError(
|
||||
f'Invalid service_id {service_id!r}: '
|
||||
'must match ^[a-z0-9][a-z0-9_-]{{0,63}}$'
|
||||
)
|
||||
|
||||
def _svc_dir(self, service_id: str) -> str:
|
||||
self._validate_service_id(service_id)
|
||||
candidate = os.path.join(self._services_dir, service_id)
|
||||
# Paranoia: ensure the resolved path stays inside _services_dir
|
||||
real_base = os.path.realpath(self._services_dir)
|
||||
real_cand = os.path.realpath(candidate)
|
||||
if not real_cand.startswith(real_base + os.sep) and real_cand != real_base:
|
||||
raise ValueError(f'service_id {service_id!r} escapes services directory')
|
||||
return candidate
|
||||
|
||||
def _compose_path(self, service_id: str) -> str:
|
||||
return os.path.join(self._svc_dir(service_id), 'docker-compose.yml')
|
||||
|
||||
def has_compose_file(self, service_id: str) -> bool:
|
||||
try:
|
||||
return os.path.exists(self._compose_path(service_id))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# ── Secrets management ────────────────────────────────────────────────
|
||||
|
||||
def _load_secrets(self) -> Dict:
|
||||
if not os.path.exists(self._secrets_path):
|
||||
return {}
|
||||
try:
|
||||
with open(self._secrets_path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning('ServiceComposer: failed to load secrets: %s', e)
|
||||
return {}
|
||||
|
||||
def _save_secrets(self, secrets: Dict) -> None:
|
||||
tmp = self._secrets_path + '.tmp'
|
||||
# 0o600: readable only by the process owner — secrets must not be world-readable
|
||||
with open(tmp, 'w',
|
||||
opener=lambda path, flags: os.open(path, flags, 0o600)) as f:
|
||||
json.dump(secrets, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, self._secrets_path)
|
||||
|
||||
def _get_or_create_secret(self, service_id: str, var_name: str) -> str:
|
||||
with self._lock:
|
||||
secrets = self._load_secrets()
|
||||
svc_secrets = secrets.setdefault(service_id, {})
|
||||
if var_name not in svc_secrets:
|
||||
svc_secrets[var_name] = _secrets_lib.token_urlsafe(24)
|
||||
self._save_secrets(secrets)
|
||||
return svc_secrets[var_name]
|
||||
|
||||
def _clear_secrets(self, service_id: str) -> None:
|
||||
with self._lock:
|
||||
secrets = self._load_secrets()
|
||||
if service_id in secrets:
|
||||
del secrets[service_id]
|
||||
self._save_secrets(secrets)
|
||||
|
||||
# ── Template rendering ────────────────────────────────────────────────
|
||||
|
||||
def render_template(self, service_id: str, manifest: Dict,
|
||||
template_content: str) -> str:
|
||||
"""
|
||||
Substitute all PIC_* variables in a compose-template.yml string.
|
||||
Returns the rendered compose YAML.
|
||||
"""
|
||||
schema = manifest.get('config_schema') or {}
|
||||
saved = self.cm.configs.get(service_id, {})
|
||||
config: Dict = {k: v['default'] for k, v in schema.items() if 'default' in v}
|
||||
config.update({k: saved[k] for k in schema if k in saved})
|
||||
|
||||
identity = self.cm.get_identity()
|
||||
domain = self.cm.get_effective_domain() or identity.get('domain', 'cell.local')
|
||||
cell_name = identity.get('cell_name', 'mycell')
|
||||
|
||||
result = template_content
|
||||
|
||||
for key, value in config.items():
|
||||
# Strip newlines/tabs to prevent YAML injection (a config string containing
|
||||
# \n could inject new YAML keys into the compose file)
|
||||
safe_val = str(value).replace('\n', '').replace('\r', '').replace('\t', ' ')
|
||||
result = result.replace(f'${{PIC_CFG_{key.upper()}}}', safe_val)
|
||||
|
||||
result = result.replace('${PIC_DOMAIN}', domain)
|
||||
result = result.replace('${PIC_CELL_NAME}', cell_name)
|
||||
result = result.replace('${PIC_SERVICE_ID}', service_id)
|
||||
|
||||
# PIC_SECRET_* — generate on first use, reuse on reconfigure
|
||||
for match in _SECRET_RE.finditer(template_content):
|
||||
var_name = match.group(1)
|
||||
secret = self._get_or_create_secret(service_id, var_name)
|
||||
result = result.replace(f'${{{var_name}}}', secret)
|
||||
|
||||
return result
|
||||
|
||||
def write_compose(self, service_id: str, manifest: Dict,
|
||||
template_content: str) -> str:
|
||||
"""Render and atomically write the per-service compose file. Returns rendered content."""
|
||||
os.makedirs(self._svc_dir(service_id), exist_ok=True)
|
||||
content = self.render_template(service_id, manifest, template_content)
|
||||
path = self._compose_path(service_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 compose file for %s', service_id)
|
||||
return content
|
||||
|
||||
# ── Subprocess helper ─────────────────────────────────────────────────
|
||||
|
||||
def _run(self, cmd: List[str], timeout: int = 120) -> Dict:
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
if r.returncode != 0 and r.stderr:
|
||||
logger.warning('ServiceComposer command failed: %s', r.stderr.strip())
|
||||
return {
|
||||
'ok': r.returncode == 0,
|
||||
'stdout': r.stdout.strip(),
|
||||
'stderr': r.stderr.strip(),
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {'ok': False, 'error': 'docker compose command timed out'}
|
||||
except Exception as e:
|
||||
logger.error('ServiceComposer._run error: %s', e)
|
||||
return {'ok': False, 'error': str(e)}
|
||||
|
||||
@staticmethod
|
||||
def _parse_ps_json(output: str) -> List[Dict]:
|
||||
"""Parse `docker compose ps --format json` output (one JSON object per line)."""
|
||||
containers = []
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
containers.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return containers
|
||||
|
||||
# ── Store-service lifecycle (per-service compose file) ────────────────
|
||||
|
||||
def _store_cmd(self, service_id: str, *args, timeout: int = 120) -> Dict:
|
||||
compose_file = self._compose_path(service_id)
|
||||
if not os.path.exists(compose_file):
|
||||
return {'ok': False, 'error': f'No compose file found for service {service_id!r}'}
|
||||
cmd = [
|
||||
'docker', 'compose',
|
||||
'-f', compose_file,
|
||||
'--project-name', f'pic-{service_id}',
|
||||
*args,
|
||||
]
|
||||
return self._run(cmd, timeout)
|
||||
|
||||
def up(self, service_id: str) -> Dict:
|
||||
# 600s: image pulls on slow connections can take several minutes
|
||||
return self._store_cmd(service_id, 'up', '-d', '--remove-orphans', timeout=600)
|
||||
|
||||
def down(self, service_id: str, remove_volumes: bool = False) -> Dict:
|
||||
args = ['down']
|
||||
if remove_volumes:
|
||||
args.append('--volumes')
|
||||
return self._store_cmd(service_id, *args)
|
||||
|
||||
def restart(self, service_id: str) -> Dict:
|
||||
return self._store_cmd(service_id, 'restart')
|
||||
|
||||
def status(self, service_id: str) -> Dict:
|
||||
result = self._store_cmd(service_id, 'ps', '--format', 'json')
|
||||
result['containers'] = self._parse_ps_json(result.get('stdout', ''))
|
||||
return result
|
||||
|
||||
def reconfigure(self, service_id: str, manifest: Dict,
|
||||
template_content: str) -> Dict:
|
||||
"""Re-render the compose file then re-apply with `up -d` (rolling update)."""
|
||||
self.write_compose(service_id, manifest, template_content)
|
||||
return self.up(service_id)
|
||||
|
||||
def install(self, service_id: str, manifest: Dict,
|
||||
template_content: str) -> Dict:
|
||||
"""Write compose file and start containers."""
|
||||
self.write_compose(service_id, manifest, template_content)
|
||||
return self.up(service_id)
|
||||
|
||||
def remove(self, service_id: str, purge_data: bool = False) -> Dict:
|
||||
"""Stop containers, optionally delete compose file, secrets, and service data dir."""
|
||||
result = self.down(service_id, remove_volumes=purge_data)
|
||||
if purge_data:
|
||||
self._clear_secrets(service_id)
|
||||
svc_dir = self._svc_dir(service_id) # already validates service_id + realpath
|
||||
if os.path.isdir(svc_dir):
|
||||
# Final realpath check: reject symlinks that escape the services dir
|
||||
real_svc = os.path.realpath(svc_dir)
|
||||
real_base = os.path.realpath(self._services_dir)
|
||||
if not real_svc.startswith(real_base + os.sep):
|
||||
logger.error('ServiceComposer: refusing rmtree outside services dir: %s', svc_dir)
|
||||
else:
|
||||
try:
|
||||
shutil.rmtree(svc_dir)
|
||||
except OSError as e:
|
||||
logger.warning('ServiceComposer: could not remove %s: %s', svc_dir, e)
|
||||
elif os.path.exists(self._compose_path(service_id)):
|
||||
# Remove compose file even without purge so stale file doesn't confuse future installs
|
||||
try:
|
||||
os.remove(self._compose_path(service_id))
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
# ── Builtin-service lifecycle (main compose stack) ─────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _main_compose() -> str:
|
||||
return os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml')
|
||||
|
||||
def restart_builtin(self, container_names: List[str]) -> Dict:
|
||||
"""Restart one or more containers that live in the main docker-compose stack."""
|
||||
if not container_names:
|
||||
return {'ok': False, 'error': 'No container names provided'}
|
||||
cmd = ['docker', 'compose', '-f', self._main_compose(),
|
||||
'restart', *container_names]
|
||||
return self._run(cmd)
|
||||
|
||||
def status_builtin(self, container_names: List[str]) -> Dict:
|
||||
"""Return status of containers from the main compose stack."""
|
||||
if not container_names:
|
||||
return {'ok': False, 'error': 'No container names provided'}
|
||||
cmd = ['docker', 'compose', '-f', self._main_compose(),
|
||||
'ps', '--format', 'json', *container_names]
|
||||
result = self._run(cmd)
|
||||
result['containers'] = self._parse_ps_json(result.get('stdout', ''))
|
||||
return result
|
||||
|
||||
# ── Unified lifecycle (dispatches based on service kind) ───────────────
|
||||
|
||||
def restart_service(self, service_id: str, manifest: Dict) -> Dict:
|
||||
"""
|
||||
Restart any service — builtin or store — using the right compose stack.
|
||||
Builtin: uses manifest.containers + main docker-compose.yml.
|
||||
Store: uses per-service compose file.
|
||||
"""
|
||||
if manifest.get('kind') == 'builtin':
|
||||
containers = manifest.get('containers') or []
|
||||
return self.restart_builtin(containers)
|
||||
return self.restart(service_id)
|
||||
|
||||
def status_service(self, service_id: str, manifest: Dict) -> Dict:
|
||||
"""
|
||||
Return container status for any service.
|
||||
Builtin: queries manifest.containers from main compose stack.
|
||||
Store: queries per-service compose project.
|
||||
"""
|
||||
if manifest.get('kind') == 'builtin':
|
||||
containers = manifest.get('containers') or []
|
||||
return self.status_builtin(containers)
|
||||
return self.status(service_id)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
ServiceRegistry — single source of truth for all PIC services.
|
||||
|
||||
Merges three layers:
|
||||
1. Manifest defaults (config_schema.*.default)
|
||||
2. Admin-saved config from ConfigManager (cell_config.json)
|
||||
3. Runtime state from installed store records
|
||||
|
||||
All consumers (CaddyManager, backup, peer services endpoint) read from here
|
||||
rather than hardcoding service names or subdomains.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote as _urlquote
|
||||
|
||||
logger = logging.getLogger('picell')
|
||||
|
||||
# Built-ins are baked into the container image at build time.
|
||||
# Do not bind-mount this path read-write in docker-compose.
|
||||
_BUILTINS_DIR = os.path.join(os.path.dirname(__file__), 'services', 'builtins')
|
||||
|
||||
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
_BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$')
|
||||
_RESERVED_SUBS = frozenset({'api', 'webui', 'admin', 'www', 'ns1', 'ns2', 'git', 'registry', 'install'})
|
||||
|
||||
|
||||
class ServiceRegistry:
|
||||
|
||||
def __init__(self, config_manager):
|
||||
self._cm = config_manager
|
||||
|
||||
# ── Manifest loading ──────────────────────────────────────────────────
|
||||
|
||||
def _load_manifest(self, path: str) -> Optional[Dict]:
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning('ServiceRegistry: failed to load manifest %s: %s', path, e)
|
||||
return None
|
||||
|
||||
def _builtin_ids(self) -> List[str]:
|
||||
if not os.path.isdir(_BUILTINS_DIR):
|
||||
return []
|
||||
return sorted(
|
||||
d for d in os.listdir(_BUILTINS_DIR)
|
||||
if os.path.isfile(os.path.join(_BUILTINS_DIR, d, 'manifest.json'))
|
||||
)
|
||||
|
||||
def _builtin_manifest(self, service_id: str) -> Optional[Dict]:
|
||||
return self._load_manifest(
|
||||
os.path.join(_BUILTINS_DIR, service_id, 'manifest.json')
|
||||
)
|
||||
|
||||
# ── Config merging ────────────────────────────────────────────────────
|
||||
|
||||
_TYPE_COERCIONS = {'integer': int, 'string': str, 'boolean': bool}
|
||||
|
||||
def _merged_config(self, manifest: Dict) -> Dict:
|
||||
"""Return manifest defaults overridden by admin-saved values, type-coerced."""
|
||||
svc_id = manifest.get('id', '')
|
||||
saved = self._cm.configs.get(svc_id, {})
|
||||
schema = manifest.get('config_schema') or {}
|
||||
merged = {k: v['default'] for k, v in schema.items() if 'default' in v}
|
||||
for k, spec in schema.items():
|
||||
if k not in saved:
|
||||
continue
|
||||
raw = saved[k]
|
||||
coerce = self._TYPE_COERCIONS.get(spec.get('type', ''))
|
||||
if coerce is not None:
|
||||
try:
|
||||
raw = coerce(raw)
|
||||
except (TypeError, ValueError):
|
||||
raw = merged.get(k, raw)
|
||||
merged[k] = raw
|
||||
return merged
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
def get(self, service_id: str) -> Optional[Dict]:
|
||||
"""Return manifest + merged config for one service, or None if unknown."""
|
||||
manifest = self._builtin_manifest(service_id)
|
||||
if manifest is None:
|
||||
record = self._cm.get_installed_services().get(service_id)
|
||||
if record:
|
||||
manifest = record.get('manifest')
|
||||
if not manifest:
|
||||
return None
|
||||
return {**manifest, 'config': self._merged_config(manifest)}
|
||||
|
||||
def list_all(self) -> List[Dict]:
|
||||
"""
|
||||
Return all services — builtins first, then installed store services —
|
||||
each with merged config attached as the 'config' key.
|
||||
"""
|
||||
results: List[Dict] = []
|
||||
seen: set = set()
|
||||
|
||||
for svc_id in self._builtin_ids():
|
||||
manifest = self._builtin_manifest(svc_id)
|
||||
if manifest:
|
||||
results.append({**manifest, 'config': self._merged_config(manifest)})
|
||||
seen.add(svc_id)
|
||||
|
||||
for svc_id, record in self._cm.get_installed_services().items():
|
||||
if svc_id in seen:
|
||||
continue
|
||||
manifest = record.get('manifest') or {}
|
||||
if manifest.get('id'):
|
||||
results.append({**manifest, 'config': self._merged_config(manifest)})
|
||||
|
||||
return results
|
||||
|
||||
def get_caddy_routes(self) -> List[Dict]:
|
||||
"""
|
||||
Return routing info for all services that have a subdomain.
|
||||
Used by CaddyManager to build service blocks without hardcoding.
|
||||
|
||||
Values are validated here as a chokepoint so Caddyfile/DNS builders
|
||||
can safely interpolate them regardless of how manifests reached disk.
|
||||
"""
|
||||
routes = []
|
||||
for svc in self.list_all():
|
||||
caps = svc.get('capabilities') or {}
|
||||
if not caps.get('has_subdomain'):
|
||||
continue
|
||||
sub = svc.get('subdomain', '')
|
||||
bknd = svc.get('backend', '')
|
||||
if not sub or not bknd:
|
||||
continue
|
||||
svc_id = svc.get('id', '?')
|
||||
if not _SUBDOMAIN_RE.match(sub) or sub in _RESERVED_SUBS:
|
||||
logger.warning('ServiceRegistry: skipping %s — invalid/reserved subdomain %r', svc_id, sub)
|
||||
continue
|
||||
if not _BACKEND_RE.match(bknd):
|
||||
logger.warning('ServiceRegistry: skipping %s — invalid backend %r', svc_id, bknd)
|
||||
continue
|
||||
extra_subs = [
|
||||
s for s in (svc.get('extra_subdomains') or [])
|
||||
if isinstance(s, str) and _SUBDOMAIN_RE.match(s) and s not in _RESERVED_SUBS
|
||||
]
|
||||
extra_backends = {
|
||||
k: v for k, v in (svc.get('extra_backends') or {}).items()
|
||||
if (isinstance(k, str) and _SUBDOMAIN_RE.match(k) and k not in _RESERVED_SUBS
|
||||
and isinstance(v, str) and _BACKEND_RE.match(v))
|
||||
}
|
||||
routes.append({
|
||||
'service_id': svc_id,
|
||||
'subdomain': sub,
|
||||
'backend': bknd,
|
||||
'extra_subdomains': extra_subs,
|
||||
'extra_backends': extra_backends,
|
||||
})
|
||||
return routes
|
||||
|
||||
def get_backup_plan(self) -> List[Dict]:
|
||||
"""
|
||||
Return backup declarations for all services that have storage.
|
||||
Used by the backup system instead of hardcoded file lists.
|
||||
|
||||
Each entry:
|
||||
service_id — service identifier
|
||||
volumes — list of {container, path, name} for docker-exec streaming
|
||||
config_paths — host-relative paths copied directly (config files)
|
||||
"""
|
||||
plan = []
|
||||
for svc in self.list_all():
|
||||
caps = svc.get('capabilities') or {}
|
||||
if not caps.get('has_storage'):
|
||||
continue
|
||||
backup = svc.get('backup') or {}
|
||||
volumes = backup.get('volumes') or []
|
||||
config_paths = backup.get('config_paths') or []
|
||||
if not volumes and not config_paths:
|
||||
continue
|
||||
plan.append({
|
||||
'service_id': svc['id'],
|
||||
'volumes': volumes,
|
||||
'config_paths': config_paths,
|
||||
})
|
||||
return plan
|
||||
|
||||
def get_peer_service_info(self, service_id: str, peer_username: str,
|
||||
domain: str, credentials: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Fill peer_config_template for one service+peer combination.
|
||||
credentials: dict of {field_name: value} for that peer+service.
|
||||
Returns None if service unknown or has no peer template.
|
||||
"""
|
||||
svc = self.get(service_id)
|
||||
if not svc:
|
||||
return None
|
||||
template = svc.get('peer_config_template')
|
||||
if not template:
|
||||
return None
|
||||
|
||||
# URL-safe peer username (safe='') — prevents path traversal in CalDAV/WebDAV URLs
|
||||
safe_username = _urlquote(peer_username, safe='')
|
||||
|
||||
result = {}
|
||||
for key, raw in template.items():
|
||||
val = raw
|
||||
val = val.replace('{domain}', domain)
|
||||
val = val.replace('{peer.username}', safe_username)
|
||||
for field, cred_val in credentials.items():
|
||||
val = val.replace(
|
||||
'{peer.service_credentials.' + service_id + '.' + field + '}',
|
||||
str(cred_val) if cred_val is not None else '',
|
||||
)
|
||||
cfg = svc.get('config') or {}
|
||||
for cfg_key, cfg_val in cfg.items():
|
||||
val = val.replace('{config.' + cfg_key + '}', str(cfg_val) if cfg_val is not None else '')
|
||||
result[key] = val
|
||||
return result
|
||||
@@ -51,6 +51,8 @@ RESERVED_SUBDOMAINS = frozenset([
|
||||
'git', 'registry', 'install',
|
||||
])
|
||||
ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$')
|
||||
SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
||||
BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -141,19 +143,55 @@ class ServiceStoreManager(BaseServiceManager):
|
||||
f'iptables_rules[].proto must be tcp or udp, got: {proto}'
|
||||
)
|
||||
|
||||
# Caddy route subdomain
|
||||
# Legacy caddy_route dict subdomain (for store manifests using the old format)
|
||||
caddy_route = m.get('caddy_route') or {}
|
||||
if isinstance(caddy_route, dict):
|
||||
subdomain = caddy_route.get('subdomain', '')
|
||||
legacy_sub = caddy_route.get('subdomain', '')
|
||||
else:
|
||||
subdomain = ''
|
||||
if subdomain:
|
||||
if subdomain in RESERVED_SUBDOMAINS:
|
||||
errors.append(f'caddy_route.subdomain is reserved: {subdomain}')
|
||||
elif not re.match(r'^[a-z][a-z0-9-]{0,30}$', subdomain):
|
||||
legacy_sub = ''
|
||||
if legacy_sub:
|
||||
if legacy_sub in RESERVED_SUBDOMAINS:
|
||||
errors.append(f'caddy_route.subdomain is reserved: {legacy_sub}')
|
||||
elif not SUBDOMAIN_RE.match(legacy_sub):
|
||||
errors.append(
|
||||
f'caddy_route.subdomain must match ^[a-z][a-z0-9-]{{0,30}}$, '
|
||||
f'got: {subdomain}'
|
||||
f'got: {legacy_sub}'
|
||||
)
|
||||
|
||||
# Top-level subdomain + backend (consumed by ServiceRegistry.get_caddy_routes)
|
||||
subdomain = m.get('subdomain', '')
|
||||
if subdomain:
|
||||
if subdomain in RESERVED_SUBDOMAINS:
|
||||
errors.append(f'subdomain is reserved: {subdomain}')
|
||||
elif not SUBDOMAIN_RE.match(subdomain):
|
||||
errors.append(
|
||||
f'subdomain must match ^[a-z][a-z0-9-]{{0,30}}$, got: {subdomain}'
|
||||
)
|
||||
|
||||
backend = m.get('backend', '')
|
||||
if backend and not BACKEND_RE.match(backend):
|
||||
errors.append(f'backend must be host:port (e.g. cell-foo:8080), got: {backend}')
|
||||
|
||||
for sub in m.get('extra_subdomains') or []:
|
||||
if not isinstance(sub, str):
|
||||
errors.append('extra_subdomains entries must be strings')
|
||||
elif sub in RESERVED_SUBDOMAINS:
|
||||
errors.append(f'extra_subdomains entry is reserved: {sub}')
|
||||
elif not SUBDOMAIN_RE.match(sub):
|
||||
errors.append(
|
||||
f'extra_subdomains entry must match ^[a-z][a-z0-9-]{{0,30}}$, got: {sub}'
|
||||
)
|
||||
|
||||
for sub, bknd in (m.get('extra_backends') or {}).items():
|
||||
if not isinstance(sub, str) or not SUBDOMAIN_RE.match(sub):
|
||||
errors.append(
|
||||
f'extra_backends key must match ^[a-z][a-z0-9-]{{0,30}}$, got: {sub!r}'
|
||||
)
|
||||
elif sub in RESERVED_SUBDOMAINS:
|
||||
errors.append(f'extra_backends key is reserved: {sub}')
|
||||
if not isinstance(bknd, str) or not BACKEND_RE.match(bknd):
|
||||
errors.append(
|
||||
f'extra_backends[{sub!r}] value must be host:port, got: {bknd!r}'
|
||||
)
|
||||
|
||||
# Env value safety
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "calendar",
|
||||
"name": "Calendar & Contacts",
|
||||
"description": "Radicale CalDAV / CardDAV server",
|
||||
"version": "1.0.0",
|
||||
"author": "pic",
|
||||
"kind": "builtin",
|
||||
"min_pic_version": "1.0",
|
||||
|
||||
"capabilities": {
|
||||
"has_subdomain": true,
|
||||
"has_accounts": true,
|
||||
"has_admin_config": true,
|
||||
"has_storage": true,
|
||||
"has_egress": true,
|
||||
"has_api_hooks": false
|
||||
},
|
||||
|
||||
"subdomain": "calendar",
|
||||
"extra_subdomains": [],
|
||||
"backend": "cell-radicale:5232",
|
||||
|
||||
"containers": ["cell-radicale"],
|
||||
|
||||
"config_schema": {
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"label": "CalDAV port (internal)",
|
||||
"default": 5232,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
}
|
||||
},
|
||||
|
||||
"peer_config_template": {
|
||||
"caldav_url": "https://calendar.{domain}/{peer.username}/",
|
||||
"carddav_url": "https://calendar.{domain}/{peer.username}/",
|
||||
"username": "{peer.username}",
|
||||
"password": "{peer.service_credentials.calendar.password}"
|
||||
},
|
||||
|
||||
"accounts": {
|
||||
"manager": "calendar_manager",
|
||||
"credentials": ["password"]
|
||||
},
|
||||
|
||||
"compose": null,
|
||||
|
||||
"backup": {
|
||||
"volumes": [
|
||||
{"container": "cell-radicale", "path": "/data", "name": "radicale_data"}
|
||||
],
|
||||
"config_paths": [
|
||||
"config/radicale"
|
||||
]
|
||||
},
|
||||
|
||||
"egress": {
|
||||
"default": "default",
|
||||
"allowed": ["default", "wireguard_ext", "openvpn", "tor"]
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"primary_path": "data/radicale",
|
||||
"quota_mb": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "email",
|
||||
"name": "Email",
|
||||
"description": "Postfix (SMTP) + Dovecot (IMAP) email server with Rainloop webmail",
|
||||
"version": "1.0.0",
|
||||
"author": "pic",
|
||||
"kind": "builtin",
|
||||
"min_pic_version": "1.0",
|
||||
|
||||
"capabilities": {
|
||||
"has_subdomain": true,
|
||||
"has_accounts": true,
|
||||
"has_admin_config": true,
|
||||
"has_storage": true,
|
||||
"has_egress": true,
|
||||
"has_api_hooks": false
|
||||
},
|
||||
|
||||
"subdomain": "mail",
|
||||
"extra_subdomains": ["webmail"],
|
||||
"backend": "cell-rainloop:8888",
|
||||
|
||||
"containers": ["cell-mail", "cell-rainloop"],
|
||||
|
||||
"config_schema": {
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"label": "Mail domain",
|
||||
"required": true
|
||||
},
|
||||
"smtp_port": {
|
||||
"type": "integer",
|
||||
"label": "SMTP port",
|
||||
"default": 25,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"submission_port": {
|
||||
"type": "integer",
|
||||
"label": "Submission port",
|
||||
"default": 587,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"imap_port": {
|
||||
"type": "integer",
|
||||
"label": "IMAP port",
|
||||
"default": 993,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"webmail_port": {
|
||||
"type": "integer",
|
||||
"label": "Webmail port (internal)",
|
||||
"default": 8888,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
}
|
||||
},
|
||||
|
||||
"peer_config_template": {
|
||||
"imap_server": "{domain}",
|
||||
"imap_port": "{config.imap_port}",
|
||||
"smtp_server": "{domain}",
|
||||
"smtp_port": "{config.submission_port}",
|
||||
"webmail_url": "https://mail.{domain}/",
|
||||
"username": "{peer.username}@{domain}",
|
||||
"password": "{peer.service_credentials.email.password}"
|
||||
},
|
||||
|
||||
"accounts": {
|
||||
"manager": "email_manager",
|
||||
"credentials": ["password"]
|
||||
},
|
||||
|
||||
"compose": null,
|
||||
|
||||
"backup": {
|
||||
"volumes": [
|
||||
{"container": "cell-mail", "path": "/var/mail", "name": "maildata"},
|
||||
{"container": "cell-mail", "path": "/var/mail-state", "name": "mailstate"},
|
||||
{"container": "cell-rainloop", "path": "/rainloop/data", "name": "rainloop"}
|
||||
],
|
||||
"config_paths": [
|
||||
"config/mail"
|
||||
]
|
||||
},
|
||||
|
||||
"egress": {
|
||||
"default": "default",
|
||||
"allowed": ["default", "wireguard_ext", "openvpn", "tor"]
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"primary_path": "data/maildata",
|
||||
"quota_mb": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "files",
|
||||
"name": "File Storage",
|
||||
"description": "FileGator browser UI + WebDAV network drive",
|
||||
"version": "1.0.0",
|
||||
"author": "pic",
|
||||
"kind": "builtin",
|
||||
"min_pic_version": "1.0",
|
||||
|
||||
"capabilities": {
|
||||
"has_subdomain": true,
|
||||
"has_accounts": true,
|
||||
"has_admin_config": true,
|
||||
"has_storage": true,
|
||||
"has_egress": true,
|
||||
"has_api_hooks": false
|
||||
},
|
||||
|
||||
"subdomain": "files",
|
||||
"extra_subdomains": ["webdav"],
|
||||
"backend": "cell-filegator:8080",
|
||||
"extra_backends": {
|
||||
"webdav": "cell-webdav:80"
|
||||
},
|
||||
|
||||
"containers": ["cell-filegator", "cell-webdav"],
|
||||
|
||||
"config_schema": {
|
||||
"manager_port": {
|
||||
"type": "integer",
|
||||
"label": "FileGator port (internal)",
|
||||
"default": 8082,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"label": "WebDAV port (internal)",
|
||||
"default": 8080,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
}
|
||||
},
|
||||
|
||||
"peer_config_template": {
|
||||
"files_url": "https://files.{domain}/",
|
||||
"webdav_url": "https://webdav.{domain}/",
|
||||
"username": "{peer.username}",
|
||||
"password": "{peer.service_credentials.files.password}"
|
||||
},
|
||||
|
||||
"accounts": {
|
||||
"manager": "file_manager",
|
||||
"credentials": ["password"]
|
||||
},
|
||||
|
||||
"compose": null,
|
||||
|
||||
"backup": {
|
||||
"volumes": [
|
||||
{"container": "cell-filegator", "path": "/var/www/filegator/private", "name": "filegator"},
|
||||
{"container": "cell-webdav", "path": "/var/lib/dav", "name": "files"}
|
||||
],
|
||||
"config_paths": [
|
||||
"config/webdav"
|
||||
]
|
||||
},
|
||||
|
||||
"egress": {
|
||||
"default": "default",
|
||||
"allowed": ["default", "wireguard_ext", "openvpn", "tor"]
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"primary_path": "data/files",
|
||||
"quota_mb": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
# PIC Service Developer Guide
|
||||
|
||||
This guide is for developers who want to build services that integrate with Personal Internet Cell (PIC). It covers the manifest format, how PIC wires up routing, DNS, backup, and account provisioning for your service, and how to package and submit your work.
|
||||
|
||||
**Prerequisites:** you should be comfortable with Docker, Docker Compose, and basic Linux networking. You do not need to know Python to build a store service.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [What a PIC service is](#1-what-a-pic-service-is)
|
||||
2. [Manifest reference](#2-manifest-reference)
|
||||
3. [Compose template variables](#3-compose-template-variables)
|
||||
4. [Account provisioning interface](#4-account-provisioning-interface)
|
||||
5. [Backup integration](#5-backup-integration)
|
||||
6. [Egress routing](#6-egress-routing)
|
||||
7. [Quick-start example](#7-quick-start-example)
|
||||
8. [Submitting to the store](#8-submitting-to-the-store)
|
||||
|
||||
---
|
||||
|
||||
## 1. What a PIC service is
|
||||
|
||||
A PIC service is a Docker container (or a set of containers) that plugs into the PIC ecosystem through a single JSON file called the **manifest**. The manifest tells PIC everything it needs to know:
|
||||
|
||||
- How to route HTTPS traffic to the service through Caddy
|
||||
- What subdomains to expose
|
||||
- Which users get accounts on the service and what credentials they receive
|
||||
- Which paths to include in automated backups
|
||||
- Which outbound network interfaces the service is allowed to use
|
||||
|
||||
PIC has two kinds of services:
|
||||
|
||||
**Builtins** ship with PIC itself (email, calendar, files). Their containers are declared in the main `docker-compose.yml`. Their manifests live under `api/services/builtins/<id>/manifest.json`. They use Python manager classes for account operations because they run inside the same Docker network and share internal access.
|
||||
|
||||
**Store services** are third-party packages installed by the cell admin from the PIC service store. PIC downloads the manifest from the store index, allocates a static IP for the container in the service pool (`172.20.x.x`, offsets 20–254 within your `ip_range` subnet), generates a Docker Compose override file, and starts the container. Store services use a small HTTP API (described in section 4) for account operations.
|
||||
|
||||
The `ServiceRegistry` in `api/service_registry.py` is the single source of truth for both kinds. `CaddyManager`, the backup system, and the peer services endpoint all read from it rather than from hardcoded lists.
|
||||
|
||||
---
|
||||
|
||||
## 2. Manifest reference
|
||||
|
||||
The manifest is a JSON file with `"schema_version": 3`. Every field is described below. The three builtin manifests (`email`, `calendar`, `files`) are the canonical examples.
|
||||
|
||||
### Top-level identity fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `schema_version` | integer | yes | Must be `3`. |
|
||||
| `id` | string | yes | Unique service identifier, lowercase, no spaces (e.g. `"notes"`). Must match the directory name for builtins, or the store index entry for store services. |
|
||||
| `name` | string | yes | Human-readable display name (e.g. `"Notes"`). |
|
||||
| `description` | string | yes | One-sentence description shown in the UI. |
|
||||
| `version` | string | yes | Semver string for the service package itself (e.g. `"1.0.0"`). |
|
||||
| `author` | string | yes | Your name or organisation. |
|
||||
| `kind` | string | yes | `"builtin"` for built-in services, `"store"` for third-party packages. |
|
||||
| `min_pic_version` | string | no | Minimum PIC version required (e.g. `"1.0"`). |
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "notes",
|
||||
"name": "Notes",
|
||||
"description": "Self-hosted Markdown notes with full-text search",
|
||||
"version": "1.0.0",
|
||||
"author": "acme",
|
||||
"kind": "store",
|
||||
"min_pic_version": "1.0"
|
||||
}
|
||||
```
|
||||
|
||||
### `capabilities`
|
||||
|
||||
A set of boolean flags that tell PIC which integrations to activate for your service.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `has_subdomain` | bool | `false` | The service gets a subdomain and a Caddy reverse-proxy route. Requires `subdomain` and `backend`. |
|
||||
| `has_accounts` | bool | `false` | The service provisions per-peer accounts. Requires `accounts`. |
|
||||
| `has_admin_config` | bool | `false` | The service has admin-configurable fields. Requires `config_schema`. |
|
||||
| `has_storage` | bool | `false` | The service has data worth backing up. Requires `backup`. |
|
||||
| `has_egress` | bool | `false` | The admin can choose which outbound interface this service uses. Requires `egress`. |
|
||||
| `has_api_hooks` | bool | `false` | Reserved for future use; set `false`. |
|
||||
|
||||
```json
|
||||
"capabilities": {
|
||||
"has_subdomain": true,
|
||||
"has_accounts": true,
|
||||
"has_admin_config": false,
|
||||
"has_storage": true,
|
||||
"has_egress": false,
|
||||
"has_api_hooks": false
|
||||
}
|
||||
```
|
||||
|
||||
### `subdomain`, `extra_subdomains`, `backend`, `extra_backends`
|
||||
|
||||
These fields are only read when `has_subdomain` is `true`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `subdomain` | string | yes (if `has_subdomain`) | The primary subdomain (e.g. `"notes"`). Results in `notes.<cell-domain>`. Must not collide with reserved names: `api`, `webui`, `admin`, `www`, `mail`, `ns1`, `ns2`, `git`, `registry`, `install`. |
|
||||
| `extra_subdomains` | array of strings | no | Additional subdomains that point to the same backend (e.g. `["webmail"]`). |
|
||||
| `backend` | string | yes (if `has_subdomain`) | The container-name:port combination that Caddy proxies to (e.g. `"cell-notes:8080"`). Uses Docker DNS on the `cell-network`. |
|
||||
| `extra_backends` | object | no | Maps extra subdomain names to separate backends. Key is the subdomain string; value is the backend string. The email service uses this to send `webdav.*` to a different container than `files.*`. |
|
||||
|
||||
```json
|
||||
"subdomain": "notes",
|
||||
"extra_subdomains": [],
|
||||
"backend": "cell-notes:8080"
|
||||
```
|
||||
|
||||
**Validation at runtime:** `ServiceRegistry.get_caddy_routes()` validates all subdomain and backend values before passing them to CaddyManager or NetworkManager. Any entry whose `subdomain` does not match `^[a-z][a-z0-9-]{0,30}$`, whose `backend` does not match `^[A-Za-z0-9._-]+:\d{1,5}$`, or whose `subdomain` appears in the reserved list is silently skipped with a warning log. The same validation applies to `extra_subdomains` and `extra_backends` keys/values. For store services, this validation is also performed during installation by `ServiceStoreManager._validate_manifest()`.
|
||||
|
||||
### `containers`
|
||||
|
||||
Array of container names that belong to this service. Used by the UI and log viewer. For builtins this is informational; for store services PIC only manages the single container declared in the manifest.
|
||||
|
||||
```json
|
||||
"containers": ["cell-notes"]
|
||||
```
|
||||
|
||||
### `config_schema`
|
||||
|
||||
Defines admin-configurable fields for this service. When `has_admin_config` is `true`, the UI renders a settings form from this schema. PIC stores admin-saved values in `cell_config.json` and merges them with your `default` values at runtime. The merged result is available as the `config` key when `ServiceRegistry.get()` returns your service.
|
||||
|
||||
Each field is an object:
|
||||
|
||||
| Key | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `type` | string | yes | One of `"string"`, `"integer"`, `"boolean"`. |
|
||||
| `label` | string | yes | Human-readable label for the settings form. |
|
||||
| `required` | bool | no | Whether the field must have a value before the service starts. |
|
||||
| `default` | any | no | Default value used when the admin has not set one. |
|
||||
| `min` / `max` | integer | no (integer only) | Inclusive bounds for integer fields. |
|
||||
|
||||
```json
|
||||
"config_schema": {
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"label": "Internal HTTP port",
|
||||
"default": 8080,
|
||||
"min": 1,
|
||||
"max": 65535
|
||||
},
|
||||
"storage_path": {
|
||||
"type": "string",
|
||||
"label": "Data directory inside container",
|
||||
"default": "/data/notes"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `peer_config_template`
|
||||
|
||||
When a peer is provisioned on this service, PIC fills this template and returns the result to the peer as their connection info. Template substitution tokens:
|
||||
|
||||
| Token | Replaced with |
|
||||
|---|---|
|
||||
| `{domain}` | The cell's public domain (e.g. `alice.pic.ngo`) |
|
||||
| `{peer.username}` | The peer's username |
|
||||
| `{peer.service_credentials.<id>.<field>}` | A credential value; `<id>` is the service `id`, `<field>` matches a name in `accounts.credentials` |
|
||||
| `{config.<key>}` | A value from the merged `config_schema` result |
|
||||
|
||||
```json
|
||||
"peer_config_template": {
|
||||
"url": "https://notes.{domain}/",
|
||||
"username": "{peer.username}",
|
||||
"password": "{peer.service_credentials.notes.password}"
|
||||
}
|
||||
```
|
||||
|
||||
### `accounts`
|
||||
|
||||
Required when `has_accounts` is `true`.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `manager` | string | For builtins: the Python manager name used internally (`"email_manager"`, `"calendar_manager"`, `"file_manager"`). For store services: set to `"http"` to indicate the HTTP API (section 4). |
|
||||
| `credentials` | array of strings | Names of credential fields this service issues per peer. Most services use `["password"]`. The names appear in `peer_config_template` tokens. |
|
||||
|
||||
```json
|
||||
"accounts": {
|
||||
"manager": "http",
|
||||
"credentials": ["password"]
|
||||
}
|
||||
```
|
||||
|
||||
### `compose`
|
||||
|
||||
For builtins set this to `null` — their containers come from the main `docker-compose.yml`. For store services this field is unused at the manifest level; compose configuration is provided via `compose-template.yml` in the package (see section 3).
|
||||
|
||||
### `backup`
|
||||
|
||||
Required when `has_storage` is `true`. Tells PIC's backup system what to snapshot.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `volumes` | array of objects | Container paths to stream out via `docker exec tar`. Each entry has three string fields: `container` (container name), `path` (absolute path inside the container), and `name` (archive filename stem). |
|
||||
| `config_paths` | array of strings | Paths relative to the PIC project root on the host that contain service configuration (not user data). Copied directly into the snapshot. |
|
||||
|
||||
Each entry in `volumes` produces an archive at `<name>.tar.gz` inside the snapshot. For example, `"name": "maildata"` produces `maildata.tar.gz`.
|
||||
|
||||
```json
|
||||
"backup": {
|
||||
"volumes": [
|
||||
{"container": "cell-notes", "path": "/data/notes", "name": "notes_data"}
|
||||
],
|
||||
"config_paths": ["config/notes"]
|
||||
}
|
||||
```
|
||||
|
||||
`ServiceRegistry.get_backup_plan()` aggregates these declarations across all installed services. The backup runner reads from that method rather than from any hardcoded list.
|
||||
|
||||
### `egress`
|
||||
|
||||
Required when `has_egress` is `true`. Declares which outbound network interfaces this service is permitted to use.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `default` | string | The interface selected when the admin has not changed anything. |
|
||||
| `allowed` | array of strings | The complete set of interfaces the admin can choose from. |
|
||||
|
||||
Valid interface identifiers: `cell_internet`, `openvpn`, `wireguard`, `tor`, `pic_cell`.
|
||||
|
||||
```json
|
||||
"egress": {
|
||||
"default": "cell_internet",
|
||||
"allowed": ["cell_internet", "openvpn", "wireguard", "tor", "pic_cell"]
|
||||
}
|
||||
```
|
||||
|
||||
How enforcement works is described in section 6.
|
||||
|
||||
### `storage`
|
||||
|
||||
Informational metadata used by the UI to show storage usage.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `primary_path` | string | The path (relative to project root) that holds the bulk of user data. |
|
||||
| `quota_mb` | integer or null | Storage quota in megabytes; `null` means no limit. |
|
||||
|
||||
```json
|
||||
"storage": {
|
||||
"primary_path": "data/notes",
|
||||
"quota_mb": null
|
||||
}
|
||||
```
|
||||
|
||||
### Store-only manifest fields
|
||||
|
||||
Store services (where `kind` is `"store"`) have additional required fields that builtins do not use. These are validated by `ServiceStoreManager._validate_manifest()` before installation is permitted.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `image` | string | yes | Docker image to pull. Must match the pattern `git.pic.ngo/roof/*`. Images from other registries are rejected. |
|
||||
| `container_name` | string | yes | The name Docker gives the running container. |
|
||||
| `volumes` | array | no | Named volumes to mount. Each entry must have `name` (the volume name) and `mount` (the absolute path inside the container). Mounts to `/`, `/etc`, `/var`, `/proc`, `/sys`, `/dev`, `/app`, `/run`, `/boot`, and paths that are a prefix of the PIC project root are forbidden. |
|
||||
| `env` | array | no | Environment variables to pass. Each entry has `key` and `value`. Values must match `^[A-Za-z0-9._@:/+\-= ]*$`. |
|
||||
| `iptables_rules` | array | no | FORWARD ACCEPT rules PIC should install in `cell-wireguard`. Each rule must have `type: "ACCEPT"`, `dest_ip: "${SERVICE_IP}"`, an integer `dest_port` (1–65535), and an optional `proto` (`"tcp"` or `"udp"`, default `"tcp"`). The literal string `${SERVICE_IP}` is replaced with the allocated container IP at install time. |
|
||||
| `caddy_route` | object | no | If the service exposes a web UI, provide `subdomain` (must not be reserved; must match `^[a-z][a-z0-9-]{0,30}$`). PIC inserts the corresponding `reverse_proxy` directive into the Caddyfile. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Compose template variables
|
||||
|
||||
This section applies only to store services. Builtins define their containers directly in `docker-compose.yml`.
|
||||
|
||||
When you ship a store service, you include a `compose-template.yml` alongside your `manifest.json`. `ServiceComposer.render_template()` substitutes the variables below before writing the per-service `docker-compose.yml`.
|
||||
|
||||
| Variable | Syntax | Value |
|
||||
|---|---|---|
|
||||
| `${PIC_CFG_<KEY>}` | uppercase `config_schema` key | The admin-saved value for that field, or the `default` from the schema if the admin has not set it. For example, `config_schema.port` → `${PIC_CFG_PORT}`. |
|
||||
| `${PIC_SECRET_<NAME>}` | any name you choose | An auto-generated random secret produced by `secrets.token_urlsafe(24)` (~32 URL-safe base64 characters). Generated once on first install, then reused unchanged on every reconfigure. Stored per service in `data/service_secrets.json`. |
|
||||
| `${PIC_DOMAIN}` | literal | Effective domain from `ConfigManager` (e.g. `alice.pic.ngo`). |
|
||||
| `${PIC_CELL_NAME}` | literal | Cell name from the identity config (e.g. `alice`). |
|
||||
| `${PIC_SERVICE_ID}` | literal | The `id` field from the service manifest (e.g. `notes`). |
|
||||
|
||||
**Volume mounts**: Because docker compose runs inside the API container but the Docker daemon runs on the host, relative volume paths in compose templates resolve relative to the compose file's directory as seen by the HOST filesystem. To avoid path resolution surprises, prefer **named volumes** for service data (Docker manages them independently). If bind mounts are required, use absolute host paths with `${PIC_PROJECT_DIR}` once that variable is implemented, or document the expected host layout clearly.
|
||||
|
||||
Example `compose-template.yml` for a notes service:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
cell-notes:
|
||||
image: git.pic.ngo/roof/pic-notes:latest
|
||||
container_name: cell-notes
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NOTES_PORT: "${PIC_CFG_PORT}"
|
||||
NOTES_DOMAIN: "${PIC_DOMAIN}"
|
||||
NOTES_DB_PASS: "${PIC_SECRET_DB_PASSWORD}"
|
||||
volumes:
|
||||
- notes-data:/data/notes
|
||||
networks:
|
||||
cell-network:
|
||||
ipv4_address: "${SERVICE_IP}"
|
||||
|
||||
volumes:
|
||||
notes-data:
|
||||
|
||||
networks:
|
||||
cell-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
The `SERVICE_IP` variable is the IP PIC allocated from the service pool. It is always set automatically.
|
||||
|
||||
---
|
||||
|
||||
## 4. Account provisioning interface
|
||||
|
||||
This section covers two related things: the `AccountManager` class that is PIC's central credential dispatcher, and the HTTP API that store services must implement to receive account operations.
|
||||
|
||||
### How AccountManager works
|
||||
|
||||
`AccountManager` (`api/account_manager.py`) is the single entry point for all account operations across every service type. It is instantiated once in `api/managers.py` and holds references to the three builtin service managers (`email_manager`, `calendar_manager`, `file_manager`).
|
||||
|
||||
When a peer account is provisioned, `AccountManager`:
|
||||
|
||||
1. Looks up the service in `ServiceRegistry` and reads `accounts.manager` from the manifest.
|
||||
2. Dispatches to the appropriate internal manager method (for builtins) or to the service's HTTP API endpoint (for store services — not yet implemented; `"http"` manager support is planned).
|
||||
3. Stores the returned credentials in `data/peer_service_credentials.json` with permissions `0o600`.
|
||||
|
||||
Credentials are stored in plaintext. This is intentional: the peer credentials endpoint needs to return them verbatim for one-time client configuration. The `0o600` permission matches the pattern used for WireGuard keys and `data/service_secrets.json`.
|
||||
|
||||
The credentials file structure is:
|
||||
|
||||
```json
|
||||
{
|
||||
"<service_id>": {
|
||||
"<peer_username>": { "password": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Writes use a write-then-rename pattern (`tmp` → final path) with `os.fsync` to avoid partial-write corruption.
|
||||
|
||||
### Manifest `accounts` field
|
||||
|
||||
The `accounts` block in the manifest wires a service into `AccountManager`.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `manager` | string | Which underlying manager handles account operations. For builtins: `"email_manager"`, `"calendar_manager"`, or `"file_manager"`. |
|
||||
| `credentials` | array of strings | Names of the credential fields this service issues per peer. Most services use `["password"]`. These names are used as token keys in `peer_config_template`. |
|
||||
|
||||
```json
|
||||
"accounts": {
|
||||
"manager": "email_manager",
|
||||
"credentials": ["password"]
|
||||
}
|
||||
```
|
||||
|
||||
The `manager` value must match a key that `AccountManager` was instantiated with. If the manager name has no registered dispatch entry, `provision()` raises `ValueError` immediately.
|
||||
|
||||
### Provision flow
|
||||
|
||||
```
|
||||
POST /api/services/catalog/<service_id>/accounts
|
||||
Content-Type: application/json
|
||||
|
||||
{ "username": "alice", "password": "optional" }
|
||||
```
|
||||
|
||||
If `password` is omitted, `AccountManager` generates one with `secrets.token_urlsafe(16)`. The response on HTTP 201 is:
|
||||
|
||||
```json
|
||||
{ "service_id": "email", "username": "alice", "provisioned": true }
|
||||
```
|
||||
|
||||
The password is not echoed in the response. To retrieve stored credentials for a provisioned peer, call `GET /api/services/catalog/<id>/accounts/<username>/credentials`.
|
||||
|
||||
Internally, `AccountManager.provision(service_id, peer_username, password)`:
|
||||
|
||||
1. Resolves the service and its manager via `_resolve_service()`.
|
||||
2. Calls the appropriate `_provision_*` method, which delegates to the concrete manager:
|
||||
- `email_manager` → `create_email_user(username, domain, password)`
|
||||
- `calendar_manager` → `create_calendar_user(username, password)`
|
||||
- `file_manager` → `create_user(username, password)`
|
||||
3. Stores `{"password": "<value>"}` under `[service_id][peer_username]` in the credentials file.
|
||||
4. Returns the credential dict to the caller.
|
||||
|
||||
If the underlying manager call returns `False`, `provision()` raises `RuntimeError`. The route handler maps this to HTTP 500.
|
||||
|
||||
For email, the domain is read from the service's merged config (`svc['config']['domain']`). If that key is absent, provisioning raises `ValueError` before calling the manager.
|
||||
|
||||
### Deprovision flow
|
||||
|
||||
```
|
||||
DELETE /api/services/catalog/<service_id>/accounts/<username>
|
||||
```
|
||||
|
||||
`AccountManager.deprovision(service_id, peer_username)`:
|
||||
|
||||
1. Calls the appropriate `_deprovision_*` method on the underlying manager.
|
||||
2. Removes the peer's entry from the credentials file. If that leaves the service block empty, the service block itself is removed.
|
||||
3. Returns `True` if the underlying call succeeded.
|
||||
|
||||
The route returns HTTP 200 with `{"message": "..."}` on success, or HTTP 400 if the service does not exist or does not support accounts.
|
||||
|
||||
**Peer deletion** calls `AccountManager.deprovision_peer(peer_username)`, which iterates over every service the peer is provisioned on and calls `deprovision()` for each. Failures on individual services are logged and skipped rather than aborting the deletion — the method returns `{service_id: bool}` for every service attempted.
|
||||
|
||||
### PIC admin API endpoints for account management
|
||||
|
||||
These endpoints are in `api/routes/services.py` and `api/routes/peers.py`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/services/catalog/<service_id>/accounts` | Return `{"service_id": "...", "accounts": ["alice", "bob"]}` — reads directly from the credentials file. |
|
||||
| `POST` | `/api/services/catalog/<service_id>/accounts` | Provision a peer account. Body: `{"username": "...", "password": "..."}` (password optional). Returns HTTP 201 with `{"service_id", "username", "provisioned": true}`. |
|
||||
| `DELETE` | `/api/services/catalog/<service_id>/accounts/<username>` | Deprovision the peer's account. Returns HTTP 200 on success, HTTP 400 if the service or username is unknown. |
|
||||
| `GET` | `/api/services/catalog/<service_id>/accounts/<username>/credentials` | Return stored credentials for one peer+service pair. Returns HTTP 404 if the peer is not provisioned on that service. Response: `{"service_id", "username", "password"}`. |
|
||||
| `GET` | `/api/peers/<peer_name>/service-credentials` | Return filled `peer_config_template` values for all services the peer is provisioned on (see below). |
|
||||
|
||||
**Admin UI:** The Email, Calendar, and Files service pages in the admin dashboard each have an **Accounts** tab. From there, admins can provision and deprovision peer accounts, and reveal stored credentials for a provisioned peer. This tab calls the same API endpoints listed above.
|
||||
|
||||
### How `peer_config_template` connects to stored credentials
|
||||
|
||||
`GET /api/peers/<peer_name>/service-credentials` is the endpoint a peer device calls during first-time setup to configure email, CalDAV, and file sync clients.
|
||||
|
||||
The route:
|
||||
|
||||
1. Calls `AccountManager.get_all_credentials(peer_name)` → `{service_id: {field: value}}`.
|
||||
2. For each service, calls `ServiceRegistry.get_peer_service_info(service_id, peer_name, domain, cred)`.
|
||||
3. `get_peer_service_info` iterates over `peer_config_template` and replaces tokens:
|
||||
- `{domain}` → effective cell domain
|
||||
- `{peer.username}` → URL-percent-encoded peer username (safe='')
|
||||
- `{peer.service_credentials.<service_id>.<field>}` → the value from stored credentials
|
||||
- `{config.<key>}` → value from the service's merged config schema
|
||||
4. Returns the filled template dict as the value for that service in the response.
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"peer": "alice",
|
||||
"services": {
|
||||
"email": {
|
||||
"imap_host": "mail.alice.pic.ngo",
|
||||
"username": "alice@alice.pic.ngo",
|
||||
"password": "<stored>"
|
||||
},
|
||||
"files": {
|
||||
"url": "https://files.alice.pic.ngo/dav/alice/",
|
||||
"username": "alice",
|
||||
"password": "<stored>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If a service has no `peer_config_template` in its manifest, `get_peer_service_info` returns `None` and the raw credential dict is used as the fallback.
|
||||
|
||||
### Container lifecycle routes
|
||||
|
||||
The following PIC API endpoints are available for all services (builtins and store services). These are called by the web UI and can be called directly from the PIC admin API.
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/services/catalog/<id>/status` | Return container status. Builtins query the main compose stack; store services query their own compose project. Response includes a `containers` array with one entry per container. |
|
||||
| `POST` | `/api/services/catalog/<id>/restart` | Restart the service containers. Builtins restart via the main compose stack; store services restart via their own compose project. |
|
||||
| `POST` | `/api/services/catalog/<id>/reconfigure` | Re-render the compose file from the template and re-apply with `up -d` (rolling update). Store services only — builtins are reconfigured through their own settings routes. The request body must include a `compose_template` field containing the new template content. |
|
||||
|
||||
### Store service HTTP API (planned)
|
||||
|
||||
When `accounts.manager` is `"http"`, PIC will call your container's HTTP API for account operations. This path is not yet implemented in `AccountManager`; the dispatch table only covers `email_manager`, `calendar_manager`, and `file_manager`. The interface below is the planned contract — implement it now so your service is ready when HTTP dispatch is wired up.
|
||||
|
||||
The base path is `/service-api/accounts` on your container's internal address. There is no authentication on this API — it is reachable only from within the `cell-network` Docker network.
|
||||
|
||||
**Create account**
|
||||
|
||||
```
|
||||
POST /service-api/accounts
|
||||
Content-Type: application/json
|
||||
|
||||
{ "username": "alice", "password": "auto-generated-by-pic" }
|
||||
```
|
||||
|
||||
PIC generates the password and passes it to your service. Return HTTP 200 with `{"ok": true}` on success. Return HTTP 400 or 409 with `{"ok": false, "error": "..."}` for expected errors (duplicate username, invalid input). Return HTTP 500 for unexpected internal errors.
|
||||
|
||||
**Delete account**
|
||||
|
||||
```
|
||||
DELETE /service-api/accounts/{username}
|
||||
```
|
||||
|
||||
Return HTTP 200 with `{"ok": true}` on success. Return HTTP 404 with `{"ok": false, "error": "not found"}` if the account does not exist.
|
||||
|
||||
**List accounts**
|
||||
|
||||
```
|
||||
GET /service-api/accounts
|
||||
```
|
||||
|
||||
Return `{"accounts": ["alice", "bob"]}` — an array of all provisioned usernames.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backup integration
|
||||
|
||||
Declare `has_storage: true` in `capabilities` and fill in the `backup` block. PIC's `ServiceRegistry.get_backup_plan()` returns the combined backup declarations for all installed services. The backup runner reads from that method.
|
||||
|
||||
### Why docker exec instead of bind mounts
|
||||
|
||||
The API container only has access to `data/api/` on the host filesystem. Service data (mailboxes, calendar collections, file trees) lives in other containers' volumes. Rather than mount every service volume into the API container — which would require compose changes per service — PIC streams data using `docker exec <container> tar czf - <path>`. This works for any container on the Docker host regardless of how its volumes are configured.
|
||||
|
||||
### `volumes` entries
|
||||
|
||||
Each object in the `volumes` array describes one directory to capture:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `container` | Name of the running container to exec into (e.g. `"cell-notes"`). |
|
||||
| `path` | Absolute path inside that container to archive (e.g. `"/data/notes"`). |
|
||||
| `name` | Archive filename stem. PIC saves the archive as `<name>.tar.gz` under `service_data/<service_id>/` in the backup directory. |
|
||||
|
||||
A service with multiple containers or multiple data directories lists one entry per directory.
|
||||
|
||||
**Security note:** The backup commands use `docker exec -- <container> tar -C <path> -czf - .` (note the `--` separator before the container name) to prevent option injection. The container name is also validated against `^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$` before the command is run.
|
||||
|
||||
### `config_paths`
|
||||
|
||||
Paths in `config_paths` are relative to the PIC project root on the host and are copied directly into the snapshot (no docker exec). Use this for configuration files the service reads at startup, not for user data.
|
||||
|
||||
### Full example
|
||||
|
||||
```json
|
||||
"backup": {
|
||||
"volumes": [
|
||||
{"container": "cell-notes", "path": "/data/notes", "name": "notes_data"}
|
||||
],
|
||||
"config_paths": ["config/notes"]
|
||||
}
|
||||
```
|
||||
|
||||
This produces one archive `notes_data.tar.gz` (streamed from the `cell-notes` container) plus a direct copy of `config/notes/` from the host.
|
||||
|
||||
### Restore
|
||||
|
||||
PIC restores each volume entry by piping the archive back via `docker exec -i -- <container> tar -C <path> -xzf -`. The `-C <path>` flag bounds extraction to the declared volume path — the same path used during backup. Archive entries are relative paths (the backup uses `tar -C <path> -czf - .`), so files land in exactly the location declared in the manifest `volumes` entry. The target container must be running at restore time.
|
||||
|
||||
---
|
||||
|
||||
## 6. Egress routing
|
||||
|
||||
When `has_egress` is `true`, the cell admin can assign a specific outbound interface to your service. PIC enforces the selection using `fwmark` rules and policy routing in the `cell-wireguard` container via the `ConnectivityManager`.
|
||||
|
||||
The valid values for `egress.allowed` and what they mean:
|
||||
|
||||
| Value | Path |
|
||||
|---|---|
|
||||
| `cell_internet` | Default route through the cell's WAN interface (no VPN). |
|
||||
| `openvpn` | Traffic leaves through `tun0` (fwmark `0x20`, table 120). |
|
||||
| `wireguard` | Traffic leaves through a second WireGuard interface `wg_ext0` (fwmark `0x10`, table 110). |
|
||||
| `tor` | Traffic is redirected to the Tor transparent proxy on port 9040 (fwmark `0x30`, table 130). |
|
||||
| `pic_cell` | Traffic routes through a connected peer cell via a site-to-site WireGuard link. |
|
||||
|
||||
List only the interfaces that make sense for your service in `allowed`. Listing all five is fine if your service is general-purpose. The `default` value is used when the admin has not changed anything. Always include `cell_internet` in `allowed` so the admin has a way to use the normal path.
|
||||
|
||||
The egress field in the manifest tells PIC what options to present in the UI. Actual enforcement requires the cell to have the corresponding exit type configured (an OpenVPN config uploaded, a WireGuard external config active, etc.). If the chosen exit is not active, packets will be dropped by the kill-switch FORWARD rule in `cell-wireguard`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Quick-start example
|
||||
|
||||
This section walks through a minimal working example: a static website served from Nginx with no accounts, no backup, and no egress policy.
|
||||
|
||||
### `manifest.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "homepage",
|
||||
"name": "Homepage",
|
||||
"description": "A static homepage served from your cell",
|
||||
"version": "1.0.0",
|
||||
"author": "acme",
|
||||
"kind": "store",
|
||||
"min_pic_version": "1.0",
|
||||
|
||||
"capabilities": {
|
||||
"has_subdomain": true,
|
||||
"has_accounts": false,
|
||||
"has_admin_config": false,
|
||||
"has_storage": false,
|
||||
"has_egress": false,
|
||||
"has_api_hooks": false
|
||||
},
|
||||
|
||||
"subdomain": "home",
|
||||
"extra_subdomains": [],
|
||||
"backend": "cell-homepage:80",
|
||||
|
||||
"containers": ["cell-homepage"],
|
||||
|
||||
"image": "git.pic.ngo/roof/pic-homepage:latest",
|
||||
"container_name": "cell-homepage",
|
||||
|
||||
"volumes": [
|
||||
{ "name": "homepage-html", "mount": "/usr/share/nginx/html" }
|
||||
],
|
||||
|
||||
"env": [],
|
||||
|
||||
"iptables_rules": [
|
||||
{
|
||||
"type": "ACCEPT",
|
||||
"dest_ip": "${SERVICE_IP}",
|
||||
"dest_port": 80,
|
||||
"proto": "tcp"
|
||||
}
|
||||
],
|
||||
|
||||
"caddy_route": {
|
||||
"subdomain": "home"
|
||||
},
|
||||
|
||||
"compose": null
|
||||
}
|
||||
```
|
||||
|
||||
### What PIC does on install
|
||||
|
||||
1. Downloads this manifest from the store index.
|
||||
2. Validates every field (image allowlist, volume safety, reserved subdomains, iptables rule format).
|
||||
3. Allocates a static IP from the service pool (`172.20.0.20`–`172.20.0.254`).
|
||||
4. Writes a Docker Compose override file that starts `cell-homepage` with the allocated IP on `cell-network`.
|
||||
5. Runs `docker compose up -d cell-homepage`.
|
||||
6. Applies the `iptables_rules` in `cell-wireguard` so peers can reach the container.
|
||||
7. Regenerates the Caddyfile so `home.<cell-domain>` proxies to `cell-homepage:80`.
|
||||
|
||||
The result is that any WireGuard peer can reach `https://home.alice.pic.ngo/` immediately after installation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Submitting to the store
|
||||
|
||||
### Package format
|
||||
|
||||
A store service package is a ZIP archive containing:
|
||||
|
||||
```
|
||||
homepage-1.0.0.zip
|
||||
├── manifest.json (required)
|
||||
├── compose-template.yml (recommended for multi-container services)
|
||||
└── install.sh (optional post-install script)
|
||||
```
|
||||
|
||||
`install.sh` is executed on the cell host after the container starts. Keep it minimal — initialise data structures, create default config files. Do not use it to install system packages or modify files outside the PIC project root.
|
||||
|
||||
### Store index entry
|
||||
|
||||
The store index at `https://git.pic.ngo/roof/pic-services/raw/branch/main/index.json` is a JSON array. Each entry looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "homepage",
|
||||
"name": "Homepage",
|
||||
"description": "A static homepage served from your cell",
|
||||
"version": "1.0.0",
|
||||
"author": "acme"
|
||||
}
|
||||
```
|
||||
|
||||
PIC fetches the full manifest from `https://git.pic.ngo/roof/pic-services/raw/branch/main/services/{id}/manifest.json` when the admin clicks install.
|
||||
|
||||
### Submission process
|
||||
|
||||
1. Fork `https://git.pic.ngo/roof/pic-services`.
|
||||
2. Create a directory `services/<your-id>/` and add your `manifest.json`.
|
||||
3. Open a pull request against `main`.
|
||||
|
||||
The review checks the following before merging:
|
||||
|
||||
**Security**
|
||||
- Image hosted on `git.pic.ngo/roof/*`. No external registries.
|
||||
- No volume mounts to system paths or to the PIC project root.
|
||||
- `iptables_rules` only declare `ACCEPT` rules (no DROP, no REJECT, no chain redirects).
|
||||
- `env` values contain only alphanumeric characters and a small set of safe punctuation.
|
||||
- `install.sh` does not call `apt`, `yum`, `curl | bash`, or modify files outside the project.
|
||||
|
||||
**Correctness**
|
||||
- `subdomain` does not collide with the reserved list or with any existing store service.
|
||||
- `backend` points to the declared `container_name`.
|
||||
- If `has_accounts: true`, the container responds correctly on all three `/service-api/accounts` endpoints.
|
||||
- If `has_storage: true`, every `volumes` entry names a container that is running and a path that exists inside it.
|
||||
|
||||
**Quality**
|
||||
- `description` is one sentence, no marketing language.
|
||||
- `version` is a valid semver string.
|
||||
- `config_schema` labels are in plain English, sentence case.
|
||||
|
||||
### Versioning
|
||||
|
||||
Increment `version` in `manifest.json` with every change you submit. PIC does not auto-update installed services; the admin manually runs an update. When an update is available, the UI shows the version mismatch between the installed record and the store index.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: manifest field quick reference
|
||||
|
||||
| Field | Builtin | Store | Notes |
|
||||
|---|---|---|---|
|
||||
| `schema_version` | required | required | Must be `3` |
|
||||
| `id` | required | required | |
|
||||
| `name` | required | required | |
|
||||
| `description` | required | required | |
|
||||
| `version` | required | required | |
|
||||
| `author` | required | required | |
|
||||
| `kind` | required | required | `"builtin"` or `"store"` |
|
||||
| `min_pic_version` | optional | optional | |
|
||||
| `capabilities.*` | required | required | All six flags must be present |
|
||||
| `subdomain` | if `has_subdomain` | if `has_subdomain` | |
|
||||
| `extra_subdomains` | optional | optional | |
|
||||
| `backend` | if `has_subdomain` | if `has_subdomain` | |
|
||||
| `extra_backends` | optional | optional | |
|
||||
| `containers` | optional | optional | Informational |
|
||||
| `config_schema` | if `has_admin_config` | if `has_admin_config` | |
|
||||
| `peer_config_template` | if `has_accounts` | if `has_accounts` | |
|
||||
| `accounts` | if `has_accounts` | if `has_accounts` | |
|
||||
| `compose` | `null` | `null` | |
|
||||
| `backup` | if `has_storage` | if `has_storage` | |
|
||||
| `egress` | if `has_egress` | if `has_egress` | |
|
||||
| `storage` | if `has_storage` | if `has_storage` | |
|
||||
| `image` | not used | required | |
|
||||
| `container_name` | not used | required | |
|
||||
| `volumes` | not used | optional | |
|
||||
| `env` | not used | optional | |
|
||||
| `iptables_rules` | not used | optional | |
|
||||
| `caddy_route` | not used | optional | |
|
||||
Binary file not shown.
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
Tests for AccountManager — per-service credential provisioning.
|
||||
|
||||
Covers:
|
||||
- provision: dispatches to right manager method, stores credentials, generates password
|
||||
- deprovision: calls manager method, removes stored credentials
|
||||
- get_credentials / list_accounts / list_peer_services
|
||||
- deprovision_peer: bulk cleanup on peer deletion
|
||||
- store_credentials: direct storage (used by peers-POST legacy route)
|
||||
- get_all_credentials: returns all creds for a peer
|
||||
- credential file is created with 0o600
|
||||
- unknown service / missing manager errors
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
|
||||
|
||||
from account_manager import AccountManager
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_am(tmp_path: Path, registry=None, **managers) -> AccountManager:
|
||||
if registry is None:
|
||||
registry = _make_registry()
|
||||
return AccountManager(service_registry=registry, data_dir=str(tmp_path), **managers)
|
||||
|
||||
|
||||
def _make_registry(services=None):
|
||||
reg = MagicMock()
|
||||
if services is None:
|
||||
services = {
|
||||
'email': {
|
||||
'id': 'email', 'kind': 'builtin',
|
||||
'accounts': {'manager': 'email_manager', 'credentials': ['password']},
|
||||
'config': {'domain': 'example.com', 'smtp_port': 25},
|
||||
},
|
||||
'calendar': {
|
||||
'id': 'calendar', 'kind': 'builtin',
|
||||
'accounts': {'manager': 'calendar_manager', 'credentials': ['password']},
|
||||
'config': {},
|
||||
},
|
||||
'files': {
|
||||
'id': 'files', 'kind': 'builtin',
|
||||
'accounts': {'manager': 'file_manager', 'credentials': ['password']},
|
||||
'config': {},
|
||||
},
|
||||
}
|
||||
reg.get.side_effect = lambda svc_id: services.get(svc_id)
|
||||
return reg
|
||||
|
||||
|
||||
def _make_email_mgr(ok=True):
|
||||
m = MagicMock()
|
||||
m.create_email_user.return_value = ok
|
||||
m.delete_email_user.return_value = ok
|
||||
return m
|
||||
|
||||
|
||||
def _make_cal_mgr(ok=True):
|
||||
m = MagicMock()
|
||||
m.create_calendar_user.return_value = ok
|
||||
m.delete_calendar_user.return_value = ok
|
||||
return m
|
||||
|
||||
|
||||
def _make_file_mgr(ok=True):
|
||||
m = MagicMock()
|
||||
m.create_user.return_value = ok
|
||||
m.delete_user.return_value = ok
|
||||
return m
|
||||
|
||||
|
||||
# ── Provision ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestProvision(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.email_mgr = _make_email_mgr()
|
||||
self.cal_mgr = _make_cal_mgr()
|
||||
self.file_mgr = _make_file_mgr()
|
||||
self.am = _make_am(
|
||||
self.tmp,
|
||||
email_manager=self.email_mgr,
|
||||
calendar_manager=self.cal_mgr,
|
||||
file_manager=self.file_mgr,
|
||||
)
|
||||
|
||||
def test_provision_email_calls_create_email_user(self):
|
||||
self.am.provision('email', 'alice', password='s3cret')
|
||||
self.email_mgr.create_email_user.assert_called_once_with('alice', 'example.com', 's3cret')
|
||||
|
||||
def test_provision_calendar_calls_create_calendar_user(self):
|
||||
self.am.provision('calendar', 'alice', password='s3cret')
|
||||
self.cal_mgr.create_calendar_user.assert_called_once_with('alice', 's3cret')
|
||||
|
||||
def test_provision_files_calls_create_user(self):
|
||||
self.am.provision('files', 'alice', password='s3cret')
|
||||
self.file_mgr.create_user.assert_called_once_with('alice', 's3cret')
|
||||
|
||||
def test_provision_generates_password_when_none_given(self):
|
||||
creds = self.am.provision('email', 'alice')
|
||||
self.assertIn('password', creds)
|
||||
self.assertTrue(len(creds['password']) >= 16)
|
||||
|
||||
def test_provision_returns_credential_dict(self):
|
||||
creds = self.am.provision('email', 'alice', password='mypassword')
|
||||
self.assertEqual(creds, {'password': 'mypassword'})
|
||||
|
||||
def test_provision_stores_credentials(self):
|
||||
self.am.provision('email', 'alice', password='pw')
|
||||
stored = self.am.get_credentials('email', 'alice')
|
||||
self.assertEqual(stored, {'password': 'pw'})
|
||||
|
||||
def test_provision_multiple_peers_stored_independently(self):
|
||||
self.am.provision('email', 'alice', password='pw-alice')
|
||||
self.am.provision('email', 'bob', password='pw-bob')
|
||||
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'pw-alice'})
|
||||
self.assertEqual(self.am.get_credentials('email', 'bob'), {'password': 'pw-bob'})
|
||||
|
||||
def test_provision_raises_for_unknown_service(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.am.provision('doesnotexist', 'alice')
|
||||
|
||||
def test_provision_raises_when_service_has_no_accounts(self):
|
||||
reg = _make_registry({'nosvc': {'id': 'nosvc', 'accounts': {}, 'config': {}}})
|
||||
am = _make_am(self.tmp, registry=reg, email_manager=self.email_mgr)
|
||||
with self.assertRaises(ValueError):
|
||||
am.provision('nosvc', 'alice')
|
||||
|
||||
def test_provision_raises_when_manager_not_registered(self):
|
||||
am = _make_am(self.tmp) # no managers passed
|
||||
with self.assertRaises(ValueError):
|
||||
am.provision('email', 'alice')
|
||||
|
||||
def test_provision_raises_runtime_error_when_manager_returns_false(self):
|
||||
am = _make_am(self.tmp, email_manager=_make_email_mgr(ok=False))
|
||||
with self.assertRaises(RuntimeError):
|
||||
am.provision('email', 'alice')
|
||||
|
||||
def test_provision_email_raises_when_domain_not_configured(self):
|
||||
reg = _make_registry({'email': {
|
||||
'id': 'email', 'accounts': {'manager': 'email_manager'},
|
||||
'config': {'domain': ''},
|
||||
}})
|
||||
am = _make_am(self.tmp, registry=reg, email_manager=self.email_mgr)
|
||||
with self.assertRaises(ValueError):
|
||||
am.provision('email', 'alice')
|
||||
|
||||
|
||||
# ── Credential file permissions ───────────────────────────────────────────────
|
||||
|
||||
class TestCredentialFilePermissions(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.am = _make_am(self.tmp, email_manager=_make_email_mgr())
|
||||
|
||||
def test_credentials_file_created_with_0600(self):
|
||||
self.am.provision('email', 'alice', password='pw')
|
||||
creds_path = self.tmp / 'peer_service_credentials.json'
|
||||
mode = stat.S_IMODE(creds_path.stat().st_mode)
|
||||
self.assertEqual(mode, 0o600, f'Expected 0o600, got {oct(mode)}')
|
||||
|
||||
|
||||
# ── Deprovision ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDeprovision(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.email_mgr = _make_email_mgr()
|
||||
self.cal_mgr = _make_cal_mgr()
|
||||
self.file_mgr = _make_file_mgr()
|
||||
self.am = _make_am(
|
||||
self.tmp,
|
||||
email_manager=self.email_mgr,
|
||||
calendar_manager=self.cal_mgr,
|
||||
file_manager=self.file_mgr,
|
||||
)
|
||||
self.am.provision('email', 'alice', password='pw')
|
||||
|
||||
def test_deprovision_email_calls_delete_email_user(self):
|
||||
self.am.deprovision('email', 'alice')
|
||||
self.email_mgr.delete_email_user.assert_called_once_with('alice', 'example.com')
|
||||
|
||||
def test_deprovision_removes_stored_credentials(self):
|
||||
self.am.deprovision('email', 'alice')
|
||||
self.assertIsNone(self.am.get_credentials('email', 'alice'))
|
||||
|
||||
def test_deprovision_returns_true_on_success(self):
|
||||
ok = self.am.deprovision('email', 'alice')
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_deprovision_raises_for_unknown_service(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.am.deprovision('ghost', 'alice')
|
||||
|
||||
def test_deprovision_removes_service_entry_when_last_peer_gone(self):
|
||||
self.am.deprovision('email', 'alice')
|
||||
creds_file = self.tmp / 'peer_service_credentials.json'
|
||||
data = json.loads(creds_file.read_text())
|
||||
self.assertNotIn('email', data)
|
||||
|
||||
def test_deprovision_calendar_calls_delete_calendar_user(self):
|
||||
self.am.provision('calendar', 'alice', password='pw')
|
||||
self.am.deprovision('calendar', 'alice')
|
||||
self.cal_mgr.delete_calendar_user.assert_called_once_with('alice')
|
||||
|
||||
def test_deprovision_files_calls_delete_user(self):
|
||||
self.am.provision('files', 'alice', password='pw')
|
||||
self.am.deprovision('files', 'alice')
|
||||
self.file_mgr.delete_user.assert_called_once_with('alice')
|
||||
|
||||
|
||||
# ── Queries ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestQueries(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.am = _make_am(
|
||||
self.tmp,
|
||||
email_manager=_make_email_mgr(),
|
||||
calendar_manager=_make_cal_mgr(),
|
||||
file_manager=_make_file_mgr(),
|
||||
)
|
||||
self.am.provision('email', 'alice', password='pw-alice-email')
|
||||
self.am.provision('email', 'bob', password='pw-bob-email')
|
||||
self.am.provision('calendar', 'alice', password='pw-alice-cal')
|
||||
|
||||
def test_get_credentials_returns_stored(self):
|
||||
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'pw-alice-email'})
|
||||
|
||||
def test_get_credentials_returns_none_for_unknown_peer(self):
|
||||
self.assertIsNone(self.am.get_credentials('email', 'nobody'))
|
||||
|
||||
def test_get_credentials_returns_none_for_unknown_service(self):
|
||||
self.assertIsNone(self.am.get_credentials('ghost', 'alice'))
|
||||
|
||||
def test_list_accounts_returns_provisioned_peers(self):
|
||||
accounts = self.am.list_accounts('email')
|
||||
self.assertIn('alice', accounts)
|
||||
self.assertIn('bob', accounts)
|
||||
|
||||
def test_list_accounts_empty_for_unprovisioned_service(self):
|
||||
self.assertEqual(self.am.list_accounts('files'), [])
|
||||
|
||||
def test_list_peer_services_returns_all_services_for_peer(self):
|
||||
services = self.am.list_peer_services('alice')
|
||||
self.assertIn('email', services)
|
||||
self.assertIn('calendar', services)
|
||||
|
||||
def test_list_peer_services_returns_empty_for_unknown_peer(self):
|
||||
self.assertEqual(self.am.list_peer_services('nobody'), [])
|
||||
|
||||
def test_is_provisioned_true_when_account_exists(self):
|
||||
self.assertTrue(self.am.is_provisioned('email', 'alice'))
|
||||
|
||||
def test_is_provisioned_false_when_no_account(self):
|
||||
self.assertFalse(self.am.is_provisioned('email', 'nobody'))
|
||||
|
||||
def test_get_all_credentials_returns_all_services(self):
|
||||
all_creds = self.am.get_all_credentials('alice')
|
||||
self.assertIn('email', all_creds)
|
||||
self.assertIn('calendar', all_creds)
|
||||
self.assertEqual(all_creds['email'], {'password': 'pw-alice-email'})
|
||||
|
||||
def test_get_all_credentials_empty_for_unknown_peer(self):
|
||||
self.assertEqual(self.am.get_all_credentials('nobody'), {})
|
||||
|
||||
|
||||
# ── Bulk deprovision ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestDeprovisionPeer(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.email_mgr = _make_email_mgr()
|
||||
self.cal_mgr = _make_cal_mgr()
|
||||
self.am = _make_am(
|
||||
self.tmp,
|
||||
email_manager=self.email_mgr,
|
||||
calendar_manager=self.cal_mgr,
|
||||
file_manager=_make_file_mgr(),
|
||||
)
|
||||
self.am.provision('email', 'alice', password='pw')
|
||||
self.am.provision('calendar', 'alice', password='pw')
|
||||
|
||||
def test_deprovision_peer_removes_from_all_services(self):
|
||||
self.am.deprovision_peer('alice')
|
||||
self.assertIsNone(self.am.get_credentials('email', 'alice'))
|
||||
self.assertIsNone(self.am.get_credentials('calendar', 'alice'))
|
||||
|
||||
def test_deprovision_peer_returns_results_dict(self):
|
||||
results = self.am.deprovision_peer('alice')
|
||||
self.assertIn('email', results)
|
||||
self.assertIn('calendar', results)
|
||||
self.assertTrue(results['email'])
|
||||
self.assertTrue(results['calendar'])
|
||||
|
||||
def test_deprovision_peer_continues_after_one_service_fails(self):
|
||||
self.email_mgr.delete_email_user.side_effect = RuntimeError('smtp down')
|
||||
results = self.am.deprovision_peer('alice')
|
||||
self.assertFalse(results.get('email'))
|
||||
# calendar should still succeed even though email failed
|
||||
self.assertTrue(results.get('calendar'))
|
||||
|
||||
def test_deprovision_peer_no_op_for_unknown_peer(self):
|
||||
results = self.am.deprovision_peer('nobody')
|
||||
self.assertEqual(results, {})
|
||||
|
||||
|
||||
# ── Direct credential storage ─────────────────────────────────────────────────
|
||||
|
||||
class TestStoreCredentials(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.am = _make_am(self.tmp)
|
||||
|
||||
def test_store_credentials_makes_them_retrievable(self):
|
||||
self.am.store_credentials('email', 'alice', {'password': 'mypassword'})
|
||||
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'mypassword'})
|
||||
|
||||
def test_store_credentials_overwrites_existing(self):
|
||||
self.am.store_credentials('email', 'alice', {'password': 'old'})
|
||||
self.am.store_credentials('email', 'alice', {'password': 'new'})
|
||||
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'new'})
|
||||
|
||||
def test_store_credentials_creates_file_with_0600(self):
|
||||
self.am.store_credentials('email', 'alice', {'password': 'pw'})
|
||||
creds_path = self.tmp / 'peer_service_credentials.json'
|
||||
mode = stat.S_IMODE(creds_path.stat().st_mode)
|
||||
self.assertEqual(mode, 0o600)
|
||||
|
||||
|
||||
# ── Thread safety ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestThreadSafety(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.am = _make_am(self.tmp)
|
||||
|
||||
def test_concurrent_store_credentials_no_data_loss(self):
|
||||
errors = []
|
||||
def worker(peer_name):
|
||||
try:
|
||||
self.am.store_credentials('email', peer_name, {'password': f'pw-{peer_name}'})
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(f'peer{i}',)) for i in range(20)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertEqual(errors, [])
|
||||
accounts = self.am.list_accounts('email')
|
||||
self.assertEqual(len(accounts), 20)
|
||||
|
||||
|
||||
class TestEdgeCases(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.email_mgr = _make_email_mgr()
|
||||
self.am = _make_am(self.tmp, email_manager=self.email_mgr,
|
||||
calendar_manager=_make_cal_mgr(),
|
||||
file_manager=_make_file_mgr())
|
||||
|
||||
def test_deprovision_peer_never_provisioned_returns_empty(self):
|
||||
self.assertEqual(self.am.deprovision_peer('ghost'), {})
|
||||
|
||||
def test_deprovision_clears_credentials_even_when_manager_returns_false(self):
|
||||
"""Credentials are removed even if underlying manager reports failure."""
|
||||
self.am.provision('email', 'alice', password='pw')
|
||||
self.email_mgr.delete_email_user.return_value = False
|
||||
self.am.deprovision('email', 'alice')
|
||||
self.assertIsNone(self.am.get_credentials('email', 'alice'))
|
||||
|
||||
def test_provision_twice_overwrites_credentials(self):
|
||||
self.am.provision('email', 'alice', password='first')
|
||||
self.am.provision('email', 'alice', password='second')
|
||||
self.assertEqual(self.am.get_credentials('email', 'alice'), {'password': 'second'})
|
||||
|
||||
def test_provision_twice_calls_manager_both_times(self):
|
||||
self.am.provision('email', 'alice', password='first')
|
||||
self.am.provision('email', 'alice', password='second')
|
||||
self.assertEqual(self.email_mgr.create_email_user.call_count, 2)
|
||||
|
||||
def test_corrupted_credentials_file_returns_empty_and_continues(self):
|
||||
"""A corrupted JSON file is treated as empty rather than crashing."""
|
||||
creds_path = self.tmp / 'peer_service_credentials.json'
|
||||
creds_path.write_text('{invalid json}')
|
||||
result = self.am.get_all_credentials('alice')
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_file_permissions_preserved_on_second_write(self):
|
||||
"""0o600 must hold even after overwriting with a second provision."""
|
||||
self.am.provision('email', 'alice', password='first')
|
||||
self.am.provision('email', 'bob', password='second')
|
||||
creds_path = self.tmp / 'peer_service_credentials.json'
|
||||
mode = stat.S_IMODE(creds_path.stat().st_mode)
|
||||
self.assertEqual(mode, 0o600, f'Expected 0o600 after overwrite, got {oct(mode)}')
|
||||
|
||||
def test_generated_password_is_url_safe(self):
|
||||
"""token_urlsafe must not produce + or / characters."""
|
||||
creds = self.am.provision('email', 'alice')
|
||||
pwd = creds['password']
|
||||
self.assertNotIn('+', pwd)
|
||||
self.assertNotIn('/', pwd)
|
||||
|
||||
def test_store_then_deprovision_removes_credentials(self):
|
||||
"""store_credentials + deprovision should cleanly remove the entry."""
|
||||
self.am.store_credentials('email', 'alice', {'password': 'stored'})
|
||||
self.am.deprovision('email', 'alice')
|
||||
self.assertIsNone(self.am.get_credentials('email', 'alice'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Tests for service-volume backup/restore in ConfigManager.
|
||||
|
||||
Covers:
|
||||
- _backup_service_volumes: happy path, container not running, timeout
|
||||
- _restore_service_volumes: happy path, missing archive, unknown service
|
||||
- backup_config: passes service_registry, records includes_service_data
|
||||
- restore_config: passes service_registry on full restore, not on selective
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
|
||||
|
||||
from config_manager import ConfigManager
|
||||
|
||||
|
||||
def _make_cm(tmp_path: Path) -> ConfigManager:
|
||||
cfg_file = tmp_path / 'cell_config.json'
|
||||
cfg_file.write_text('{}')
|
||||
cm = ConfigManager(config_file=str(cfg_file), data_dir=str(tmp_path))
|
||||
return cm
|
||||
|
||||
|
||||
def _make_registry(plan=None):
|
||||
"""Return a mock ServiceRegistry with a preset backup plan."""
|
||||
reg = MagicMock()
|
||||
reg.get_backup_plan.return_value = plan if plan is not None else [
|
||||
{
|
||||
'service_id': 'email',
|
||||
'volumes': [
|
||||
{'container': 'cell-mail', 'path': '/var/mail', 'name': 'maildata'},
|
||||
{'container': 'cell-mail', 'path': '/var/mail-state', 'name': 'mailstate'},
|
||||
],
|
||||
'config_paths': [],
|
||||
},
|
||||
{
|
||||
'service_id': 'calendar',
|
||||
'volumes': [
|
||||
{'container': 'cell-radicale', 'path': '/data', 'name': 'radicale_data'},
|
||||
],
|
||||
'config_paths': [],
|
||||
},
|
||||
]
|
||||
return reg
|
||||
|
||||
|
||||
class TestBackupServiceVolumesHappyPath(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.cm = _make_cm(self.tmp)
|
||||
self.backup_path = self.tmp / 'test_backup'
|
||||
self.backup_path.mkdir()
|
||||
|
||||
def _run_backup(self, registry=None):
|
||||
if registry is None:
|
||||
registry = _make_registry()
|
||||
self.cm._backup_service_volumes(self.backup_path, registry)
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_creates_service_data_dir(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
|
||||
self._run_backup()
|
||||
self.assertTrue((self.backup_path / 'service_data' / 'email').is_dir())
|
||||
self.assertTrue((self.backup_path / 'service_data' / 'calendar').is_dir())
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_calls_docker_exec_tar_for_each_volume(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
|
||||
self._run_backup()
|
||||
commands = [tuple(c.args[0]) for c in mock_run.call_args_list]
|
||||
self.assertIn(
|
||||
('docker', 'exec', '--', 'cell-mail', 'tar', '-C', '/var/mail', '-czf', '-', '.'),
|
||||
commands,
|
||||
)
|
||||
self.assertIn(
|
||||
('docker', 'exec', '--', 'cell-mail', 'tar', '-C', '/var/mail-state', '-czf', '-', '.'),
|
||||
commands,
|
||||
)
|
||||
self.assertIn(
|
||||
('docker', 'exec', '--', 'cell-radicale', 'tar', '-C', '/data', '-czf', '-', '.'),
|
||||
commands,
|
||||
)
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_writes_archive_files(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
|
||||
self._run_backup()
|
||||
self.assertTrue((self.backup_path / 'service_data' / 'email' / 'maildata.tar.gz').exists())
|
||||
self.assertTrue((self.backup_path / 'service_data' / 'email' / 'mailstate.tar.gz').exists())
|
||||
self.assertTrue((self.backup_path / 'service_data' / 'calendar' / 'radicale_data.tar.gz').exists())
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_removes_archive_on_nonzero_returncode(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr=b'container not running')
|
||||
self._run_backup()
|
||||
self.assertFalse(
|
||||
(self.backup_path / 'service_data' / 'email' / 'maildata.tar.gz').exists()
|
||||
)
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_continues_after_one_volume_fails(self, mock_run):
|
||||
def side_effect(cmd, **kwargs):
|
||||
if 'cell-mail' in cmd:
|
||||
return MagicMock(returncode=1, stderr=b'error')
|
||||
return MagicMock(returncode=0, stderr=b'')
|
||||
mock_run.side_effect = side_effect
|
||||
self._run_backup()
|
||||
# radicale should still succeed
|
||||
self.assertTrue(
|
||||
(self.backup_path / 'service_data' / 'calendar' / 'radicale_data.tar.gz').exists()
|
||||
)
|
||||
|
||||
@patch('config_manager.subprocess.run', side_effect=subprocess.TimeoutExpired('docker', 300))
|
||||
def test_timeout_removes_partial_archive(self, _mock_run):
|
||||
self._run_backup()
|
||||
# no archive should remain after a timeout
|
||||
for svc in ('email', 'calendar'):
|
||||
for name in ('maildata', 'mailstate', 'radicale_data'):
|
||||
self.assertFalse(
|
||||
(self.backup_path / 'service_data' / svc / f'{name}.tar.gz').exists()
|
||||
)
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_empty_volumes_list_skipped(self, mock_run):
|
||||
registry = _make_registry(plan=[
|
||||
{'service_id': 'widget', 'volumes': [], 'config_paths': []}
|
||||
])
|
||||
self.cm._backup_service_volumes(self.backup_path, registry)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_get_backup_plan_exception_is_handled(self, mock_run):
|
||||
registry = MagicMock()
|
||||
registry.get_backup_plan.side_effect = RuntimeError('registry unavailable')
|
||||
# should not raise
|
||||
self.cm._backup_service_volumes(self.backup_path, registry)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_unsafe_container_name_rejected(self, mock_run):
|
||||
registry = _make_registry(plan=[{
|
||||
'service_id': 'evil', 'config_paths': [],
|
||||
'volumes': [{'container': '-it cell-api', 'path': '/data', 'name': 'data'}],
|
||||
}])
|
||||
self.cm._backup_service_volumes(self.backup_path, registry)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_path_traversal_in_volume_path_rejected(self, mock_run):
|
||||
registry = _make_registry(plan=[{
|
||||
'service_id': 'evil', 'config_paths': [],
|
||||
'volumes': [{'container': 'cell-mail', 'path': '/../etc', 'name': 'etc'}],
|
||||
}])
|
||||
self.cm._backup_service_volumes(self.backup_path, registry)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_relative_volume_path_rejected(self, mock_run):
|
||||
registry = _make_registry(plan=[{
|
||||
'service_id': 'evil', 'config_paths': [],
|
||||
'volumes': [{'container': 'cell-mail', 'path': 'data/maildata', 'name': 'data'}],
|
||||
}])
|
||||
self.cm._backup_service_volumes(self.backup_path, registry)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_unsafe_volume_name_rejected(self, mock_run):
|
||||
registry = _make_registry(plan=[{
|
||||
'service_id': 'evil', 'config_paths': [],
|
||||
'volumes': [{'container': 'cell-mail', 'path': '/var/mail', 'name': '../../etc/passwd'}],
|
||||
}])
|
||||
self.cm._backup_service_volumes(self.backup_path, registry)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_atomic_write_no_archive_on_partial_failure(self, mock_run):
|
||||
"""If an exception occurs during subprocess, no .tar.gz file should remain."""
|
||||
mock_run.side_effect = OSError('disk full')
|
||||
self._run_backup()
|
||||
for f in self.backup_path.rglob('*.tar.gz'):
|
||||
self.fail(f'Archive {f} should not exist after exception during backup')
|
||||
|
||||
|
||||
class TestRestoreServiceVolumes(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.cm = _make_cm(self.tmp)
|
||||
self.backup_path = self.tmp / 'test_backup'
|
||||
# Prepare a realistic backup structure
|
||||
svc_data = self.backup_path / 'service_data'
|
||||
(svc_data / 'email').mkdir(parents=True)
|
||||
(svc_data / 'email' / 'maildata.tar.gz').write_bytes(b'fake-archive')
|
||||
(svc_data / 'calendar').mkdir(parents=True)
|
||||
(svc_data / 'calendar' / 'radicale_data.tar.gz').write_bytes(b'fake-archive')
|
||||
|
||||
def _make_registry_with_manifests(self):
|
||||
reg = MagicMock()
|
||||
def get_side_effect(service_id):
|
||||
manifests = {
|
||||
'email': {'backup': {'volumes': [
|
||||
{'container': 'cell-mail', 'path': '/var/mail', 'name': 'maildata'},
|
||||
]}},
|
||||
'calendar': {'backup': {'volumes': [
|
||||
{'container': 'cell-radicale', 'path': '/data', 'name': 'radicale_data'},
|
||||
]}},
|
||||
}
|
||||
return manifests.get(service_id)
|
||||
reg.get.side_effect = get_side_effect
|
||||
return reg
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_calls_docker_exec_tar_for_each_archive(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
|
||||
registry = self._make_registry_with_manifests()
|
||||
self.cm._restore_service_volumes(self.backup_path, registry)
|
||||
commands = [tuple(c.args[0]) for c in mock_run.call_args_list]
|
||||
self.assertIn(
|
||||
('docker', 'exec', '-i', '--', 'cell-mail', 'tar', '-C', '/var/mail', '-xzf', '-'),
|
||||
commands,
|
||||
)
|
||||
self.assertIn(
|
||||
('docker', 'exec', '-i', '--', 'cell-radicale', 'tar', '-C', '/data', '-xzf', '-'),
|
||||
commands,
|
||||
)
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_skips_missing_archive(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
|
||||
registry = MagicMock()
|
||||
registry.get.return_value = {'backup': {'volumes': [
|
||||
{'container': 'cell-mail', 'path': '/var/mail', 'name': 'no_such_archive'},
|
||||
]}}
|
||||
self.cm._restore_service_volumes(self.backup_path, registry)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_skips_unknown_service(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr=b'')
|
||||
registry = MagicMock()
|
||||
registry.get.return_value = None
|
||||
self.cm._restore_service_volumes(self.backup_path, registry)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_no_service_data_dir_is_noop(self, mock_run):
|
||||
empty_backup = self.tmp / 'empty_backup'
|
||||
empty_backup.mkdir()
|
||||
registry = self._make_registry_with_manifests()
|
||||
self.cm._restore_service_volumes(empty_backup, registry)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch('config_manager.subprocess.run', side_effect=subprocess.TimeoutExpired('docker', 300))
|
||||
def test_timeout_is_handled_gracefully(self, _mock_run):
|
||||
registry = self._make_registry_with_manifests()
|
||||
# should not raise
|
||||
self.cm._restore_service_volumes(self.backup_path, registry)
|
||||
|
||||
@patch('config_manager.subprocess.run')
|
||||
def test_continues_after_docker_exec_failure(self, mock_run):
|
||||
call_count = [0]
|
||||
def side_effect(cmd, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return MagicMock(returncode=1, stderr=b'container not running')
|
||||
return MagicMock(returncode=0, stderr=b'')
|
||||
mock_run.side_effect = side_effect
|
||||
registry = self._make_registry_with_manifests()
|
||||
self.cm._restore_service_volumes(self.backup_path, registry)
|
||||
self.assertEqual(call_count[0], 2)
|
||||
|
||||
|
||||
class TestBackupConfigWithRegistry(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.cm = _make_cm(self.tmp)
|
||||
|
||||
@patch.object(ConfigManager, '_backup_service_volumes')
|
||||
def test_backup_calls_volume_backup_when_registry_given(self, mock_bsv):
|
||||
registry = _make_registry()
|
||||
self.cm.backup_config(service_registry=registry)
|
||||
mock_bsv.assert_called_once()
|
||||
args = mock_bsv.call_args
|
||||
self.assertIs(args[0][1], registry)
|
||||
|
||||
@patch.object(ConfigManager, '_backup_service_volumes')
|
||||
def test_backup_skips_volume_backup_when_no_registry(self, mock_bsv):
|
||||
self.cm.backup_config(service_registry=None)
|
||||
mock_bsv.assert_not_called()
|
||||
|
||||
@patch.object(ConfigManager, '_backup_service_volumes')
|
||||
def test_manifest_records_includes_service_data_true(self, _mock_bsv):
|
||||
registry = _make_registry()
|
||||
backup_id = self.cm.backup_config(service_registry=registry)
|
||||
manifest = json.loads((self.cm.backup_dir / backup_id / 'manifest.json').read_text())
|
||||
self.assertTrue(manifest['includes_service_data'])
|
||||
|
||||
@patch.object(ConfigManager, '_backup_service_volumes')
|
||||
def test_manifest_records_includes_service_data_false(self, _mock_bsv):
|
||||
backup_id = self.cm.backup_config(service_registry=None)
|
||||
manifest = json.loads((self.cm.backup_dir / backup_id / 'manifest.json').read_text())
|
||||
self.assertFalse(manifest['includes_service_data'])
|
||||
|
||||
|
||||
class TestRestoreConfigWithRegistry(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.cm = _make_cm(self.tmp)
|
||||
# Create a minimal backup
|
||||
backup_id = 'backup_20260101_000000'
|
||||
bp = self.cm.backup_dir / backup_id
|
||||
bp.mkdir(parents=True)
|
||||
(bp / 'cell_config.json').write_text('{}')
|
||||
manifest = {'backup_id': backup_id, 'timestamp': '2026-01-01T00:00:00', 'services': []}
|
||||
(bp / 'manifest.json').write_text(json.dumps(manifest))
|
||||
self.backup_id = backup_id
|
||||
|
||||
@patch.object(ConfigManager, '_restore_service_volumes')
|
||||
def test_full_restore_calls_volume_restore_when_registry_given(self, mock_rsv):
|
||||
registry = _make_registry()
|
||||
self.cm.restore_config(self.backup_id, service_registry=registry)
|
||||
mock_rsv.assert_called_once()
|
||||
args = mock_rsv.call_args
|
||||
self.assertIs(args[0][1], registry)
|
||||
|
||||
@patch.object(ConfigManager, '_restore_service_volumes')
|
||||
def test_full_restore_skips_volume_restore_when_no_registry(self, mock_rsv):
|
||||
self.cm.restore_config(self.backup_id, service_registry=None)
|
||||
mock_rsv.assert_not_called()
|
||||
|
||||
@patch.object(ConfigManager, '_restore_service_volumes')
|
||||
def test_selective_restore_never_calls_volume_restore(self, mock_rsv):
|
||||
"""Volume restore is skipped for selective restores (service list specified)."""
|
||||
registry = _make_registry()
|
||||
self.cm.restore_config(self.backup_id, services=['email'], service_registry=registry)
|
||||
mock_rsv.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,528 @@
|
||||
"""Integration tests for registry-driven CaddyManager and NetworkManager routing.
|
||||
|
||||
These tests cover the new registry path introduced in Step 5 of the PIC Services
|
||||
Architecture. The no-registry (fallback) paths are already covered by
|
||||
test_caddy_manager.py and test_network_manager.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
|
||||
|
||||
from caddy_manager import CaddyManager # noqa: E402
|
||||
from network_manager import NetworkManager # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mgr_with_registry(registry=None):
|
||||
"""Build a CaddyManager wired to an optional mock registry."""
|
||||
cm = MagicMock()
|
||||
cm.get_identity.return_value = {}
|
||||
return CaddyManager(config_manager=cm, service_registry=registry)
|
||||
|
||||
|
||||
def _mock_registry():
|
||||
"""Return a mock ServiceRegistry that reproduces the 3 builtin service routes."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.return_value = [
|
||||
{
|
||||
'service_id': 'calendar',
|
||||
'subdomain': 'calendar',
|
||||
'backend': 'cell-radicale:5232',
|
||||
'extra_subdomains': [],
|
||||
'extra_backends': {},
|
||||
},
|
||||
{
|
||||
'service_id': 'email',
|
||||
'subdomain': 'mail',
|
||||
'backend': 'cell-rainloop:8888',
|
||||
'extra_subdomains': ['webmail'],
|
||||
'extra_backends': {},
|
||||
},
|
||||
{
|
||||
'service_id': 'files',
|
||||
'subdomain': 'files',
|
||||
'backend': 'cell-filegator:8080',
|
||||
'extra_subdomains': ['webdav'],
|
||||
'extra_backends': {'webdav': 'cell-webdav:80'},
|
||||
},
|
||||
]
|
||||
return reg
|
||||
|
||||
|
||||
def _nm(registry=None):
|
||||
"""Build a NetworkManager backed by temp dirs and an optional mock registry."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
nm = NetworkManager(
|
||||
data_dir=os.path.join(tmpdir, 'data'),
|
||||
config_dir=os.path.join(tmpdir, 'config'),
|
||||
service_registry=registry,
|
||||
)
|
||||
nm._tmpdir = tmpdir # stash so the caller can clean up
|
||||
return nm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestBuildRegistryServiceRoutes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildRegistryServiceRoutes(unittest.TestCase):
|
||||
|
||||
def test_returns_hardcoded_when_no_registry(self):
|
||||
"""service_registry=None produces the same output as _build_core_service_routes."""
|
||||
mgr = _mgr_with_registry(registry=None)
|
||||
domain = 'alpha.pic.ngo'
|
||||
result = mgr._build_registry_service_routes(domain)
|
||||
expected = CaddyManager._build_core_service_routes(domain)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_returns_hardcoded_when_registry_empty(self):
|
||||
"""An empty route list from the registry falls back to hardcoded."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.return_value = []
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
domain = 'alpha.pic.ngo'
|
||||
result = mgr._build_registry_service_routes(domain)
|
||||
expected = CaddyManager._build_core_service_routes(domain)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_registry_error_falls_back(self):
|
||||
"""When get_caddy_routes raises, output equals _build_core_service_routes."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.side_effect = Exception('registry unavailable')
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
domain = 'alpha.pic.ngo'
|
||||
result = mgr._build_registry_service_routes(domain)
|
||||
expected = CaddyManager._build_core_service_routes(domain)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_single_service_no_extras(self):
|
||||
"""One service with no extra_subdomains produces one matcher + handle + api block."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.return_value = [
|
||||
{
|
||||
'service_id': 'calendar',
|
||||
'subdomain': 'calendar',
|
||||
'backend': 'cell-radicale:5232',
|
||||
'extra_subdomains': [],
|
||||
'extra_backends': {},
|
||||
}
|
||||
]
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
result = mgr._build_registry_service_routes('test.cell')
|
||||
self.assertIn('@calendar host calendar.test.cell', result)
|
||||
self.assertIn('reverse_proxy cell-radicale:5232', result)
|
||||
self.assertIn('@api host api.test.cell', result)
|
||||
self.assertIn('reverse_proxy cell-api:3000', result)
|
||||
# Only two named-matcher definition lines: @calendar and @api
|
||||
matcher_lines = [l for l in result.splitlines() if l.strip().startswith('@') and 'host' in l]
|
||||
self.assertEqual(len(matcher_lines), 2)
|
||||
|
||||
def test_extra_subdomain_same_backend(self):
|
||||
"""An extra_subdomain NOT in extra_backends shares the primary matcher host line."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.return_value = [
|
||||
{
|
||||
'service_id': 'email',
|
||||
'subdomain': 'mail',
|
||||
'backend': 'cell-rainloop:8888',
|
||||
'extra_subdomains': ['webmail'],
|
||||
'extra_backends': {}, # webmail not listed → shares backend
|
||||
}
|
||||
]
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
result = mgr._build_registry_service_routes('test.cell')
|
||||
# Both subdomains appear in the same host matcher line
|
||||
self.assertIn('@mail host mail.test.cell webmail.test.cell', result)
|
||||
# Only one reverse_proxy for cell-rainloop (shared block)
|
||||
self.assertEqual(result.count('reverse_proxy cell-rainloop:8888'), 1)
|
||||
# No separate @webmail block
|
||||
self.assertNotIn('@webmail host', result)
|
||||
|
||||
def test_extra_subdomain_different_backend(self):
|
||||
"""An extra_subdomain listed in extra_backends gets its own matcher + handle block."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.return_value = [
|
||||
{
|
||||
'service_id': 'files',
|
||||
'subdomain': 'files',
|
||||
'backend': 'cell-filegator:8080',
|
||||
'extra_subdomains': ['webdav'],
|
||||
'extra_backends': {'webdav': 'cell-webdav:80'},
|
||||
}
|
||||
]
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
result = mgr._build_registry_service_routes('test.cell')
|
||||
# files gets its own block (webdav not in shared list)
|
||||
self.assertIn('@files host files.test.cell', result)
|
||||
self.assertIn('reverse_proxy cell-filegator:8080', result)
|
||||
# webdav gets a separate block
|
||||
self.assertIn('@webdav host webdav.test.cell', result)
|
||||
self.assertIn('reverse_proxy cell-webdav:80', result)
|
||||
# webdav must NOT appear in the @files host line
|
||||
files_line = [l for l in result.splitlines() if '@files host' in l][0]
|
||||
self.assertNotIn('webdav', files_line)
|
||||
|
||||
def test_api_always_appended(self):
|
||||
"""The @api block is always the last block even when registry has no api entry."""
|
||||
reg = _mock_registry()
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
result = mgr._build_registry_service_routes('alpha.pic.ngo')
|
||||
self.assertIn('@api host api.alpha.pic.ngo', result)
|
||||
self.assertIn('reverse_proxy cell-api:3000', result)
|
||||
# api block is at the end
|
||||
api_idx = result.rfind('@api')
|
||||
other_matchers = ['@calendar', '@mail', '@files', '@webdav']
|
||||
for m in other_matchers:
|
||||
self.assertLess(result.index(m), api_idx,
|
||||
f'{m} should appear before @api')
|
||||
|
||||
def test_api_not_duplicated_when_registry_returns_api(self):
|
||||
"""Even if registry somehow returns an 'api' route, the injected api block is cell-api:3000."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.return_value = [
|
||||
{
|
||||
'service_id': 'api',
|
||||
'subdomain': 'api',
|
||||
'backend': 'cell-other:9999', # wrong backend — should be overridden
|
||||
'extra_subdomains': [],
|
||||
'extra_backends': {},
|
||||
}
|
||||
]
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
result = mgr._build_registry_service_routes('test.cell')
|
||||
# The infrastructure api block is always appended with the canonical backend
|
||||
self.assertIn('reverse_proxy cell-api:3000', result)
|
||||
# api host matcher appears at least once (from registry AND from append)
|
||||
self.assertGreaterEqual(result.count('@api host api.test.cell'), 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestHttp01ServicePairs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHttp01ServicePairs(unittest.TestCase):
|
||||
|
||||
def test_pairs_from_registry(self):
|
||||
"""With the 3 builtins the pairs list matches expected (subdomain, backend) tuples."""
|
||||
reg = _mock_registry()
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
pairs = mgr._http01_service_pairs()
|
||||
pairs_dict = dict(pairs)
|
||||
self.assertEqual(pairs_dict['calendar'], 'cell-radicale:5232')
|
||||
self.assertEqual(pairs_dict['mail'], 'cell-rainloop:8888')
|
||||
self.assertEqual(pairs_dict['webmail'], 'cell-rainloop:8888')
|
||||
self.assertEqual(pairs_dict['files'], 'cell-filegator:8080')
|
||||
self.assertEqual(pairs_dict['webdav'], 'cell-webdav:80')
|
||||
self.assertEqual(pairs_dict['api'], 'cell-api:3000')
|
||||
|
||||
def test_webdav_gets_own_backend(self):
|
||||
"""webdav must map to cell-webdav:80, not to cell-filegator:8080."""
|
||||
reg = _mock_registry()
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
pairs = mgr._http01_service_pairs()
|
||||
webdav_entry = next((b for s, b in pairs if s == 'webdav'), None)
|
||||
self.assertIsNotNone(webdav_entry)
|
||||
self.assertEqual(webdav_entry, 'cell-webdav:80')
|
||||
self.assertNotEqual(webdav_entry, 'cell-filegator:8080')
|
||||
|
||||
def test_fallback_when_no_registry(self):
|
||||
"""Without a registry the hardcoded pairs are returned, including api."""
|
||||
mgr = _mgr_with_registry(registry=None)
|
||||
pairs = mgr._http01_service_pairs()
|
||||
subdomains = [s for s, _ in pairs]
|
||||
self.assertIn('calendar', subdomains)
|
||||
self.assertIn('mail', subdomains)
|
||||
self.assertIn('webmail', subdomains)
|
||||
self.assertIn('files', subdomains)
|
||||
self.assertIn('webdav', subdomains)
|
||||
self.assertIn('api', subdomains)
|
||||
|
||||
def test_fallback_when_registry_error(self):
|
||||
"""When get_caddy_routes raises, falls back to hardcoded pairs."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.side_effect = RuntimeError('boom')
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
pairs = mgr._http01_service_pairs()
|
||||
subdomains = [s for s, _ in pairs]
|
||||
self.assertIn('calendar', subdomains)
|
||||
self.assertIn('api', subdomains)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCaddyfileWithRegistry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCaddyfileWithRegistry(unittest.TestCase):
|
||||
|
||||
def _generate(self, domain_mode, cell_name='alpha', domain_name=None,
|
||||
registry=None, services=None):
|
||||
reg = registry if registry is not None else _mock_registry()
|
||||
mgr = _mgr_with_registry(registry=reg)
|
||||
identity = {'cell_name': cell_name, 'domain_mode': domain_mode}
|
||||
if domain_name:
|
||||
identity['domain_name'] = domain_name
|
||||
return mgr.generate_caddyfile(identity, services or [])
|
||||
|
||||
def test_pic_ngo_with_registry_has_correct_routes(self):
|
||||
"""pic_ngo Caddyfile has all service matchers with correct subdomains and backends."""
|
||||
out = self._generate('pic_ngo', cell_name='alpha')
|
||||
# calendar
|
||||
self.assertIn('@calendar host calendar.alpha.pic.ngo', out)
|
||||
self.assertIn('reverse_proxy cell-radicale:5232', out)
|
||||
# mail + webmail share one matcher
|
||||
self.assertIn('@mail host mail.alpha.pic.ngo webmail.alpha.pic.ngo', out)
|
||||
self.assertIn('reverse_proxy cell-rainloop:8888', out)
|
||||
# files
|
||||
self.assertIn('@files host files.alpha.pic.ngo', out)
|
||||
self.assertIn('reverse_proxy cell-filegator:8080', out)
|
||||
# webdav separate block
|
||||
self.assertIn('@webdav host webdav.alpha.pic.ngo', out)
|
||||
self.assertIn('reverse_proxy cell-webdav:80', out)
|
||||
# api always present
|
||||
self.assertIn('@api host api.alpha.pic.ngo', out)
|
||||
self.assertIn('reverse_proxy cell-api:3000', out)
|
||||
|
||||
def test_cloudflare_with_registry_uses_registry_routes(self):
|
||||
"""cloudflare Caddyfile routes are sourced from registry, not hardcoded."""
|
||||
out = self._generate('cloudflare', cell_name='beta',
|
||||
domain_name='example.com')
|
||||
self.assertIn('@calendar host calendar.example.com', out)
|
||||
self.assertIn('@mail host mail.example.com webmail.example.com', out)
|
||||
self.assertIn('@files host files.example.com', out)
|
||||
self.assertIn('@webdav host webdav.example.com', out)
|
||||
self.assertIn('@api host api.example.com', out)
|
||||
# Correct DNS plugin block is still present
|
||||
self.assertIn('dns cloudflare {$CF_API_TOKEN}', out)
|
||||
|
||||
def test_duckdns_with_registry_uses_registry_routes(self):
|
||||
"""duckdns Caddyfile routes are sourced from registry."""
|
||||
out = self._generate('duckdns', cell_name='gamma')
|
||||
self.assertIn('@calendar host calendar.gamma.duckdns.org', out)
|
||||
self.assertIn('@api host api.gamma.duckdns.org', out)
|
||||
self.assertIn('dns duckdns {$DUCKDNS_TOKEN}', out)
|
||||
|
||||
def test_http01_with_registry_has_per_host_blocks(self):
|
||||
"""http01 Caddyfile has individual per-host blocks for every service subdomain."""
|
||||
out = self._generate('http01', cell_name='delta',
|
||||
domain_name='delta.noip.me')
|
||||
self.assertIn('calendar.delta.noip.me {', out)
|
||||
self.assertIn('mail.delta.noip.me {', out)
|
||||
self.assertIn('webmail.delta.noip.me {', out)
|
||||
self.assertIn('files.delta.noip.me {', out)
|
||||
self.assertIn('webdav.delta.noip.me {', out)
|
||||
self.assertIn('api.delta.noip.me {', out)
|
||||
# Correct backends
|
||||
self.assertIn('reverse_proxy cell-radicale:5232', out)
|
||||
self.assertIn('reverse_proxy cell-rainloop:8888', out)
|
||||
self.assertIn('reverse_proxy cell-filegator:8080', out)
|
||||
self.assertIn('reverse_proxy cell-webdav:80', out)
|
||||
|
||||
def test_pic_ngo_fallback_when_registry_empty(self):
|
||||
"""pic_ngo falls back to hardcoded routes when registry returns empty list."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.return_value = []
|
||||
out = self._generate('pic_ngo', cell_name='alpha', registry=reg)
|
||||
# Hardcoded routes should appear
|
||||
self.assertIn('@calendar host calendar.alpha.pic.ngo', out)
|
||||
self.assertIn('@mail host mail.alpha.pic.ngo webmail.alpha.pic.ngo', out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestNetworkManagerGetServiceSubdomains
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNetworkManagerGetServiceSubdomains(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.managers = []
|
||||
|
||||
def tearDown(self):
|
||||
for nm in self.managers:
|
||||
shutil.rmtree(nm._tmpdir, ignore_errors=True)
|
||||
|
||||
def _make(self, registry=None):
|
||||
nm = _nm(registry=registry)
|
||||
self.managers.append(nm)
|
||||
return nm
|
||||
|
||||
def test_no_registry_returns_hardcoded(self):
|
||||
"""Without a registry the hardcoded service subdomain list is returned."""
|
||||
nm = self._make(registry=None)
|
||||
subs = nm._get_service_subdomains()
|
||||
self.assertCountEqual(subs, ['calendar', 'files', 'mail', 'webmail', 'webdav'])
|
||||
|
||||
def test_registry_returns_all_subdomains(self):
|
||||
"""Primary + extra_subdomains from all routes are returned."""
|
||||
reg = _mock_registry()
|
||||
nm = self._make(registry=reg)
|
||||
subs = nm._get_service_subdomains()
|
||||
# calendar (primary), mail (primary), webmail (extra), files (primary), webdav (extra)
|
||||
for expected in ('calendar', 'mail', 'webmail', 'files', 'webdav'):
|
||||
self.assertIn(expected, subs)
|
||||
|
||||
def test_registry_error_falls_back(self):
|
||||
"""When get_caddy_routes raises, hardcoded list is returned."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.side_effect = Exception('broken registry')
|
||||
nm = self._make(registry=reg)
|
||||
subs = nm._get_service_subdomains()
|
||||
self.assertCountEqual(subs, ['calendar', 'files', 'mail', 'webmail', 'webdav'])
|
||||
|
||||
def test_registry_extra_subdomains_included(self):
|
||||
"""extra_subdomains from each route are included in the returned list."""
|
||||
reg = MagicMock()
|
||||
reg.get_caddy_routes.return_value = [
|
||||
{
|
||||
'service_id': 'files',
|
||||
'subdomain': 'files',
|
||||
'backend': 'cell-filegator:8080',
|
||||
'extra_subdomains': ['webdav', 'dav'],
|
||||
'extra_backends': {},
|
||||
}
|
||||
]
|
||||
nm = self._make(registry=reg)
|
||||
subs = nm._get_service_subdomains()
|
||||
self.assertIn('files', subs)
|
||||
self.assertIn('webdav', subs)
|
||||
self.assertIn('dav', subs)
|
||||
|
||||
def test_build_dns_records_with_registry(self):
|
||||
"""All registry subdomains appear as A records in _build_dns_records output."""
|
||||
reg = _mock_registry()
|
||||
nm = self._make(registry=reg)
|
||||
# Override WG IP lookup so we get a predictable value
|
||||
nm._get_wg_server_ip = lambda: '10.0.0.1'
|
||||
records = nm._build_dns_records('mycell', '172.20.0.0/16')
|
||||
names = [r['name'] for r in records]
|
||||
for expected in ('mycell', 'api', 'webui', 'calendar', 'mail',
|
||||
'webmail', 'files', 'webdav'):
|
||||
self.assertIn(expected, names,
|
||||
f'{expected!r} should be in DNS records but is not')
|
||||
# All records must point to the WG server IP
|
||||
for r in records:
|
||||
self.assertEqual(r['value'], '10.0.0.1')
|
||||
self.assertEqual(r['type'], 'A')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestNetworkManagerStaleSet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNetworkManagerStaleSet(unittest.TestCase):
|
||||
"""Verify that registry subdomains drive stale record cleanup in update_split_horizon_zone."""
|
||||
|
||||
def setUp(self):
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
data_dir = os.path.join(self.test_dir, 'data')
|
||||
config_dir = os.path.join(self.test_dir, 'config')
|
||||
os.makedirs(os.path.join(data_dir, 'dns'), exist_ok=True)
|
||||
os.makedirs(os.path.join(config_dir, 'dns'), exist_ok=True)
|
||||
self.reg = _mock_registry()
|
||||
self.nm = NetworkManager(
|
||||
data_dir=data_dir,
|
||||
config_dir=config_dir,
|
||||
service_registry=self.reg,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
|
||||
def _write_zone(self, zone_name: str, content: str):
|
||||
path = os.path.join(self.nm.dns_zones_dir, f'{zone_name}.zone')
|
||||
with open(path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
def test_stale_set_includes_registry_subdomains(self):
|
||||
"""Registry subdomains (calendar, mail, webmail, files, webdav) are treated as
|
||||
stale service records and removed from the parent zone during
|
||||
update_split_horizon_zone."""
|
||||
import subprocess
|
||||
# Build a parent zone with stale service records that the registry knows about
|
||||
stale_records = [
|
||||
{'name': 'pic2', 'type': 'A', 'value': '10.0.0.1'},
|
||||
{'name': 'api', 'type': 'A', 'value': '10.0.0.1'},
|
||||
{'name': 'webui', 'type': 'A', 'value': '10.0.0.1'},
|
||||
{'name': 'calendar', 'type': 'A', 'value': '10.0.0.1'},
|
||||
{'name': 'mail', 'type': 'A', 'value': '10.0.0.1'},
|
||||
{'name': 'webmail', 'type': 'A', 'value': '10.0.0.1'},
|
||||
{'name': 'files', 'type': 'A', 'value': '10.0.0.1'},
|
||||
{'name': 'webdav', 'type': 'A', 'value': '10.0.0.1'},
|
||||
]
|
||||
from unittest.mock import patch
|
||||
with patch('subprocess.run'):
|
||||
self.nm.update_dns_zone('pic.ngo', stale_records)
|
||||
self.nm.update_split_horizon_zone(
|
||||
'pic2.pic.ngo', '172.20.0.2', primary_domain='pic.ngo'
|
||||
)
|
||||
|
||||
parent_zone = os.path.join(self.nm.dns_zones_dir, 'pic.ngo.zone')
|
||||
content = open(parent_zone).read()
|
||||
|
||||
# All registry subdomains must be gone
|
||||
for stale in ('api', 'webui', 'calendar', 'mail', 'webmail', 'files', 'webdav'):
|
||||
# Check that no line *starts* with the stale name (to avoid false positives
|
||||
# on SOA/NS lines that may contain the zone name as a suffix)
|
||||
lines_with_stale = [
|
||||
l for l in content.splitlines()
|
||||
if l.startswith(stale + ' ') or l.startswith(stale + '\t')
|
||||
]
|
||||
self.assertEqual(
|
||||
lines_with_stale, [],
|
||||
f'Stale record {stale!r} should have been removed from pic.ngo zone'
|
||||
)
|
||||
|
||||
def test_stale_set_uses_registry_not_hardcoded(self):
|
||||
"""When a registry provides a custom subdomain, it is treated as stale too."""
|
||||
custom_reg = MagicMock()
|
||||
custom_reg.get_caddy_routes.return_value = [
|
||||
{
|
||||
'service_id': 'chat',
|
||||
'subdomain': 'chat',
|
||||
'backend': 'cell-chat:9000',
|
||||
'extra_subdomains': ['im'],
|
||||
'extra_backends': {},
|
||||
}
|
||||
]
|
||||
data_dir = os.path.join(self.test_dir, 'data2')
|
||||
config_dir = os.path.join(self.test_dir, 'config2')
|
||||
os.makedirs(os.path.join(data_dir, 'dns'), exist_ok=True)
|
||||
os.makedirs(os.path.join(config_dir, 'dns'), exist_ok=True)
|
||||
nm = NetworkManager(data_dir=data_dir, config_dir=config_dir,
|
||||
service_registry=custom_reg)
|
||||
|
||||
stale_records = [
|
||||
{'name': 'pic3', 'type': 'A', 'value': '10.0.0.1'},
|
||||
{'name': 'chat', 'type': 'A', 'value': '10.0.0.1'},
|
||||
{'name': 'im', 'type': 'A', 'value': '10.0.0.1'},
|
||||
]
|
||||
from unittest.mock import patch
|
||||
with patch('subprocess.run'):
|
||||
nm.update_dns_zone('pic.ngo', stale_records)
|
||||
nm.update_split_horizon_zone(
|
||||
'pic3.pic.ngo', '172.20.0.2', primary_domain='pic.ngo'
|
||||
)
|
||||
|
||||
parent_zone = os.path.join(nm.dns_zones_dir, 'pic.ngo.zone')
|
||||
content = open(parent_zone).read()
|
||||
for stale in ('chat', 'im'):
|
||||
lines_with_stale = [
|
||||
l for l in content.splitlines()
|
||||
if l.startswith(stale + ' ') or l.startswith(stale + '\t')
|
||||
]
|
||||
self.assertEqual(
|
||||
lines_with_stale, [],
|
||||
f'Custom registry subdomain {stale!r} should have been removed'
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -119,14 +119,17 @@ class TestRestoreConfigBackup(unittest.TestCase):
|
||||
content_type='application/json',
|
||||
)
|
||||
mock_cm.restore_config.assert_called_once_with(
|
||||
'backup_001', services=['network', 'wireguard']
|
||||
'backup_001', services=['network', 'wireguard'], service_registry=None
|
||||
)
|
||||
|
||||
@patch('app.config_manager')
|
||||
def test_restore_passes_none_services_when_no_body(self, mock_cm):
|
||||
from unittest.mock import ANY
|
||||
mock_cm.restore_config.return_value = True
|
||||
self.client.post('/api/config/restore/backup_001')
|
||||
mock_cm.restore_config.assert_called_once_with('backup_001', services=None)
|
||||
mock_cm.restore_config.assert_called_once_with(
|
||||
'backup_001', services=None, service_registry=ANY
|
||||
)
|
||||
|
||||
|
||||
class TestExportConfig(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"""
|
||||
Unit tests for ServiceComposer.
|
||||
|
||||
All subprocess calls and filesystem writes are mocked — no Docker daemon required.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, mock_open, call
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
|
||||
|
||||
from service_composer import ServiceComposer, _SECRET_RE
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_cm(identity=None, service_config=None) -> MagicMock:
|
||||
cm = MagicMock()
|
||||
ident = identity or {'cell_name': 'testcell', 'domain': 'cell.local', 'domain_mode': 'lan'}
|
||||
cm.get_identity.return_value = ident
|
||||
cm.get_effective_domain.return_value = ident.get('domain', 'cell.local')
|
||||
cm.configs = {}
|
||||
if service_config:
|
||||
cm.configs.update(service_config)
|
||||
return cm
|
||||
|
||||
|
||||
def _make_manifest(service_id='myservice', kind='store', schema=None):
|
||||
return {
|
||||
'id': service_id,
|
||||
'kind': kind,
|
||||
'config_schema': schema or {
|
||||
'port': {'type': 'integer', 'default': 8080},
|
||||
'username': {'type': 'string', 'default': 'admin'},
|
||||
},
|
||||
'containers': [f'cell-{service_id}'],
|
||||
}
|
||||
|
||||
|
||||
def _composer(cm=None, data_dir=None):
|
||||
if data_dir is None:
|
||||
data_dir = '/fake/data'
|
||||
return ServiceComposer(config_manager=cm or _make_cm(), data_dir=data_dir)
|
||||
|
||||
|
||||
# ── Template rendering ────────────────────────────────────────────────────────
|
||||
|
||||
class TestRenderTemplate(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cm = _make_cm(service_config={'myservice': {'port': 9090}})
|
||||
self.composer = _composer(self.cm)
|
||||
|
||||
def test_substitutes_pic_cfg_uppercase(self):
|
||||
manifest = _make_manifest()
|
||||
template = 'PORT=${PIC_CFG_PORT}'
|
||||
result = self.composer.render_template('myservice', manifest, template)
|
||||
self.assertEqual(result, 'PORT=9090')
|
||||
|
||||
def test_substitutes_default_when_no_saved_config(self):
|
||||
cm = _make_cm()
|
||||
composer = _composer(cm)
|
||||
manifest = _make_manifest()
|
||||
template = 'USER=${PIC_CFG_USERNAME}'
|
||||
result = composer.render_template('myservice', manifest, template)
|
||||
self.assertEqual(result, 'USER=admin')
|
||||
|
||||
def test_pic_domain_substituted(self):
|
||||
manifest = _make_manifest()
|
||||
template = 'DOMAIN=${PIC_DOMAIN}'
|
||||
result = self.composer.render_template('myservice', manifest, template)
|
||||
self.assertIn('cell.local', result)
|
||||
|
||||
def test_pic_cell_name_substituted(self):
|
||||
manifest = _make_manifest()
|
||||
template = 'CELL=${PIC_CELL_NAME}'
|
||||
result = self.composer.render_template('myservice', manifest, template)
|
||||
self.assertIn('testcell', result)
|
||||
|
||||
def test_pic_service_id_substituted(self):
|
||||
manifest = _make_manifest(service_id='myservice')
|
||||
template = 'ID=${PIC_SERVICE_ID}'
|
||||
result = self.composer.render_template('myservice', manifest, template)
|
||||
self.assertEqual(result, 'ID=myservice')
|
||||
|
||||
def test_pic_secret_generated_and_substituted(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = _composer(data_dir=tmpdir)
|
||||
manifest = _make_manifest()
|
||||
template = 'PASS=${PIC_SECRET_DB_PASS}'
|
||||
result = composer.render_template('myservice', manifest, template)
|
||||
self.assertNotIn('${PIC_SECRET_DB_PASS}', result)
|
||||
self.assertNotEqual(result, 'PASS=')
|
||||
# Secret is a non-empty string
|
||||
password = result.replace('PASS=', '')
|
||||
self.assertTrue(len(password) > 8)
|
||||
|
||||
def test_pic_secret_stable_across_calls(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = _composer(data_dir=tmpdir)
|
||||
manifest = _make_manifest()
|
||||
template = 'P=${PIC_SECRET_MY_PASS}'
|
||||
r1 = composer.render_template('myservice', manifest, template)
|
||||
r2 = composer.render_template('myservice', manifest, template)
|
||||
self.assertEqual(r1, r2)
|
||||
|
||||
def test_pic_secret_different_per_service(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = _composer(data_dir=tmpdir)
|
||||
m1 = _make_manifest('svc1')
|
||||
m2 = _make_manifest('svc2')
|
||||
t = 'P=${PIC_SECRET_PASS}'
|
||||
r1 = composer.render_template('svc1', m1, t)
|
||||
r2 = composer.render_template('svc2', m2, t)
|
||||
self.assertNotEqual(r1, r2)
|
||||
|
||||
def test_multiple_secrets_all_replaced(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = _composer(data_dir=tmpdir)
|
||||
manifest = _make_manifest()
|
||||
template = 'A=${PIC_SECRET_KEY_A}\nB=${PIC_SECRET_KEY_B}'
|
||||
result = composer.render_template('myservice', manifest, template)
|
||||
self.assertNotIn('${PIC_SECRET_', result)
|
||||
|
||||
def test_no_unknown_vars_left_from_schema(self):
|
||||
# Use a fresh composer with no saved config so defaults apply
|
||||
composer = _composer(_make_cm())
|
||||
manifest = _make_manifest(schema={
|
||||
'port': {'type': 'integer', 'default': 3000},
|
||||
})
|
||||
template = 'PORT=${PIC_CFG_PORT}\nOTHER=${PIC_CFG_UNKNOWN}'
|
||||
result = composer.render_template('myservice', manifest, template)
|
||||
# Known var substituted with default, unknown left alone (no crash)
|
||||
self.assertIn('PORT=3000', result)
|
||||
self.assertIn('${PIC_CFG_UNKNOWN}', result)
|
||||
|
||||
|
||||
# ── Write compose file ────────────────────────────────────────────────────────
|
||||
|
||||
class TestWriteCompose(unittest.TestCase):
|
||||
|
||||
def test_writes_rendered_content_to_correct_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
cm = _make_cm()
|
||||
composer = ServiceComposer(config_manager=cm, data_dir=tmpdir)
|
||||
manifest = _make_manifest()
|
||||
template = 'PORT=${PIC_CFG_PORT}'
|
||||
composer.write_compose('myservice', manifest, template)
|
||||
|
||||
expected_path = os.path.join(
|
||||
tmpdir, 'services', 'myservice', 'docker-compose.yml'
|
||||
)
|
||||
self.assertTrue(os.path.exists(expected_path))
|
||||
with open(expected_path) as f:
|
||||
content = f.read()
|
||||
self.assertIn('8080', content)
|
||||
|
||||
def test_has_compose_file_false_before_write(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = _composer(data_dir=tmpdir)
|
||||
self.assertFalse(composer.has_compose_file('newservice'))
|
||||
|
||||
def test_has_compose_file_true_after_write(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
|
||||
manifest = _make_manifest()
|
||||
composer.write_compose('myservice', manifest, 'content: true')
|
||||
self.assertTrue(composer.has_compose_file('myservice'))
|
||||
|
||||
def test_atomic_write_via_tmp_file(self):
|
||||
"""If fsync fails, the compose file should not be partially written."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
|
||||
manifest = _make_manifest()
|
||||
# Should not raise even if fsync not available
|
||||
composer.write_compose('myservice', manifest, 'content: yes')
|
||||
path = os.path.join(tmpdir, 'services', 'myservice', 'docker-compose.yml')
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
|
||||
# ── Secrets ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSecrets(unittest.TestCase):
|
||||
|
||||
def test_secrets_persisted_to_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = _composer(data_dir=tmpdir)
|
||||
composer._get_or_create_secret('svc', 'PIC_SECRET_PASS')
|
||||
secrets_path = os.path.join(tmpdir, 'service_secrets.json')
|
||||
self.assertTrue(os.path.exists(secrets_path))
|
||||
with open(secrets_path) as f:
|
||||
data = json.load(f)
|
||||
self.assertIn('svc', data)
|
||||
self.assertIn('PIC_SECRET_PASS', data['svc'])
|
||||
|
||||
def test_clear_secrets_removes_service_entry(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = _composer(data_dir=tmpdir)
|
||||
composer._get_or_create_secret('svc', 'PIC_SECRET_KEY')
|
||||
composer._clear_secrets('svc')
|
||||
secrets = composer._load_secrets()
|
||||
self.assertNotIn('svc', secrets)
|
||||
|
||||
def test_clear_secrets_noop_when_no_secrets_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = _composer(data_dir=tmpdir)
|
||||
# Should not raise
|
||||
composer._clear_secrets('nonexistent')
|
||||
|
||||
def test_load_secrets_returns_empty_when_file_missing(self):
|
||||
composer = _composer(data_dir='/nonexistent/path')
|
||||
self.assertEqual(composer._load_secrets(), {})
|
||||
|
||||
|
||||
# ── Subprocess execution ──────────────────────────────────────────────────────
|
||||
|
||||
class TestDockerComposeExecution(unittest.TestCase):
|
||||
|
||||
def _composer_with_compose_file(self, tmpdir, service_id='myservice'):
|
||||
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
|
||||
svc_dir = os.path.join(tmpdir, 'services', service_id)
|
||||
os.makedirs(svc_dir)
|
||||
with open(os.path.join(svc_dir, 'docker-compose.yml'), 'w') as f:
|
||||
f.write('services:\n app:\n image: nginx\n')
|
||||
return composer
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_up_calls_docker_compose_up(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = self._composer_with_compose_file(tmpdir)
|
||||
composer.up('myservice')
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn('up', cmd)
|
||||
self.assertIn('-d', cmd)
|
||||
self.assertIn('--project-name', cmd)
|
||||
self.assertIn('pic-myservice', cmd)
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_down_calls_docker_compose_down(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = self._composer_with_compose_file(tmpdir)
|
||||
composer.down('myservice')
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn('down', cmd)
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_down_with_purge_passes_volumes_flag(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = self._composer_with_compose_file(tmpdir)
|
||||
composer.down('myservice', remove_volumes=True)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn('--volumes', cmd)
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_restart_calls_docker_compose_restart(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = self._composer_with_compose_file(tmpdir)
|
||||
composer.restart('myservice')
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn('restart', cmd)
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_status_parses_json_output(self, mock_run):
|
||||
container_info = {'Name': 'myservice-app', 'State': 'running'}
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps(container_info),
|
||||
stderr='',
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = self._composer_with_compose_file(tmpdir)
|
||||
result = composer.status('myservice')
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual(len(result['containers']), 1)
|
||||
self.assertEqual(result['containers'][0]['Name'], 'myservice-app')
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_status_returns_empty_containers_on_bad_json(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='not json', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = self._composer_with_compose_file(tmpdir)
|
||||
result = composer.status('myservice')
|
||||
self.assertEqual(result['containers'], [])
|
||||
|
||||
def test_store_cmd_returns_error_when_no_compose_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = _composer(data_dir=tmpdir)
|
||||
result = composer.up('nonexistent')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('No compose file', result['error'])
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_up_uses_600s_timeout(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = self._composer_with_compose_file(tmpdir)
|
||||
composer.up('myservice')
|
||||
_, kwargs = mock_run.call_args
|
||||
self.assertGreaterEqual(kwargs.get('timeout', 0), 600)
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_run_returns_error_on_timeout(self, mock_run):
|
||||
import subprocess
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd=[], timeout=120)
|
||||
composer = _composer()
|
||||
result = composer._run(['docker', 'compose', 'up'])
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('timed out', result['error'])
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_run_returns_false_on_nonzero_exit(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout='', stderr='error msg')
|
||||
composer = _composer()
|
||||
result = composer._run(['docker', 'compose', 'up'])
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertEqual(result['stderr'], 'error msg')
|
||||
|
||||
|
||||
# ── Builtin lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestBuiltinLifecycle(unittest.TestCase):
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_restart_builtin_includes_container_names(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
composer = _composer()
|
||||
composer.restart_builtin(['cell-radicale'])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn('cell-radicale', cmd)
|
||||
self.assertIn('restart', cmd)
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_status_builtin_includes_container_names(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
composer = _composer()
|
||||
composer.status_builtin(['cell-mail', 'cell-rainloop'])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn('cell-mail', cmd)
|
||||
self.assertIn('cell-rainloop', cmd)
|
||||
|
||||
def test_restart_builtin_empty_list_returns_error(self):
|
||||
composer = _composer()
|
||||
result = composer.restart_builtin([])
|
||||
self.assertFalse(result['ok'])
|
||||
|
||||
def test_status_builtin_empty_list_returns_error(self):
|
||||
composer = _composer()
|
||||
result = composer.status_builtin([])
|
||||
self.assertFalse(result['ok'])
|
||||
|
||||
|
||||
# ── Unified dispatch ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestUnifiedDispatch(unittest.TestCase):
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_restart_service_builtin_uses_main_compose(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
composer = _composer()
|
||||
manifest = _make_manifest(kind='builtin')
|
||||
manifest['containers'] = ['cell-myservice']
|
||||
composer.restart_service('myservice', manifest)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn('cell-myservice', cmd)
|
||||
# Main compose flag present
|
||||
self.assertIn('-f', cmd)
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_restart_service_store_uses_per_service_compose(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
|
||||
# Create compose file for the store service
|
||||
svc_dir = os.path.join(tmpdir, 'services', 'storesvc')
|
||||
os.makedirs(svc_dir)
|
||||
with open(os.path.join(svc_dir, 'docker-compose.yml'), 'w') as f:
|
||||
f.write('services:\n app:\n image: nginx\n')
|
||||
manifest = _make_manifest('storesvc', kind='store')
|
||||
composer.restart_service('storesvc', manifest)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn('pic-storesvc', cmd)
|
||||
|
||||
|
||||
# ── Remove ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestServiceIdValidation(unittest.TestCase):
|
||||
|
||||
def test_valid_ids_accepted(self):
|
||||
for sid in ('email', 'my-service', 'svc123', 'a1b2-c3'):
|
||||
ServiceComposer._validate_service_id(sid) # should not raise
|
||||
|
||||
def test_dotdot_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
ServiceComposer._validate_service_id('..')
|
||||
|
||||
def test_dot_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
ServiceComposer._validate_service_id('.')
|
||||
|
||||
def test_slash_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
ServiceComposer._validate_service_id('evil/path')
|
||||
|
||||
def test_uppercase_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
ServiceComposer._validate_service_id('MyService')
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
ServiceComposer._validate_service_id('')
|
||||
|
||||
def test_newline_in_config_value_stripped(self):
|
||||
"""A newline in a config value must not create a new YAML key (injection)."""
|
||||
cm = _make_cm(service_config={'svc': {'port': '80\nnewline_attack: true'}})
|
||||
composer = _composer(cm)
|
||||
manifest = _make_manifest(schema={'port': {'type': 'string', 'default': '80'}})
|
||||
result = composer.render_template('svc', manifest, 'PORT=${PIC_CFG_PORT}')
|
||||
# The newline is stripped — 'newline_attack' is concatenated, not a separate YAML key
|
||||
self.assertNotIn('\n', result)
|
||||
|
||||
|
||||
class TestRemove(unittest.TestCase):
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_remove_deletes_compose_file(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
|
||||
svc_dir = os.path.join(tmpdir, 'services', 'oldsvc')
|
||||
os.makedirs(svc_dir)
|
||||
compose_file = os.path.join(svc_dir, 'docker-compose.yml')
|
||||
with open(compose_file, 'w') as f:
|
||||
f.write('services: {}')
|
||||
composer.remove('oldsvc', purge_data=False)
|
||||
self.assertFalse(os.path.exists(compose_file))
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_remove_purge_deletes_service_directory(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
|
||||
svc_dir = os.path.join(tmpdir, 'services', 'purgesvc')
|
||||
os.makedirs(svc_dir)
|
||||
with open(os.path.join(svc_dir, 'docker-compose.yml'), 'w') as f:
|
||||
f.write('services: {}')
|
||||
with open(os.path.join(svc_dir, 'data.txt'), 'w') as f:
|
||||
f.write('important data')
|
||||
composer.remove('purgesvc', purge_data=True)
|
||||
self.assertFalse(os.path.exists(svc_dir))
|
||||
|
||||
@patch('service_composer.subprocess.run')
|
||||
def test_remove_purge_clears_secrets(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
|
||||
composer._get_or_create_secret('purgesvc', 'PIC_SECRET_KEY')
|
||||
svc_dir = os.path.join(tmpdir, 'services', 'purgesvc')
|
||||
os.makedirs(svc_dir)
|
||||
with open(os.path.join(svc_dir, 'docker-compose.yml'), 'w') as f:
|
||||
f.write('services: {}')
|
||||
composer.remove('purgesvc', purge_data=True)
|
||||
secrets = composer._load_secrets()
|
||||
self.assertNotIn('purgesvc', secrets)
|
||||
|
||||
|
||||
# ── Parse ps json ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParsePsJson(unittest.TestCase):
|
||||
|
||||
def test_single_json_object(self):
|
||||
line = json.dumps({'Name': 'c1', 'State': 'running'})
|
||||
result = ServiceComposer._parse_ps_json(line)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['Name'], 'c1')
|
||||
|
||||
def test_multiple_json_lines(self):
|
||||
lines = '\n'.join([
|
||||
json.dumps({'Name': 'c1'}),
|
||||
json.dumps({'Name': 'c2'}),
|
||||
])
|
||||
result = ServiceComposer._parse_ps_json(lines)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_ignores_blank_lines(self):
|
||||
lines = '\n'.join([json.dumps({'Name': 'c1'}), '', json.dumps({'Name': 'c2'})])
|
||||
result = ServiceComposer._parse_ps_json(lines)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_returns_empty_list_for_empty_output(self):
|
||||
self.assertEqual(ServiceComposer._parse_ps_json(''), [])
|
||||
|
||||
def test_bad_json_lines_skipped(self):
|
||||
lines = '\n'.join(['not json', json.dumps({'Name': 'c1'})])
|
||||
result = ServiceComposer._parse_ps_json(lines)
|
||||
self.assertEqual(len(result), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
Unit tests for ServiceRegistry.
|
||||
|
||||
Tests load actual built-in manifests from api/services/builtins/ and verify
|
||||
that the registry merges config correctly, returns expected routes/backup plans,
|
||||
and handles missing manifests gracefully.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
|
||||
|
||||
from service_registry import ServiceRegistry, _BUILTINS_DIR
|
||||
|
||||
|
||||
def _make_cm(configs: dict = None) -> MagicMock:
|
||||
cm = MagicMock()
|
||||
cm.configs = configs or {}
|
||||
cm.get_installed_services.return_value = {}
|
||||
return cm
|
||||
|
||||
|
||||
class TestBuiltinManifests(unittest.TestCase):
|
||||
"""Verify the built-in manifest files are valid JSON with required fields."""
|
||||
|
||||
def _load(self, service_id: str) -> dict:
|
||||
path = os.path.join(_BUILTINS_DIR, service_id, 'manifest.json')
|
||||
self.assertTrue(os.path.exists(path), f'Missing manifest for {service_id}')
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
def _assert_required(self, manifest: dict):
|
||||
for field in ('schema_version', 'id', 'name', 'kind', 'capabilities'):
|
||||
self.assertIn(field, manifest, f'Missing required field: {field}')
|
||||
caps = manifest['capabilities']
|
||||
for cap in ('has_subdomain', 'has_accounts', 'has_admin_config',
|
||||
'has_storage', 'has_egress', 'has_api_hooks'):
|
||||
self.assertIn(cap, caps, f'Missing capability flag: {cap}')
|
||||
|
||||
def test_email_manifest_valid(self):
|
||||
m = self._load('email')
|
||||
self._assert_required(m)
|
||||
self.assertEqual(m['id'], 'email')
|
||||
self.assertEqual(m['kind'], 'builtin')
|
||||
self.assertIn('mail', [m.get('subdomain')] + (m.get('extra_subdomains') or []))
|
||||
self.assertIn('webmail', m.get('extra_subdomains', []))
|
||||
self.assertEqual(m['capabilities']['has_accounts'], True)
|
||||
|
||||
def test_calendar_manifest_valid(self):
|
||||
m = self._load('calendar')
|
||||
self._assert_required(m)
|
||||
self.assertEqual(m['id'], 'calendar')
|
||||
self.assertEqual(m['subdomain'], 'calendar')
|
||||
|
||||
def test_files_manifest_valid(self):
|
||||
m = self._load('files')
|
||||
self._assert_required(m)
|
||||
self.assertEqual(m['id'], 'files')
|
||||
self.assertIn('webdav', m.get('extra_subdomains', []))
|
||||
|
||||
def test_all_builtins_have_backup_volumes(self):
|
||||
for svc_id in ('email', 'calendar', 'files'):
|
||||
m = self._load(svc_id)
|
||||
volumes = m.get('backup', {}).get('volumes')
|
||||
self.assertTrue(volumes, f'{svc_id}: backup.volumes must not be empty')
|
||||
for vol in volumes:
|
||||
for field in ('container', 'path', 'name'):
|
||||
self.assertIn(field, vol,
|
||||
f'{svc_id}: backup volume entry missing {field!r}')
|
||||
|
||||
def test_all_builtins_have_peer_config_template(self):
|
||||
for svc_id in ('email', 'calendar', 'files'):
|
||||
m = self._load(svc_id)
|
||||
self.assertTrue(m.get('peer_config_template'),
|
||||
f'{svc_id}: peer_config_template must not be empty')
|
||||
|
||||
def test_config_schema_defaults_are_correct_types(self):
|
||||
for svc_id in ('email', 'calendar', 'files'):
|
||||
m = self._load(svc_id)
|
||||
for field, spec in (m.get('config_schema') or {}).items():
|
||||
if 'default' in spec:
|
||||
if spec['type'] == 'integer':
|
||||
self.assertIsInstance(
|
||||
spec['default'], int,
|
||||
f'{svc_id}.{field}: integer default must be int')
|
||||
elif spec['type'] == 'string':
|
||||
self.assertIsInstance(
|
||||
spec['default'], str,
|
||||
f'{svc_id}.{field}: string default must be str')
|
||||
|
||||
|
||||
class TestServiceRegistryListAll(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cm = _make_cm()
|
||||
self.registry = ServiceRegistry(self.cm)
|
||||
|
||||
def test_lists_three_builtins(self):
|
||||
services = self.registry.list_all()
|
||||
ids = [s['id'] for s in services]
|
||||
self.assertIn('email', ids)
|
||||
self.assertIn('calendar', ids)
|
||||
self.assertIn('files', ids)
|
||||
|
||||
def test_builtins_come_before_store_services(self):
|
||||
self.cm.get_installed_services.return_value = {
|
||||
'zstore': {'manifest': {
|
||||
'id': 'zstore', 'name': 'Z Store', 'kind': 'store',
|
||||
'capabilities': {}, 'config_schema': {}
|
||||
}}
|
||||
}
|
||||
services = self.registry.list_all()
|
||||
ids = [s['id'] for s in services]
|
||||
# builtins (email, calendar, files) should all appear before zstore
|
||||
for builtin_id in ('email', 'calendar', 'files'):
|
||||
self.assertLess(ids.index(builtin_id), ids.index('zstore'))
|
||||
|
||||
def test_each_service_has_config_key(self):
|
||||
for svc in self.registry.list_all():
|
||||
self.assertIn('config', svc, f'{svc["id"]} missing config key')
|
||||
|
||||
def test_no_duplicate_ids(self):
|
||||
services = self.registry.list_all()
|
||||
ids = [s['id'] for s in services]
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
|
||||
|
||||
class TestServiceRegistryConfigMerge(unittest.TestCase):
|
||||
|
||||
def test_defaults_used_when_no_saved_config(self):
|
||||
cm = _make_cm({'calendar': {}})
|
||||
reg = ServiceRegistry(cm)
|
||||
svc = reg.get('calendar')
|
||||
self.assertEqual(svc['config']['port'], 5232)
|
||||
|
||||
def test_saved_config_overrides_defaults(self):
|
||||
cm = _make_cm({'calendar': {'port': 9999}})
|
||||
reg = ServiceRegistry(cm)
|
||||
svc = reg.get('calendar')
|
||||
self.assertEqual(svc['config']['port'], 9999)
|
||||
|
||||
def test_unknown_saved_keys_excluded(self):
|
||||
cm = _make_cm({'calendar': {'port': 5232, 'unknown_field': 'x'}})
|
||||
reg = ServiceRegistry(cm)
|
||||
svc = reg.get('calendar')
|
||||
self.assertNotIn('unknown_field', svc['config'])
|
||||
|
||||
def test_partial_override_keeps_other_defaults(self):
|
||||
cm = _make_cm({'email': {'smtp_port': 2525}})
|
||||
reg = ServiceRegistry(cm)
|
||||
svc = reg.get('email')
|
||||
self.assertEqual(svc['config']['smtp_port'], 2525)
|
||||
self.assertEqual(svc['config']['imap_port'], 993)
|
||||
|
||||
|
||||
class TestServiceRegistryGet(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cm = _make_cm()
|
||||
self.registry = ServiceRegistry(self.cm)
|
||||
|
||||
def test_returns_none_for_unknown_id(self):
|
||||
self.assertIsNone(self.registry.get('nonexistent_service'))
|
||||
|
||||
def test_returns_builtin_by_id(self):
|
||||
svc = self.registry.get('email')
|
||||
self.assertIsNotNone(svc)
|
||||
self.assertEqual(svc['id'], 'email')
|
||||
|
||||
def test_returns_store_service_from_installed(self):
|
||||
self.cm.get_installed_services.return_value = {
|
||||
'mywiki': {'manifest': {
|
||||
'id': 'mywiki', 'name': 'Wiki', 'kind': 'store',
|
||||
'capabilities': {}, 'config_schema': {}
|
||||
}}
|
||||
}
|
||||
svc = self.registry.get('mywiki')
|
||||
self.assertIsNotNone(svc)
|
||||
self.assertEqual(svc['id'], 'mywiki')
|
||||
|
||||
|
||||
class TestServiceRegistryGetCaddyRoutes(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cm = _make_cm()
|
||||
self.registry = ServiceRegistry(self.cm)
|
||||
|
||||
def test_all_builtins_appear_in_routes(self):
|
||||
routes = self.registry.get_caddy_routes()
|
||||
route_ids = [r['service_id'] for r in routes]
|
||||
for svc_id in ('email', 'calendar', 'files'):
|
||||
self.assertIn(svc_id, route_ids)
|
||||
|
||||
def test_email_route_has_webmail_extra_subdomain(self):
|
||||
routes = self.registry.get_caddy_routes()
|
||||
email_route = next(r for r in routes if r['service_id'] == 'email')
|
||||
self.assertIn('webmail', email_route['extra_subdomains'])
|
||||
|
||||
def test_files_route_has_webdav_extra_subdomain(self):
|
||||
routes = self.registry.get_caddy_routes()
|
||||
files_route = next(r for r in routes if r['service_id'] == 'files')
|
||||
self.assertIn('webdav', files_route['extra_subdomains'])
|
||||
|
||||
def test_services_without_subdomain_excluded(self):
|
||||
self.cm.get_installed_services.return_value = {
|
||||
'nosubdomain': {'manifest': {
|
||||
'id': 'nosubdomain', 'name': 'NoSub', 'kind': 'store',
|
||||
'capabilities': {'has_subdomain': False},
|
||||
'config_schema': {}
|
||||
}}
|
||||
}
|
||||
routes = self.registry.get_caddy_routes()
|
||||
self.assertNotIn('nosubdomain', [r['service_id'] for r in routes])
|
||||
|
||||
|
||||
class TestServiceRegistryGetBackupPlan(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cm = _make_cm()
|
||||
self.registry = ServiceRegistry(self.cm)
|
||||
|
||||
def test_all_builtins_in_backup_plan(self):
|
||||
plan = self.registry.get_backup_plan()
|
||||
plan_ids = [p['service_id'] for p in plan]
|
||||
for svc_id in ('email', 'calendar', 'files'):
|
||||
self.assertIn(svc_id, plan_ids)
|
||||
|
||||
def test_email_backup_includes_maildata_volume(self):
|
||||
plan = self.registry.get_backup_plan()
|
||||
email_plan = next(p for p in plan if p['service_id'] == 'email')
|
||||
names = [v['name'] for v in email_plan['volumes']]
|
||||
self.assertIn('maildata', names)
|
||||
vol = next(v for v in email_plan['volumes'] if v['name'] == 'maildata')
|
||||
self.assertEqual(vol['container'], 'cell-mail')
|
||||
self.assertEqual(vol['path'], '/var/mail')
|
||||
|
||||
def test_calendar_backup_includes_radicale_volume(self):
|
||||
plan = self.registry.get_backup_plan()
|
||||
cal_plan = next(p for p in plan if p['service_id'] == 'calendar')
|
||||
names = [v['name'] for v in cal_plan['volumes']]
|
||||
self.assertIn('radicale_data', names)
|
||||
vol = next(v for v in cal_plan['volumes'] if v['name'] == 'radicale_data')
|
||||
self.assertEqual(vol['container'], 'cell-radicale')
|
||||
self.assertEqual(vol['path'], '/data')
|
||||
|
||||
def test_files_backup_includes_both_volumes(self):
|
||||
plan = self.registry.get_backup_plan()
|
||||
files_plan = next(p for p in plan if p['service_id'] == 'files')
|
||||
names = {v['name'] for v in files_plan['volumes']}
|
||||
self.assertIn('filegator', names)
|
||||
self.assertIn('files', names)
|
||||
|
||||
def test_service_without_storage_excluded(self):
|
||||
self.cm.get_installed_services.return_value = {
|
||||
'nostorage': {'manifest': {
|
||||
'id': 'nostorage', 'name': 'NoStorage', 'kind': 'store',
|
||||
'capabilities': {'has_storage': False},
|
||||
'config_schema': {}
|
||||
}}
|
||||
}
|
||||
plan = self.registry.get_backup_plan()
|
||||
self.assertNotIn('nostorage', [p['service_id'] for p in plan])
|
||||
|
||||
|
||||
class TestServiceRegistryGetPeerServiceInfo(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cm = _make_cm({'calendar': {}})
|
||||
self.registry = ServiceRegistry(self.cm)
|
||||
|
||||
def test_fills_domain_placeholder(self):
|
||||
info = self.registry.get_peer_service_info(
|
||||
'calendar', 'alice', 'example.com', {})
|
||||
self.assertIn('example.com', info['caldav_url'])
|
||||
|
||||
def test_fills_peer_username(self):
|
||||
info = self.registry.get_peer_service_info(
|
||||
'calendar', 'bob', 'example.com', {})
|
||||
self.assertIn('bob', info['caldav_url'])
|
||||
|
||||
def test_fills_credentials(self):
|
||||
info = self.registry.get_peer_service_info(
|
||||
'calendar', 'alice', 'example.com', {'password': 'secret123'})
|
||||
self.assertEqual(info['password'], 'secret123')
|
||||
|
||||
def test_returns_none_for_unknown_service(self):
|
||||
result = self.registry.get_peer_service_info(
|
||||
'unknown_svc', 'alice', 'example.com', {})
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_username_url_encoded_in_peer_url(self):
|
||||
info = self.registry.get_peer_service_info(
|
||||
'calendar', 'alice/../../etc', 'example.com', {})
|
||||
self.assertNotIn('../', info['caldav_url'])
|
||||
self.assertIn('alice%2F', info['caldav_url'])
|
||||
|
||||
def test_domain_not_altered_by_username(self):
|
||||
info = self.registry.get_peer_service_info(
|
||||
'calendar', 'alice@evil.com', 'legit.example.com', {})
|
||||
self.assertIn('legit.example.com', info['caldav_url'])
|
||||
|
||||
|
||||
class TestServiceRegistryConfigMergeTypeCoercion(unittest.TestCase):
|
||||
|
||||
def test_string_in_config_coerced_to_int(self):
|
||||
cm = _make_cm({'calendar': {'port': '9999'}})
|
||||
reg = ServiceRegistry(cm)
|
||||
svc = reg.get('calendar')
|
||||
self.assertIsInstance(svc['config']['port'], int)
|
||||
self.assertEqual(svc['config']['port'], 9999)
|
||||
|
||||
def test_unconvertible_value_falls_back_to_default(self):
|
||||
cm = _make_cm({'calendar': {'port': 'not_a_number'}})
|
||||
reg = ServiceRegistry(cm)
|
||||
svc = reg.get('calendar')
|
||||
self.assertEqual(svc['config']['port'], 5232)
|
||||
|
||||
|
||||
class TestServiceRegistryWithBrokenManifest(unittest.TestCase):
|
||||
"""Registry must not crash when a manifest file is corrupt or missing."""
|
||||
|
||||
def test_missing_builtins_dir_returns_empty(self):
|
||||
with patch('service_registry._BUILTINS_DIR', '/nonexistent/path'):
|
||||
reg = ServiceRegistry(_make_cm())
|
||||
self.assertEqual(reg.list_all(), [])
|
||||
|
||||
def test_malformed_json_manifest_skipped(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bad_dir = os.path.join(tmpdir, 'bad_svc')
|
||||
os.makedirs(bad_dir)
|
||||
with open(os.path.join(bad_dir, 'manifest.json'), 'w') as f:
|
||||
f.write('this is not json {{{')
|
||||
with patch('service_registry._BUILTINS_DIR', tmpdir):
|
||||
reg = ServiceRegistry(_make_cm())
|
||||
# Should not raise; just return empty list
|
||||
result = reg.list_all()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Users, Plus, Trash2, Eye, EyeOff, CheckCircle, XCircle, Copy, CheckCheck } from 'lucide-react';
|
||||
import { accountsAPI } from '../services/api';
|
||||
|
||||
const CRED_LABELS = {
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
imap_host: 'IMAP server',
|
||||
imap_port: 'IMAP port',
|
||||
smtp_host: 'SMTP server',
|
||||
smtp_port: 'SMTP port',
|
||||
caldav_url: 'CalDAV URL',
|
||||
webdav_url: 'WebDAV URL',
|
||||
server: 'Server',
|
||||
address: 'Email address',
|
||||
};
|
||||
|
||||
function Toast({ msg, type }) {
|
||||
if (!msg) return null;
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
|
||||
type === 'error' ? 'bg-red-600' : 'bg-green-600'
|
||||
}`}>
|
||||
{type === 'error' ? <XCircle className="h-4 w-4 shrink-0" /> : <CheckCircle className="h-4 w-4 shrink-0" />}
|
||||
{msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyBtn({ text }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
|
||||
className="ml-1 shrink-0 text-gray-400 hover:text-gray-600"
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
{copied ? <CheckCheck className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServiceAccountsPanel({ serviceId, serviceName }) {
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [provisioning, setProvisioning] = useState(false);
|
||||
const [removing, setRemoving] = useState(null);
|
||||
const [credentials, setCredentials] = useState({});
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = (msg, type = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3500);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const r = await accountsAPI.list(serviceId);
|
||||
setAccounts(r.data.accounts || []);
|
||||
} catch {
|
||||
setAccounts([]);
|
||||
setLoadError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [serviceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleProvision = async () => {
|
||||
const username = newUsername.trim();
|
||||
if (!username) return;
|
||||
setProvisioning(true);
|
||||
try {
|
||||
await accountsAPI.provision(serviceId, username);
|
||||
showToast(`${serviceName} account created for ${username}`);
|
||||
setNewUsername('');
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast(err?.response?.data?.error || 'Failed to provision account', 'error');
|
||||
} finally {
|
||||
setProvisioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeprovision = async (username) => {
|
||||
if (!window.confirm(`Remove the ${serviceName.toLowerCase()} account for "${username}"? This cannot be undone.`)) return;
|
||||
setRemoving(username);
|
||||
try {
|
||||
await accountsAPI.deprovision(serviceId, username);
|
||||
showToast(`Account removed for ${username}`);
|
||||
setCredentials(prev => { const n = { ...prev }; delete n[username]; return n; });
|
||||
load();
|
||||
} catch (err) {
|
||||
showToast(err?.response?.data?.error || 'Failed to remove account', 'error');
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCredentials = async (username) => {
|
||||
if (credentials[username]) {
|
||||
setCredentials(prev => { const n = { ...prev }; delete n[username]; return n; });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await accountsAPI.getCredentials(serviceId, username);
|
||||
setCredentials(prev => ({ ...prev, [username]: r.data }));
|
||||
} catch (err) {
|
||||
showToast(err?.response?.data?.error || 'Failed to load credentials', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Toast {...(toast || {})} />
|
||||
|
||||
{/* Provision form */}
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Users className="h-5 w-5 text-primary-500" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Provision Account</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Create a {serviceName.toLowerCase()} account for a peer. The peer uses their dashboard password to authenticate.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`provision-${serviceId}`} className="block text-sm font-medium text-gray-700">
|
||||
Peer username
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id={`provision-${serviceId}`}
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={e => setNewUsername(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleProvision()}
|
||||
placeholder="e.g. alice"
|
||||
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleProvision}
|
||||
disabled={provisioning || !newUsername.trim()}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{provisioning ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account list */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-medium text-gray-900">
|
||||
Provisioned Accounts
|
||||
{accounts.length > 0 && (
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">({accounts.length})</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-400 py-4 text-center">Loading…</p>
|
||||
) : loadError ? (
|
||||
<p className="text-sm text-red-500 py-4 text-center">
|
||||
Failed to load accounts. Check your connection and try again.
|
||||
</p>
|
||||
) : accounts.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 py-4 text-center">
|
||||
No peer accounts provisioned yet. Use the form above to create one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{accounts.map(username => (
|
||||
<div key={username} className="rounded-lg border border-gray-100 bg-gray-50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2.5">
|
||||
<span className="text-sm font-medium text-gray-800">{username}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => toggleCredentials(username)}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary-600 hover:text-primary-800 font-medium"
|
||||
>
|
||||
{credentials[username]
|
||||
? <><EyeOff className="h-3.5 w-3.5" /> Hide</>
|
||||
: <><Eye className="h-3.5 w-3.5" /> Credentials</>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeprovision(username)}
|
||||
disabled={removing === username}
|
||||
className="inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-800 font-medium disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{removing === username ? 'Removing…' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentials[username] && (
|
||||
<div className="border-t border-gray-200 px-3 py-2 bg-white space-y-1">
|
||||
{Object.entries(credentials[username]).map(([k, v]) => (
|
||||
<div key={k} className="flex flex-wrap items-start gap-x-2 gap-y-0.5 text-xs py-0.5">
|
||||
<span className="text-gray-500 w-28 shrink-0">{CRED_LABELS[k] || k}</span>
|
||||
<div className="flex items-start min-w-0 flex-1">
|
||||
<span className="font-mono text-gray-700 break-all">{String(v)}</span>
|
||||
<CopyBtn text={String(v)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Field, TextInput, NumberInput } from '../../components/FormFields';
|
||||
import { detectPortConflicts, validateCalendarConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
|
||||
import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
|
||||
|
||||
const CAL_DEFAULTS = { port: 5232, data_dir: '' };
|
||||
|
||||
@@ -68,6 +69,7 @@ function Toast({ msg, type }) {
|
||||
export default function CalendarPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
|
||||
const draftConfig = useDraftConfig();
|
||||
|
||||
@@ -160,11 +162,31 @@ export default function CalendarPage() {
|
||||
<div>
|
||||
<Toast {...(toast || {})} />
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Calendar & Contacts</h1>
|
||||
<p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-6 overflow-x-auto">
|
||||
{[{id:'overview',label:'Overview'},{id:'accounts',label:'Accounts'}].map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`whitespace-nowrap px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'accounts' && isAdmin ? (
|
||||
<ServiceAccountsPanel serviceId="calendar" serviceName="Calendar" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Connection info */}
|
||||
<div className="card">
|
||||
@@ -287,7 +309,10 @@ export default function CalendarPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && activeTab !== 'accounts' && (
|
||||
<AdminConfigSection
|
||||
calCfg={calCfg}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Field, TextInput, NumberInput } from '../../components/FormFields';
|
||||
import { detectPortConflicts, validateEmailConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
|
||||
import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
|
||||
|
||||
const EMAIL_DEFAULTS = { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 };
|
||||
|
||||
@@ -78,6 +79,7 @@ function Toast({ msg, type }) {
|
||||
export default function EmailPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
|
||||
const draftConfig = useDraftConfig();
|
||||
|
||||
@@ -182,11 +184,31 @@ export default function EmailPage() {
|
||||
<div>
|
||||
<Toast {...(toast || {})} />
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
|
||||
<p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-6 overflow-x-auto">
|
||||
{[{id:'overview',label:'Overview'},{id:'accounts',label:'Accounts'}].map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`whitespace-nowrap px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'accounts' && isAdmin ? (
|
||||
<ServiceAccountsPanel serviceId="email" serviceName="Email" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* IMAP */}
|
||||
<div className="card">
|
||||
@@ -291,8 +313,10 @@ export default function EmailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin config form */}
|
||||
{isAdmin && (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && activeTab !== 'accounts' && (
|
||||
<AdminConfigSection
|
||||
emailCfg={emailCfg}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDraftConfig } from '../../contexts/DraftConfigContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Field, TextInput, NumberInput } from '../../components/FormFields';
|
||||
import { detectPortConflicts, validateFilesConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
|
||||
import ServiceAccountsPanel from '../../components/ServiceAccountsPanel';
|
||||
|
||||
const FILES_DEFAULTS = { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 };
|
||||
|
||||
@@ -75,6 +76,7 @@ function Toast({ msg, type }) {
|
||||
export default function FilesPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
|
||||
const draftConfig = useDraftConfig();
|
||||
|
||||
@@ -171,11 +173,31 @@ export default function FilesPage() {
|
||||
<div>
|
||||
<Toast {...(toast || {})} />
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
|
||||
<p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-6 overflow-x-auto">
|
||||
{[{id:'overview',label:'Overview'},{id:'accounts',label:'Accounts'}].map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`whitespace-nowrap px-1 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'accounts' && isAdmin ? (
|
||||
<ServiceAccountsPanel serviceId="files" serviceName="Files" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* File manager */}
|
||||
<div className="card">
|
||||
@@ -291,7 +313,10 @@ export default function FilesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && activeTab !== 'accounts' && (
|
||||
<AdminConfigSection
|
||||
filesCfg={filesCfg}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -273,6 +273,20 @@ export const servicesAPI = {
|
||||
restartService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/restart`),
|
||||
};
|
||||
|
||||
// Accounts API (peer service account provisioning via AccountManager)
|
||||
export const accountsAPI = {
|
||||
list: (serviceId) => api.get(`/api/services/catalog/${serviceId}/accounts`),
|
||||
provision: (serviceId, username, password) =>
|
||||
api.post(`/api/services/catalog/${serviceId}/accounts`, {
|
||||
username,
|
||||
...(password ? { password } : {}),
|
||||
}),
|
||||
deprovision: (serviceId, username) =>
|
||||
api.delete(`/api/services/catalog/${serviceId}/accounts/${username}`),
|
||||
getCredentials: (serviceId, username) =>
|
||||
api.get(`/api/services/catalog/${serviceId}/accounts/${username}/credentials`),
|
||||
};
|
||||
|
||||
// Cell-to-cell connections API
|
||||
export const cellLinkAPI = {
|
||||
getInvite: () => api.get('/api/cells/invite'),
|
||||
|
||||
Reference in New Issue
Block a user