From 61d0a00d41032c0629a361cdff37e58bb24c5791 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 18:58:33 +0000 Subject: [PATCH 01/11] feat: Add GitHub docker-compose import functionality - Add backend API endpoints for scanning GitHub repos and detecting docker-compose files - Implement GitHub URL parsing supporting various URL formats - Add compose file parsing and service/env extraction - Create ImportFromGitHubModal component with multi-step wizard: - URL input with branch selection - Compose file detection and selection - Service selection from detected services - Shadow header configuration interface - Environment variables configuration with secret handling - Add import button to Services page header - Support importing services with custom shadow headers for routing - Save imported service configuration to settings store --- ushadow/backend/main.py | 2 + ushadow/backend/src/models/github_import.py | 206 +++++ ushadow/backend/src/routers/github_import.py | 612 ++++++++++++++ .../src/components/ImportFromGitHubModal.tsx | 762 ++++++++++++++++++ ushadow/frontend/src/pages/ServicesPage.tsx | 25 +- ushadow/frontend/src/services/api.ts | 142 ++++ 6 files changed, 1748 insertions(+), 1 deletion(-) create mode 100644 ushadow/backend/src/models/github_import.py create mode 100644 ushadow/backend/src/routers/github_import.py create mode 100644 ushadow/frontend/src/components/ImportFromGitHubModal.tsx diff --git a/ushadow/backend/main.py b/ushadow/backend/main.py index bbab13e7..118b4912 100644 --- a/ushadow/backend/main.py +++ b/ushadow/backend/main.py @@ -22,6 +22,7 @@ from src.routers import health, wizard, chronicle, auth, feature_flags from src.routers import services, deployments, providers, instances, chat from src.routers import kubernetes, tailscale, unodes, docker +from src.routers import github_import from src.routers import settings as settings_api from src.middleware import setup_middleware from src.services.unode_manager import init_unode_manager, get_unode_manager @@ -180,6 +181,7 @@ def send_telemetry(): app.include_router(chat.router, prefix="/api/chat", tags=["chat"]) app.include_router(deployments.router, tags=["deployments"]) app.include_router(tailscale.router, tags=["tailscale"]) +app.include_router(github_import.router, prefix="/api/github-import", tags=["github-import"]) # Setup MCP server for LLM tool access setup_mcp_server(app) diff --git a/ushadow/backend/src/models/github_import.py b/ushadow/backend/src/models/github_import.py new file mode 100644 index 00000000..7b69eda5 --- /dev/null +++ b/ushadow/backend/src/models/github_import.py @@ -0,0 +1,206 @@ +""" +Models for GitHub Docker Compose Import functionality. + +Supports importing docker-compose files from GitHub repositories +and configuring shadow headers and environment variables. +""" + +from typing import Dict, List, Optional, Any +from pydantic import BaseModel, Field, field_validator +import re + + +class GitHubUrlInfo(BaseModel): + """Parsed GitHub URL information.""" + owner: str + repo: str + branch: str = "main" + path: str = "" + + @property + def api_url(self) -> str: + """Get GitHub API URL for contents.""" + if self.path: + return f"https://api.github.com/repos/{self.owner}/{self.repo}/contents/{self.path}?ref={self.branch}" + return f"https://api.github.com/repos/{self.owner}/{self.repo}/contents?ref={self.branch}" + + @property + def raw_url(self) -> str: + """Get raw GitHub URL for file content.""" + if self.path: + return f"https://raw.githubusercontent.com/{self.owner}/{self.repo}/{self.branch}/{self.path}" + return f"https://raw.githubusercontent.com/{self.owner}/{self.repo}/{self.branch}" + + +class GitHubImportRequest(BaseModel): + """Request to import from a GitHub URL.""" + github_url: str = Field(..., description="GitHub repository or file URL") + branch: Optional[str] = Field(None, description="Branch to use (defaults to main/master)") + compose_path: Optional[str] = Field(None, description="Path to docker-compose file if not auto-detected") + + @field_validator('github_url') + @classmethod + def validate_github_url(cls, v: str) -> str: + """Validate that the URL is a GitHub URL.""" + if not v: + raise ValueError("GitHub URL is required") + if not ('github.com' in v or 'raw.githubusercontent.com' in v): + raise ValueError("URL must be a GitHub URL") + return v + + +class DetectedComposeFile(BaseModel): + """A docker-compose file detected in the repository.""" + path: str + name: str + download_url: str + size: int = 0 + + +class ComposeEnvVarInfo(BaseModel): + """Environment variable extracted from compose file.""" + name: str + has_default: bool = False + default_value: Optional[str] = None + is_required: bool = True + description: Optional[str] = None + + +class ComposeServiceInfo(BaseModel): + """Service information extracted from compose file.""" + name: str + image: Optional[str] = None + ports: List[Dict[str, Any]] = Field(default_factory=list) + environment: List[ComposeEnvVarInfo] = Field(default_factory=list) + depends_on: List[str] = Field(default_factory=list) + volumes: List[str] = Field(default_factory=list) + networks: List[str] = Field(default_factory=list) + command: Optional[str] = None + healthcheck: Optional[Dict[str, Any]] = None + + +class ShadowHeaderConfig(BaseModel): + """Configuration for shadow header routing.""" + enabled: bool = True + header_name: str = Field(default="X-Shadow-Service", description="Header name for service identification") + header_value: Optional[str] = Field(None, description="Header value (defaults to service name)") + route_path: Optional[str] = Field(None, description="Tailscale Serve route path (e.g., /myservice)") + + +class EnvVarConfigItem(BaseModel): + """Configuration for a single environment variable.""" + name: str + source: str = "literal" # "literal", "setting", "default" + value: Optional[str] = None + setting_path: Optional[str] = None + is_secret: bool = False + + +class ImportedServiceConfig(BaseModel): + """Full configuration for an imported service.""" + service_name: str + display_name: Optional[str] = None + description: Optional[str] = None + github_url: str + compose_path: str + shadow_header: ShadowHeaderConfig = Field(default_factory=ShadowHeaderConfig) + env_vars: List[EnvVarConfigItem] = Field(default_factory=list) + enabled: bool = True + + +class GitHubScanResponse(BaseModel): + """Response from scanning a GitHub repository.""" + success: bool + github_info: Optional[GitHubUrlInfo] = None + compose_files: List[DetectedComposeFile] = Field(default_factory=list) + message: Optional[str] = None + error: Optional[str] = None + + +class ComposeParseResponse(BaseModel): + """Response from parsing a docker-compose file.""" + success: bool + compose_path: str + services: List[ComposeServiceInfo] = Field(default_factory=list) + networks: List[str] = Field(default_factory=list) + volumes: List[str] = Field(default_factory=list) + message: Optional[str] = None + error: Optional[str] = None + + +class ImportServiceRequest(BaseModel): + """Request to import and register a service from GitHub.""" + github_url: str + compose_path: str + service_name: str + config: ImportedServiceConfig + + +class ImportServiceResponse(BaseModel): + """Response from importing a service.""" + success: bool + service_id: Optional[str] = None + service_name: Optional[str] = None + message: str + compose_file_path: Optional[str] = None + + +def parse_github_url(url: str) -> GitHubUrlInfo: + """ + Parse a GitHub URL into its components. + + Supports: + - https://github.com/owner/repo + - https://github.com/owner/repo/tree/branch + - https://github.com/owner/repo/tree/branch/path/to/dir + - https://github.com/owner/repo/blob/branch/path/to/file + - https://raw.githubusercontent.com/owner/repo/branch/path/to/file + """ + url = url.strip() + + # Handle raw.githubusercontent.com URLs + raw_match = re.match( + r'https?://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)(?:/(.+))?', + url + ) + if raw_match: + owner, repo, branch, path = raw_match.groups() + return GitHubUrlInfo( + owner=owner, + repo=repo, + branch=branch, + path=path or "" + ) + + # Handle github.com URLs + github_match = re.match( + r'https?://github\.com/([^/]+)/([^/]+)(?:/(tree|blob)/([^/]+)(?:/(.+))?)?(?:\.git)?', + url + ) + if github_match: + owner, repo, url_type, branch, path = github_match.groups() + # Remove .git suffix if present + repo = repo.replace('.git', '') + return GitHubUrlInfo( + owner=owner, + repo=repo, + branch=branch or "main", + path=path or "" + ) + + # Simple github.com/owner/repo pattern + simple_match = re.match( + r'https?://github\.com/([^/]+)/([^/]+)/?$', + url + ) + if simple_match: + owner, repo = simple_match.groups() + repo = repo.replace('.git', '') + return GitHubUrlInfo( + owner=owner, + repo=repo, + branch="main", + path="" + ) + + raise ValueError(f"Could not parse GitHub URL: {url}") diff --git a/ushadow/backend/src/routers/github_import.py b/ushadow/backend/src/routers/github_import.py new file mode 100644 index 00000000..c4c53ab7 --- /dev/null +++ b/ushadow/backend/src/routers/github_import.py @@ -0,0 +1,612 @@ +""" +GitHub Docker Compose Import Router. + +Provides endpoints for: +- Scanning GitHub repositories for docker-compose files +- Parsing compose files and extracting service/env info +- Importing and registering services with shadow header configuration +""" + +import logging +import os +import re +from pathlib import Path +from typing import List, Dict, Any, Optional + +import httpx +from fastapi import APIRouter, HTTPException, Depends +from ruamel.yaml import YAML + +from src.models.github_import import ( + GitHubImportRequest, + GitHubScanResponse, + GitHubUrlInfo, + DetectedComposeFile, + ComposeParseResponse, + ComposeServiceInfo, + ComposeEnvVarInfo, + ImportServiceRequest, + ImportServiceResponse, + ImportedServiceConfig, + ShadowHeaderConfig, + EnvVarConfigItem, + parse_github_url, +) +from src.services.auth import get_current_user +from src.models.user import User +from src.config.yaml_parser import ComposeParser + +logger = logging.getLogger(__name__) +router = APIRouter() + +# Common docker-compose file patterns to look for +COMPOSE_FILE_PATTERNS = [ + 'docker-compose.yml', + 'docker-compose.yaml', + 'compose.yml', + 'compose.yaml', + 'docker-compose.dev.yml', + 'docker-compose.dev.yaml', + 'docker-compose.prod.yml', + 'docker-compose.prod.yaml', + 'docker-compose.override.yml', + 'docker-compose.override.yaml', +] + + +async def fetch_github_contents(url: str, token: Optional[str] = None) -> Dict[str, Any]: + """Fetch contents from GitHub API.""" + headers = { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Ushadow-Import' + } + if token: + headers['Authorization'] = f'token {token}' + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + if response.status_code == 404: + raise HTTPException(status_code=404, detail="Repository or path not found") + if response.status_code == 403: + raise HTTPException(status_code=403, detail="GitHub API rate limit exceeded or access denied") + response.raise_for_status() + return response.json() + + +async def fetch_raw_content(url: str, token: Optional[str] = None) -> str: + """Fetch raw file content from GitHub.""" + headers = {'User-Agent': 'Ushadow-Import'} + if token: + headers['Authorization'] = f'token {token}' + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.text + + +def detect_compose_files(contents: List[Dict[str, Any]]) -> List[DetectedComposeFile]: + """Detect docker-compose files from GitHub directory listing.""" + compose_files = [] + + for item in contents: + if item.get('type') != 'file': + continue + + name = item.get('name', '').lower() + + # Check if file matches any compose pattern + for pattern in COMPOSE_FILE_PATTERNS: + if name == pattern.lower() or name.endswith('-compose.yml') or name.endswith('-compose.yaml'): + compose_files.append(DetectedComposeFile( + path=item.get('path', ''), + name=item.get('name', ''), + download_url=item.get('download_url', ''), + size=item.get('size', 0) + )) + break + + return compose_files + + +def parse_env_var_line(line: str) -> Optional[ComposeEnvVarInfo]: + """Parse an environment variable line from compose format.""" + if not line or not isinstance(line, str): + return None + + line = line.strip() + if not line: + return None + + # Pattern for ${VAR:-default} + env_pattern = re.compile(r'\$\{([^:}]+)(?::-([^}]*))?\}') + + # Check for KEY=VALUE format + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + if not value: + return ComposeEnvVarInfo(name=key, has_default=False, is_required=True) + + # Check for ${VAR:-default} pattern + match = env_pattern.search(value) + if match: + var_name, default = match.groups() + has_default = default is not None and default != "" + return ComposeEnvVarInfo( + name=key, + has_default=has_default, + default_value=default if has_default else None, + is_required=not has_default + ) + + # Plain value - has a hardcoded default + return ComposeEnvVarInfo( + name=key, + has_default=True, + default_value=value, + is_required=False + ) + + # Bare variable name - required + return ComposeEnvVarInfo(name=line, has_default=False, is_required=True) + + +def parse_compose_content(content: str) -> Dict[str, Any]: + """Parse docker-compose YAML content.""" + yaml = YAML() + yaml.preserve_quotes = True + from io import StringIO + return yaml.load(StringIO(content)) or {} + + +def extract_services_from_compose(data: Dict[str, Any]) -> List[ComposeServiceInfo]: + """Extract service information from parsed compose data.""" + services = [] + services_data = data.get('services', {}) + + for name, service_data in services_data.items(): + # Parse environment variables + env_vars = [] + environment = service_data.get('environment', []) + + if isinstance(environment, list): + for item in environment: + env_info = parse_env_var_line(str(item) if item else "") + if env_info: + env_vars.append(env_info) + elif isinstance(environment, dict): + for key, value in environment.items(): + if value is None: + env_vars.append(ComposeEnvVarInfo(name=key, has_default=False, is_required=True)) + else: + env_info = parse_env_var_line(f"{key}={value}") + if env_info: + env_vars.append(env_info) + + # Parse ports + ports = [] + for port in service_data.get('ports', []): + if isinstance(port, str): + port_str = port.replace('/tcp', '').replace('/udp', '') + if ':' in port_str: + host, container = port_str.rsplit(':', 1) + ports.append({'host': host, 'container': container}) + else: + ports.append({'container': port_str}) + elif isinstance(port, dict): + ports.append({ + 'host': str(port.get('published', '')), + 'container': str(port.get('target', '')) + }) + + # Parse depends_on + depends_on = service_data.get('depends_on', []) + if isinstance(depends_on, dict): + depends_on = list(depends_on.keys()) + + # Parse volumes + volumes = service_data.get('volumes', []) + if isinstance(volumes, list): + volumes = [str(v) for v in volumes] + + # Parse networks + networks = service_data.get('networks', []) + if isinstance(networks, dict): + networks = list(networks.keys()) + + services.append(ComposeServiceInfo( + name=name, + image=service_data.get('image'), + ports=ports, + environment=env_vars, + depends_on=depends_on, + volumes=volumes, + networks=networks, + command=service_data.get('command'), + healthcheck=service_data.get('healthcheck') + )) + + return services + + +@router.post("/scan", response_model=GitHubScanResponse) +async def scan_github_repo( + request: GitHubImportRequest, + current_user: User = Depends(get_current_user) +) -> GitHubScanResponse: + """ + Scan a GitHub repository for docker-compose files. + + Accepts a GitHub URL (repository, directory, or specific file) and returns + a list of detected docker-compose files. + """ + try: + github_info = parse_github_url(request.github_url) + logger.info(f"Scanning GitHub repo: {github_info.owner}/{github_info.repo}") + + # If a specific compose path is provided, verify it exists + if request.compose_path: + github_info.path = request.compose_path + + # Fetch directory contents + contents = await fetch_github_contents(github_info.api_url) + + # Handle single file case + if isinstance(contents, dict) and contents.get('type') == 'file': + return GitHubScanResponse( + success=True, + github_info=github_info, + compose_files=[DetectedComposeFile( + path=contents.get('path', ''), + name=contents.get('name', ''), + download_url=contents.get('download_url', ''), + size=contents.get('size', 0) + )], + message="Found specified compose file" + ) + + # Detect compose files in directory + compose_files = detect_compose_files(contents if isinstance(contents, list) else []) + + if not compose_files: + return GitHubScanResponse( + success=True, + github_info=github_info, + compose_files=[], + message="No docker-compose files found in the specified location" + ) + + return GitHubScanResponse( + success=True, + github_info=github_info, + compose_files=compose_files, + message=f"Found {len(compose_files)} docker-compose file(s)" + ) + + except ValueError as e: + return GitHubScanResponse( + success=False, + error=str(e) + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error scanning GitHub repo: {e}") + return GitHubScanResponse( + success=False, + error=str(e) + ) + + +@router.post("/parse", response_model=ComposeParseResponse) +async def parse_compose_file( + github_url: str, + compose_path: str, + current_user: User = Depends(get_current_user) +) -> ComposeParseResponse: + """ + Parse a docker-compose file and extract service/environment information. + + Returns structured information about services, including environment variables + that need to be configured. + """ + try: + github_info = parse_github_url(github_url) + github_info.path = compose_path + + # Build raw URL for file content + raw_url = f"https://raw.githubusercontent.com/{github_info.owner}/{github_info.repo}/{github_info.branch}/{compose_path}" + + # Fetch compose file content + content = await fetch_raw_content(raw_url) + + # Parse the compose file + data = parse_compose_content(content) + + # Extract services + services = extract_services_from_compose(data) + + # Extract networks + networks = list(data.get('networks', {}).keys()) + + # Extract volumes + volumes = list(data.get('volumes', {}).keys()) + + return ComposeParseResponse( + success=True, + compose_path=compose_path, + services=services, + networks=networks, + volumes=volumes, + message=f"Successfully parsed {len(services)} service(s)" + ) + + except Exception as e: + logger.error(f"Error parsing compose file: {e}") + return ComposeParseResponse( + success=False, + compose_path=compose_path, + error=str(e) + ) + + +@router.post("/register", response_model=ImportServiceResponse) +async def register_imported_service( + request: ImportServiceRequest, + current_user: User = Depends(get_current_user) +) -> ImportServiceResponse: + """ + Register an imported service from GitHub. + + Downloads the compose file, saves it locally, and registers the service + with the configured shadow header and environment variables. + """ + try: + github_info = parse_github_url(request.github_url) + github_info.path = request.compose_path + config = request.config + + # Build raw URL and fetch compose content + raw_url = f"https://raw.githubusercontent.com/{github_info.owner}/{github_info.repo}/{github_info.branch}/{request.compose_path}" + content = await fetch_raw_content(raw_url) + + # Parse to validate + data = parse_compose_content(content) + if not data.get('services'): + return ImportServiceResponse( + success=False, + message="No services found in compose file" + ) + + # Add x-ushadow metadata to the compose content + yaml = YAML() + yaml.preserve_quotes = True + from io import StringIO + compose_data = yaml.load(StringIO(content)) or {} + + # Build x-ushadow section + x_ushadow = compose_data.get('x-ushadow', {}) + + # Add metadata for the service + service_meta = { + 'display_name': config.display_name or request.service_name, + 'description': config.description or f"Imported from {github_info.owner}/{github_info.repo}", + 'github_source': { + 'url': request.github_url, + 'owner': github_info.owner, + 'repo': github_info.repo, + 'branch': github_info.branch, + 'path': request.compose_path + } + } + + # Add shadow header config + if config.shadow_header.enabled: + service_meta['shadow_header'] = { + 'enabled': True, + 'header_name': config.shadow_header.header_name, + 'header_value': config.shadow_header.header_value or request.service_name + } + if config.shadow_header.route_path: + service_meta['route_path'] = config.shadow_header.route_path + + x_ushadow[request.service_name] = service_meta + compose_data['x-ushadow'] = x_ushadow + + # Ensure compose directory exists + compose_dir = Path('/compose') + if not compose_dir.exists(): + compose_dir = Path('compose') + compose_dir.mkdir(parents=True, exist_ok=True) + + # Generate filename + safe_name = re.sub(r'[^a-zA-Z0-9_-]', '-', request.service_name) + compose_filename = f"{safe_name}-compose.yaml" + compose_path = compose_dir / compose_filename + + # Write the compose file with modifications + output = StringIO() + yaml.dump(compose_data, output) + compose_content = output.getvalue() + + # Add environment variable overrides if configured + env_file_content = [] + for env_config in config.env_vars: + if env_config.source == "literal" and env_config.value: + env_file_content.append(f"{env_config.name}={env_config.value}") + + # Write compose file + with open(compose_path, 'w') as f: + f.write(compose_content) + + # Write env file if we have overrides + if env_file_content: + env_file_path = compose_dir / f"{safe_name}.env" + with open(env_file_path, 'w') as f: + f.write('\n'.join(env_file_content) + '\n') + + # Save service configuration to settings + from src.config.omegaconf_settings import get_settings_store + settings = get_settings_store() + + service_config_key = f"imported_services.{safe_name}" + await settings.update({ + service_config_key: { + 'github_url': request.github_url, + 'compose_path': request.compose_path, + 'compose_file': str(compose_path), + 'service_name': request.service_name, + 'display_name': config.display_name, + 'description': config.description, + 'shadow_header': config.shadow_header.model_dump(), + 'env_vars': [ev.model_dump() for ev in config.env_vars], + 'enabled': config.enabled + } + }) + + # Refresh compose registry to pick up new service + from src.services.compose_registry import get_compose_registry + registry = get_compose_registry() + registry.refresh() + + logger.info(f"Imported service '{request.service_name}' from GitHub: {request.github_url}") + + return ImportServiceResponse( + success=True, + service_id=f"{compose_filename.replace('.yaml', '')}:{request.service_name}", + service_name=request.service_name, + message=f"Successfully imported service '{request.service_name}'", + compose_file_path=str(compose_path) + ) + + except Exception as e: + logger.error(f"Error registering imported service: {e}") + return ImportServiceResponse( + success=False, + message=f"Failed to import service: {str(e)}" + ) + + +@router.get("/imported") +async def list_imported_services( + current_user: User = Depends(get_current_user) +) -> List[Dict[str, Any]]: + """List all imported services from GitHub.""" + try: + from src.config.omegaconf_settings import get_settings_store + settings = get_settings_store() + + imported = settings.get("imported_services", {}) + return [ + { + 'id': key, + **value + } + for key, value in imported.items() + ] if isinstance(imported, dict) else [] + + except Exception as e: + logger.error(f"Error listing imported services: {e}") + return [] + + +@router.delete("/imported/{service_id}") +async def delete_imported_service( + service_id: str, + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """Delete an imported service.""" + try: + from src.config.omegaconf_settings import get_settings_store + settings = get_settings_store() + + imported = settings.get("imported_services", {}) + if service_id not in imported: + raise HTTPException(status_code=404, detail=f"Imported service '{service_id}' not found") + + service_config = imported[service_id] + compose_file = service_config.get('compose_file') + + # Remove compose file if it exists + if compose_file and os.path.exists(compose_file): + os.remove(compose_file) + logger.info(f"Removed compose file: {compose_file}") + + # Remove env file if it exists + env_file = compose_file.replace('-compose.yaml', '.env') if compose_file else None + if env_file and os.path.exists(env_file): + os.remove(env_file) + + # Remove from settings + del imported[service_id] + await settings.update({"imported_services": imported}) + + # Refresh compose registry + from src.services.compose_registry import get_compose_registry + registry = get_compose_registry() + registry.refresh() + + return { + "success": True, + "message": f"Deleted imported service '{service_id}'" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting imported service: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/imported/{service_id}/config") +async def update_imported_service_config( + service_id: str, + config: ImportedServiceConfig, + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """Update configuration for an imported service.""" + try: + from src.config.omegaconf_settings import get_settings_store + settings = get_settings_store() + + imported = settings.get("imported_services", {}) + if service_id not in imported: + raise HTTPException(status_code=404, detail=f"Imported service '{service_id}' not found") + + # Update configuration + imported[service_id].update({ + 'display_name': config.display_name, + 'description': config.description, + 'shadow_header': config.shadow_header.model_dump(), + 'env_vars': [ev.model_dump() for ev in config.env_vars], + 'enabled': config.enabled + }) + + await settings.update({"imported_services": imported}) + + # Update env file with new values + compose_file = imported[service_id].get('compose_file') + if compose_file: + env_file_content = [] + for env_config in config.env_vars: + if env_config.source == "literal" and env_config.value: + env_file_content.append(f"{env_config.name}={env_config.value}") + + if env_file_content: + env_file_path = compose_file.replace('-compose.yaml', '.env') + with open(env_file_path, 'w') as f: + f.write('\n'.join(env_file_content) + '\n') + + return { + "success": True, + "message": f"Updated configuration for '{service_id}'" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating imported service config: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx new file mode 100644 index 00000000..6f2124ef --- /dev/null +++ b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx @@ -0,0 +1,762 @@ +import { useState, useEffect } from 'react' +import { + X, + Github, + Loader2, + FileCode, + ChevronRight, + ChevronLeft, + Settings, + Key, + Check, + AlertCircle, + Server, + Eye, + EyeOff, + RefreshCw, +} from 'lucide-react' +import { + githubImportApi, + DetectedComposeFile, + ComposeServiceInfo, + ComposeEnvVarInfo, + ShadowHeaderConfig, + EnvVarConfigItem, +} from '../services/api' + +interface ImportFromGitHubModalProps { + isOpen: boolean + onClose: () => void + onServiceImported: () => void +} + +type WizardStep = 'url' | 'compose' | 'service' | 'config' | 'complete' + +export default function ImportFromGitHubModal({ + isOpen, + onClose, + onServiceImported, +}: ImportFromGitHubModalProps) { + // Wizard state + const [step, setStep] = useState('url') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // URL step + const [githubUrl, setGithubUrl] = useState('') + const [branch, setBranch] = useState('') + + // Compose file selection + const [composeFiles, setComposeFiles] = useState([]) + const [selectedComposeFile, setSelectedComposeFile] = useState(null) + + // Service selection + const [services, setServices] = useState([]) + const [selectedService, setSelectedService] = useState(null) + + // Configuration + const [serviceName, setServiceName] = useState('') + const [displayName, setDisplayName] = useState('') + const [description, setDescription] = useState('') + const [shadowHeader, setShadowHeader] = useState({ + enabled: true, + header_name: 'X-Shadow-Service', + header_value: '', + route_path: '', + }) + const [envVars, setEnvVars] = useState([]) + const [showSecrets, setShowSecrets] = useState>({}) + + // Reset state when modal opens/closes + useEffect(() => { + if (!isOpen) { + setStep('url') + setGithubUrl('') + setBranch('') + setComposeFiles([]) + setSelectedComposeFile(null) + setServices([]) + setSelectedService(null) + setServiceName('') + setDisplayName('') + setDescription('') + setShadowHeader({ + enabled: true, + header_name: 'X-Shadow-Service', + header_value: '', + route_path: '', + }) + setEnvVars([]) + setError(null) + } + }, [isOpen]) + + // Scan GitHub repository + const handleScanRepo = async () => { + if (!githubUrl.trim()) { + setError('Please enter a GitHub URL') + return + } + + setLoading(true) + setError(null) + + try { + const response = await githubImportApi.scan(githubUrl, branch || undefined) + const data = response.data + + if (!data.success) { + setError(data.error || 'Failed to scan repository') + return + } + + if (data.compose_files.length === 0) { + setError('No docker-compose files found in the repository') + return + } + + setComposeFiles(data.compose_files) + setStep('compose') + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to scan repository') + } finally { + setLoading(false) + } + } + + // Parse selected compose file + const handleSelectComposeFile = async (file: DetectedComposeFile) => { + setSelectedComposeFile(file) + setLoading(true) + setError(null) + + try { + const response = await githubImportApi.parse(githubUrl, file.path) + const data = response.data + + if (!data.success) { + setError(data.error || 'Failed to parse compose file') + return + } + + if (data.services.length === 0) { + setError('No services found in compose file') + return + } + + setServices(data.services) + setStep('service') + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to parse compose file') + } finally { + setLoading(false) + } + } + + // Select service and prepare configuration + const handleSelectService = (service: ComposeServiceInfo) => { + setSelectedService(service) + setServiceName(service.name) + setDisplayName(service.name.charAt(0).toUpperCase() + service.name.slice(1).replace(/-/g, ' ')) + setShadowHeader({ + enabled: true, + header_name: 'X-Shadow-Service', + header_value: service.name, + route_path: `/${service.name}`, + }) + + // Initialize env vars from service + const envConfig: EnvVarConfigItem[] = service.environment.map((env) => ({ + name: env.name, + source: env.has_default ? 'default' : 'literal', + value: env.default_value || '', + is_secret: env.name.toLowerCase().includes('key') || + env.name.toLowerCase().includes('secret') || + env.name.toLowerCase().includes('password') || + env.name.toLowerCase().includes('token'), + })) + setEnvVars(envConfig) + setStep('config') + } + + // Update environment variable + const updateEnvVar = (index: number, updates: Partial) => { + setEnvVars((prev) => + prev.map((ev, i) => (i === index ? { ...ev, ...updates } : ev)) + ) + } + + // Import the service + const handleImport = async () => { + if (!selectedComposeFile || !selectedService) { + setError('Please select a compose file and service') + return + } + + setLoading(true) + setError(null) + + try { + const response = await githubImportApi.register({ + github_url: githubUrl, + compose_path: selectedComposeFile.path, + service_name: serviceName, + config: { + service_name: serviceName, + display_name: displayName, + description, + github_url: githubUrl, + compose_path: selectedComposeFile.path, + shadow_header: shadowHeader, + env_vars: envVars, + enabled: true, + }, + }) + + const data = response.data + + if (!data.success) { + setError(data.message || 'Failed to import service') + return + } + + setStep('complete') + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to import service') + } finally { + setLoading(false) + } + } + + // Handle completion + const handleComplete = () => { + onServiceImported() + onClose() + } + + if (!isOpen) return null + + const renderStepIndicator = () => { + const steps: { key: WizardStep; label: string }[] = [ + { key: 'url', label: 'URL' }, + { key: 'compose', label: 'Compose' }, + { key: 'service', label: 'Service' }, + { key: 'config', label: 'Configure' }, + { key: 'complete', label: 'Done' }, + ] + + const currentIndex = steps.findIndex((s) => s.key === step) + + return ( +
+ {steps.map((s, index) => ( +
+
+ {index < currentIndex ? : index + 1} +
+ {index < steps.length - 1 && ( +
+ )} +
+ ))} +
+ ) + } + + const renderUrlStep = () => ( +
+
+ + setGithubUrl(e.target.value)} + placeholder="https://github.com/owner/repo" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +

+ Supports repository URLs, specific branches, or direct paths to compose files +

+
+ +
+ + setBranch(e.target.value)} + placeholder="main" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ ) + + const renderComposeStep = () => ( +
+

+ Found {composeFiles.length} docker-compose file(s). Select one to continue: +

+
+ {composeFiles.map((file) => ( + + ))} +
+
+ ) + + const renderServiceStep = () => ( +
+

+ Found {services.length} service(s) in the compose file. Select one to import: +

+
+ {services.map((service) => ( + + ))} +
+
+ ) + + const renderConfigStep = () => ( +
+ {/* Basic Info */} +
+

+ + Basic Information +

+
+
+ + setServiceName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm" + /> +
+
+ + setDisplayName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm" + /> +
+
+
+ +