diff --git a/Makefile b/Makefile index cbf3d1e..f0949cb 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ # Detect docker compose command (v2 plugin preferred, fallback to v1 standalone) DC := $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose") +# Full compose command: includes docker-compose.services.yml when it exists +DCF = $(DC) $(if $(wildcard docker-compose.services.yml),-f docker-compose.yml -f docker-compose.services.yml,-f docker-compose.yml) + # Default target help: @echo "Personal Internet Cell - Management Commands" @@ -93,12 +96,12 @@ init-peers: start: @echo "Starting Personal Internet Cell..." - PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full up -d --build @echo "Services started. Check status with 'make status'" stop: @echo "Stopping Personal Internet Cell..." - PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down @echo "Services stopped." restart: @@ -109,16 +112,16 @@ restart: status: @echo "Personal Internet Cell Status:" @echo "================================" - $(DC) ps + $(DCF) ps @echo "" @echo "API Status:" @curl -s http://localhost:3000/health || echo "API not responding" logs: - $(DC) logs -f + $(DCF) logs -f logs-%: - $(DC) logs -f $* + $(DCF) logs -f $* shell-%: docker exec -it cell-$* /bin/bash 2>/dev/null || docker exec -it cell-$* /bin/sh @@ -135,12 +138,12 @@ update: $(MAKE) setup; \ fi @echo "Rebuilding and restarting services..." - PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full up -d --build @echo "Update complete. Run 'make status' to verify." reinstall: @echo "Reinstalling Personal Internet Cell from scratch..." - PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v 2>/dev/null || true + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down -v 2>/dev/null || true @sudo rm -rf config/ data/ @$(MAKE) setup @$(MAKE) start @@ -169,14 +172,14 @@ uninstall: case "$$ans" in \ y|Y) \ echo "Stopping containers and removing images..."; \ - PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v --rmi all 2>/dev/null || true; \ + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down -v --rmi all 2>/dev/null || true; \ echo "Deleting config/ and data/..."; \ sudo rm -rf config/ data/; \ echo "Uninstall complete. Git repo and scripts remain."; \ ;; \ n|N|"") \ echo "Stopping and removing containers (keeping images and data)..."; \ - PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down 2>/dev/null || true; \ + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down 2>/dev/null || true; \ echo "Done. Images, config/ and data/ are untouched. Run 'make start' to bring it back up."; \ ;; \ *) \ @@ -208,7 +211,7 @@ build-webui: start-core: @echo "Starting core services (caddy, dns, wireguard, api, webui)..." - PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile core up -d --build + PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core up -d --build @echo "Core services started. Run 'make start' to also bring up optional services." start-dns: diff --git a/api/app.py b/api/app.py index 2c15978..3d2e606 100644 --- a/api/app.py +++ b/api/app.py @@ -42,7 +42,7 @@ from managers import ( routing_manager, vault_manager, container_manager, cell_link_manager, auth_manager, setup_manager, caddy_manager, - ddns_manager, + ddns_manager, service_store_manager, firewall_manager, EventType, ) # Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns` @@ -374,6 +374,11 @@ def _apply_startup_enforcement(): sync_summary = cell_link_manager.replay_pending_pushes() if sync_summary.get('attempted'): logger.info(f"Startup permission sync: {sync_summary}") + # Service store: re-apply firewall/caddy rules for installed services + try: + service_store_manager.reapply_on_startup() + except Exception as _sse: + logger.warning(f"service_store reapply_on_startup failed (non-fatal): {_sse}") except Exception as e: logger.warning(f"Startup enforcement failed (non-fatal): {e}") @@ -465,6 +470,9 @@ app.register_blueprint(_services_bp) app.register_blueprint(_peer_dashboard_bp) app.register_blueprint(_config_bp) +from routes.service_store import store_bp +app.register_blueprint(store_bp) + # Re-export config helpers so existing test imports/patches keep working from routes.config import ( _set_pending_restart, _clear_pending_restart, diff --git a/api/caddy_manager.py b/api/caddy_manager.py index 6e248dc..8b5ccc4 100644 --- a/api/caddy_manager.py +++ b/api/caddy_manager.py @@ -373,6 +373,12 @@ class CaddyManager(BaseServiceManager): # ── certificate status ──────────────────────────────────────────────── + def regenerate_with_installed(self, installed_services: list) -> bool: + """Regenerate Caddyfile with installed services and reload.""" + identity = self.config_manager.get_identity() + content = self.generate_caddyfile(identity, installed_services) + return self.write_caddyfile(content) + def get_cert_status(self) -> Dict[str, Any]: """Return TLS cert status from identity['tls'] if present.""" default = {'status': 'unknown', 'expiry': None, 'days_remaining': None} diff --git a/api/config_manager.py b/api/config_manager.py index 08a9a7f..7711379 100644 --- a/api/config_manager.py +++ b/api/config_manager.py @@ -474,6 +474,20 @@ class ConfigManager: self.configs['_identity'][key] = value self._save_all_configs() + def get_installed_services(self) -> dict: + return self.configs.get('_identity', {}).get('installed_services', {}) + + def set_installed_service(self, service_id: str, record: dict): + ident = self.configs.setdefault('_identity', {}) + ident.setdefault('installed_services', {})[service_id] = record + self._save_all_configs() + + def remove_installed_service(self, service_id: str): + ident = self.configs.setdefault('_identity', {}) + ident.setdefault('installed_services', {}).pop(service_id, None) + ident.setdefault('service_ips', {}).pop(service_id, None) + self._save_all_configs() + def get_all_configs(self) -> Dict[str, Dict]: """Get all service configurations""" return self.configs.copy() diff --git a/api/firewall_manager.py b/api/firewall_manager.py index c7b94fc..cd9b40f 100644 --- a/api/firewall_manager.py +++ b/api/firewall_manager.py @@ -804,3 +804,52 @@ def apply_all_dns_rules(peers: List[Dict[str, Any]], corefile_path: str = COREFI if ok: reload_coredns() return ok + + +# --------------------------------------------------------------------------- +# Service store firewall rules +# --------------------------------------------------------------------------- + +def _service_tag(service_id: str) -> str: + safe = re.sub(r'[^a-z0-9]', '-', service_id.lower()) + return f'pic-svc-{safe}' + + +def apply_service_rules(service_id: str, service_ip: str, rules: list) -> bool: + """Apply manifest-declared ACCEPT rules for an installed service.""" + tag = _service_tag(service_id) + clear_service_rules(service_id) + for r in rules: + if r.get('type') != 'ACCEPT': + continue + dest_ip = r['dest_ip'].replace('${SERVICE_IP}', service_ip) + dport = str(r['dest_port']) + proto = r.get('proto', 'tcp') + _iptables(['-I', 'FORWARD', + '-d', dest_ip, '-p', proto, '--dport', dport, + '-m', 'comment', '--comment', tag, + '-j', 'ACCEPT']) + return True + + +def clear_service_rules(service_id: str) -> None: + """Remove all iptables rules tagged for this service using save/restore.""" + tag = _service_tag(service_id) + comment_re = re.compile(rf'--comment\s+["\']?{re.escape(tag)}["\']?(\s|$)') + try: + save = _wg_exec(['iptables-save']) + if save.returncode != 0: + return + lines = save.stdout.splitlines() + filtered = [l for l in lines if not comment_re.search(l)] + if len(filtered) == len(lines): + return + restore_input = '\n'.join(filtered) + '\n' + restore = subprocess.run( + ['docker', 'exec', '-i', WIREGUARD_CONTAINER, 'iptables-restore'], + input=restore_input, capture_output=True, text=True, timeout=10 + ) + if restore.returncode != 0: + logger.warning(f'clear_service_rules iptables-restore failed: {restore.stderr.strip()}') + except Exception as e: + logger.error(f'clear_service_rules({service_id}): {e}') diff --git a/api/managers.py b/api/managers.py index 05d3844..f1c52e3 100644 --- a/api/managers.py +++ b/api/managers.py @@ -60,6 +60,15 @@ setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_ma caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR) ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR) +from service_store_manager import ServiceStoreManager +service_store_manager = ServiceStoreManager( + config_manager=config_manager, + caddy_manager=caddy_manager, + container_manager=container_manager, + data_dir=DATA_DIR, + config_dir=CONFIG_DIR, +) + # Service logger configuration _service_log_configs = { 'network': {'level': 'INFO', 'formatter': 'json', 'console': False}, @@ -93,7 +102,7 @@ __all__ = [ 'email_manager', 'calendar_manager', 'file_manager', 'routing_manager', 'vault_manager', 'container_manager', 'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager', - 'ddns_manager', + 'ddns_manager', 'service_store_manager', 'firewall_manager', 'EventType', 'DATA_DIR', 'CONFIG_DIR', ] diff --git a/api/routes/service_store.py b/api/routes/service_store.py new file mode 100644 index 0000000..b17c3ad --- /dev/null +++ b/api/routes/service_store.py @@ -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//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//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/', 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 diff --git a/api/service_store_manager.py b/api/service_store_manager.py new file mode 100644 index 0000000..911b2ad --- /dev/null +++ b/api/service_store_manager.py @@ -0,0 +1,526 @@ +#!/usr/bin/env python3 +""" +Service Store Manager for Personal Internet Cell. + +Manages installation, removal, and lifecycle of third-party services from the +PIC service store index. Each installed service runs as a Docker container +declared in a compose override file and has: + - An allocated IP in the service pool (172.20.0.20–254 by default) + - Optional iptables FORWARD rules declared in its manifest + - Optional Caddy reverse-proxy route declared in its manifest +""" + +import logging +import os +import re +import threading +import subprocess +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +import requests +import yaml + +from base_service_manager import BaseServiceManager +from ip_utils import CONTAINER_OFFSETS + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +SERVICE_POOL_START = 20 +SERVICE_POOL_END = 254 + +INDEX_URL_DEFAULT = ( + 'https://git.pic.ngo/roof/pic-services/raw/branch/main/index.json' +) +MANIFEST_URL_TPL = ( + 'https://git.pic.ngo/roof/pic-services/raw/branch/main/services/{id}/manifest.json' +) + +IMAGE_ALLOWLIST_RE = re.compile( + r'^git\.pic\.ngo/roof/[a-z0-9._/-]+(:[a-zA-Z0-9._-]+)?$' +) +FORBIDDEN_MOUNTS = frozenset([ + '/', '/etc', '/var', '/proc', '/sys', '/dev', '/app', '/run', '/boot', +]) +RESERVED_SUBDOMAINS = frozenset([ + 'api', 'webui', 'admin', 'www', 'mail', 'ns1', 'ns2', + 'git', 'registry', 'install', +]) +ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$') + + +# --------------------------------------------------------------------------- +# ServiceStoreManager +# --------------------------------------------------------------------------- + +class ServiceStoreManager(BaseServiceManager): + """Manages service store: install, remove, and list available/installed services.""" + + def __init__(self, config_manager, caddy_manager, container_manager, + data_dir: str = '', config_dir: str = ''): + super().__init__('service_store', data_dir, config_dir) + self.config_manager = config_manager + self.caddy_manager = caddy_manager + self.container_manager = container_manager + self.compose_override = os.environ.get( + 'COMPOSE_SERVICES_PATH', '/app/docker-compose.services.yml' + ) + self.index_url = os.environ.get('PIC_STORE_INDEX_URL', INDEX_URL_DEFAULT) + self._lock = threading.Lock() + self._index_cache: Optional[list] = None + self._index_cache_time: float = 0 + self._cache_ttl: int = 300 # 5 min + + # ── BaseServiceManager required ─────────────────────────────────────── + + def get_status(self) -> Dict[str, Any]: + installed = self.config_manager.get_installed_services() + return { + 'service': self.service_name, + 'running': True, + 'installed_count': len(installed), + } + + def test_connectivity(self) -> Dict[str, Any]: + try: + resp = requests.get(self.index_url, timeout=5) + return {'success': resp.status_code == 200} + except Exception as e: + return {'success': False, 'error': str(e)} + + # ── Manifest validation ─────────────────────────────────────────────── + + @staticmethod + def _validate_manifest(m: dict) -> Tuple[bool, List[str]]: + """Validate a service manifest. Returns (ok, [errors]).""" + errors: List[str] = [] + + # Required top-level fields + for field in ('id', 'name', 'version', 'author', 'image', 'container_name'): + if not m.get(field): + errors.append(f'Missing required field: {field}') + + # Image allowlist + image = m.get('image', '') + if image and not IMAGE_ALLOWLIST_RE.match(image): + errors.append( + f'image must match git.pic.ngo/roof/* pattern, got: {image}' + ) + + # Volume mount safety + for vol in m.get('volumes', []): + mount = vol.get('mount', '') + if mount in FORBIDDEN_MOUNTS: + errors.append(f'Forbidden volume mount: {mount}') + elif mount.startswith('/home/roof/pic'): + errors.append(f'Volume mount cannot be a prefix of /home/roof/pic: {mount}') + + # iptables rules + for rule in m.get('iptables_rules', []): + if rule.get('type') != 'ACCEPT': + errors.append( + f'iptables_rules[].type must be ACCEPT, got: {rule.get("type")}' + ) + if rule.get('dest_ip') != '${SERVICE_IP}': + errors.append( + f'iptables_rules[].dest_ip must be exactly ${{SERVICE_IP}}, ' + f'got: {rule.get("dest_ip")}' + ) + port = rule.get('dest_port') + if not isinstance(port, int) or not (1 <= port <= 65535): + errors.append( + f'iptables_rules[].dest_port must be an integer 1-65535, got: {port}' + ) + proto = rule.get('proto', 'tcp') + if proto not in ('tcp', 'udp'): + errors.append( + f'iptables_rules[].proto must be tcp or udp, got: {proto}' + ) + + # Caddy route subdomain + caddy_route = m.get('caddy_route') or {} + if isinstance(caddy_route, dict): + subdomain = caddy_route.get('subdomain', '') + else: + subdomain = '' + if subdomain: + if subdomain in RESERVED_SUBDOMAINS: + errors.append(f'caddy_route.subdomain is reserved: {subdomain}') + elif not re.match(r'^[a-z][a-z0-9-]{0,30}$', subdomain): + errors.append( + f'caddy_route.subdomain must match ^[a-z][a-z0-9-]{{0,30}}$, ' + f'got: {subdomain}' + ) + + # Env value safety + for env_entry in m.get('env', []): + val = str(env_entry.get('value', '')) + if not ENV_VALUE_RE.match(val): + errors.append( + f'env[].value contains disallowed characters: {val!r}' + ) + + return (len(errors) == 0, errors) + + # ── IP allocation ───────────────────────────────────────────────────── + + def _allocate_service_ip(self, service_id: str) -> str: + """Allocate the next free IP from the service pool.""" + identity = self.config_manager.get_identity() + ip_range = identity.get('ip_range', '172.20.0.0/16') + + import ipaddress + network = ipaddress.IPv4Network(ip_range, strict=False) + base = int(network.network_address) + + # IPs already assigned to named containers + reserved_offsets = set(CONTAINER_OFFSETS.values()) + + # IPs already assigned to installed services + service_ips: Dict[str, str] = identity.get('service_ips', {}) + taken_ips = set(service_ips.values()) + + for offset in range(SERVICE_POOL_START, SERVICE_POOL_END + 1): + if offset in reserved_offsets: + continue + candidate = str(ipaddress.IPv4Address(base + offset)) + if candidate not in taken_ips: + return candidate + + raise RuntimeError('Service IP pool exhausted (offsets 20-254 all taken)') + + # ── Compose override ────────────────────────────────────────────────── + + def _render_compose_override(self, installed_records: dict) -> str: + """Generate docker-compose YAML override for all installed services.""" + services: Dict[str, Any] = {} + + for svc_id, record in installed_records.items(): + manifest = record.get('manifest', {}) + container_name = record.get('container_name', svc_id) + image = manifest.get('image', record.get('image', '')) + service_ip = record.get('service_ip', '') + + # Volumes + volumes = [] + for vol in manifest.get('volumes', []): + vol_name = vol.get('name', '') + mount = vol.get('mount', '') + if vol_name and mount: + volumes.append(f'{vol_name}:{mount}') + + # Environment + environment: Dict[str, str] = {} + for env_entry in manifest.get('env', []): + k = env_entry.get('key', '') + v = str(env_entry.get('value', '')) + if k: + environment[k] = v + + svc_def: Dict[str, Any] = { + 'image': image, + 'container_name': container_name, + 'restart': 'unless-stopped', + 'logging': { + 'driver': 'json-file', + 'options': { + 'max-size': '10m', + 'max-file': '5', + }, + }, + 'networks': { + 'cell-network': { + 'ipv4_address': service_ip, + } + }, + } + if volumes: + svc_def['volumes'] = volumes + if environment: + svc_def['environment'] = environment + + services[container_name] = svc_def + + # Collect named volumes + named_volumes: Dict[str, Any] = {} + for svc_id, record in installed_records.items(): + manifest = record.get('manifest', {}) + for vol in manifest.get('volumes', []): + vol_name = vol.get('name', '') + if vol_name: + named_volumes[vol_name] = None # Docker default driver + + doc: Dict[str, Any] = { + 'version': '3.8', + 'services': services, + 'networks': { + 'cell-network': { + 'external': True, + } + }, + } + if named_volumes: + doc['volumes'] = named_volumes + + return yaml.dump(doc, default_flow_style=False, allow_unicode=True) + + def _write_compose_override(self, content: str) -> None: + """Atomic write of the compose override file.""" + tmp_path = self.compose_override + '.tmp' + try: + os.makedirs(os.path.dirname(os.path.abspath(self.compose_override)), + exist_ok=True) + except (PermissionError, OSError): + pass + with open(tmp_path, 'w') as f: + f.write(content) + f.flush() + try: + os.fsync(f.fileno()) + except OSError: + pass + os.replace(tmp_path, self.compose_override) + + # ── Index / manifest fetching ───────────────────────────────────────── + + def fetch_index(self) -> list: + """Fetch and cache the service index.""" + import time + now = time.time() + if self._index_cache is not None and (now - self._index_cache_time) < self._cache_ttl: + return self._index_cache + try: + resp = requests.get(self.index_url, timeout=10) + resp.raise_for_status() + data = resp.json() + self._index_cache = data if isinstance(data, list) else data.get('services', []) + self._index_cache_time = now + return self._index_cache + except Exception as e: + logger.warning(f'fetch_index failed: {e}') + return self._index_cache or [] + + def _fetch_manifest(self, service_id: str) -> dict: + """Fetch a service manifest by ID.""" + url = MANIFEST_URL_TPL.format(id=service_id) + resp = requests.get(url, timeout=10) + resp.raise_for_status() + return resp.json() + + # ── Core operations ─────────────────────────────────────────────────── + + def install(self, service_id: str) -> dict: + """Install a service from the store.""" + from firewall_manager import apply_service_rules + + with self._lock: + # Already installed? + installed = self.config_manager.get_installed_services() + if service_id in installed: + return {'ok': True, 'already_installed': True} + + # Fetch and validate manifest + try: + manifest = self._fetch_manifest(service_id) + except Exception as e: + return {'ok': False, 'error': f'Failed to fetch manifest: {e}'} + + ok, errs = self._validate_manifest(manifest) + if not ok: + return {'ok': False, 'errors': errs} + + # Allocate IP + try: + ip = self._allocate_service_ip(service_id) + except RuntimeError as e: + return {'ok': False, 'error': str(e)} + + # Build install record + record = { + 'id': service_id, + 'name': manifest.get('name', service_id), + 'container_name': manifest['container_name'], + 'image': manifest.get('image', ''), + 'service_ip': ip, + 'caddy_route': manifest.get('caddy_route'), + 'iptables_rules': manifest.get('iptables_rules', []), + 'manifest': manifest, + 'installed_at': datetime.utcnow().isoformat(), + } + + # Persist to config + self.config_manager.set_installed_service(service_id, record) + identity = self.config_manager.get_identity() + service_ips = dict(identity.get('service_ips', {})) + service_ips[service_id] = ip + self.config_manager.set_identity_field('service_ips', service_ips) + + # Write compose override + all_installed = self.config_manager.get_installed_services() + try: + content = self._render_compose_override(all_installed) + self._write_compose_override(content) + except Exception as e: + logger.error(f'Failed to write compose override: {e}') + + # Apply iptables rules (best-effort) + try: + apply_service_rules(service_id, ip, manifest.get('iptables_rules', [])) + except Exception as e: + logger.warning(f'apply_service_rules for {service_id} failed (non-fatal): {e}') + + # Regenerate Caddyfile + try: + caddy_routes = [ + r.get('caddy_route') + for r in all_installed.values() + if r.get('caddy_route') + ] + self.caddy_manager.regenerate_with_installed(caddy_routes) + except Exception as e: + logger.warning(f'caddy regenerate for {service_id} failed (non-fatal): {e}') + + # Start the container via docker compose + base_compose = os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml') + try: + result = subprocess.run( + ['docker', 'compose', + '-f', base_compose, + '-f', self.compose_override, + 'up', '-d', manifest['container_name']], + capture_output=True, text=True, timeout=120, + ) + if result.returncode != 0: + logger.warning( + f'docker compose up for {service_id} failed: {result.stderr.strip()}' + ) + except Exception as e: + logger.warning(f'docker compose up for {service_id} failed (non-fatal): {e}') + + return { + 'ok': True, + 'service_ip': ip, + 'container_name': manifest['container_name'], + } + + def remove(self, service_id: str, purge_data: bool = False) -> dict: + """Remove an installed service.""" + from firewall_manager import clear_service_rules + + with self._lock: + installed = self.config_manager.get_installed_services() + record = installed.get(service_id) + if not record: + return {'ok': False, 'error': f'Service {service_id} is not installed'} + + container_name = record.get('container_name', service_id) + manifest = record.get('manifest', {}) + base_compose = os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml') + + # Stop and remove container + try: + subprocess.run( + ['docker', 'compose', + '-f', base_compose, + '-f', self.compose_override, + 'stop', container_name], + capture_output=True, text=True, timeout=60, + ) + except Exception as e: + logger.warning(f'docker compose stop for {service_id} failed (non-fatal): {e}') + + try: + subprocess.run( + ['docker', 'rm', '-f', container_name], + capture_output=True, text=True, timeout=30, + ) + except Exception as e: + logger.warning(f'docker rm for {service_id} failed (non-fatal): {e}') + + # Clear iptables rules + try: + clear_service_rules(service_id) + except Exception as e: + logger.warning(f'clear_service_rules for {service_id} failed (non-fatal): {e}') + + # Remove from config, regenerate compose + caddy + self.config_manager.remove_installed_service(service_id) + remaining = self.config_manager.get_installed_services() + + try: + content = self._render_compose_override(remaining) + self._write_compose_override(content) + except Exception as e: + logger.error(f'Failed to write compose override after remove: {e}') + + try: + caddy_routes = [ + r.get('caddy_route') + for r in remaining.values() + if r.get('caddy_route') + ] + self.caddy_manager.regenerate_with_installed(caddy_routes) + except Exception as e: + logger.warning(f'caddy regenerate after remove failed (non-fatal): {e}') + + # Purge named volumes if requested + if purge_data: + for vol in manifest.get('volumes', []): + vol_name = vol.get('name', '') + if vol_name: + try: + subprocess.run( + ['docker', 'volume', 'rm', vol_name], + capture_output=True, text=True, timeout=30, + ) + except Exception as e: + logger.warning( + f'docker volume rm {vol_name} failed (non-fatal): {e}' + ) + + return {'ok': True} + + def list_services(self) -> dict: + """Return available (from index) and installed services.""" + available = self.fetch_index() + installed = self.config_manager.get_installed_services() + return {'available': available, 'installed': installed} + + def reapply_on_startup(self) -> None: + """Re-apply firewall and Caddy rules for all installed services on startup.""" + from firewall_manager import apply_service_rules + + installed = self.config_manager.get_installed_services() + if not installed: + return + + # Regenerate compose override in case it was deleted + try: + content = self._render_compose_override(installed) + self._write_compose_override(content) + except Exception as e: + logger.warning(f'reapply_on_startup: compose override write failed: {e}') + + # Re-apply iptables rules + for svc_id, record in installed.items(): + ip = record.get('service_ip', '') + rules = record.get('iptables_rules', []) + try: + apply_service_rules(svc_id, ip, rules) + except Exception as e: + logger.warning(f'reapply_on_startup: apply_service_rules({svc_id}) failed: {e}') + + # Regenerate Caddyfile + try: + caddy_routes = [ + r.get('caddy_route') + for r in installed.values() + if r.get('caddy_route') + ] + self.caddy_manager.regenerate_with_installed(caddy_routes) + except Exception as e: + logger.warning(f'reapply_on_startup: caddy regenerate failed: {e}') diff --git a/docker-compose.services.yml b/docker-compose.services.yml new file mode 100644 index 0000000..f5772f5 --- /dev/null +++ b/docker-compose.services.yml @@ -0,0 +1,6 @@ +version: '3.3' +services: {} +networks: + cell-network: + external: true + name: pic_cell-network diff --git a/docker-compose.yml b/docker-compose.yml index aa79511..c657b9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -215,6 +215,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock - ./.env:/app/.env.compose - ./docker-compose.yml:/app/docker-compose.yml:ro + - ./docker-compose.services.yml:/app/docker-compose.services.yml - ./scripts:/app/scripts:ro pid: host restart: unless-stopped diff --git a/tests/test_service_store_manager.py b/tests/test_service_store_manager.py new file mode 100644 index 0000000..7188222 --- /dev/null +++ b/tests/test_service_store_manager.py @@ -0,0 +1,1008 @@ +""" +Tests for ServiceStoreManager — manifest validation, IP allocation, +compose-override rendering, index listing, install, and remove. + +All external I/O (requests, subprocess, docker, config_manager, caddy_manager, +container_manager) is mocked so these tests run without any live infrastructure. +""" + +import os +import sys +import time +import unittest +from unittest.mock import MagicMock, patch, call + +import yaml + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api')) + +from service_store_manager import ServiceStoreManager +from ip_utils import CONTAINER_OFFSETS + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_manager(tmp_dir=None, installed=None, identity=None): + """Build a ServiceStoreManager backed by mock dependencies.""" + cm = MagicMock() + cm.get_installed_services.return_value = installed or {} + cm.get_identity.return_value = identity or { + 'ip_range': '172.20.0.0/16', + 'service_ips': {}, + } + caddy = MagicMock() + container = MagicMock() + d = tmp_dir or '/tmp/pic-ssm-test' + mgr = ServiceStoreManager( + config_manager=cm, + caddy_manager=caddy, + container_manager=container, + data_dir=d, + config_dir=d, + ) + # Redirect compose override writes to a temp location so tests don't need /app + mgr.compose_override = os.path.join(d, 'docker-compose.services.yml') + return mgr + + +def _valid_manifest(**overrides): + """Return a minimal valid manifest, with optional field overrides.""" + m = { + 'id': 'myapp', + 'name': 'My App', + 'version': '1.0.0', + 'author': 'Test Author', + 'image': 'git.pic.ngo/roof/myapp:latest', + 'container_name': 'cell-myapp', + } + m.update(overrides) + return m + + +# --------------------------------------------------------------------------- +# _validate_manifest — required fields +# --------------------------------------------------------------------------- + +class TestValidateManifestRequiredFields(unittest.TestCase): + + def test_valid_manifest_passes(self): + ok, errs = ServiceStoreManager._validate_manifest(_valid_manifest()) + self.assertTrue(ok) + self.assertEqual(errs, []) + + def test_missing_id_produces_error(self): + m = _valid_manifest() + del m['id'] + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertFalse(ok) + self.assertTrue(any('id' in e for e in errs)) + + def test_missing_name_produces_error(self): + m = _valid_manifest() + del m['name'] + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertFalse(ok) + self.assertTrue(any('name' in e for e in errs)) + + def test_missing_version_produces_error(self): + m = _valid_manifest() + del m['version'] + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertFalse(ok) + self.assertTrue(any('version' in e for e in errs)) + + def test_missing_author_produces_error(self): + m = _valid_manifest() + del m['author'] + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertFalse(ok) + self.assertTrue(any('author' in e for e in errs)) + + def test_missing_image_produces_error(self): + m = _valid_manifest() + del m['image'] + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertFalse(ok) + self.assertTrue(any('image' in e for e in errs)) + + def test_missing_container_name_produces_error(self): + m = _valid_manifest() + del m['container_name'] + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertFalse(ok) + self.assertTrue(any('container_name' in e for e in errs)) + + def test_all_required_fields_missing_produces_six_errors(self): + ok, errs = ServiceStoreManager._validate_manifest({}) + self.assertFalse(ok) + self.assertEqual(len(errs), 6) + + +# --------------------------------------------------------------------------- +# _validate_manifest — image allowlist +# --------------------------------------------------------------------------- + +class TestValidateManifestImage(unittest.TestCase): + + def test_image_outside_allowlist_rejected(self): + m = _valid_manifest(image='docker.io/library/nginx:latest') + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertFalse(ok) + self.assertTrue(any('image must match' in e for e in errs)) + + def test_image_matching_git_pic_ngo_roof_with_tag_passes(self): + m = _valid_manifest(image='git.pic.ngo/roof/something:1.2.3') + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertTrue(ok) + self.assertEqual(errs, []) + + def test_image_git_pic_ngo_roof_no_tag_passes(self): + m = _valid_manifest(image='git.pic.ngo/roof/myservice') + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertTrue(ok) + + def test_image_wrong_registry_rejected(self): + m = _valid_manifest(image='ghcr.io/roof/myapp:latest') + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertFalse(ok) + + def test_image_partial_match_rejected(self): + # Must be at root of git.pic.ngo/roof/, not nested elsewhere + m = _valid_manifest(image='evil.git.pic.ngo/roof/myapp:latest') + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertFalse(ok) + + +# --------------------------------------------------------------------------- +# _validate_manifest — volume mounts +# --------------------------------------------------------------------------- + +class TestValidateManifestVolumes(unittest.TestCase): + + def _make_with_volume(self, mount): + m = _valid_manifest() + m['volumes'] = [{'name': 'mydata', 'mount': mount}] + return m + + def test_root_mount_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest(self._make_with_volume('/')) + self.assertFalse(ok) + self.assertTrue(any('Forbidden volume mount' in e for e in errs)) + + def test_etc_mount_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest(self._make_with_volume('/etc')) + self.assertFalse(ok) + + def test_var_mount_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest(self._make_with_volume('/var')) + self.assertFalse(ok) + + def test_proc_mount_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest(self._make_with_volume('/proc')) + self.assertFalse(ok) + + def test_sys_mount_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest(self._make_with_volume('/sys')) + self.assertFalse(ok) + + def test_dev_mount_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest(self._make_with_volume('/dev')) + self.assertFalse(ok) + + def test_app_mount_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest(self._make_with_volume('/app')) + self.assertFalse(ok) + + def test_run_mount_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest(self._make_with_volume('/run')) + self.assertFalse(ok) + + def test_boot_mount_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest(self._make_with_volume('/boot')) + self.assertFalse(ok) + + def test_home_roof_pic_prefix_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_volume('/home/roof/pic/data') + ) + self.assertFalse(ok) + self.assertTrue(any('/home/roof/pic' in e for e in errs)) + + def test_home_roof_pic_exact_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_volume('/home/roof/pic') + ) + self.assertFalse(ok) + + def test_safe_data_mount_passes(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_volume('/data/myservice') + ) + self.assertTrue(ok) + self.assertEqual(errs, []) + + def test_safe_srv_mount_passes(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_volume('/srv/myapp') + ) + self.assertTrue(ok) + + +# --------------------------------------------------------------------------- +# _validate_manifest — iptables rules +# --------------------------------------------------------------------------- + +class TestValidateManifestIptables(unittest.TestCase): + + def _make_with_rule(self, **rule_fields): + m = _valid_manifest() + base_rule = { + 'type': 'ACCEPT', + 'dest_ip': '${SERVICE_IP}', + 'dest_port': 8080, + 'proto': 'tcp', + } + base_rule.update(rule_fields) + m['iptables_rules'] = [base_rule] + return m + + def test_valid_rule_passes(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(type='ACCEPT', dest_ip='${SERVICE_IP}', dest_port=8080) + ) + self.assertTrue(ok) + self.assertEqual(errs, []) + + def test_type_not_accept_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(type='DROP') + ) + self.assertFalse(ok) + self.assertTrue(any('type must be ACCEPT' in e for e in errs)) + + def test_type_reject_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(type='REJECT') + ) + self.assertFalse(ok) + + def test_dest_ip_not_service_ip_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(dest_ip='10.0.0.1') + ) + self.assertFalse(ok) + self.assertTrue(any('dest_ip must be exactly' in e for e in errs)) + + def test_port_zero_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(dest_port=0) + ) + self.assertFalse(ok) + self.assertTrue(any('dest_port' in e for e in errs)) + + def test_port_65536_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(dest_port=65536) + ) + self.assertFalse(ok) + + def test_port_1_accepted(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(dest_port=1) + ) + self.assertTrue(ok) + + def test_port_65535_accepted(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(dest_port=65535) + ) + self.assertTrue(ok) + + def test_port_as_string_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(dest_port='8080') + ) + self.assertFalse(ok) + + def test_proto_invalid_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(proto='icmp') + ) + self.assertFalse(ok) + self.assertTrue(any('proto' in e for e in errs)) + + def test_proto_udp_accepted(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_rule(proto='udp') + ) + self.assertTrue(ok) + + +# --------------------------------------------------------------------------- +# _validate_manifest — env values +# --------------------------------------------------------------------------- + +class TestValidateManifestEnv(unittest.TestCase): + + def _make_with_env(self, value): + m = _valid_manifest() + m['env'] = [{'key': 'MY_VAR', 'value': value}] + return m + + def test_safe_alphanumeric_value_passes(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_env('hello123') + ) + self.assertTrue(ok) + + def test_safe_value_with_allowed_chars_passes(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_env('user@example.com') + ) + self.assertTrue(ok) + + def test_command_substitution_dollar_paren_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_env('$(cmd)') + ) + self.assertFalse(ok) + self.assertTrue(any('disallowed characters' in e for e in errs)) + + def test_backtick_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_env('`cmd`') + ) + self.assertFalse(ok) + + def test_semicolon_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_env('val;rm -rf /') + ) + self.assertFalse(ok) + + def test_pipe_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_env('val|cat /etc/passwd') + ) + self.assertFalse(ok) + + def test_empty_value_passes(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_env('') + ) + self.assertTrue(ok) + + +# --------------------------------------------------------------------------- +# _validate_manifest — caddy_route subdomain +# --------------------------------------------------------------------------- + +class TestValidateManifestSubdomain(unittest.TestCase): + + def _make_with_subdomain(self, subdomain): + m = _valid_manifest() + m['caddy_route'] = {'subdomain': subdomain, 'upstream': 'cell-myapp:8080'} + return m + + def test_valid_subdomain_passes(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_subdomain('myapp') + ) + self.assertTrue(ok) + self.assertEqual(errs, []) + + def test_reserved_api_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_subdomain('api') + ) + self.assertFalse(ok) + self.assertTrue(any('reserved' in e for e in errs)) + + def test_reserved_admin_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_subdomain('admin') + ) + self.assertFalse(ok) + + def test_reserved_www_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_subdomain('www') + ) + self.assertFalse(ok) + + def test_reserved_webui_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_subdomain('webui') + ) + self.assertFalse(ok) + + def test_subdomain_with_uppercase_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_subdomain('MyApp') + ) + self.assertFalse(ok) + self.assertTrue(any('subdomain must match' in e for e in errs)) + + def test_subdomain_starting_with_digit_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_subdomain('1app') + ) + self.assertFalse(ok) + + def test_subdomain_with_underscore_rejected(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_subdomain('my_app') + ) + self.assertFalse(ok) + + def test_subdomain_with_hyphen_passes(self): + ok, errs = ServiceStoreManager._validate_manifest( + self._make_with_subdomain('my-app') + ) + self.assertTrue(ok) + + def test_no_subdomain_in_caddy_route_passes(self): + m = _valid_manifest() + m['caddy_route'] = {'upstream': 'cell-myapp:8080'} + ok, errs = ServiceStoreManager._validate_manifest(m) + self.assertTrue(ok) + + def test_no_caddy_route_passes(self): + ok, errs = ServiceStoreManager._validate_manifest(_valid_manifest()) + self.assertTrue(ok) + + +# --------------------------------------------------------------------------- +# _allocate_service_ip +# --------------------------------------------------------------------------- + +class TestAllocateServiceIp(unittest.TestCase): + + def test_first_allocation_skips_reserved_offsets_and_returns_first_free(self): + """The first free offset after SERVICE_POOL_START(20) must not be in CONTAINER_OFFSETS.""" + reserved_offsets = set(CONTAINER_OFFSETS.values()) + # Find expected first offset (>= 20, not reserved) + expected_offset = None + for off in range(20, 255): + if off not in reserved_offsets: + expected_offset = off + break + expected_ip = f'172.20.0.{expected_offset}' + + mgr = _make_manager() + ip = mgr._allocate_service_ip('svc-alpha') + self.assertEqual(ip, expected_ip) + + def test_first_allocation_returns_172_20_0_20_for_clean_pool(self): + """Offset 20 is not in CONTAINER_OFFSETS, so it should be the first allocated IP.""" + self.assertNotIn(20, CONTAINER_OFFSETS.values(), + "If offset 20 is now reserved, update this test") + mgr = _make_manager() + ip = mgr._allocate_service_ip('svc1') + self.assertEqual(ip, '172.20.0.20') + + def test_reserved_container_offsets_are_skipped(self): + """No allocated IP should land on a CONTAINER_OFFSETS offset.""" + reserved_offsets = set(CONTAINER_OFFSETS.values()) + mgr = _make_manager() + ip = mgr._allocate_service_ip('svc2') + import ipaddress + allocated_offset = int(ipaddress.IPv4Address(ip)) - int(ipaddress.IPv4Address('172.20.0.0')) + self.assertNotIn(allocated_offset, reserved_offsets) + + def test_already_taken_ips_are_skipped(self): + """Already-assigned service IPs in service_ips are not reallocated.""" + identity = { + 'ip_range': '172.20.0.0/16', + 'service_ips': {'svc-existing': '172.20.0.20'}, + } + mgr = _make_manager(identity=identity) + ip = mgr._allocate_service_ip('svc-new') + # 172.20.0.20 is taken, so must get the next available one + self.assertNotEqual(ip, '172.20.0.20') + # Should be 172.20.0.21 (offset 21 is vip_calendar in CONTAINER_OFFSETS — skip it) + # Find what the next free one should be + reserved_offsets = set(CONTAINER_OFFSETS.values()) + expected_offset = None + for off in range(20, 255): + if off not in reserved_offsets and f'172.20.0.{off}' != '172.20.0.20': + expected_offset = off + break + self.assertEqual(ip, f'172.20.0.{expected_offset}') + + def test_multiple_taken_ips_skipped_sequentially(self): + """Allocator advances past multiple taken IPs correctly.""" + reserved_offsets = set(CONTAINER_OFFSETS.values()) + # Pre-fill the first few non-reserved offsets + free_offsets = [off for off in range(20, 255) if off not in reserved_offsets] + # Take the first 3 + service_ips = {f'svc{i}': f'172.20.0.{off}' for i, off in enumerate(free_offsets[:3])} + identity = {'ip_range': '172.20.0.0/16', 'service_ips': service_ips} + mgr = _make_manager(identity=identity) + ip = mgr._allocate_service_ip('svc-fourth') + self.assertEqual(ip, f'172.20.0.{free_offsets[3]}') + + def test_exhausted_pool_raises_runtime_error(self): + """Fill all 20-254 non-reserved offsets and expect RuntimeError.""" + reserved_offsets = set(CONTAINER_OFFSETS.values()) + service_ips = {} + idx = 0 + for off in range(20, 255): + if off not in reserved_offsets: + service_ips[f'svc{idx}'] = f'172.20.0.{off}' + idx += 1 + identity = {'ip_range': '172.20.0.0/16', 'service_ips': service_ips} + mgr = _make_manager(identity=identity) + with self.assertRaises(RuntimeError) as ctx: + mgr._allocate_service_ip('overflow') + self.assertIn('exhausted', str(ctx.exception).lower()) + + def test_uses_ip_range_from_identity(self): + """Allocation respects a different ip_range like 10.10.0.0/16.""" + identity = {'ip_range': '10.10.0.0/16', 'service_ips': {}} + mgr = _make_manager(identity=identity) + ip = mgr._allocate_service_ip('svc') + self.assertTrue(ip.startswith('10.10.'), f'Expected 10.10.x.x, got {ip}') + + +# --------------------------------------------------------------------------- +# _render_compose_override +# --------------------------------------------------------------------------- + +class TestRenderComposeOverride(unittest.TestCase): + + def test_empty_records_produces_valid_yaml_with_empty_services(self): + mgr = _make_manager() + output = mgr._render_compose_override({}) + doc = yaml.safe_load(output) + self.assertIn('services', doc) + self.assertEqual(doc['services'], {}) + self.assertIn('networks', doc) + self.assertIn('cell-network', doc['networks']) + + def test_empty_records_has_no_volumes_key(self): + mgr = _make_manager() + output = mgr._render_compose_override({}) + doc = yaml.safe_load(output) + self.assertNotIn('volumes', doc) + + def test_single_service_renders_correct_definition(self): + mgr = _make_manager() + records = { + 'myapp': { + 'container_name': 'cell-myapp', + 'service_ip': '172.20.0.20', + 'manifest': { + 'image': 'git.pic.ngo/roof/myapp:1.0', + }, + } + } + output = mgr._render_compose_override(records) + doc = yaml.safe_load(output) + svc = doc['services']['cell-myapp'] + self.assertEqual(svc['image'], 'git.pic.ngo/roof/myapp:1.0') + self.assertEqual(svc['container_name'], 'cell-myapp') + self.assertEqual(svc['networks']['cell-network']['ipv4_address'], '172.20.0.20') + self.assertEqual(svc['restart'], 'unless-stopped') + + def test_named_volumes_declared_at_top_level(self): + mgr = _make_manager() + records = { + 'myapp': { + 'container_name': 'cell-myapp', + 'service_ip': '172.20.0.20', + 'manifest': { + 'image': 'git.pic.ngo/roof/myapp:1.0', + 'volumes': [ + {'name': 'myapp-data', 'mount': '/data'}, + {'name': 'myapp-config', 'mount': '/config'}, + ], + }, + } + } + output = mgr._render_compose_override(records) + doc = yaml.safe_load(output) + self.assertIn('volumes', doc) + self.assertIn('myapp-data', doc['volumes']) + self.assertIn('myapp-config', doc['volumes']) + + def test_named_volumes_appear_in_service_volumes_list(self): + mgr = _make_manager() + records = { + 'myapp': { + 'container_name': 'cell-myapp', + 'service_ip': '172.20.0.20', + 'manifest': { + 'image': 'git.pic.ngo/roof/myapp:1.0', + 'volumes': [{'name': 'myapp-data', 'mount': '/data'}], + }, + } + } + output = mgr._render_compose_override(records) + doc = yaml.safe_load(output) + svc_volumes = doc['services']['cell-myapp']['volumes'] + self.assertIn('myapp-data:/data', svc_volumes) + + def test_environment_rendered_in_service(self): + mgr = _make_manager() + records = { + 'myapp': { + 'container_name': 'cell-myapp', + 'service_ip': '172.20.0.20', + 'manifest': { + 'image': 'git.pic.ngo/roof/myapp:1.0', + 'env': [ + {'key': 'FOO', 'value': 'bar'}, + {'key': 'PORT', 'value': '8080'}, + ], + }, + } + } + output = mgr._render_compose_override(records) + doc = yaml.safe_load(output) + env = doc['services']['cell-myapp']['environment'] + self.assertEqual(env['FOO'], 'bar') + self.assertEqual(env['PORT'], '8080') + + def test_no_volumes_key_in_service_when_manifest_has_no_volumes(self): + mgr = _make_manager() + records = { + 'myapp': { + 'container_name': 'cell-myapp', + 'service_ip': '172.20.0.20', + 'manifest': {'image': 'git.pic.ngo/roof/myapp:1.0'}, + } + } + output = mgr._render_compose_override(records) + doc = yaml.safe_load(output) + self.assertNotIn('volumes', doc['services']['cell-myapp']) + + def test_network_declared_as_external(self): + mgr = _make_manager() + output = mgr._render_compose_override({}) + doc = yaml.safe_load(output) + self.assertTrue(doc['networks']['cell-network']['external']) + + +# --------------------------------------------------------------------------- +# get_status +# --------------------------------------------------------------------------- + +class TestGetStatus(unittest.TestCase): + + def test_returns_dict_with_required_keys(self): + mgr = _make_manager(installed={'svc1': {}, 'svc2': {}}) + status = mgr.get_status() + self.assertIn('service', status) + self.assertIn('running', status) + self.assertIn('installed_count', status) + + def test_installed_count_reflects_config_manager(self): + mgr = _make_manager(installed={'svc1': {}, 'svc2': {}, 'svc3': {}}) + self.assertEqual(mgr.get_status()['installed_count'], 3) + + def test_installed_count_zero_when_none_installed(self): + mgr = _make_manager(installed={}) + self.assertEqual(mgr.get_status()['installed_count'], 0) + + def test_running_is_true(self): + mgr = _make_manager() + self.assertTrue(mgr.get_status()['running']) + + def test_service_name_is_service_store(self): + mgr = _make_manager() + self.assertEqual(mgr.get_status()['service'], 'service_store') + + +# --------------------------------------------------------------------------- +# list_services / fetch_index (caching) +# --------------------------------------------------------------------------- + +class TestListServices(unittest.TestCase): + + def _fake_index(self): + return [ + {'id': 'svc1', 'name': 'Service One'}, + {'id': 'svc2', 'name': 'Service Two'}, + ] + + def test_returns_available_and_installed_keys(self): + mgr = _make_manager() + with patch('service_store_manager.requests.get') as mock_get: + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: self._fake_index(), + ) + mock_get.return_value.raise_for_status = MagicMock() + result = mgr.list_services() + self.assertIn('available', result) + self.assertIn('installed', result) + + def test_available_list_comes_from_index(self): + mgr = _make_manager() + with patch('service_store_manager.requests.get') as mock_get: + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: self._fake_index(), + ) + mock_get.return_value.raise_for_status = MagicMock() + result = mgr.list_services() + self.assertEqual(len(result['available']), 2) + self.assertEqual(result['available'][0]['id'], 'svc1') + + def test_installed_flag_reflects_config_manager(self): + installed = {'svc1': {'id': 'svc1', 'name': 'Service One'}} + mgr = _make_manager(installed=installed) + with patch('service_store_manager.requests.get') as mock_get: + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: self._fake_index(), + ) + mock_get.return_value.raise_for_status = MagicMock() + result = mgr.list_services() + self.assertIn('svc1', result['installed']) + + def test_cache_prevents_second_http_request_within_ttl(self): + mgr = _make_manager() + with patch('service_store_manager.requests.get') as mock_get: + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: self._fake_index(), + ) + mock_get.return_value.raise_for_status = MagicMock() + mgr.fetch_index() + mgr.fetch_index() + # Only one HTTP call despite two fetches + mock_get.assert_called_once() + + def test_cache_expires_after_ttl_and_refetches(self): + mgr = _make_manager() + mgr._cache_ttl = 1 # 1 second TTL for the test + with patch('service_store_manager.requests.get') as mock_get: + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: self._fake_index(), + ) + mock_get.return_value.raise_for_status = MagicMock() + mgr.fetch_index() + # Simulate TTL expiry by winding back the cache timestamp + mgr._index_cache_time -= 2 + mgr.fetch_index() + self.assertEqual(mock_get.call_count, 2) + + def test_index_as_dict_with_services_key(self): + """Index JSON wrapped in {'services': [...]} is also handled.""" + mgr = _make_manager() + with patch('service_store_manager.requests.get') as mock_get: + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: {'services': self._fake_index()}, + ) + mock_get.return_value.raise_for_status = MagicMock() + result = mgr.list_services() + self.assertEqual(len(result['available']), 2) + + +# --------------------------------------------------------------------------- +# install +# --------------------------------------------------------------------------- + +class TestInstall(unittest.TestCase): + + def _mock_fetch(self, mgr, manifest): + mgr._fetch_manifest = MagicMock(return_value=manifest) + + def _mock_write_compose(self, mgr): + mgr._write_compose_override = MagicMock() + + def test_install_already_installed_returns_ok_already_installed(self): + installed = {'myapp': {'id': 'myapp'}} + mgr = _make_manager(installed=installed) + with patch('firewall_manager.apply_service_rules'): + result = mgr.install('myapp') + self.assertTrue(result['ok']) + self.assertTrue(result.get('already_installed')) + + def test_install_invalid_manifest_returns_errors(self): + mgr = _make_manager() + bad_manifest = {'id': 'myapp', 'image': 'bad-registry.io/img:latest'} + self._mock_fetch(mgr, bad_manifest) + self._mock_write_compose(mgr) + with patch('firewall_manager.apply_service_rules'): + result = mgr.install('myapp') + self.assertFalse(result['ok']) + self.assertIn('errors', result) + + def test_install_valid_manifest_returns_ok_true(self): + mgr = _make_manager() + manifest = _valid_manifest(id='myapp', container_name='cell-myapp') + self._mock_fetch(mgr, manifest) + self._mock_write_compose(mgr) + with patch('firewall_manager.apply_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + result = mgr.install('myapp') + self.assertTrue(result['ok']) + self.assertFalse(result.get('already_installed', False)) + + def test_install_returns_service_ip(self): + mgr = _make_manager() + manifest = _valid_manifest(id='myapp', container_name='cell-myapp') + self._mock_fetch(mgr, manifest) + self._mock_write_compose(mgr) + with patch('firewall_manager.apply_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + result = mgr.install('myapp') + self.assertIn('service_ip', result) + self.assertTrue(result['service_ip'].startswith('172.20.')) + + def test_install_returns_container_name(self): + mgr = _make_manager() + manifest = _valid_manifest(id='myapp', container_name='cell-myapp') + self._mock_fetch(mgr, manifest) + self._mock_write_compose(mgr) + with patch('firewall_manager.apply_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + result = mgr.install('myapp') + self.assertEqual(result['container_name'], 'cell-myapp') + + def test_install_calls_set_installed_service(self): + mgr = _make_manager() + manifest = _valid_manifest(id='myapp', container_name='cell-myapp') + self._mock_fetch(mgr, manifest) + self._mock_write_compose(mgr) + with patch('firewall_manager.apply_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + mgr.install('myapp') + mgr.config_manager.set_installed_service.assert_called_once() + args = mgr.config_manager.set_installed_service.call_args[0] + self.assertEqual(args[0], 'myapp') + + def test_install_calls_caddy_regenerate_when_service_has_caddy_route(self): + mgr = _make_manager() + manifest = _valid_manifest( + id='myapp', + container_name='cell-myapp', + caddy_route={'subdomain': 'myapp', 'upstream': 'cell-myapp:8080'}, + ) + self._mock_fetch(mgr, manifest) + self._mock_write_compose(mgr) + with patch('firewall_manager.apply_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + mgr.install('myapp') + mgr.caddy_manager.regenerate_with_installed.assert_called() + + def test_install_saves_service_ip_in_identity(self): + mgr = _make_manager() + manifest = _valid_manifest(id='myapp', container_name='cell-myapp') + self._mock_fetch(mgr, manifest) + self._mock_write_compose(mgr) + with patch('firewall_manager.apply_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + mgr.install('myapp') + mgr.config_manager.set_identity_field.assert_called() + call_args = mgr.config_manager.set_identity_field.call_args[0] + self.assertEqual(call_args[0], 'service_ips') + self.assertIn('myapp', call_args[1]) + + def test_install_fetch_failure_returns_error(self): + mgr = _make_manager() + mgr._fetch_manifest = MagicMock(side_effect=Exception('connection refused')) + result = mgr.install('nonexistent') + self.assertFalse(result['ok']) + self.assertIn('error', result) + self.assertIn('fetch', result['error'].lower()) + + +# --------------------------------------------------------------------------- +# remove +# --------------------------------------------------------------------------- + +class TestRemove(unittest.TestCase): + + def _mgr_with_installed(self, tmp_dir, service_id='myapp'): + record = { + 'container_name': 'cell-myapp', + 'service_ip': '172.20.0.20', + 'manifest': {'image': 'git.pic.ngo/roof/myapp:1.0', 'volumes': []}, + 'iptables_rules': [], + } + installed = {service_id: record} + mgr = _make_manager(tmp_dir=tmp_dir, installed=installed) + # After remove, config_manager.get_installed_services returns empty + mgr.config_manager.remove_installed_service = MagicMock() + mgr.config_manager.get_installed_services.side_effect = [ + installed, # first call (inside remove, initial check) + {}, # second call (after removal, for compose rewrite) + ] + mgr._write_compose_override = MagicMock() + return mgr + + def test_remove_not_installed_returns_error(self): + mgr = _make_manager() + with patch('firewall_manager.clear_service_rules'): + result = mgr.remove('nosuchapp') + self.assertFalse(result['ok']) + self.assertIn('error', result) + self.assertIn('not installed', result['error']) + + def test_remove_installed_returns_ok_true(self, tmp_dir='/tmp/pic-ssm-rm-test'): + import tempfile, shutil + tmp = tempfile.mkdtemp() + try: + mgr = self._mgr_with_installed(tmp) + with patch('firewall_manager.clear_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + result = mgr.remove('myapp') + self.assertTrue(result['ok']) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_remove_calls_remove_installed_service(self): + import tempfile, shutil + tmp = tempfile.mkdtemp() + try: + mgr = self._mgr_with_installed(tmp) + with patch('firewall_manager.clear_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + mgr.remove('myapp') + mgr.config_manager.remove_installed_service.assert_called_once_with('myapp') + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_remove_calls_caddy_regenerate(self): + import tempfile, shutil + tmp = tempfile.mkdtemp() + try: + mgr = self._mgr_with_installed(tmp) + with patch('firewall_manager.clear_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + mgr.remove('myapp') + mgr.caddy_manager.regenerate_with_installed.assert_called() + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_remove_purge_data_calls_docker_volume_rm(self): + import tempfile, shutil + tmp = tempfile.mkdtemp() + try: + record = { + 'container_name': 'cell-myapp', + 'service_ip': '172.20.0.20', + 'manifest': { + 'image': 'git.pic.ngo/roof/myapp:1.0', + 'volumes': [{'name': 'myapp-data', 'mount': '/data'}], + }, + } + mgr = _make_manager(tmp_dir=tmp, installed={'myapp': record}) + mgr.config_manager.remove_installed_service = MagicMock() + mgr.config_manager.get_installed_services.side_effect = [ + {'myapp': record}, {} + ] + mgr._write_compose_override = MagicMock() + with patch('firewall_manager.clear_service_rules'), \ + patch('service_store_manager.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + mgr.remove('myapp', purge_data=True) + # Check that docker volume rm was called with the volume name + calls = [str(c) for c in mock_run.call_args_list] + self.assertTrue( + any('myapp-data' in c for c in calls), + f'Expected docker volume rm myapp-data in calls: {calls}', + ) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 9967d3e..0b14340 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -12,6 +12,7 @@ import { Wifi, Server, Key, + Package, Package2, Settings as SettingsIcon, Link2, @@ -42,6 +43,7 @@ import Login from './pages/Login'; import AccountSettings from './pages/AccountSettings'; import PeerDashboard from './pages/PeerDashboard'; import MyServices from './pages/MyServices'; +import Store from './pages/Store'; import Setup from './pages/Setup'; import SetupGuard from './components/SetupGuard'; @@ -238,6 +240,7 @@ function AppCore() { { name: 'Routing', href: '/routing', icon: Wifi }, { name: 'Vault', href: '/vault', icon: Key }, { name: 'Containers', href: '/containers', icon: Package2 }, + { name: 'Store', href: '/store', icon: Package }, { name: 'Cell Network', href: '/cell-network', icon: Link2 }, { name: 'Logs', href: '/logs', icon: Activity }, { name: 'Settings', href: '/settings', icon: SettingsIcon }, @@ -343,6 +346,7 @@ function AppCore() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/webui/src/pages/Store.jsx b/webui/src/pages/Store.jsx new file mode 100644 index 0000000..59da050 --- /dev/null +++ b/webui/src/pages/Store.jsx @@ -0,0 +1,429 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Package, + Download, + Trash2, + RefreshCw, + CheckCircle, + AlertCircle, +} from 'lucide-react'; +import { storeAPI } from '../services/api'; + +// ── Toast helpers (same pattern as Settings.jsx) ───────────────────────────── + +function toastEvent(msg, type = 'success') { + window.dispatchEvent(new CustomEvent('store-toast', { detail: { msg, type } })); +} + +function Toast({ toasts }) { + return ( +
+ {toasts.map((t) => ( +
+ {t.type === 'success' ? ( + + ) : ( + + )} + {t.msg} +
+ ))} +
+ ); +} + +function useToasts() { + const [toasts, setToasts] = useState([]); + useEffect(() => { + const handler = (e) => { + const id = Date.now(); + setToasts((prev) => [...prev, { ...e.detail, id }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); + }; + window.addEventListener('store-toast', handler); + return () => window.removeEventListener('store-toast', handler); + }, []); + return toasts; +} + +// ── Skeleton card ───────────────────────────────────────────────────────────── + +function SkeletonCard() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + +// ── Confirm remove dialog ───────────────────────────────────────────────────── + +function ConfirmRemoveDialog({ service, onConfirm, onCancel }) { + const [purge, setPurge] = useState(false); + + return ( +
+
+
+ +
+

Remove {service.name}?

+

+ The service will be stopped and uninstalled. By default, data is kept on disk. +

+
+
+ + + +
+ + +
+
+
+ ); +} + +// ── Service card ────────────────────────────────────────────────────────────── + +function ServiceCard({ service, isInstalled, installedInfo, onInstall, onRemove, installing, removing }) { + return ( +
+ {/* Header row */} +
+
+ + {service.name} +
+ {isInstalled && ( + + + Installed + + )} +
+ + {/* Description */} +

