feat: replace hardcoded service names with ServiceRegistry-driven Caddy and CoreDNS config
Unit Tests / test (push) Failing after 11s
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user