add core-api controllers to maintain portainer, npm and organizr deploys
This commit is contained in:
@@ -4,6 +4,25 @@ Global configuration for Core Code API
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
# Import infrastructure credentials from gitignored module
|
||||
try:
|
||||
from src.credentials import (
|
||||
PORTAINER_URL, PORTAINER_API_KEY,
|
||||
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
|
||||
KUMA_URL, KUMA_USERNAME, KUMA_PASSWORD
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback to empty strings if credentials.py doesn't exist
|
||||
# (e.g., fresh clone before credentials setup)
|
||||
PORTAINER_URL = "http://localhost:8001"
|
||||
PORTAINER_API_KEY = ""
|
||||
NPM_URL = "http://localhost:81"
|
||||
NPM_EMAIL = ""
|
||||
NPM_PASSWORD = ""
|
||||
KUMA_URL = "http://localhost:3001"
|
||||
KUMA_USERNAME = ""
|
||||
KUMA_PASSWORD = ""
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Global application settings"""
|
||||
@@ -58,17 +77,17 @@ class Settings(BaseSettings):
|
||||
embedding_dimension: int = 384
|
||||
embedding_batch_size: int = 32
|
||||
|
||||
# Infrastructure Management
|
||||
portainer_url: str = "http://localhost:8001"
|
||||
portainer_api_key: str = ""
|
||||
# Infrastructure Management (from credentials.py)
|
||||
portainer_url: str = PORTAINER_URL
|
||||
portainer_api_key: str = PORTAINER_API_KEY
|
||||
|
||||
npm_url: str = "http://localhost:81"
|
||||
npm_email: str = ""
|
||||
npm_password: str = ""
|
||||
npm_url: str = NPM_URL
|
||||
npm_email: str = NPM_EMAIL
|
||||
npm_password: str = NPM_PASSWORD
|
||||
|
||||
kuma_url: str = "http://localhost:3001"
|
||||
kuma_username: str = ""
|
||||
kuma_password: str = ""
|
||||
kuma_url: str = KUMA_URL
|
||||
kuma_username: str = KUMA_USERNAME
|
||||
kuma_password: str = KUMA_PASSWORD
|
||||
|
||||
@property
|
||||
def model_aliases(self) -> dict:
|
||||
|
||||
@@ -5,8 +5,8 @@ Provides API endpoints for automated infrastructure management,
|
||||
including service deployment, configuration, and monitoring setup.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from src.controllers.base import BaseController
|
||||
from src.clients.portainer_client import get_portainer_client
|
||||
@@ -21,11 +21,20 @@ class ServiceInfo(BaseModel):
|
||||
"""Information about a deployed service"""
|
||||
name: str
|
||||
stack_id: Optional[int]
|
||||
status: str
|
||||
status: Union[str, int]
|
||||
endpoint_id: Optional[int]
|
||||
ports: List[int] = []
|
||||
domains: List[str] = []
|
||||
|
||||
@field_validator('status', mode='before')
|
||||
@classmethod
|
||||
def convert_status(cls, v):
|
||||
"""Convert status to string representation"""
|
||||
if isinstance(v, int):
|
||||
# Portainer status: 1=active, 2=inactive
|
||||
return "active" if v == 1 else "inactive"
|
||||
return v
|
||||
|
||||
|
||||
class PortInfo(BaseModel):
|
||||
"""Information about an allocated port"""
|
||||
@@ -52,6 +61,41 @@ class InfrastructureHealth(BaseModel):
|
||||
total_proxy_hosts: int
|
||||
|
||||
|
||||
# Request models for write operations
|
||||
class DeployServiceRequest(BaseModel):
|
||||
"""Request to deploy a new service"""
|
||||
name: str
|
||||
compose_content: str
|
||||
endpoint_id: int = 3 # Default to local endpoint
|
||||
|
||||
|
||||
class UpdateServiceRequest(BaseModel):
|
||||
"""Request to update an existing service"""
|
||||
compose_content: str
|
||||
prune: bool = False
|
||||
pull_image: bool = True
|
||||
|
||||
|
||||
class CreateProxyRequest(BaseModel):
|
||||
"""Request to create a new proxy host"""
|
||||
domain_names: List[str]
|
||||
forward_host: str
|
||||
forward_port: int
|
||||
forward_scheme: str = "http"
|
||||
ssl_enabled: bool = False
|
||||
request_ssl_certificate: bool = False
|
||||
block_exploits: bool = True
|
||||
websocket_upgrade: bool = True
|
||||
http2_support: bool = True
|
||||
|
||||
|
||||
class OperationResult(BaseModel):
|
||||
"""Result of an infrastructure operation"""
|
||||
success: bool
|
||||
message: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class InfrastructureController(BaseController):
|
||||
"""
|
||||
Controller for infrastructure management operations
|
||||
@@ -263,6 +307,222 @@ class InfrastructureController(BaseController):
|
||||
logger.error(f"Failed to list domains: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Write endpoints
|
||||
@router.post(
|
||||
"/services",
|
||||
response_model=OperationResult,
|
||||
summary="Deploy a new service",
|
||||
status_code=201
|
||||
)
|
||||
async def deploy_service(request: DeployServiceRequest):
|
||||
"""
|
||||
Deploy a new service via Portainer stack
|
||||
|
||||
Args:
|
||||
request: Service deployment configuration
|
||||
|
||||
Returns:
|
||||
Operation result with stack details
|
||||
"""
|
||||
portainer = get_portainer_client()
|
||||
|
||||
try:
|
||||
# Check if stack already exists
|
||||
stacks = await portainer.get_stacks()
|
||||
existing = next((s for s in stacks if s.get("Name") == request.name), None)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Service '{request.name}' already exists with ID {existing.get('Id')}"
|
||||
)
|
||||
|
||||
# Create new stack
|
||||
result = await portainer.create_stack(
|
||||
name=request.name,
|
||||
stack_file_content=request.compose_content,
|
||||
endpoint_id=request.endpoint_id
|
||||
)
|
||||
|
||||
logger.info(f"Deployed service '{request.name}' (stack ID: {result.get('Id')})")
|
||||
|
||||
return OperationResult(
|
||||
success=True,
|
||||
message=f"Service '{request.name}' deployed successfully",
|
||||
details=result
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to deploy service '{request.name}': {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put(
|
||||
"/services/{name}",
|
||||
response_model=OperationResult,
|
||||
summary="Update an existing service"
|
||||
)
|
||||
async def update_service(name: str, request: UpdateServiceRequest):
|
||||
"""
|
||||
Update an existing service's compose configuration
|
||||
|
||||
Args:
|
||||
name: Service/stack name
|
||||
request: Update configuration
|
||||
|
||||
Returns:
|
||||
Operation result with updated stack details
|
||||
"""
|
||||
portainer = get_portainer_client()
|
||||
|
||||
try:
|
||||
# Find stack by name
|
||||
stacks = await portainer.get_stacks()
|
||||
stack = next(
|
||||
(s for s in stacks if s.get("Name", "").lower() == name.lower()),
|
||||
None
|
||||
)
|
||||
|
||||
if not stack:
|
||||
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
|
||||
|
||||
stack_id = stack.get("Id")
|
||||
endpoint_id = stack.get("EndpointId")
|
||||
|
||||
# Update stack
|
||||
result = await portainer.update_stack(
|
||||
stack_id=stack_id,
|
||||
stack_file_content=request.compose_content,
|
||||
endpoint_id=endpoint_id,
|
||||
prune=request.prune,
|
||||
pull_image=request.pull_image
|
||||
)
|
||||
|
||||
logger.info(f"Updated service '{name}' (stack ID: {stack_id})")
|
||||
|
||||
return OperationResult(
|
||||
success=True,
|
||||
message=f"Service '{name}' updated successfully",
|
||||
details=result
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update service '{name}': {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete(
|
||||
"/services/{name}",
|
||||
response_model=OperationResult,
|
||||
summary="Delete a service"
|
||||
)
|
||||
async def delete_service(name: str):
|
||||
"""
|
||||
Delete a service and remove its stack
|
||||
|
||||
Args:
|
||||
name: Service/stack name
|
||||
|
||||
Returns:
|
||||
Operation result confirmation
|
||||
"""
|
||||
portainer = get_portainer_client()
|
||||
|
||||
try:
|
||||
# Find stack by name
|
||||
stacks = await portainer.get_stacks()
|
||||
stack = next(
|
||||
(s for s in stacks if s.get("Name", "").lower() == name.lower()),
|
||||
None
|
||||
)
|
||||
|
||||
if not stack:
|
||||
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
|
||||
|
||||
stack_id = stack.get("Id")
|
||||
endpoint_id = stack.get("EndpointId")
|
||||
|
||||
# Delete stack
|
||||
await portainer.delete_stack(
|
||||
stack_id=stack_id,
|
||||
endpoint_id=endpoint_id
|
||||
)
|
||||
|
||||
logger.info(f"Deleted service '{name}' (stack ID: {stack_id})")
|
||||
|
||||
return OperationResult(
|
||||
success=True,
|
||||
message=f"Service '{name}' deleted successfully",
|
||||
details={"stack_id": stack_id}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete service '{name}': {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post(
|
||||
"/proxy",
|
||||
response_model=OperationResult,
|
||||
summary="Create a new proxy host",
|
||||
status_code=201
|
||||
)
|
||||
async def create_proxy(request: CreateProxyRequest):
|
||||
"""
|
||||
Create a new Nginx Proxy Manager proxy host
|
||||
|
||||
Optionally request an SSL certificate from Let's Encrypt.
|
||||
|
||||
Args:
|
||||
request: Proxy host configuration
|
||||
|
||||
Returns:
|
||||
Operation result with proxy host details
|
||||
"""
|
||||
npm = get_npm_client()
|
||||
|
||||
try:
|
||||
certificate_id = 0
|
||||
|
||||
# Request SSL certificate if requested
|
||||
if request.request_ssl_certificate:
|
||||
logger.info(f"Requesting SSL certificate for {request.domain_names}")
|
||||
cert_result = await npm.create_certificate(
|
||||
domain_names=request.domain_names
|
||||
)
|
||||
certificate_id = cert_result.get("id", 0)
|
||||
logger.info(f"SSL certificate created: ID {certificate_id}")
|
||||
|
||||
# Create proxy host
|
||||
proxy_result = await npm.create_proxy_host(
|
||||
domain_names=request.domain_names,
|
||||
forward_host=request.forward_host,
|
||||
forward_port=request.forward_port,
|
||||
forward_scheme=request.forward_scheme,
|
||||
certificate_id=certificate_id,
|
||||
ssl_forced=request.ssl_enabled,
|
||||
block_exploits=request.block_exploits,
|
||||
websocket_upgrade=request.websocket_upgrade,
|
||||
http2_support=request.http2_support
|
||||
)
|
||||
|
||||
logger.info(f"Created proxy host for {request.domain_names} → {request.forward_host}:{request.forward_port}")
|
||||
|
||||
return OperationResult(
|
||||
success=True,
|
||||
message=f"Proxy host created for {', '.join(request.domain_names)}",
|
||||
details={
|
||||
"proxy_host": proxy_result,
|
||||
"certificate_id": certificate_id if certificate_id > 0 else None
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create proxy host: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.api.v1.chat import router as chat_router
|
||||
from src.api.v1.models import router as models_router
|
||||
from src.api.v1.conversations import router as conversations_router
|
||||
from src.models.ollama_client import get_ollama_client, close_ollama_client
|
||||
from src.controllers.infrastructure_controller import infrastructure_controller
|
||||
|
||||
# Initialize settings
|
||||
settings = get_settings()
|
||||
@@ -79,6 +80,22 @@ app = FastAPI(
|
||||
- **Tier 1**: Fast in-memory buffer (last 10 turns)
|
||||
- **Tier 2/3**: Unified Qdrant storage (persistent + semantic search)
|
||||
|
||||
### Infrastructure Management
|
||||
**Read Endpoints:**
|
||||
- `GET /infrastructure/health` - Check Portainer & NPM connectivity
|
||||
- `GET /infrastructure/services` - List all deployed services
|
||||
- `GET /infrastructure/services/{name}` - Get service details
|
||||
- `GET /infrastructure/ports` - List allocated ports
|
||||
- `GET /infrastructure/domains` - List configured domains
|
||||
|
||||
**Write Endpoints:**
|
||||
- `POST /infrastructure/services` - Deploy new service from compose YAML
|
||||
- `PUT /infrastructure/services/{name}` - Update existing service
|
||||
- `DELETE /infrastructure/services/{name}` - Remove service and stack
|
||||
- `POST /infrastructure/proxy` - Create proxy host with optional SSL
|
||||
|
||||
Automates infrastructure operations via Portainer and Nginx Proxy Manager APIs.
|
||||
|
||||
### Web Scraper
|
||||
Intelligent web scraping with main content extraction.
|
||||
Perfect for extracting articles, documentation, and blog posts for LLM consumption.
|
||||
@@ -142,6 +159,7 @@ async def root():
|
||||
"models": "/v1/models",
|
||||
"conversations": "/v1/conversations",
|
||||
"web_scraper": "/web-scraper/scrape",
|
||||
"infrastructure": "/infrastructure",
|
||||
"health": "/health"
|
||||
}
|
||||
}
|
||||
@@ -171,10 +189,11 @@ async def health_check():
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(chat_router) # /v1/chat/completions
|
||||
app.include_router(models_router) # /v1/models
|
||||
app.include_router(conversations_router) # /v1/conversations
|
||||
app.include_router(web_scraper_router) # /web-scraper/scrape
|
||||
app.include_router(chat_router) # /v1/chat/completions
|
||||
app.include_router(models_router) # /v1/models
|
||||
app.include_router(conversations_router) # /v1/conversations
|
||||
app.include_router(web_scraper_router) # /web-scraper/scrape
|
||||
app.include_router(infrastructure_controller.create_router()) # /infrastructure/*
|
||||
|
||||
|
||||
# Global exception handler
|
||||
|
||||
Reference in New Issue
Block a user