Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68909e6576 | ||
|
|
435a3faeed | ||
|
|
06cb4c17c8 | ||
|
|
ffa075528b | ||
|
|
dbbb92d292 | ||
|
|
3ecfb91cc2 |
+47
-5
@@ -1,15 +1,57 @@
|
|||||||
# Core Code API Configuration
|
# Core Code API Configuration
|
||||||
|
# Copy to .env and fill in real values
|
||||||
|
|
||||||
# Application settings
|
# =============================================================================
|
||||||
|
# Application
|
||||||
|
# =============================================================================
|
||||||
APP_NAME="Core Code API"
|
APP_NAME="Core Code API"
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Server settings
|
# =============================================================================
|
||||||
|
# Server
|
||||||
|
# =============================================================================
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8083
|
PORT=8083
|
||||||
|
|
||||||
# CORS settings (default allows all origins for internal use)
|
# CORS (default allows all origins for internal use)
|
||||||
# CORS_ORIGINS=["http://192.168.86.149:82"]
|
# CORS_ORIGINS=["http://192.168.86.149:82"]
|
||||||
|
|
||||||
# Logging
|
# =============================================================================
|
||||||
LOG_LEVEL=INFO
|
# Infrastructure Services
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Portainer API (required for container/stack management)
|
||||||
|
PORTAINER_URL=http://portainer:9000
|
||||||
|
PORTAINER_API_KEY=ptr_your-api-key-here
|
||||||
|
|
||||||
|
# Nginx Proxy Manager API
|
||||||
|
NPM_URL=http://npm:81
|
||||||
|
NPM_EMAIL=admin@example.com
|
||||||
|
NPM_PASSWORD=your-npm-password
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Home Automation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Home Assistant API
|
||||||
|
HOMEASSISTANT_URL=http://homeassistant:8123
|
||||||
|
HOMEASSISTANT_TOKEN=your-long-lived-access-token
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AI Services
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Ollama API
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
|
||||||
|
# SearXNG (self-hosted search)
|
||||||
|
SEARXNG_URL=http://searxng:8080
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Vector Database
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Qdrant
|
||||||
|
QDRANT_HOST=qdrant
|
||||||
|
QDRANT_PORT=6333
|
||||||
@@ -105,7 +105,6 @@ data/
|
|||||||
|
|
||||||
# Credentials and secrets
|
# Credentials and secrets
|
||||||
src/credentials.py
|
src/credentials.py
|
||||||
credentials.py
|
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
secrets/
|
secrets/
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.3.1] - 2025-12-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified configuration: all settings now read from environment variables/.env only
|
||||||
|
- Removed Docker socket fallback for container operations (Portainer API is now required)
|
||||||
|
- Updated default search provider to SearXNG
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `src/credentials.py` - credentials now managed via environment variables
|
||||||
|
- Docker socket fallback methods from Portainer client
|
||||||
|
- Unused search API settings (Brave, Google)
|
||||||
|
|
||||||
## [1.3.0] - 2025-12-31
|
## [1.3.0] - 2025-12-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.3.0"
|
version = "1.3.1"
|
||||||
description = "Core Code API - Infrastructure management and tools API"
|
description = "Core Code API - Infrastructure management and tools API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+12
-102
@@ -438,70 +438,14 @@ class PortainerClient:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Docker Socket Fallback (for containers not managed by Portainer)
|
# Helper methods for agent tools (auto-detect endpoint)
|
||||||
# ========================================================================
|
|
||||||
|
|
||||||
async def _list_containers_via_socket(self, all_containers: bool = True) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Fallback: List containers directly via Docker socket
|
|
||||||
|
|
||||||
Used when Portainer API doesn't return complete data (e.g., containers
|
|
||||||
started outside Portainer, AMP game servers, etc.)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
all_containers: Include stopped containers
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of container details in Docker API format
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Docker socket is mounted at /var/run/docker.sock
|
|
||||||
# Use httpx with unix socket transport
|
|
||||||
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
|
|
||||||
async with httpx.AsyncClient(transport=transport, timeout=10) as client:
|
|
||||||
params = {"all": 1 if all_containers else 0}
|
|
||||||
response = await client.get(
|
|
||||||
"http://localhost/v1.41/containers/json",
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Docker socket fallback failed: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def _inspect_container_via_socket(self, container_id_or_name: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Fallback: Inspect container directly via Docker socket
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_id_or_name: Container ID or name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container details or None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
|
|
||||||
async with httpx.AsyncClient(transport=transport, timeout=10) as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"http://localhost/v1.41/containers/{container_id_or_name}/json"
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Docker socket inspect fallback failed for '{container_id_or_name}': {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# Helper methods for agent tools (auto-detect endpoint + fallback)
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
||||||
async def list_containers(self, all_containers: bool = True) -> List[Dict[str, Any]]:
|
async def list_containers(self, all_containers: bool = True) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
List containers using auto-detected endpoint with Docker socket fallback
|
List containers using auto-detected endpoint
|
||||||
|
|
||||||
This is a convenience wrapper that automatically uses the first/default endpoint.
|
This is a convenience wrapper that automatically uses the first/default endpoint.
|
||||||
If Portainer doesn't have complete data, falls back to Docker socket.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
all_containers: Include stopped containers (default: True)
|
all_containers: Include stopped containers (default: True)
|
||||||
@@ -509,34 +453,18 @@ class PortainerClient:
|
|||||||
Returns:
|
Returns:
|
||||||
List of container details
|
List of container details
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
# Try Portainer first
|
|
||||||
endpoints = await self.get_endpoints()
|
endpoints = await self.get_endpoints()
|
||||||
if endpoints:
|
if not endpoints:
|
||||||
|
raise RuntimeError("No Portainer endpoints available")
|
||||||
|
|
||||||
endpoint_id = endpoints[0]["Id"]
|
endpoint_id = endpoints[0]["Id"]
|
||||||
containers = await self.get_containers(endpoint_id, all_containers)
|
return await self.get_containers(endpoint_id, all_containers)
|
||||||
if containers:
|
|
||||||
return containers
|
|
||||||
|
|
||||||
# Fallback to Docker socket
|
|
||||||
logger.info("Portainer returned no containers, trying Docker socket fallback...")
|
|
||||||
return await self._list_containers_via_socket(all_containers)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error listing containers: {e}")
|
|
||||||
# Try fallback even on exception
|
|
||||||
try:
|
|
||||||
return await self._list_containers_via_socket(all_containers)
|
|
||||||
except Exception as fallback_error:
|
|
||||||
logger.error(f"Fallback also failed: {fallback_error}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def inspect_container(self, container_name: str) -> Optional[Dict[str, Any]]:
|
async def inspect_container(self, container_name: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Inspect a container by name using auto-detected endpoint with Docker socket fallback
|
Inspect a container by name using auto-detected endpoint
|
||||||
|
|
||||||
This is a convenience wrapper that automatically uses the first/default endpoint.
|
This is a convenience wrapper that automatically uses the first/default endpoint.
|
||||||
If Portainer doesn't find the container, falls back to Docker socket.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
container_name: Container name (e.g., "jellyfin", "ollama")
|
container_name: Container name (e.g., "jellyfin", "ollama")
|
||||||
@@ -544,43 +472,25 @@ class PortainerClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Container details or None if not found
|
Container details or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
# Try Portainer first
|
|
||||||
endpoints = await self.get_endpoints()
|
endpoints = await self.get_endpoints()
|
||||||
if endpoints:
|
if not endpoints:
|
||||||
|
raise RuntimeError("No Portainer endpoints available")
|
||||||
|
|
||||||
endpoint_id = endpoints[0]["Id"]
|
endpoint_id = endpoints[0]["Id"]
|
||||||
|
|
||||||
# First list all containers to find the one matching the name
|
# List all containers to find the one matching the name
|
||||||
all_containers = await self.get_containers(endpoint_id, all_containers=True)
|
all_containers = await self.get_containers(endpoint_id, all_containers=True)
|
||||||
|
|
||||||
matching_container = None
|
|
||||||
for container in all_containers:
|
for container in all_containers:
|
||||||
# Container names come as array like ['/jellyfin']
|
# Container names come as array like ['/jellyfin']
|
||||||
names = container.get('Names', [])
|
names = container.get('Names', [])
|
||||||
for name in names:
|
for name in names:
|
||||||
clean_name = name.lstrip('/')
|
clean_name = name.lstrip('/')
|
||||||
if clean_name == container_name or clean_name.lower() == container_name.lower():
|
if clean_name == container_name or clean_name.lower() == container_name.lower():
|
||||||
matching_container = container
|
|
||||||
break
|
|
||||||
if matching_container:
|
|
||||||
break
|
|
||||||
|
|
||||||
if matching_container:
|
|
||||||
# Get detailed info using container ID
|
# Get detailed info using container ID
|
||||||
container_id = matching_container['Id']
|
container_id = container['Id']
|
||||||
return await self.get_container(endpoint_id, container_id)
|
return await self.get_container(endpoint_id, container_id)
|
||||||
|
|
||||||
# Not found in Portainer, try Docker socket fallback
|
|
||||||
logger.info(f"Container '{container_name}' not found in Portainer, trying Docker socket fallback...")
|
|
||||||
return await self._inspect_container_via_socket(container_name)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error inspecting container '{container_name}': {e}")
|
|
||||||
# Try fallback even on exception
|
|
||||||
try:
|
|
||||||
return await self._inspect_container_via_socket(container_name)
|
|
||||||
except Exception as fallback_error:
|
|
||||||
logger.error(f"Fallback also failed: {fallback_error}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-38
@@ -1,5 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Global configuration for Core Code API
|
Global configuration for Core Code API
|
||||||
|
|
||||||
|
All configuration is loaded from environment variables or .env file.
|
||||||
|
See .env.example for available settings.
|
||||||
"""
|
"""
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -20,29 +23,6 @@ def _get_version_from_pyproject() -> str:
|
|||||||
|
|
||||||
__version__ = _get_version_from_pyproject()
|
__version__ = _get_version_from_pyproject()
|
||||||
|
|
||||||
# Import infrastructure credentials from gitignored module
|
|
||||||
try:
|
|
||||||
from src.credentials import (
|
|
||||||
PORTAINER_URL, PORTAINER_API_KEY,
|
|
||||||
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
|
|
||||||
BRAVE_SEARCH_API_KEY,
|
|
||||||
GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID,
|
|
||||||
HOMEASSISTANT_URL, HOMEASSISTANT_TOKEN
|
|
||||||
)
|
|
||||||
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 = ""
|
|
||||||
BRAVE_SEARCH_API_KEY = ""
|
|
||||||
GOOGLE_SEARCH_API_KEY = ""
|
|
||||||
GOOGLE_SEARCH_ENGINE_ID = ""
|
|
||||||
HOMEASSISTANT_URL = "http://localhost:8123"
|
|
||||||
HOMEASSISTANT_TOKEN = ""
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Global application settings"""
|
"""Global application settings"""
|
||||||
@@ -109,25 +89,21 @@ class Settings(BaseSettings):
|
|||||||
embedding_batch_size: int = 32
|
embedding_batch_size: int = 32
|
||||||
|
|
||||||
# Search Configuration
|
# Search Configuration
|
||||||
search_provider: str = "google" # Options: google, brave, searxng, duckduckgo
|
search_provider: str = "searxng"
|
||||||
searxng_url: str = "http://searxng:8080" # For future self-hosted SearxNG
|
searxng_url: str = "http://searxng:8080"
|
||||||
|
|
||||||
# Search API Keys (from credentials.py)
|
# Infrastructure Management (Portainer)
|
||||||
brave_search_api_key: str = BRAVE_SEARCH_API_KEY # https://brave.com/search/api/
|
portainer_url: str = "http://portainer:9000"
|
||||||
google_search_api_key: str = GOOGLE_SEARCH_API_KEY # https://console.cloud.google.com/
|
portainer_api_key: str = ""
|
||||||
google_search_engine_id: str = GOOGLE_SEARCH_ENGINE_ID # Custom Search Engine ID
|
|
||||||
|
|
||||||
# Infrastructure Management (from credentials.py)
|
# Infrastructure Management (Nginx Proxy Manager)
|
||||||
portainer_url: str = PORTAINER_URL
|
npm_url: str = "http://npm:81"
|
||||||
portainer_api_key: str = PORTAINER_API_KEY
|
npm_email: str = ""
|
||||||
|
npm_password: str = ""
|
||||||
npm_url: str = NPM_URL
|
|
||||||
npm_email: str = NPM_EMAIL
|
|
||||||
npm_password: str = NPM_PASSWORD
|
|
||||||
|
|
||||||
# Home Assistant Configuration
|
# Home Assistant Configuration
|
||||||
homeassistant_url: str = HOMEASSISTANT_URL
|
homeassistant_url: str = "http://homeassistant:8123"
|
||||||
homeassistant_token: str = HOMEASSISTANT_TOKEN
|
homeassistant_token: str = ""
|
||||||
homeassistant_timeout: int = 30
|
homeassistant_timeout: int = 30
|
||||||
|
|
||||||
# Core-AI Service (AI performance metrics)
|
# Core-AI Service (AI performance metrics)
|
||||||
@@ -163,6 +139,7 @@ class Settings(BaseSettings):
|
|||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = False
|
case_sensitive = False
|
||||||
|
extra = "ignore" # Ignore extra env vars not defined in Settings
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
"""
|
|
||||||
Infrastructure Credentials Template
|
|
||||||
|
|
||||||
INSTRUCTIONS:
|
|
||||||
1. Copy this file to credentials.py
|
|
||||||
2. Fill in your actual credentials
|
|
||||||
3. DO NOT commit credentials.py to version control (it's in .gitignore)
|
|
||||||
|
|
||||||
This file should be committed to the repository as a template.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Portainer Configuration
|
|
||||||
PORTAINER_URL = "http://localhost:8001"
|
|
||||||
PORTAINER_API_KEY = "ptr_your_api_token_here" # Create in Portainer UI: User menu → My account → Access tokens
|
|
||||||
|
|
||||||
# Nginx Proxy Manager Configuration
|
|
||||||
NPM_URL = "http://localhost:81"
|
|
||||||
NPM_EMAIL = "admin@example.com"
|
|
||||||
NPM_PASSWORD = "your_password_here"
|
|
||||||
|
|
||||||
# Home Assistant Configuration
|
|
||||||
HOMEASSISTANT_URL = "http://192.168.86.149:8123" # Or http://home-assistant:8123 in Docker
|
|
||||||
HOMEASSISTANT_TOKEN = "your_long_lived_access_token_here" # Create in HA: Profile → Long-Lived Access Tokens
|
|
||||||
+23
-112
@@ -391,40 +391,12 @@ class TestPortainerClientContainers:
|
|||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
class TestPortainerClientDockerSocketFallback:
|
|
||||||
"""Test Docker socket fallback methods."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_containers_via_socket_returns_empty_on_error(self):
|
|
||||||
"""_list_containers_via_socket should return empty list on error."""
|
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
|
||||||
|
|
||||||
with patch("httpx.AsyncHTTPTransport") as mock_transport:
|
|
||||||
mock_transport.side_effect = Exception("Socket not available")
|
|
||||||
|
|
||||||
result = await client._list_containers_via_socket()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inspect_container_via_socket_returns_none_on_error(self):
|
|
||||||
"""_inspect_container_via_socket should return None on error."""
|
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
|
||||||
|
|
||||||
with patch("httpx.AsyncHTTPTransport") as mock_transport:
|
|
||||||
mock_transport.side_effect = Exception("Socket not available")
|
|
||||||
|
|
||||||
result = await client._inspect_container_via_socket("container_name")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestPortainerClientWrapperMethods:
|
class TestPortainerClientWrapperMethods:
|
||||||
"""Test convenience wrapper methods."""
|
"""Test convenience wrapper methods."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_containers_uses_portainer_first(self):
|
async def test_list_containers_uses_portainer(self):
|
||||||
"""list_containers should try Portainer first."""
|
"""list_containers should use Portainer API."""
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||||
|
|
||||||
containers = [{"Id": "abc123", "Names": ["/test"]}]
|
containers = [{"Id": "abc123", "Names": ["/test"]}]
|
||||||
@@ -442,57 +414,19 @@ class TestPortainerClientWrapperMethods:
|
|||||||
mock_containers.assert_called_once()
|
mock_containers.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_containers_falls_back_to_socket(self):
|
async def test_list_containers_raises_when_no_endpoints(self):
|
||||||
"""list_containers should fallback to Docker socket if Portainer returns empty."""
|
"""list_containers should raise RuntimeError when no endpoints available."""
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||||
|
|
||||||
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
|
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
|
||||||
mock_endpoints.return_value = [{"Id": 1}]
|
mock_endpoints.return_value = []
|
||||||
|
|
||||||
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_containers:
|
with pytest.raises(RuntimeError, match="No Portainer endpoints available"):
|
||||||
mock_containers.return_value = []
|
await client.list_containers()
|
||||||
|
|
||||||
with patch.object(client, "_list_containers_via_socket", new_callable=AsyncMock) as mock_socket:
|
|
||||||
mock_socket.return_value = [{"Id": "from_socket"}]
|
|
||||||
|
|
||||||
result = await client.list_containers()
|
|
||||||
|
|
||||||
assert result == [{"Id": "from_socket"}]
|
|
||||||
mock_socket.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_containers_handles_exception_with_fallback(self):
|
async def test_inspect_container_uses_portainer(self):
|
||||||
"""list_containers should try fallback even on exception."""
|
"""inspect_container should use Portainer API."""
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
|
||||||
|
|
||||||
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
|
|
||||||
mock_endpoints.side_effect = Exception("API error")
|
|
||||||
|
|
||||||
with patch.object(client, "_list_containers_via_socket", new_callable=AsyncMock) as mock_socket:
|
|
||||||
mock_socket.return_value = [{"Id": "fallback"}]
|
|
||||||
|
|
||||||
result = await client.list_containers()
|
|
||||||
|
|
||||||
assert result == [{"Id": "fallback"}]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_containers_returns_empty_when_all_fails(self):
|
|
||||||
"""list_containers should return empty list when everything fails."""
|
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
|
||||||
|
|
||||||
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
|
|
||||||
mock_endpoints.side_effect = Exception("API error")
|
|
||||||
|
|
||||||
with patch.object(client, "_list_containers_via_socket", new_callable=AsyncMock) as mock_socket:
|
|
||||||
mock_socket.side_effect = Exception("Socket error")
|
|
||||||
|
|
||||||
result = await client.list_containers()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inspect_container_uses_portainer_first(self):
|
|
||||||
"""inspect_container should try Portainer first."""
|
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||||
|
|
||||||
container_list = [{"Id": "abc123", "Names": ["/mycontainer"]}]
|
container_list = [{"Id": "abc123", "Names": ["/mycontainer"]}]
|
||||||
@@ -512,54 +446,31 @@ class TestPortainerClientWrapperMethods:
|
|||||||
assert result == container_detail
|
assert result == container_detail
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_container_falls_back_to_socket(self):
|
async def test_inspect_container_returns_none_when_not_found(self):
|
||||||
"""inspect_container should fallback if not found in Portainer."""
|
"""inspect_container should return None if container not found."""
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||||
|
|
||||||
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
|
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
|
||||||
mock_endpoints.return_value = [{"Id": 1}]
|
mock_endpoints.return_value = [{"Id": 1}]
|
||||||
|
|
||||||
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_list:
|
with patch.object(client, "get_containers", new_callable=AsyncMock) as mock_list:
|
||||||
mock_list.return_value = [] # Container not found
|
mock_list.return_value = [] # No containers
|
||||||
|
|
||||||
with patch.object(client, "_inspect_container_via_socket", new_callable=AsyncMock) as mock_socket:
|
|
||||||
mock_socket.return_value = {"Id": "from_socket"}
|
|
||||||
|
|
||||||
result = await client.inspect_container("missing_container")
|
result = await client.inspect_container("missing_container")
|
||||||
|
|
||||||
assert result == {"Id": "from_socket"}
|
|
||||||
mock_socket.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inspect_container_handles_exception_with_fallback(self):
|
|
||||||
"""inspect_container should try fallback even on exception."""
|
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
|
||||||
|
|
||||||
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
|
|
||||||
mock_endpoints.side_effect = Exception("API error")
|
|
||||||
|
|
||||||
with patch.object(client, "_inspect_container_via_socket", new_callable=AsyncMock) as mock_socket:
|
|
||||||
mock_socket.return_value = {"Id": "fallback"}
|
|
||||||
|
|
||||||
result = await client.inspect_container("container")
|
|
||||||
|
|
||||||
assert result == {"Id": "fallback"}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inspect_container_returns_none_when_all_fails(self):
|
|
||||||
"""inspect_container should return None when everything fails."""
|
|
||||||
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
|
||||||
|
|
||||||
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
|
|
||||||
mock_endpoints.side_effect = Exception("API error")
|
|
||||||
|
|
||||||
with patch.object(client, "_inspect_container_via_socket", new_callable=AsyncMock) as mock_socket:
|
|
||||||
mock_socket.side_effect = Exception("Socket error")
|
|
||||||
|
|
||||||
result = await client.inspect_container("container")
|
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_container_raises_when_no_endpoints(self):
|
||||||
|
"""inspect_container should raise RuntimeError when no endpoints available."""
|
||||||
|
client = PortainerClient(base_url="http://portainer:9000", api_key="key")
|
||||||
|
|
||||||
|
with patch.object(client, "get_endpoints", new_callable=AsyncMock) as mock_endpoints:
|
||||||
|
mock_endpoints.return_value = []
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="No Portainer endpoints available"):
|
||||||
|
await client.inspect_container("container")
|
||||||
|
|
||||||
|
|
||||||
class TestPortainerClientStackFile:
|
class TestPortainerClientStackFile:
|
||||||
"""Test stack file operations."""
|
"""Test stack file operations."""
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
|
||||||
|
#!/bin/bash
|
||||||
|
# Core-API Server Startup Script
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${GREEN}Starting Core-API server...${NC}"
|
||||||
|
|
||||||
|
# Check if port 8788 is already in use
|
||||||
|
if lsof -Pi :8788 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||||
|
echo -e "${RED}Error: Port 8788 is already in use${NC}"
|
||||||
|
echo "Run: lsof -i :8788 to see what's using it"
|
||||||
|
echo "Or run: kill \$(lsof -t -i:8788) to stop it"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Activate virtual environment if not already activated
|
||||||
|
if [ -z "$VIRTUAL_ENV" ]; then
|
||||||
|
if [ -d ".venv" ]; then
|
||||||
|
echo -e "${YELLOW}Activating virtual environment...${NC}"
|
||||||
|
source .venv/bin/activate
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: Virtual environment not found${NC}"
|
||||||
|
echo "Run: python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create logs directory if it doesn't exist
|
||||||
|
LOGS_DIR="logs"
|
||||||
|
mkdir -p "$LOGS_DIR"
|
||||||
|
|
||||||
|
# Clear/create log file
|
||||||
|
LOG_FILE="$LOGS_DIR/server.log"
|
||||||
|
> "$LOG_FILE"
|
||||||
|
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
echo -e "${GREEN}Starting uvicorn server on http://tower-of-joy:8788${NC}"
|
||||||
|
echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
uvicorn src.main:app --reload --host 0.0.0.0 --port 8788 2>&1 | tee "$LOG_FILE"
|
||||||
Reference in New Issue
Block a user