feat: replace hardcoded service names with ServiceRegistry-driven Caddy and CoreDNS config
Unit Tests / test (push) Failing after 11s

Previously, CaddyManager and NetworkManager contained hardcoded lists of
service names (calendar, files, mail, webdav, etc.), meaning every new
service required a code change to appear in Caddy routes and DNS records.
Now both managers accept a service_registry parameter and derive their
service lists dynamically from the registry at runtime.

- CaddyManager: new _build_registry_service_routes() and
  _http01_service_pairs() methods pull routes from the registry
- NetworkManager: new _get_service_subdomains() method returns registry
  subdomains with a hardcoded fallback when no registry is wired in;
  _build_dns_records, stale-record detection, and service name sets all
  use the registry
- managers.py: service_registry constructed before network_manager so it
  can be injected into both CaddyManager and NetworkManager
- service_registry.py: validation chokepoint in get_caddy_routes() rejects
  invalid subdomain/backend values and reserved service names
- service_store_manager.py: _validate_manifest now validates top-level
  subdomain, backend, extra_subdomains, and extra_backends fields
- tests: 24 new tests covering registry-driven routing and DNS subdomain
  generation (test_caddy_registry_integration.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 18:27:52 -04:00
parent 63c0dfb9d9
commit 16fb362df7
12 changed files with 1312 additions and 46 deletions
+168
View File
@@ -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: