#!/usr/bin/env python3 """Deploy public/ -> /dedinit.ru on Jino (SFTP-only, password auth). Единственный способ деплоя сайта (Gitea Actions / локально). Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён, поэтому mirror-заливка через SFTP с удалением лишнего (как rsync --delete). Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md). Инкрементально: файл пропускается, если на сервере уже есть файл того же размера И с тем же SHA1-хешем (точно). В CI (Gitea Actions) mtime после checkout всегда свежий, поэтому mtime НЕ используется для сравнения — только размер + содержимое. Это гарантирует, что тяжёлые неизменные файлы (PDF/M4V) не переливаются повторно. Защита от зависаний: - Transport.set_keepalive(15) — пинги каждые 15с; при мёртвом соединении канал падает сам. - Таймаут на канал SFTP: channel.settimeout(120) — операция дольше 120с прерывается с ошибкой (а не висит вечно). - Ретраи: put/remove при обрыве переподключаются и повторяют операцию (до 3 попыток). """ import hashlib import os import sys import time try: import paramiko except ImportError: print("ОШИБКА: нет paramiko. Установите: pip install paramiko", file=sys.stderr) sys.exit(1) HOST = os.environ.get("DEDINIT_HOST", "kpa39l.myjino.ru") PORT = int(os.environ.get("DEDINIT_PORT", "2222")) USER = os.environ.get("DEDINIT_USER", "kpa39l_dedinit") PASS = os.environ.get("SSHPASS", "") LOCAL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "public") REMOTE = os.environ.get("DEDINIT_REMOTE", "/dedinit.ru") DRYRUN = "--dry-run" in sys.argv if not PASS and not DRYRUN: print("ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md", file=sys.stderr) sys.exit(1) LOCAL = os.path.abspath(LOCAL) if not os.path.isdir(LOCAL): print(f"ОШИБКА: нет каталога {LOCAL}. Сначала: make build", file=sys.stderr) sys.exit(1) def connect(): t = paramiko.Transport((HOST, PORT)) t.banner_timeout = 30 t.set_keepalive(15) t.connect(username=USER, password=PASS) sftp = paramiko.SFTPClient.from_transport(t) ch = sftp.get_channel() ch.settimeout(120) return t, sftp def disconnect(t, sftp): try: sftp.close() except Exception: pass try: t.close() except Exception: pass t = None sftp = None def rlist(path): out = [] try: entries = sftp.listdir_attr(path) except IOError: return out for e in entries: full = os.path.join(path, e.filename) rel = os.path.relpath(full, REMOTE) isdir = e.st_mode and (e.st_mode & 0o170000) == 0o040000 out.append((rel, isdir)) if isdir: out.extend(rlist(full)) return out def local_sha1(lpath): h = hashlib.sha1() with open(lpath, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break h.update(chunk) return h.hexdigest() def remote_sha1(rpath): """SHA1 содержимого удалённого файла через SFTP (поток, без временных копий).""" h = hashlib.sha1() with sftp.open(rpath, "rb") as rf: while True: chunk = rf.read(1024 * 1024) if not chunk: break h.update(chunk) return h.hexdigest() def same_file(lpath, rstat): """True, если удалённый файл совпадает с локальным (размер + SHA1). mtime НЕ используется (в CI после checkout он всегда свежий). Для каждого файла: если размеры разные — льём сразу; если равны — сравниваем SHA1 содержимого (медленно для крупных, но надёжно и позволяет пропускать неизменные PDF/M4V). """ lsize = os.path.getsize(lpath) if rstat.st_size != lsize: return False try: return local_sha1(lpath) == remote_sha1(rpath) except Exception: return False def with_retry(fn, *args, retries=3, desc=""): global t, sftp for attempt in range(1, retries + 1): try: return fn(*args) except Exception as e: if attempt == retries: print(f" ! {desc}: {e} (после {retries} попыток)", file=sys.stderr) raise print(f" ! {desc}: {e} — переподключение ({attempt}/{retries})", file=sys.stderr) disconnect(t, sftp) time.sleep(2) t, sftp = connect() try: t, sftp = connect() except Exception as e: print(f"ОШИБКА: не удалось подключиться к {HOST}:{PORT}: {e}", file=sys.stderr) sys.exit(1) print(f"Копирую {LOCAL} -> sftp://{USER}@{HOST}:{PORT}{REMOTE}/" + (" (DRY-RUN)" if DRYRUN else "")) local_files = [] for root, dirs, files in os.walk(LOCAL): for f in files: local_files.append(os.path.relpath(os.path.join(root, f), LOCAL)) remote_entries = rlist(REMOTE) if not DRYRUN else [] remote_files = {r for r, d in remote_entries if not d} uploaded = skipped = 0 for rel in sorted(local_files): lpath = os.path.join(LOCAL, rel) rpath = os.path.join(REMOTE, rel) rdir = os.path.dirname(rpath) cur = REMOTE for seg in os.path.relpath(rdir, REMOTE).split(os.sep): if not seg: continue cur = os.path.join(cur, seg) try: sftp.stat(cur) except IOError: try: sftp.mkdir(cur) except IOError: pass try: rstat = sftp.stat(rpath) except IOError: rstat = None if rstat is not None and same_file(lpath, rstat): skipped += 1 print(f" = {rel}") continue if not DRYRUN: with_retry(sftp.put, lpath, rpath, desc=f"put {rel}") uploaded += 1 print(f" + {rel}") if not DRYRUN: to_delete = sorted(remote_files - set(local_files)) for rel in to_delete: with_retry(sftp.remove, os.path.join(REMOTE, rel), desc=f"remove {rel}") print(f" - {rel}") disconnect(t, sftp) print(f"Готово: {uploaded} залито, {skipped} пропущено." if not DRYRUN else f"DRY-RUN завершён: будет залито {uploaded}, пропущено {skipped}.")