e87022dc55
Unit Tests / test (push) Successful in 11m22s
- docker-compose.services.yml: change external network name from pic_cell-network to cell-network so store-service compose files can find it. The project-prefixed name was overriding the explicit name: cell-network fix in docker-compose.yml when both files were merged by make start. - service_store.py: normalize docker compose stderr into the error key in the 400 response so the Store page shows the actual failure reason instead of the generic fallback message. - app.py: skip health checks for email/calendar/files managers when those optional store services are not installed — prevents false Down alerts and unnecessary noise in health history. - Logs.jsx: remove Email/Calendar/Files columns from the health history table; they are optional store services, not core builtins that should always appear. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
110 lines
3.5 KiB
Python
110 lines
3.5 KiB
Python
"""
|
|
Service Store Blueprint — /api/store
|
|
|
|
Provides routes to browse, install, and remove services from the PIC
|
|
service store. Authentication is enforced by the global before_request
|
|
hook in app.py (admin session required for all /api/* routes except
|
|
/api/auth/*).
|
|
"""
|
|
|
|
import logging
|
|
from flask import Blueprint, request, jsonify
|
|
|
|
import requests as _requests
|
|
|
|
from service_store_manager import MANIFEST_URL_TPL
|
|
|
|
logger = logging.getLogger('picell')
|
|
|
|
store_bp = Blueprint('service_store', __name__, url_prefix='/api/store')
|
|
|
|
|
|
def _ssm():
|
|
"""Lazy import of service_store_manager to avoid circular import at module load."""
|
|
from app import service_store_manager
|
|
return service_store_manager
|
|
|
|
|
|
def _cfg():
|
|
from app import config_manager
|
|
return config_manager
|
|
|
|
|
|
@store_bp.route('/services', methods=['GET'])
|
|
def list_store_services():
|
|
"""Return available and installed services."""
|
|
try:
|
|
return jsonify(_ssm().list_services())
|
|
except Exception as e:
|
|
logger.error(f'list_store_services: {e}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@store_bp.route('/services/<service_id>/manifest', methods=['GET'])
|
|
def get_manifest(service_id: str):
|
|
"""Fetch and return the manifest for a specific service."""
|
|
try:
|
|
url = MANIFEST_URL_TPL.format(id=service_id)
|
|
resp = _requests.get(url, timeout=10)
|
|
resp.raise_for_status()
|
|
return jsonify(resp.json())
|
|
except _requests.HTTPError as e:
|
|
return jsonify({'error': f'Manifest not found: {e}'}), 404
|
|
except Exception as e:
|
|
logger.error(f'get_manifest({service_id}): {e}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@store_bp.route('/services/<service_id>/install', methods=['POST'])
|
|
def install_service(service_id: str):
|
|
"""Install a service from the store."""
|
|
try:
|
|
result = _ssm().install(service_id)
|
|
if result.get('ok'):
|
|
return jsonify(result)
|
|
# Normalize docker compose stderr into the error key so the frontend
|
|
# can display the actual failure reason rather than a generic message.
|
|
if not result.get('error') and result.get('stderr'):
|
|
result = {**result, 'error': result['stderr']}
|
|
return jsonify(result), 400
|
|
except Exception as e:
|
|
logger.error(f'install_service({service_id}): {e}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@store_bp.route('/services/<service_id>', methods=['DELETE'])
|
|
def remove_service(service_id: str):
|
|
"""Remove an installed service."""
|
|
try:
|
|
purge = request.args.get('purge') == 'true'
|
|
result = _ssm().remove(service_id, purge_data=purge)
|
|
if result.get('ok'):
|
|
return jsonify(result)
|
|
return jsonify(result), 404
|
|
except Exception as e:
|
|
logger.error(f'remove_service({service_id}): {e}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@store_bp.route('/installed', methods=['GET'])
|
|
def get_installed():
|
|
"""Return all currently installed services."""
|
|
try:
|
|
return jsonify({'installed': _cfg().get_installed_services()})
|
|
except Exception as e:
|
|
logger.error(f'get_installed: {e}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@store_bp.route('/refresh', methods=['POST'])
|
|
def refresh_index():
|
|
"""Invalidate the index cache and return a fresh service list."""
|
|
try:
|
|
ssm = _ssm()
|
|
ssm._index_cache = None
|
|
ssm._index_cache_time = 0
|
|
return jsonify(ssm.list_services())
|
|
except Exception as e:
|
|
logger.error(f'refresh_index: {e}')
|
|
return jsonify({'error': str(e)}), 500
|