0a21f22076
- ServiceStoreManager: manifest allowlist (git.pic.ngo/roof/*), volume
denylist, ACCEPT-only iptables rules, ${SERVICE_IP}-only dest_ip
- IP allocator: pool 172.20.0.20-254, skips CONTAINER_OFFSETS VIPs
- Compose overlay: docker-compose.services.yml auto-included via DCF
- Flask blueprint at /api/store: list, install, remove, refresh
- Store.jsx: full install/remove UI with spinners and toast notifications
- 95 new unit tests for ServiceStoreManager (all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
106 lines
3.2 KiB
Python
106 lines
3.2 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)
|
|
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
|