commit 0f7da18e953e6b480d479d59c977c7a9e04f9e75 Author: Admin Date: Fri Dec 19 09:17:39 2025 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90aee44 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..800859e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..686c356 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +uvicorn app.main:app --workers 1 diff --git a/app/checks/__init__.py b/app/checks/__init__.py new file mode 100644 index 0000000..87401ec --- /dev/null +++ b/app/checks/__init__.py @@ -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] diff --git a/app/checks/http.py b/app/checks/http.py new file mode 100644 index 0000000..af85321 --- /dev/null +++ b/app/checks/http.py @@ -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 có 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), + } diff --git a/app/checks/shell.py b/app/checks/shell.py new file mode 100644 index 0000000..d698151 --- /dev/null +++ b/app/checks/shell.py @@ -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() + } diff --git a/app/engine.py b/app/engine.py new file mode 100644 index 0000000..3aa8695 --- /dev/null +++ b/app/engine.py @@ -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()) diff --git a/app/loader.py b/app/loader.py new file mode 100644 index 0000000..74d2d87 --- /dev/null +++ b/app/loader.py @@ -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 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..54e3135 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..6252c43 --- /dev/null +++ b/app/scheduler.py @@ -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) diff --git a/app/schemas/health_response.yaml b/app/schemas/health_response.yaml new file mode 100644 index 0000000..aa808f9 --- /dev/null +++ b/app/schemas/health_response.yaml @@ -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 diff --git a/app/store.py b/app/store.py new file mode 100644 index 0000000..45dc9c1 --- /dev/null +++ b/app/store.py @@ -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) diff --git a/app/utils/schema.py b/app/utils/schema.py new file mode 100644 index 0000000..16175b4 --- /dev/null +++ b/app/utils/schema.py @@ -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/.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 + diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 0000000..247f1e1 --- /dev/null +++ b/app/utils/security.py @@ -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 diff --git a/app/utils/template.py b/app/utils/template.py new file mode 100644 index 0000000..d9de84a --- /dev/null +++ b/app/utils/template.py @@ -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() + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..59f6b1f --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2edb1e5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +pyyaml +httpx +jsonpath-ng \ No newline at end of file diff --git a/services/bids-service.yaml b/services/bids-service.yaml new file mode 100644 index 0000000..4303cf4 --- /dev/null +++ b/services/bids-service.yaml @@ -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 diff --git a/services/default.yaml b/services/default.yaml new file mode 100644 index 0000000..644a08b --- /dev/null +++ b/services/default.yaml @@ -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