first commit

This commit is contained in:
Admin 2025-12-19 09:17:39 +07:00
commit 0f7da18e95
19 changed files with 888 additions and 0 deletions

72
.gitignore vendored Normal file
View File

@ -0,0 +1,72 @@
# ===============================
# Python
# ===============================
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
.venv/
venv/
env/
ENV/
# Pyenv
.python-version
# ===============================
# FastAPI / Uvicorn
# ===============================
*.log
uvicorn.log
# ===============================
# Environment variables
# ===============================
.env
.env.*
!.env.example
# ===============================
# IDE / Editor
# ===============================
.vscode/
.idea/
*.swp
*.swo
# ===============================
# OS
# ===============================
.DS_Store
Thumbs.db
# ===============================
# Test / Coverage
# ===============================
.pytest_cache/
.coverage
htmlcov/
# ===============================
# Build / Distribution
# ===============================
build/
dist/
*.egg-info/
# ===============================
# Database / Runtime files
# ===============================
*.sqlite3
*.db
# ===============================
# Cache / Temp
# ===============================
.cache/
tmp/
temp/
service/*
!service/default.yaml
*.db

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM python:3.10-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
procps \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source code vào image
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

1
README.md Normal file
View File

@ -0,0 +1 @@
uvicorn app.main:app --workers 1

11
app/checks/__init__.py Normal file
View File

@ -0,0 +1,11 @@
from app.checks import http, shell
CHECK_MODULES = {
"http": http,
"shell": shell,
}
def load_check_module(check_type: str):
if check_type not in CHECK_MODULES:
raise ValueError(f"Unsupported check type: {check_type}")
return CHECK_MODULES[check_type]

146
app/checks/http.py Normal file
View File

@ -0,0 +1,146 @@
import time
import httpx
import json
import jsonpath_ng.ext as jp
from app.utils.template import render, render_headers
from app.utils.security import mask_sensitive
MAX_BODY = 500
def safe_body(resp: httpx.Response):
try:
data = resp.json()
return json.dumps(mask_sensitive(data))[:MAX_BODY]
except Exception:
return resp.text[:MAX_BODY]
def extract_cookies(resp: httpx.Response):
return {k: v for k, v in resp.cookies.items()}
def build_cookies(context: dict):
"""
Ưu tiên:
1. context["cookies"] (save toàn bộ)
2. các key chứa token
"""
if isinstance(context.get("cookies"), dict):
return context["cookies"]
cookies = {}
for k, v in context.items():
if "token" in k.lower():
cookies[k] = v
return cookies
async def run(check, context):
start = time.monotonic()
method = check.get("method", "GET").upper()
# --- BASE URL ---
base_url = context.get("base_url") or check.get("base_url")
url = check.get("url", "")
if base_url and url.startswith("/"):
url = base_url.rstrip("/") + url
url = render(url, context)
# --- PARAMS ---
params = check.get("params")
if params:
params = {k: render(v, context) for k, v in params.items()}
try:
async with httpx.AsyncClient(
timeout=check.get("timeout", 5),
cookies=build_cookies(context),
) as client:
r = await client.request(
method=method,
url=url,
json=check.get("body"),
headers=render_headers(check.get("headers", {}), context),
params=params,
)
latency = int((time.monotonic() - start) * 1000)
ok = True
reason = None
# ---------- EXPECT ----------
if "expect" in check:
exp = check["expect"]
if "status_code" in exp and r.status_code != exp["status_code"]:
ok = False
reason = f"status_code != {exp['status_code']}"
if ok and "json" in exp:
try:
data = r.json()
except Exception:
ok = False
reason = "response is not json"
else:
for _, expr in exp["json"].items():
if not jp.parse(expr).find(data):
ok = False
reason = f"jsonpath mismatch: {expr}"
break
# ---------- SAVE ----------
if ok and "save" in check:
try:
data = r.json()
except Exception:
data = {}
for key, expr in check["save"].items():
# save toàn bộ cookies
if expr == "__cookies__":
context[key] = extract_cookies(r)
# save cookie cụ thể
elif isinstance(expr, str) and expr.startswith("cookie:"):
cookie_name = expr.split(":", 1)[1]
if cookie_name in r.cookies:
context[key] = r.cookies[cookie_name]
# save từ json
else:
matches = jp.parse(expr).find(data)
if matches:
context[key] = matches[0].value
return {
"name": check["name"],
"type": "http",
"method": method,
"url": url,
"params": params,
"ok": ok,
"status": r.status_code,
"latency_ms": latency,
"reason": reason,
"response": {
"body": safe_body(r)
},
}
except Exception as e:
return {
"name": check["name"],
"type": "http",
"method": method,
"url": url,
"params": params,
"ok": False,
"error": str(e),
"latency_ms": int((time.monotonic() - start) * 1000),
}

14
app/checks/shell.py Normal file
View File

@ -0,0 +1,14 @@
import subprocess
async def run(check):
result = subprocess.run(
check["command"],
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
ok = result.returncode == 0
return {
"status": "PASS" if ok else "FAIL",
"stdout": result.stdout.decode()
}

48
app/engine.py Normal file
View File

@ -0,0 +1,48 @@
from app.checks import load_check_module
async def run_checks(service_cfg):
context = {
"base_url": service_cfg.get("base_url")
}
results = {}
executed = []
healthy = True
checks = service_cfg["checks"]
# index theo name
check_map = {c["name"]: c for c in checks}
for check in checks:
name = check["name"]
deps = check.get("depends_on", [])
# ---------- CHECK DEPENDENCY ----------
unmet = [
d for d in deps
if d not in results or results[d]["ok"] is False
]
if unmet:
results[name] = {
"name": name,
"type": check["type"],
"ok": False,
"skipped": True,
"reason": f"depends_on failed: {unmet}",
}
healthy = False
continue
# ---------- RUN CHECK ----------
module = load_check_module(check["type"])
result = await module.run(check, context)
results[name] = result
executed.append(name)
if not result["ok"]:
healthy = False
return healthy, list(results.values())

22
app/loader.py Normal file
View File

@ -0,0 +1,22 @@
import os
import yaml
SERVICES_DIR = "services"
def load_services():
services = []
for filename in os.listdir(SERVICES_DIR):
if not filename.endswith(".yaml"):
continue
path = os.path.join(SERVICES_DIR, filename)
with open(path, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
if not cfg.get("enabled", True):
continue
services.append(cfg)
return services

68
app/main.py Normal file
View File

@ -0,0 +1,68 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.loader import load_services
from app.store import all, get, history, init_db
from app.scheduler import run_service
from app.utils.schema import load_schema, render_response
services = load_services()
TASKS = []
@asynccontextmanager
async def lifespan(app: FastAPI):
# ===== STARTUP =====
init_db()
for svc in services:
task = asyncio.create_task(run_service(svc))
TASKS.append(task)
yield # 👉 App chạy ở đây
# ===== SHUTDOWN =====
for task in TASKS:
task.cancel()
app = FastAPI(
title="Health Agent",
lifespan=lifespan
)
@app.get("/health")
def health():
health_schema = load_schema("health_response")
services = []
for name, data in all().items():
checks = data.get("checks", [])
failed = sum(1 for c in checks if not c.get("ok"))
services.append({
"message": f"Service {name} is {data.get('status')}",
"name": name,
"status": data.get("status") == "HEALTHY",
"total_checks": len(checks),
"failed_checks": failed,
"last_updated": data.get("last_updated"),
})
print(services)
return render_response(health_schema, services)
@app.get("/services")
def services_list():
return all()
@app.get("/services/{name}")
def service_detail(name: str):
return get(name)
@app.get("/services/{name}/history")
def service_history(name: str, limit: int = 20):
return history(name, limit)

56
app/scheduler.py Normal file
View File

@ -0,0 +1,56 @@
import asyncio
import logging
from app.engine import run_checks
from app.store import update
logger = logging.getLogger("health-agent")
async def run_service(service_cfg):
interval = service_cfg.get("interval", 30)
service_name = service_cfg.get("service", "unknown")
while True:
try:
healthy, checks = await run_checks(service_cfg)
# 🔹 Health result (PASS / FAIL) → dữ liệu hợp lệ
update(
service_name,
{
"status": "HEALTHY" if healthy else "UNHEALTHY",
"checks": checks,
}
)
if not healthy:
logger.warning(
"Service %s is UNHEALTHY", service_name
)
except asyncio.CancelledError:
# 🔹 App shutdown → task bị cancel
logger.info(
"Health check task stopped for service %s",
service_name
)
raise
except Exception as e:
# ❗ BUG của agent / engine (KHÔNG phải health fail)
update(
service_name,
{
"status": "UNHEALTHY",
"checks": [],
"error": str(e),
}
)
logger.error(
"Agent error while checking service %s: %s",
service_name,
e,
exc_info=True, # stacktrace chỉ dùng cho BUG
)
await asyncio.sleep(interval)

View File

@ -0,0 +1,14 @@
type: array # 🔥 response là mảng
item:
fields:
name:
type: string # tên service
status:
type: boolean # HEALTHY | UNHEALTHY
message: string
meta:
description: Health status của từng service

78
app/store.py Normal file
View File

@ -0,0 +1,78 @@
import sqlite3
import json
from datetime import datetime
from threading import Lock
DB_PATH = "health.db"
RESULTS = {}
LOCK = Lock()
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS service_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service TEXT,
status TEXT,
checked_at TEXT,
details TEXT
)
""")
conn.commit()
conn.close()
def update(service, data):
now = datetime.utcnow().isoformat()
with LOCK:
# RAM
RESULTS[service] = {
**data,
"last_check": now
}
# SQLite
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute(
"INSERT INTO service_results (service, status, checked_at, details) VALUES (?, ?, ?, ?)",
(
service,
data["status"],
now,
json.dumps(data)
)
)
conn.commit()
conn.close()
def history(service, limit=20):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
SELECT status, checked_at, details
FROM service_results
WHERE service = ?
ORDER BY checked_at DESC
LIMIT ?
""", (service, limit))
rows = c.fetchall()
conn.close()
return [
{
"status": r[0],
"checked_at": r[1],
"details": json.loads(r[2])
}
for r in rows
]
def all():
return RESULTS
def get(service):
return RESULTS.get(service)

50
app/utils/schema.py Normal file
View File

@ -0,0 +1,50 @@
import yaml
import time
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
def load_schema(name: str) -> dict:
"""
Load schema YAML từ app/schemas/<name>.yaml
"""
path = BASE_DIR / "schemas" / f"{name}.yaml"
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def render_response(schema: dict, data):
schema_type = schema.get("type")
# ---------- ARRAY ----------
if schema_type == "array":
if not isinstance(data, list):
return []
item_schema = schema.get("item", {})
return [
render_response(
{
"type": "object",
"fields": item_schema.get("fields", {})
},
item
)
for item in data
]
# ---------- OBJECT ----------
if schema_type == "object":
fields = schema.get("fields", {})
result = {}
for key, field_cfg in fields.items():
if key in data:
result[key] = data[key]
return result
# ---------- FALLBACK ----------
return data

23
app/utils/security.py Normal file
View File

@ -0,0 +1,23 @@
SENSITIVE_KEYS = {
"password",
"token",
"access_token",
"refresh_token",
"authorization",
}
def mask_sensitive(data):
if isinstance(data, dict):
return {
k: (
"***"
if k.lower() in SENSITIVE_KEYS
else mask_sensitive(v)
)
for k, v in data.items()
}
if isinstance(data, list):
return [mask_sensitive(v) for v in data]
return data

20
app/utils/template.py Normal file
View File

@ -0,0 +1,20 @@
import re
_PATTERN = re.compile(r"\{\{(\w+)\}\}")
def render(value, context: dict):
if not isinstance(value, str):
return value
def replace(match):
key = match.group(1)
return str(context.get(key, ""))
return _PATTERN.sub(replace, value)
def render_headers(headers: dict, context: dict):
return {
k: render(v, context)
for k, v in headers.items()
}

18
docker-compose.yml Normal file
View File

@ -0,0 +1,18 @@
version: "3.9"
services:
health-agent:
build: .
container_name: health-agent
ports:
- "8000:8000"
volumes:
# 🔥 mount folder services
- ./services:/app/services
environment:
- SERVICES_DIR=/app/services
- TZ=Asia/Ho_Chi_Minh
restart: unless-stopped

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
fastapi
uvicorn
pyyaml
httpx
jsonpath-ng

129
services/bids-service.yaml Normal file
View File

@ -0,0 +1,129 @@
service: bids-service
enabled: true
interval: 30
base_url: http://localhost:4000/api/v1/admin
checks:
- type: http
name: login
url: /auth/login
method: POST
body:
username: admin
password: Admin@123
expect:
status_code: 201
save:
cookies: __cookies__
- type: http
name: me
depends_on: [login]
url: /auth/me
expect:
status_code: 200
- type: http
name: webs
depends_on: [login]
url: /web-bids
expect:
status_code: 200
- type: http
name: create-webs
depends_on: [login]
body:
origin_url: http://abc.com
method: POST
url: /web-bids
expect:
status_code: 400
- type: http
name: update-webs
depends_on: [login]
body:
origin_url: http://abc.com
method: PUT
url: /web-bids/0
expect:
status_code: 404
- type: http
name: delete-webs
depends_on: [login]
method: DELETE
url: /web-bids/0
expect:
status_code: 404
- type: http
name: admins
depends_on: [login]
url: /admins
expect:
status_code: 200
- type: http
name: admins
depends_on: [login]
url: /admins
expect:
status_code: 200
- type: http
name: admins
depends_on: [login]
url: /admins
expect:
status_code: 200
- type: http
name: bids
depends_on: [login]
url: /bids
expect:
status_code: 200
- type: http
name: create-bids
depends_on: [login]
body:
url: http://abc.com
method: POST
url: /bids
expect:
status_code: 400
- type: http
name: update-bids
depends_on: [login]
body:
url: http://abc.com
method: PUT
url: /bids/0
expect:
status_code: 400
- type: http
name: delete-bids
depends_on: [login]
method: DELETE
url: /bids/0
expect:
status_code: 404
- type: http
name: out-bid-logs
depends_on: [login]
url: /out-bid-logs
expect:
status_code: 200
- type: http
name: send-message-histories
depends_on: [login]
url: /send-message-histories
expect:
status_code: 200

90
services/default.yaml Normal file
View File

@ -0,0 +1,90 @@
# ================================
# SERVICE CONFIG
# ================================
service: default-service # Tên service (duy nhất trong toàn hệ thống)
enabled: false # true = bật check, false = bỏ qua service này
interval: 30 # Chu kỳ chạy health check (giây)
# ================================
# CHECK LIST
# ================================
checks:
# ------------------------------------------------
# 1⃣ LOGIN CHECK (TOKEN TRONG RESPONSE BODY)
# - API trả access_token trong JSON
# - Token sẽ được save vào context
# ------------------------------------------------
- type: http # Loại check: http | shell
name: login-token # Tên check
url: http://localhost:4000/api/v1/admin/auth/login
method: POST
timeout: 5
body:
email: admin@example.com
password: Admin@1234
# Điều kiện pass
expect:
status_code: 200
json:
access_token: $.data.access_token
# ↑ JSONPath phải tồn tại
# Lưu token vào context
save:
token: $.data.access_token
# context["token"] = response.data.access_token
# cookies: __cookies__ => Trường hợp dùng cho cần save cookie
# 👉 Check này BLOCKING
# 👉 Fail là các check phụ thuộc không chạy
# ------------------------------------------------
# 2⃣ ME CHECK (DÙNG TOKEN TỪ CONTEXT)
# - Authorization header dùng {{token}}
# ------------------------------------------------
- type: http
name: me
url: http://localhost:4000/api/v1/admin/me
method: GET
timeout: 3
headers:
Authorization: "Bearer {{token}}"
# ↑ token lấy từ context của service này
expect:
status_code: 200
# ------------------------------------------------
# 3⃣ HEALTH CHECK (ĐỘC LẬP)
# - Không cần login
# - Login fail vẫn chạy
# ------------------------------------------------
- type: http
name: health
url: http://localhost:4000/health
method: GET
timeout: 2
expect:
status_code: 200
independent: true # 🔥 Không phụ thuộc login
# ------------------------------------------------
# 4⃣ PROCESS CHECK (SHELL)
# - Kiểm tra process OS
# -> Chưa test nào dùng thì viết tiếp
# ------------------------------------------------
- type: shell
name: process
command: "pgrep -f auth-service"
independent: true