263 lines
9.1 KiB
Python
263 lines
9.1 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List, Optional
|
|
import yaml
|
|
from backend.runner import runner
|
|
from backend.config import load_settings, get_setting
|
|
from backend.remote import run_remote_quick, build_ssh_prefix
|
|
|
|
async def get_pangolin_stack_status() -> Dict[str, Any]:
|
|
settings = load_settings()
|
|
vps_host = settings.get("vps_host", "").strip()
|
|
p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip()
|
|
|
|
if not vps_host:
|
|
return {
|
|
"vps_configured": False,
|
|
"installed": False,
|
|
"path": p_dir,
|
|
"compose_exists": False,
|
|
"config_exists": False,
|
|
"database": {"found": False, "path": None, "size_bytes": 0},
|
|
"containers": [],
|
|
"running_count": 0,
|
|
"total_count": 0,
|
|
"api_online": False,
|
|
"message": "Configure VPS Host and SSH credentials in Settings to manage Pangolin."
|
|
}
|
|
|
|
# Query Remote VPS for files, DB, and Docker status
|
|
check_script = f"""
|
|
P_DIR="{p_dir}"
|
|
if [ ! -d "$P_DIR" ] || [ -z "$(ls -A "$P_DIR" 2>/dev/null)" ]; then
|
|
DETECTED=$(docker inspect --format '{{{{index .Config.Labels \"com.docker.compose.project.working_dir\"}}}}' $(docker ps -aq --filter "name=pangolin" 2>/dev/null) 2>/dev/null | head -n 1)
|
|
if [ -z "$DETECTED" ] || [ ! -d "$DETECTED" ]; then
|
|
for candidate in /root/pangolin /opt/pangolin /home/*/pangolin /var/lib/pangolin /srv/pangolin /etc/pangolin; do
|
|
if [ -d "$candidate" ] && [ -n "$(ls -A "$candidate" 2>/dev/null)" ]; then
|
|
DETECTED="$candidate"
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
if [ -n "$DETECTED" ] && [ -d "$DETECTED" ]; then
|
|
P_DIR="$DETECTED"
|
|
fi
|
|
fi
|
|
echo "RESOLVED_DIR:$P_DIR"
|
|
|
|
[ -f "$P_DIR/docker-compose.yml" ] || [ -f "$P_DIR/compose.yaml" ] && echo "COMPOSE_EXISTS" || echo "COMPOSE_NO"
|
|
[ -f "$P_DIR/config/config.yml" ] || [ -f "$P_DIR/config.yml" ] && echo "CONFIG_EXISTS" || echo "CONFIG_NO"
|
|
|
|
# Check DB
|
|
db=$(find "$P_DIR" -name "*.db" -o -name "*.sqlite" 2>/dev/null | head -n 1)
|
|
if [ -n "$db" ]; then
|
|
echo "DB_PATH:$db"
|
|
wc -c "$db" 2>/dev/null | awk '{{print $1}}'
|
|
else
|
|
echo "DB_PATH:NONE"
|
|
echo "0"
|
|
fi
|
|
|
|
# Docker containers
|
|
docker ps -a --format '{{{{json .}}}}' 2>/dev/null || true
|
|
"""
|
|
ret, stdout, stderr = await run_remote_quick(check_script, settings)
|
|
|
|
if ret != 0:
|
|
return {
|
|
"vps_configured": True,
|
|
"connected": False,
|
|
"installed": False,
|
|
"path": resolved_path,
|
|
"compose_exists": False,
|
|
"config_exists": False,
|
|
"database": {"found": False, "path": None, "size_bytes": 0},
|
|
"containers": [],
|
|
"running_count": 0,
|
|
"total_count": 0,
|
|
"api_online": False,
|
|
"error": stderr.strip() or "SSH connection to VPS failed."
|
|
}
|
|
|
|
lines = stdout.splitlines()
|
|
resolved_path = p_dir
|
|
for l in lines:
|
|
if l.startswith("RESOLVED_DIR:"):
|
|
resolved_path = l.split(":", 1)[1].strip()
|
|
break
|
|
|
|
compose_exists = any("COMPOSE_EXISTS" in l for l in lines)
|
|
config_exists = any("CONFIG_EXISTS" in l for l in lines)
|
|
|
|
db_path = None
|
|
db_size = 0
|
|
for idx, l in enumerate(lines):
|
|
if l.startswith("DB_PATH:") and not l.endswith(":NONE"):
|
|
db_path = l.split(":", 1)[1].strip()
|
|
if idx + 1 < len(lines) and lines[idx + 1].strip().isdigit():
|
|
db_size = int(lines[idx + 1].strip())
|
|
break
|
|
|
|
# Parse Docker JSON lines
|
|
containers = []
|
|
for l in lines:
|
|
l = l.strip()
|
|
if not l or not l.startswith("{"):
|
|
continue
|
|
try:
|
|
data = json.loads(l)
|
|
names = data.get("Names", "")
|
|
image = data.get("Image", "")
|
|
if any(k in names.lower() or k in image.lower() for k in ["pangolin", "gerbil", "traefik", "newt"]):
|
|
containers.append({
|
|
"id": data.get("ID", ""),
|
|
"name": names,
|
|
"image": image,
|
|
"status": data.get("Status", ""),
|
|
"state": data.get("State", ""),
|
|
"created": data.get("CreatedAt", ""),
|
|
"ports": data.get("Ports", "")
|
|
})
|
|
except Exception:
|
|
continue
|
|
|
|
running = sum(1 for c in containers if c.get("state") == "running")
|
|
|
|
return {
|
|
"vps_configured": True,
|
|
"connected": True,
|
|
"installed": compose_exists,
|
|
"path": resolved_path,
|
|
"compose_exists": compose_exists,
|
|
"config_exists": config_exists,
|
|
"database": {
|
|
"found": db_path is not None,
|
|
"path": db_path,
|
|
"size_bytes": db_size
|
|
},
|
|
"containers": containers,
|
|
"running_count": running,
|
|
"total_count": len(containers),
|
|
"api_online": running > 0
|
|
}
|
|
|
|
async def trigger_pangolin_update() -> Dict[str, Any]:
|
|
settings = load_settings()
|
|
vps_host = settings.get("vps_host", "").strip()
|
|
p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip()
|
|
|
|
if not vps_host:
|
|
return {"success": False, "error": "No VPS configured. Set VPS Host in Settings."}
|
|
|
|
ssh_prefix = build_ssh_prefix(settings)
|
|
|
|
# Remote update script executed on the VPS
|
|
remote_script = f"""
|
|
echo "=== STEP 1: Connecting to Pangolin VPS ({vps_host}) ==="
|
|
cd "{p_dir}" || exit 1
|
|
|
|
echo ""
|
|
echo "=== STEP 2: Pulling latest images on VPS ==="
|
|
docker compose pull
|
|
|
|
echo ""
|
|
echo "=== STEP 3: Applying stack updates & recreating containers ==="
|
|
docker compose up -d --remove-orphans
|
|
|
|
echo ""
|
|
echo "=== STEP 4: Verifying running container status ==="
|
|
docker compose ps
|
|
|
|
echo ""
|
|
echo "=== STEP 5: Cleaning up unused images on VPS ==="
|
|
docker image prune -f
|
|
|
|
echo ""
|
|
echo "=== Remote Pangolin Stack update completed successfully! ==="
|
|
"""
|
|
full_cmd = ssh_prefix + [remote_script]
|
|
|
|
res = await runner.run_command_job(
|
|
name=f"Update Remote Pangolin Stack ({vps_host})",
|
|
cmd=full_cmd,
|
|
job_type="update_pangolin",
|
|
description=f"Pull latest docker images and recreate Pangolin stack on remote VPS ({vps_host})"
|
|
)
|
|
return res
|
|
|
|
async def container_action(container_name: str, action: str) -> Dict[str, Any]:
|
|
if action not in ["start", "stop", "restart"]:
|
|
return {"success": False, "error": "Invalid action"}
|
|
|
|
settings = load_settings()
|
|
vps_host = settings.get("vps_host", "").strip()
|
|
if not vps_host:
|
|
return {"success": False, "error": "No VPS configured."}
|
|
|
|
ret, stdout, stderr = await run_remote_quick(f"docker {action} {container_name}", settings)
|
|
if ret == 0:
|
|
return {"success": True, "output": stdout.strip()}
|
|
return {"success": False, "error": stderr.strip()}
|
|
|
|
async def get_container_logs(container_name: str, lines: int = 150) -> Dict[str, Any]:
|
|
settings = load_settings()
|
|
vps_host = settings.get("vps_host", "").strip()
|
|
if not vps_host:
|
|
return {"success": False, "error": "No VPS configured."}
|
|
|
|
ret, stdout, stderr = await run_remote_quick(f"docker logs --tail {lines} {container_name} 2>&1", settings)
|
|
return {"success": True, "logs": stdout or stderr or "No logs available."}
|
|
|
|
async def get_stack_config_files() -> Dict[str, Any]:
|
|
settings = load_settings()
|
|
vps_host = settings.get("vps_host", "").strip()
|
|
p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip()
|
|
|
|
if not vps_host:
|
|
return {}
|
|
|
|
candidates = ["docker-compose.yml", "compose.yaml", ".env", "config/config.yml", "config.yml"]
|
|
result = {}
|
|
|
|
for c in candidates:
|
|
ret, stdout, _ = await run_remote_quick(f'[ -f "{p_dir}/{c}" ] && cat "{p_dir}/{c}"', settings)
|
|
if ret == 0 and stdout:
|
|
result[c] = stdout
|
|
return result
|
|
|
|
async def save_stack_config_file(filename: str, content: str) -> Dict[str, Any]:
|
|
settings = load_settings()
|
|
vps_host = settings.get("vps_host", "").strip()
|
|
p_dir = settings.get("vps_pangolin_dir", "/opt/pangolin").strip()
|
|
|
|
if not vps_host:
|
|
return {"success": False, "error": "No VPS configured."}
|
|
if ".." in filename or filename.startswith("/"):
|
|
return {"success": False, "error": "Invalid filename path."}
|
|
|
|
# Validate YAML locally before pushing to VPS
|
|
if filename.endswith((".yml", ".yaml")):
|
|
try:
|
|
yaml.safe_load(content)
|
|
except yaml.YAMLError as ye:
|
|
return {"success": False, "error": f"Invalid YAML syntax: {str(ye)}"}
|
|
|
|
# Push to remote VPS with backup of old file
|
|
safe_target = f"{p_dir}/{filename}"
|
|
remote_write_script = f"""
|
|
mkdir -p "$(dirname '{safe_target}')"
|
|
if [ -f '{safe_target}' ]; then
|
|
cp '{safe_target}' '{safe_target}.bak.{int(time.time())}'
|
|
fi
|
|
cat << 'PANGOLIN_EOF_SAVE' > '{safe_target}'
|
|
{content}
|
|
PANGOLIN_EOF_SAVE
|
|
"""
|
|
ret, _, stderr = await run_remote_quick(remote_write_script, settings)
|
|
if ret == 0:
|
|
return {"success": True, "message": f"Saved {filename} on remote VPS with automatic snapshot backup."}
|
|
return {"success": False, "error": f"Failed to save on remote VPS: {stderr}"}
|