⬅ חזרה לתיקייה · הורד קובץ · ערוך

📄 tripserver_admin_supervisor.py

C:\TripServer\tripserver_admin_supervisor.py

# -*- coding: utf-8 -*-
"""
TripServer Admin Supervisor
Runs outside admin_server.py and owns starting/restarting/updating it.
Install as a Windows Scheduled Task at user logon using install_tripserver_supervisor.cmd.
"""
import os
import sys
import time
import json
import shutil
import socket
import urllib.request
import urllib.error
import subprocess
import py_compile
from pathlib import Path

BASE_DIR = os.environ.get("TRIPSERVER_BASE_DIR", r"C:\TripServer")
ADMIN_PORT = int(os.environ.get("TRIPSERVER_ADMIN_PORT", "8000"))
ADMIN_SCRIPT = os.environ.get("TRIPSERVER_ADMIN_SCRIPT", os.path.join(BASE_DIR, "admin_server.py"))
SUPERVISOR_DIR = os.path.join(BASE_DIR, "supervisor")
PENDING_ADMIN = os.path.join(SUPERVISOR_DIR, "pending_admin_server.py")
REQUEST_FILE = os.path.join(SUPERVISOR_DIR, "restart_request.json")
REQUEST_DONE_FILE = os.path.join(SUPERVISOR_DIR, "restart_request.done.json")
REQUEST_FAILED_FILE = os.path.join(SUPERVISOR_DIR, "restart_request.failed.json")
LOCK_FILE = os.path.join(SUPERVISOR_DIR, "tripserver_supervisor.lock")
LOG_PATH = os.path.join(BASE_DIR, "tripserver_supervisor.log")
BACKUP_DIR = os.path.join(BASE_DIR, "backups", "admin_server")
CHECK_INTERVAL = float(os.environ.get("TRIPSERVER_SUPERVISOR_INTERVAL", "3"))
START_GRACE_SECONDS = float(os.environ.get("TRIPSERVER_SUPERVISOR_START_GRACE", "8"))

CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
DETACHED_PROCESS = getattr(subprocess, "DETACHED_PROCESS", 0)
CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)


def now():
    return time.strftime("%Y-%m-%d %H:%M:%S")


def ensure_dirs():
    os.makedirs(BASE_DIR, exist_ok=True)
    os.makedirs(SUPERVISOR_DIR, exist_ok=True)
    os.makedirs(BACKUP_DIR, exist_ok=True)


def log(msg):
    ensure_dirs()
    line = f"{now()} | supervisor | {msg}"
    try:
        with open(LOG_PATH, "a", encoding="utf-8", errors="replace") as f:
            f.write(line + "\n")
    except Exception:
        pass


def get_python_console():
    exe = sys.executable or "python.exe"
    if os.name == "nt":
        folder = os.path.dirname(exe)
        if os.path.basename(exe).lower() == "pythonw.exe" and folder:
            cand = os.path.join(folder, "python.exe")
            if os.path.exists(cand):
                return cand
    return exe


def file_sha256(path):
    import hashlib
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def http_ping(timeout=1.5):
    try:
        with urllib.request.urlopen(f"http://127.0.0.1:{ADMIN_PORT}/ping.json", timeout=timeout) as r:
            data = r.read(200)
            return r.status == 200 and b"ok" in data
    except Exception:
        return False


def port_open():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(0.5)
    try:
        return s.connect_ex(("127.0.0.1", ADMIN_PORT)) == 0
    finally:
        try:
            s.close()
        except Exception:
            pass


def port_pids():
    if os.name != "nt":
        return []
    try:
        cp = subprocess.run(["netstat", "-ano"], capture_output=True, text=True, errors="replace", timeout=8)
        pids = set()
        for line in (cp.stdout or "").splitlines():
            if f":{ADMIN_PORT}" not in line:
                continue
            if "LISTENING" not in line.upper():
                continue
            parts = line.split()
            if parts and parts[-1].isdigit():
                pids.add(int(parts[-1]))
        return sorted(pids)
    except Exception as exc:
        log("port_pids failed: " + repr(exc))
        return []


def kill_pids(pids):
    for pid in pids:
        if pid == os.getpid():
            continue
        try:
            cp = subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True, errors="replace", timeout=10)
            log(f"taskkill pid={pid} rc={cp.returncode} out={(cp.stdout or '').strip()} err={(cp.stderr or '').strip()}")
        except Exception as exc:
            log(f"taskkill pid={pid} failed: {exc!r}")