+ {service.description || 'No description available.'} +

+ + {/* Meta row */} +
+ {service.version && ( + v{service.version} + )} + {service.author && ( + by {service.author} + )} + {isInstalled && installedInfo?.installed_at && ( + Installed {new Date(installedInfo.installed_at).toLocaleDateString()} + )} +
+ + {/* Action */} +
+ {isInstalled ? ( + + ) : ( + + )} +
+
+ ); +} + +// ── Main Store component ────────────────────────────────────────────────────── + +function Store() { + const toasts = useToasts(); + + const [services, setServices] = useState([]); // available services array + const [installed, setInstalled] = useState({}); // map of id -> installed info + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + // Per-service operation state: { [id]: 'installing' | 'removing' | null } + const [opState, setOpState] = useState({}); + + // Pending remove confirmation dialog + const [removeTarget, setRemoveTarget] = useState(null); // service object or null + + const loadStore = useCallback(async () => { + setLoadError(null); + try { + const res = await storeAPI.listServices(); + const data = res.data || {}; + setServices(Array.isArray(data.available) ? data.available : []); + setInstalled(data.installed && typeof data.installed === 'object' ? data.installed : {}); + } catch (err) { + const msg = + err.response?.data?.error || + err.response?.data?.message || + 'Could not load the service store. Check that the API is reachable.'; + setLoadError(msg); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadStore(); + }, [loadStore]); + + const handleRefresh = async () => { + setRefreshing(true); + try { + await storeAPI.refreshIndex(); + toastEvent('Store index refreshed'); + await loadStore(); + } catch (err) { + const msg = + err.response?.data?.error || + err.response?.data?.message || + 'Failed to refresh store index'; + toastEvent(msg, 'error'); + } finally { + setRefreshing(false); + } + }; + + const handleInstall = async (service) => { + setOpState((s) => ({ ...s, [service.id]: 'installing' })); + try { + await storeAPI.installService(service.id); + toastEvent(`${service.name} installed successfully`); + await loadStore(); + } catch (err) { + const msg = + err.response?.data?.error || + err.response?.data?.message || + `Failed to install ${service.name}`; + toastEvent(msg, 'error'); + } finally { + setOpState((s) => ({ ...s, [service.id]: null })); + } + }; + + const handleRemoveClick = (service) => { + setRemoveTarget(service); + }; + + const handleRemoveConfirm = async (purge) => { + const service = removeTarget; + setRemoveTarget(null); + setOpState((s) => ({ ...s, [service.id]: 'removing' })); + try { + await storeAPI.removeService(service.id, purge); + toastEvent(`${service.name} removed`); + await loadStore(); + } catch (err) { + const msg = + err.response?.data?.error || + err.response?.data?.message || + `Failed to remove ${service.name}`; + toastEvent(msg, 'error'); + } finally { + setOpState((s) => ({ ...s, [service.id]: null })); + } + }; + + // ── Render ──────────────────────────────────────────────────────────────── + + const installedServices = services.filter((s) => installed[s.id]); + const availableServices = services.filter((s) => !installed[s.id]); + + return ( +
+ + + {/* Page header */} +
+
+

