Files
vmis/backend/app/services/docker_client.py
VMIS Developer 42d1420f9c feat(backend): Phase 1-4 全新開發完成,37/37 TDD 通過
[Phase 0 Reset]
- 清除舊版 app/、alembic/versions/、雜亂測試腳本
- 新 requirements.txt (移除 caldav/redis/keycloak-lib,加入 apscheduler/croniter/docker/paramiko/ping3/dnspython)

[Phase 1 資料庫]
- 9 張資料表 SQLAlchemy Models:tenants / accounts / schedules / schedule_logs /
  tenant_schedule_results / account_schedule_results / servers / server_status_logs / system_status_logs
- Alembic migration 001_create_all_tables (已套用到 10.1.0.20:5433/virtual_mis)
- seed.py:schedules 初始 3 筆 / servers 初始 4 筆

[Phase 2 CRUD API]
- GET/POST/PUT/DELETE: /api/v1/tenants / accounts / servers / schedules
- /api/v1/system-status
- 帳號編碼自動產生 (prefix + seq_no 4碼左補0)
- 燈號 (lights) 從最新排程結果取得

[Phase 3 Watchdog]
- APScheduler interval 3分鐘,原子 UPDATE status=Going 防重複執行
- 手動觸發 API: POST /api/v1/schedules/{id}/run

[Phase 4 Service Clients]
- KeycloakClient:vmis-admin realm,REST API (不用 python-keycloak)
- MailClient:Docker Mailserver @ 10.1.0.254:8080,含 MX DNS 驗證
- DockerClient:docker-py 本機 + paramiko SSH 遠端 compose
- NextcloudClient:OCS API user/quota
- SystemChecker:功能驗證 (traefik routers>0 / keycloak token / SMTP EHLO / DB SELECT 1 / ping)

[TDD]
- 37 tests / 37 passed (2.11s)
- SQLite in-memory + StaticPool,無需外部 DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 13:10:15 +08:00

87 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
DockerClient — docker-py (本機 Docker socket) + paramiko SSH (遠端 docker compose)
管理租戶的 NC / OO 容器。
"""
import logging
from typing import Optional
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
class DockerClient:
def __init__(self):
self._docker = None
def _get_docker(self):
if self._docker is None:
import docker
self._docker = docker.from_env()
return self._docker
def check_traefik_route(self, domain: str) -> bool:
"""
Traefik API: GET http://localhost:8080/api/http/routers
驗證 routers 中包含 domain且 routers 數量 > 0
"""
try:
resp = httpx.get("http://localhost:8080/api/overview", timeout=5.0)
if resp.status_code != 200:
return False
data = resp.json()
# Verify actual routes exist (functional check)
http_count = data.get("http", {}).get("routers", {}).get("total", 0)
if http_count == 0:
return False
# Check domain-specific router
routers_resp = httpx.get("http://localhost:8080/api/http/routers", timeout=5.0)
if routers_resp.status_code != 200:
return False
routers = routers_resp.json()
return any(domain in str(r.get("rule", "")) for r in routers)
except Exception as e:
logger.warning(f"Traefik check failed for {domain}: {e}")
return False
def ensure_container_running(self, container_name: str, tenant_code: str, realm: str) -> bool:
"""Check container status; start if exited; deploy via SSH if not found."""
try:
docker_client = self._get_docker()
container = docker_client.containers.get(container_name)
if container.status == "running":
return True
elif container.status == "exited":
container.start()
container.reload()
return container.status == "running"
except Exception as e:
if "Not Found" in str(e) or "404" in str(e):
return self._ssh_compose_up(tenant_code, realm)
logger.error(f"Docker check failed for {container_name}: {e}")
return False
return False
def _ssh_compose_up(self, tenant_code: str, realm: str) -> bool:
"""SSH into 10.1.0.254 and run docker compose up -d"""
try:
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
settings.DOCKER_SSH_HOST,
username=settings.DOCKER_SSH_USER,
timeout=15,
)
deploy_dir = f"{settings.TENANT_DEPLOY_BASE}/{tenant_code}"
stdin, stdout, stderr = client.exec_command(
f"cd {deploy_dir} && docker compose up -d 2>&1"
)
exit_status = stdout.channel.recv_exit_status()
client.close()
return exit_status == 0
except Exception as e:
logger.error(f"SSH compose up failed for {tenant_code}: {e}")
return False