def start_admin():
    if not os.path.exists(ADMIN_SCRIPT):
        log("admin script missing: " + ADMIN_SCRIPT)
        return False
    py = get_python_console()
    flags = 0
    if os.name == "nt":
        flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
    try:
        proc = subprocess.Popen(
            [py, ADMIN_SCRIPT],
            cwd=os.path.dirname(ADMIN_SCRIPT) or BASE_DIR,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            creationflags=flags,
            close_fds=False if os.name == "nt" else True,
            start_new_session=(os.name != "nt"),
        )
        log(f"started admin pid={proc.pid} python={py} script={ADMIN_SCRIPT}")
        time.sleep(START_GRACE_SECONDS)
        log(f"post-start ping={http_ping()} port_open={port_open()} pids={port_pids()}")
        return True
    except Exception as exc:
        log("start_admin failed: " + repr(exc))
        return False


def stop_admin():
    pids = port_pids()
    if pids:
        log("stopping admin pids=" + repr(pids))
        kill_pids(pids)
    for _ in range(20):
        if not port_open():
            return True
        time.sleep(0.5)
    log("port still open after stop; pids=" + repr(port_pids()))
    return not port_open()


def compile_admin(path):
    py_compile.compile(path, doraise=True)


def backup_active():
    if not os.path.exists(ADMIN_SCRIPT):
        return None
    stamp = time.strftime("%Y%m%d_%H%M%S")
    target = os.path.join(BACKUP_DIR, f"admin_server_supervisor_backup_{stamp}.py")
    shutil.copy2(ADMIN_SCRIPT, target)
    return target


def handle_update_request():
    if not os.path.exists(REQUEST_FILE):
        return False
    ensure_dirs()
    try:
        with open(REQUEST_FILE, "r", encoding="utf-8", errors="replace") as f:
            req = json.load(f)
    except Exception as exc:
        log("bad request file: " + repr(exc))
        try:
            shutil.move(REQUEST_FILE, REQUEST_FAILED_FILE)
        except Exception:
            pass
        return True

    log("update request: " + json.dumps(req, ensure_ascii=False))
    pending = req.get("pending_admin_path") or PENDING_ADMIN
    try:
        if not os.path.exists(pending):
            raise RuntimeError("pending admin file missing: " + pending)
        compile_admin(pending)
        expected = req.get("expected_sha256")
        actual = file_sha256(pending)
        if expected and actual.lower() != str(expected).lower():
            raise RuntimeError(f"pending sha mismatch expected={expected} actual={actual}")
        backup = backup_active()
        stop_admin()
        tmp_active = ADMIN_SCRIPT + ".supervisor_new"
        shutil.copy2(pending, tmp_active)
        os.replace(tmp_active, ADMIN_SCRIPT)
        log(f"admin replaced from pending | sha={actual} | backup={backup}")
        try:
            os.remove(pending)
        except Exception:
            pass
        start_admin()
        req["handled_at"] = now()
        req["result"] = "ok"
        req["backup"] = backup
        with open(REQUEST_DONE_FILE, "w", encoding="utf-8") as f:
            json.dump(req, f, ensure_ascii=False, indent=2)
        try:
            os.remove(REQUEST_FILE)
        except Exception:
            pass
        return True
    except Exception as exc:
        log("update request failed: " + repr(exc))
        req["handled_at"] = now()
        req["result"] = "failed"
        req["error"] = repr(exc)
        try:
            with open(REQUEST_FAILED_FILE, "w", encoding="utf-8") as f:
                json.dump(req, f, ensure_ascii=False, indent=2)
        except Exception:
            pass
        # Keep request file so user can inspect/retry after fixing; avoid tight loop by renaming it.
        try:
            os.replace(REQUEST_FILE, REQUEST_FILE + ".failed")
        except Exception:
            pass
        return True


def single_instance_ok():
    ensure_dirs()
    if os.name != "nt":
        return True
    # Simple stale lock: if another supervisor is running, this is best-effort only.
    try:
        if os.path.exists(LOCK_FILE):
            age = time.time() - os.path.getmtime(LOCK_FILE)
            if age < 30:
                log("another supervisor lock is fresh; exiting")
                return False
        with open(LOCK_FILE, "w", encoding="utf-8") as f:
            f.write(str(os.getpid()))
        return True
    except Exception:
        return True


def loop():
    ensure_dirs()
    log("starting supervisor | python=" + sys.executable + " | admin=" + ADMIN_SCRIPT)
    if not single_instance_ok():
        return
    last_ok = 0
    while True:
        try:
            # update lock heartbeat
            try:
                with open(LOCK_FILE, "w", encoding="utf-8") as f:
                    f.write(str(os.getpid()) + " " + now())
            except Exception:
                pass

            if handle_update_request():
                time.sleep(CHECK_INTERVAL)
                continue

            if http_ping():
                last_ok = time.time()
            else:
                po = port_open()
                pids = port_pids() if po else []
                if po:
                    log("ping failed but port open; killing stuck admin pids=" + repr(pids))
                    kill_pids(pids)
                    time.sleep(1.5)
                else:
                    log("admin not listening; starting")
                start_admin()
        except Exception as exc:
            log("loop error: " + repr(exc))
        time.sleep(CHECK_INTERVAL)


if __name__ == "__main__":
    loop()