Service Store

+

+ Browse and install optional services for your Personal Internet Cell +

+
+ +
+ + {/* Loading state */} + {isLoading && ( +
+
+
+ {[1, 2, 3, 4, 5, 6].map((n) => ( + + ))} +
+
+ )} + + {/* Error state */} + {!isLoading && loadError && ( +
+
+ +
+

Failed to load store

+

{loadError}

+
+ +
+
+ )} + + {/* Content */} + {!isLoading && !loadError && ( + <> + {/* Installed services section */} + {installedServices.length > 0 && ( +
+

+ Installed ({installedServices.length}) +

+
+ {installedServices.map((svc) => ( + + ))} +
+
+ )} + + {/* Available services section */} +
+

+ {installedServices.length > 0 ? 'Available to Install' : 'Available Services'} + {availableServices.length > 0 && ` (${availableServices.length})`} +

+ + {availableServices.length === 0 && installedServices.length === 0 && ( +
+ +

No services in the store yet

+

+ Click "Refresh Store" to check for available services. +

+
+ )} + + {availableServices.length === 0 && installedServices.length > 0 && ( +
+ +

All available services are installed.

+
+ )} + + {availableServices.length > 0 && ( +
+ {availableServices.map((svc) => ( + + ))} +
+ )} +
+ + )} + + {/* Remove confirmation dialog */} + {removeTarget && ( + setRemoveTarget(null)} + /> + )} +
+ ); +} + +export default Store; diff --git a/webui/src/services/api.js b/webui/src/services/api.js index 444c19b..415a896 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -288,6 +288,16 @@ export const cellLinkAPI = { getServices: () => api.get('/api/cells/services'), }; +// Service Store API +export const storeAPI = { + listServices: () => api.get('/api/store/services'), + getManifest: (id) => api.get(`/api/store/services/${id}/manifest`), + installService: (id) => api.post(`/api/store/services/${id}/install`), + removeService: (id, purge = false) => api.delete(`/api/store/services/${id}`, { params: { purge } }), + listInstalled: () => api.get('/api/store/installed'), + refreshIndex: () => api.post('/api/store/refresh'), +}; + // Health check export const healthAPI = { check: () => api.get('/health'),