Phase 4: service store — manifest validation, install/remove, Store UI
- 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>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user