Files
ordinaceprojekt/Webináře/deploy_tower.py
T
2026-06-17 11:53:54 +02:00

281 lines
12 KiB
Python

#!/usr/bin/env python3
"""
deploy_tower.py — nasazení webinar-watcheru na tower (Unraid, python-runner).
Heslo se NIKDY neukládá do souboru — bere se z proměnné prostředí TOWER_PW:
TOWER_PW=... python deploy_tower.py recon
TOWER_PW=... python deploy_tower.py deploy
TOWER_PW=... python deploy_tower.py schedule
TOWER_PW=... python deploy_tower.py smoke # rychlý test (neblokuje na Telegramu)
Vzor převzat z EmailAgent / MedicusFirebird:
- skripty v /mnt/user/Scripts/<Název>/ → v kontejneru /scripts/<Název>/
- spouští se: docker exec python-runner python3 /scripts/Webinare/watcher.py
- plánování přes Unraid User Scripts plugin (wrapper + schedule.json cron)
"""
import os
import sys
import json
import posixpath
import paramiko
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HOST = "192.168.1.76"
USER = "root"
CONTAINER = "python-runner"
LOCAL_DIR = os.path.dirname(os.path.abspath(__file__))
HOST_DIR = "/mnt/user/Scripts/Webinare" # na hostiteli (Unraid)
CONT_DIR = "/scripts/Webinare" # uvnitř kontejneru
PLUGIN_DIR = "/boot/config/plugins/user.scripts"
USERSCRIPTS = PLUGIN_DIR + "/scripts"
SCHEDULE_JSON = PLUGIN_DIR + "/schedule.json"
CUSTOM_CRON = PLUGIN_DIR + "/customSchedule.cron"
US_NAME = "WebinarWatcher"
CRON_EXPR = "0 8 * * *"
# soubory, které kopírujeme na server (telegram_notify.py = přibalená kopie,
# protože /scripts/Knihovny na serveru není)
FILES = ["watcher.py", "telegram_notify.py", "config.json", "requirements.txt", "NOTES.md"]
def connect():
pw = os.environ.get("TOWER_PW")
if not pw:
sys.exit("Chybí TOWER_PW v prostředí.")
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=pw, timeout=20, allow_agent=False, look_for_keys=False)
return c
def run(c, cmd, timeout=180):
_in, out, err = c.exec_command(cmd, timeout=timeout)
o = out.read().decode("utf-8", "replace")
e = err.read().decode("utf-8", "replace")
rc = out.channel.recv_exit_status()
return rc, o, e
def show(c, cmd, timeout=180):
rc, o, e = run(c, cmd, timeout)
print(f"$ {cmd}")
body = (o + (("\n[stderr] " + e) if e.strip() else "")).rstrip()
print(body if body else "(prázdné)")
print(f" rc={rc}\n")
return rc, o, e
# ── RECON ────────────────────────────────────────────────────────────────────
def recon(c):
show(c, "hostname; uname -r")
show(c, "docker ps --format '{{.Names}}' | sort")
show(c, "ls -la /mnt/user/Scripts/ | head -50")
show(c, f"docker exec {CONTAINER} ls /scripts/ | head -50")
show(c, f"docker exec {CONTAINER} ls -la /scripts/Knihovny/telegram_notify.py")
show(c, f"docker exec {CONTAINER} sh -lc 'test -f /scripts/Medevio/.env && grep -oE \"^(TELEGRAM_BOT_TOKEN|TELEGRAM_CHAT_ID)=\" /scripts/Medevio/.env || echo NENI_ENV'")
show(c, f"docker exec {CONTAINER} python3 -c \"import requests,bs4;print('deps_ok requests',requests.__version__,'bs4',bs4.__version__)\"")
show(c, f"ls -la {USERSCRIPTS}/ | head -50")
# vzor existujícího wrapperu + rozvrhu (StahovaniFaktur)
show(c, f"cat {USERSCRIPTS}/StahovaniFaktur/script 2>/dev/null")
show(c, f"cat {USERSCRIPTS}/StahovaniFaktur/schedule.json 2>/dev/null")
show(c, "grep -n 'Scripts' /etc/cron.d/root 2>/dev/null | head")
# ── DEPLOY (kopie souborů) ───────────────────────────────────────────────────
def deploy(c):
run(c, f"mkdir -p {HOST_DIR} /mnt/user/Scripts/logs")
sftp = c.open_sftp()
for f in FILES:
lp = os.path.join(LOCAL_DIR, f)
if not os.path.exists(lp):
print(f" přeskakuji (není lokálně): {f}")
continue
rp = posixpath.join(HOST_DIR, f)
sftp.put(lp, rp)
print(f"{f}{rp}")
# seed state.json jen když na serveru ještě není (ať se nepřemazává běhový stav)
rp_state = posixpath.join(HOST_DIR, "state.json")
rc, _o, _e = run(c, f"test -f {rp_state}")
if rc != 0:
lp_state = os.path.join(LOCAL_DIR, "state.json")
if os.path.exists(lp_state):
sftp.put(lp_state, rp_state)
print(f" ↑ state.json (seed) → {rp_state}")
else:
with sftp.open(rp_state, "w") as fh:
fh.write('{"last_id": null}\n')
print(" ↑ state.json (prázdný)")
else:
print(" state.json na serveru už existuje — neměním.")
sftp.close()
show(c, f"ls -la {HOST_DIR}/")
# ── ENV (naplní /scripts/Webinare/.env Telegram klíči z lokálního Medevio/.env) ─
def env(c):
src = os.path.join(os.path.dirname(LOCAL_DIR), "Medevio", ".env")
if not os.path.exists(src):
sys.exit("Lokální Medevio/.env nenalezen.")
chteji = ("TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID")
radky = []
with open(src, encoding="utf-8") as fh:
for line in fh:
s = line.strip()
if "=" in s and not s.startswith("#"):
k = s.split("=", 1)[0].strip()
if k in chteji:
radky.append(s)
keys = [r.split("=", 1)[0] for r in radky]
if not all(k in keys for k in chteji):
sys.exit(f"V Medevio/.env chybí některý z klíčů: {chteji}")
run(c, f"mkdir -p {HOST_DIR}")
sftp = c.open_sftp()
with sftp.open(posixpath.join(HOST_DIR, ".env"), "w") as fh:
fh.write("\n".join(radky) + "\n")
sftp.chmod(posixpath.join(HOST_DIR, ".env"), 0o600)
sftp.close()
print(f" .env zapsán na server ({', '.join(keys)}) — hodnoty se nevypisují.")
show(c, f"docker exec {CONTAINER} sh -lc 'grep -oE \"^(TELEGRAM_BOT_TOKEN|TELEGRAM_CHAT_ID)=\" {CONT_DIR}/.env'")
# ── CRON RECON (zjistí, jak User Scripts ukládá rozvrh) ──────────────────────
def cron(c):
show(c, "ls -la /boot/config/plugins/user.scripts/scripts/StahovaniFaktur/")
show(c, "ls -la /boot/config/plugins/user.scripts/scripts/MedicusFirebirdRestore/")
show(c, "cat /boot/config/plugins/user.scripts/scripts/MedicusFirebirdRestore/schedule.json 2>/dev/null || echo bez_schedule_json")
show(c, "ls -la /etc/cron.d/")
show(c, "cat /etc/cron.d/root 2>/dev/null")
show(c, "crontab -l 2>/dev/null | tail -40")
# ── CRONSTORE RECON (kam plugin persistuje rozvrh přes reboot) ───────────────
def cronstore(c):
show(c, "ls -la /boot/config/plugins/user.scripts/")
show(c, "find /boot/config/plugins/user.scripts/ -maxdepth 1 -type f -exec ls -la {} +")
show(c, "grep -rsl 'StahovaniFaktur' /boot/config/ 2>/dev/null | grep -v '/scripts/StahovaniFaktur/'")
show(c, "grep -rsn '6,18\\|cron\\|schedule' /boot/config/plugins/user.scripts/ --include='*.json' --include='*.cfg' --include='*.dat' --include='*.php' 2>/dev/null | head -40")
# ── CRONFILES (dump přesného formátu schedule.json + customSchedule.cron) ────
def cronfiles(c):
show(c, "sed -n '185,210p' /boot/config/plugins/user.scripts/schedule.json")
show(c, "head -8 /boot/config/plugins/user.scripts/schedule.json")
show(c, "tail -8 /boot/config/plugins/user.scripts/schedule.json")
show(c, "cat /boot/config/plugins/user.scripts/customSchedule.cron")
show(c, "ls -la /usr/local/sbin/update_cron /usr/local/emhttp/plugins/user.scripts/startCustom.php 2>&1")
# ── SMOKE TEST (neblokuje na Telegramu) ──────────────────────────────────────
def smoke(c):
# ověří přibalený telegram modul + načtení .env (jen délky, ne hodnoty)
# + detekci webináře na webu. NEodesílá Telegram ani registraci.
py = (
"import sys; sys.path.insert(0,'/scripts/Webinare');"
"import telegram_notify as t;"
"print('telegram .env OK: token_len',len(t._token()),'chat_id_set',bool(t._resolve_chat_id(None)));"
"import json,requests,re;"
"from bs4 import BeautifulSoup;"
"cfg=json.load(open('/scripts/Webinare/config.json',encoding='utf-8'));"
"s=requests.Session(); s.get(cfg['watch_url'],headers={'User-Agent':'Mozilla/5.0'},timeout=30);"
"r=s.get(cfg['watch_url'],headers={'User-Agent':'Mozilla/5.0'},timeout=30);"
"a=BeautifulSoup(r.text,'html.parser').select('a[href*=\\\"webinar.php?idwebinar=\\\"]')[0];"
"print('detekce OK webinar=',re.search(r'idwebinar=(\\\\d+)',a['href']).group(1))"
)
show(c, f"docker exec {CONTAINER} python3 -c \"{py}\"", timeout=90)
# ── SCHEDULE (User Scripts plugin, denně 8:00) ───────────────────────────────
def schedule(c):
d = f"{USERSCRIPTS}/{US_NAME}"
script_path = f"{d}/script"
cron_line = (f"{CRON_EXPR} /usr/local/emhttp/plugins/user.scripts/startCustom.php "
f"{script_path} > /dev/null 2>&1")
# wrapper (styl převzat z StahovaniFaktur: flock + docker exec + log s datem/rc)
wrapper = (
"#!/bin/bash\n"
"# WebinarWatcher - denne 8:00, hlidac webinaru praktickylekar.online. flock proti prekryvu.\n"
"LOG=/mnt/user/Scripts/logs/webinar_watcher.log\n"
"mkdir -p /mnt/user/Scripts/logs\n"
"exec 9>/tmp/webinar_watcher.lock\n"
"flock -n 9 || exit 0\n"
"OUT=$(docker exec -e PYTHONIOENCODING=utf-8 -e TZ=Europe/Prague " + CONTAINER + " python3 " + CONT_DIR + "/watcher.py 2>&1)\n"
"RC=$?\n"
"{ echo \"===== $(date '+%F %T') (rc=$RC) =====\"; echo \"$OUT\"; } >> \"$LOG\"\n"
)
run(c, f"mkdir -p {d}")
sftp = c.open_sftp()
with sftp.open(script_path, "w") as fh:
fh.write(wrapper)
with sftp.open(f"{d}/name", "w") as fh:
fh.write(US_NAME)
with sftp.open(f"{d}/description", "w") as fh:
fh.write("Hlidac webinaru praktickylekar.online, denne 8:00")
# ── schedule.json: přidej/aktualizuj záznam (se zálohou) ──
run(c, f"cp -a {SCHEDULE_JSON} {SCHEDULE_JSON}.bak_webinar")
with sftp.open(SCHEDULE_JSON, "r") as fh:
data = json.loads(fh.read().decode("utf-8"))
data[script_path] = {
"script": script_path,
"frequency": "custom",
"id": "schedule" + US_NAME,
"custom": CRON_EXPR,
}
with sftp.open(SCHEDULE_JSON, "w") as fh:
fh.write(json.dumps(data, indent=2))
# ── customSchedule.cron: přidej řádek (se zálohou), pokud chybí ──
with sftp.open(CUSTOM_CRON, "r") as fh:
cron_txt = fh.read().decode("utf-8")
if script_path not in cron_txt:
run(c, f"cp -a {CUSTOM_CRON} {CUSTOM_CRON}.bak_webinar")
with sftp.open(CUSTOM_CRON, "w") as fh:
fh.write(cron_txt.rstrip() + "\n\n" + cron_line + "\n")
sftp.close()
run(c, f"chmod +x {script_path}")
# ── regeneruj systémový cron + ověř ──
show(c, "/usr/local/sbin/update_cron")
print("── OVĚŘENÍ ──")
show(c, f"ls -la {d}/")
show(c, f"grep -n '{US_NAME}' {CUSTOM_CRON}")
show(c, f"grep -n '{US_NAME}' /etc/cron.d/root")
show(c, f"grep -n '{US_NAME}' {SCHEDULE_JSON}")
# ── PRODRUN (spustí přesně to, co pustí cron — pro ruční test/trigger) ────────
def prodrun(c):
show(c, f"docker exec -e PYTHONIOENCODING=utf-8 {CONTAINER} python3 {CONT_DIR}/watcher.py",
timeout=200)
MODES = {"recon": recon, "deploy": deploy, "env": env, "cron": cron,
"cronstore": cronstore, "cronfiles": cronfiles, "smoke": smoke,
"schedule": schedule, "prodrun": prodrun}
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "recon"
if mode not in MODES:
sys.exit(f"Neznámý režim '{mode}'. Použij: {', '.join(MODES)}")
c = connect()
try:
print(f"=== {mode.upper()} na {USER}@{HOST} ===\n")
MODES[mode](c)
finally:
c.close()
if __name__ == "__main__":
main()