Files
vmis/backend/app/services/system_checker.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

106 lines
3.8 KiB
Python
Raw Permalink 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.
"""
SystemChecker — 功能驗證(不只 handshake
traefik: routers > 0 / keycloak: token 取得 / mail: EHLO / db: SELECT 1 / server: ping
"""
import logging
import smtplib
from typing import Optional
import httpx
import psycopg2
from app.core.config import settings
logger = logging.getLogger(__name__)
class SystemChecker:
def check_traefik(self, host: str = "localhost", port: int = 8080) -> bool:
"""Traefik API: overview + routers count > 0"""
try:
resp = httpx.get(f"http://{host}:{port}/api/overview", timeout=5.0)
if resp.status_code != 200:
return False
data = resp.json()
total_routers = data.get("http", {}).get("routers", {}).get("total", 0)
return total_routers > 0
except Exception as e:
logger.warning(f"Traefik check failed: {e}")
return False
def check_keycloak(self, base_url: str, realm: str = "master") -> bool:
"""
Step 1: GET /realms/master → 200
Step 2: POST /realms/master/protocol/openid-connect/token with client_credentials
"""
try:
resp = httpx.get(f"{base_url}/realms/{realm}", timeout=8.0)
if resp.status_code != 200:
return False
# Functional check: get admin token
token_resp = httpx.post(
f"{base_url}/realms/{settings.KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token",
data={
"grant_type": "client_credentials",
"client_id": settings.KEYCLOAK_ADMIN_CLIENT_ID,
"client_secret": settings.KEYCLOAK_ADMIN_CLIENT_SECRET,
},
timeout=8.0,
)
return token_resp.status_code == 200 and "access_token" in token_resp.json()
except Exception as e:
logger.warning(f"Keycloak check failed ({base_url}): {e}")
return False
def check_smtp(self, host: str, port: int = 587) -> bool:
"""SMTP connect + EHLO (functional protocol check)"""
try:
with smtplib.SMTP(host, port, timeout=8) as smtp:
smtp.ehlo()
return True
except Exception as e:
logger.warning(f"SMTP check failed ({host}:{port}): {e}")
return False
def check_postgres(self, host: str, port: int = 5432) -> bool:
"""psycopg2 connect + SELECT 1"""
try:
conn = psycopg2.connect(
host=host, port=port, dbname="postgres",
user="admin", password="DC1qaz2wsx",
connect_timeout=8,
)
cur = conn.cursor()
cur.execute("SELECT 1")
result = cur.fetchone()
conn.close()
return result == (1,)
except Exception as e:
logger.warning(f"PostgreSQL check failed ({host}:{port}): {e}")
return False
def ping_server(self, ip_address: str) -> Optional[float]:
"""
ICMP ping, returns response time in ms or None if unreachable.
Falls back to TCP port 22 if ping requires root privileges.
"""
try:
import ping3
result = ping3.ping(ip_address, timeout=3)
if result is not None and result is not False:
return round(result * 1000, 2) # convert to ms
except PermissionError:
# Fallback: TCP connect to port 22
import socket
import time
try:
start = time.time()
sock = socket.create_connection((ip_address, 22), timeout=3)
sock.close()
return round((time.time() - start) * 1000, 2)
except Exception:
pass
except Exception as e:
logger.warning(f"Ping failed for {ip_address}: {e}")
return None