16fb362df7
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>
460 lines
20 KiB
Python
460 lines
20 KiB
Python
import logging
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
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:
|
|
from app import service_bus
|
|
return jsonify(service_bus.get_service_status_summary())
|
|
except Exception as e:
|
|
logger.error(f"Error getting service bus status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/services/bus/events', methods=['GET'])
|
|
def get_service_bus_events():
|
|
try:
|
|
from app import service_bus
|
|
from service_bus import EventType
|
|
event_type = request.args.get('type')
|
|
source = request.args.get('source')
|
|
limit = int(request.args.get('limit', 100))
|
|
events = service_bus.get_event_history(
|
|
EventType(event_type) if event_type else None,
|
|
source,
|
|
limit
|
|
)
|
|
return jsonify([{
|
|
'event_id': e.event_id,
|
|
'event_type': e.event_type.value,
|
|
'source': e.source,
|
|
'data': e.data,
|
|
'timestamp': e.timestamp.isoformat()
|
|
} for e in events])
|
|
except Exception as e:
|
|
logger.error(f"Error getting service bus events: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/services/bus/services/<service_name>/start', methods=['POST'])
|
|
def start_service(service_name):
|
|
try:
|
|
from app import service_bus
|
|
success = service_bus.orchestrate_service_start(service_name)
|
|
if success:
|
|
return jsonify({"message": f"Service {service_name} started successfully"})
|
|
return jsonify({"error": f"Failed to start service {service_name}"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error starting service {service_name}: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/services/bus/services/<service_name>/stop', methods=['POST'])
|
|
def stop_service(service_name):
|
|
try:
|
|
from app import service_bus
|
|
success = service_bus.orchestrate_service_stop(service_name)
|
|
if success:
|
|
return jsonify({"message": f"Service {service_name} stopped successfully"})
|
|
return jsonify({"error": f"Failed to stop service {service_name}"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error stopping service {service_name}: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/services/bus/services/<service_name>/restart', methods=['POST'])
|
|
def restart_service(service_name):
|
|
try:
|
|
from app import service_bus
|
|
success = service_bus.orchestrate_service_restart(service_name)
|
|
if success:
|
|
return jsonify({"message": f"Service {service_name} restarted successfully"})
|
|
return jsonify({"error": f"Failed to restart service {service_name}"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error restarting service {service_name}: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/logs/services/<service>', methods=['GET'])
|
|
def get_service_logs(service):
|
|
try:
|
|
from app import log_manager
|
|
level = request.args.get('level', 'INFO')
|
|
lines = int(request.args.get('lines', 50))
|
|
logs = log_manager.get_service_logs(service, level, lines)
|
|
return jsonify({"service": service, "logs": logs})
|
|
except Exception as e:
|
|
logger.error(f"Error getting logs for {service}: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/logs/search', methods=['POST'])
|
|
def search_logs():
|
|
try:
|
|
from app import log_manager
|
|
data = request.get_json(silent=True) or {}
|
|
results = log_manager.search_logs(
|
|
data.get('query', ''),
|
|
data.get('time_range'),
|
|
data.get('services'),
|
|
data.get('level')
|
|
)
|
|
return jsonify({"results": results, "count": len(results)})
|
|
except Exception as e:
|
|
logger.error(f"Error searching logs: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/logs/export', methods=['POST'])
|
|
def export_logs():
|
|
try:
|
|
from app import log_manager
|
|
data = request.get_json(silent=True) or {}
|
|
format = data.get('format', 'json')
|
|
log_data = log_manager.export_logs(format, data.get('filters', {}))
|
|
return jsonify({"logs": log_data, "format": format})
|
|
except Exception as e:
|
|
logger.error(f"Error exporting logs: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/logs/statistics', methods=['GET'])
|
|
def get_log_statistics():
|
|
try:
|
|
from app import log_manager
|
|
return jsonify(log_manager.get_log_statistics(request.args.get('service')))
|
|
except Exception as e:
|
|
logger.error(f"Error getting log statistics: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/logs/rotate', methods=['POST'])
|
|
def rotate_logs():
|
|
try:
|
|
from app import log_manager
|
|
data = request.get_json(silent=True) or {}
|
|
log_manager.rotate_logs(data.get('service'))
|
|
return jsonify({"message": "Logs rotated successfully"})
|
|
except Exception as e:
|
|
logger.error(f"Error rotating logs: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/logs/files', methods=['GET'])
|
|
def get_log_file_infos():
|
|
try:
|
|
from app import log_manager
|
|
return jsonify(log_manager.get_all_log_file_infos())
|
|
except Exception as e:
|
|
logger.error(f"Error listing log files: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/logs/verbosity', methods=['GET'])
|
|
def get_log_verbosity():
|
|
try:
|
|
from app import log_manager
|
|
return jsonify(log_manager.get_service_levels())
|
|
except Exception as e:
|
|
logger.error(f"Error getting log verbosity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/logs/verbosity', methods=['PUT'])
|
|
def set_log_verbosity():
|
|
try:
|
|
from app import log_manager
|
|
data = request.get_json(silent=True) or {}
|
|
for service, level in data.items():
|
|
log_manager.set_service_level(service, level)
|
|
levels_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config', 'log_levels.json')
|
|
os.makedirs(os.path.dirname(levels_file), exist_ok=True)
|
|
current = {}
|
|
if os.path.exists(levels_file):
|
|
try:
|
|
with open(levels_file) as f:
|
|
current = json.load(f)
|
|
except Exception:
|
|
pass
|
|
current.update(data)
|
|
with open(levels_file, 'w') as f:
|
|
json.dump(current, f, indent=2)
|
|
return jsonify({"message": "Log levels updated", "levels": log_manager.get_service_levels()})
|
|
except Exception as e:
|
|
logger.error(f"Error setting log verbosity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/services/status', methods=['GET'])
|
|
def get_all_services_status():
|
|
try:
|
|
from app import service_bus
|
|
services_status = {}
|
|
for service_name in service_bus.list_services():
|
|
try:
|
|
service = service_bus.get_service(service_name)
|
|
status = service.get_status()
|
|
if isinstance(status, dict):
|
|
clean_status = {
|
|
'status': status.get('status', 'unknown'),
|
|
'running': status.get('running', False),
|
|
'timestamp': status.get('timestamp', datetime.utcnow().isoformat())
|
|
}
|
|
if service_name == 'network':
|
|
clean_status.update({
|
|
'dns_status': status.get('dns_running', False),
|
|
'dhcp_status': status.get('dhcp_running', False),
|
|
'ntp_status': status.get('ntp_running', False)
|
|
})
|
|
elif service_name == 'wireguard':
|
|
clean_status.update({
|
|
'peers_count': status.get('peers_count', 0),
|
|
'interface': status.get('interface', 'unknown')
|
|
})
|
|
elif service_name == 'email':
|
|
clean_status.update({
|
|
'users_count': status.get('users_count', 0),
|
|
'domain': status.get('domain', 'unknown')
|
|
})
|
|
elif service_name == 'calendar':
|
|
clean_status.update({
|
|
'users_count': status.get('users_count', 0),
|
|
'calendars_count': status.get('calendars_count', 0)
|
|
})
|
|
elif service_name == 'files':
|
|
clean_status.update({
|
|
'users_count': status.get('users_count', 0),
|
|
'storage_used': status.get('total_storage_used', {})
|
|
})
|
|
elif service_name == 'routing':
|
|
clean_status.update({
|
|
'nat_rules_count': status.get('nat_rules_count', 0),
|
|
'peer_routes_count': status.get('peer_routes_count', 0),
|
|
'firewall_rules_count': status.get('firewall_rules_count', 0)
|
|
})
|
|
elif service_name == 'vault':
|
|
clean_status.update({
|
|
'certificates_count': status.get('certificates_count', 0),
|
|
'trusted_keys_count': status.get('trusted_keys_count', 0)
|
|
})
|
|
services_status[service_name] = clean_status
|
|
else:
|
|
services_status[service_name] = {'status': str(status), 'running': bool(status)}
|
|
except Exception as e:
|
|
services_status[service_name] = {'error': str(e), 'status': 'offline', 'running': False}
|
|
return jsonify({
|
|
"network": services_status.get('network', {}),
|
|
"wireguard": services_status.get('wireguard', {}),
|
|
"email": services_status.get('email', {}),
|
|
"calendar": services_status.get('calendar', {}),
|
|
"files": services_status.get('files', {}),
|
|
"routing": services_status.get('routing', {}),
|
|
"vault": services_status.get('vault', {}),
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error getting all services status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/services/connectivity', methods=['GET'])
|
|
def test_all_services_connectivity():
|
|
try:
|
|
from app import service_bus
|
|
connectivity_results = {}
|
|
for service_name in service_bus.list_services():
|
|
try:
|
|
service = service_bus.get_service(service_name)
|
|
if hasattr(service, 'test_connectivity'):
|
|
connectivity_results[service_name] = service.test_connectivity()
|
|
else:
|
|
connectivity_results[service_name] = {'status': 'ok', 'message': 'No connectivity test available'}
|
|
except Exception as e:
|
|
connectivity_results[service_name] = {'status': 'error', 'message': str(e)}
|
|
return jsonify({
|
|
"network": connectivity_results.get('network', {}),
|
|
"wireguard": connectivity_results.get('wireguard', {}),
|
|
"email": connectivity_results.get('email', {}),
|
|
"calendar": connectivity_results.get('calendar', {}),
|
|
"files": connectivity_results.get('files', {}),
|
|
"routing": connectivity_results.get('routing', {}),
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error testing all services connectivity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@bp.route('/api/logs', methods=['GET'])
|
|
def get_backend_logs():
|
|
log_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'picell.log')
|
|
lines = int(request.args.get('lines', 100))
|
|
try:
|
|
if not os.path.exists(log_file):
|
|
return jsonify({"error": "Log file not found."}), 404
|
|
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
|
all_lines = f.readlines()
|
|
tail_lines = all_lines[-lines:] if lines > 0 else all_lines
|
|
return jsonify({"log": ''.join(tail_lines)})
|
|
except Exception as e:
|
|
logger.error(f"Error reading log file: {e}")
|
|
return jsonify({"error": str(e)}), 500
|