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:
2026-05-09 10:19:39 -04:00
parent f77d7fabcd
commit 0a21f22076
14 changed files with 2190 additions and 12 deletions
+105
View File
@@ -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