#!/usr/bin/env python3 """Deploy public/ -> /dedinit.ru on Jino (SFTP-only, password auth). Используется командой `make deploy` (единственный способ деплоя сайта). Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён, поэтому заливаем через SFTP с рекурсивным mirror и --delete (как rsync). Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md). Инкрементально: файлы, которые уже есть на сервере с тем же размером и mtime (допуск 300с), пропускаются — заливаются только новые/изменённые. Таймауты: соединение banner_timeout=30/timeout=30, операция — socket timeout 120с. """ import os import socket import stat as stmod import sys 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 MTIME_TOLERANCE = int(os.environ.get("DEDINIT_MTIME_TOLERANCE", "300")) 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) # Защита от бесконечного зависания при обрыве соединения. socket.setdefaulttimeout(120) t = paramiko.Transport((HOST, PORT)) t.banner_timeout = 30 try: t.connect(username=USER, password=PASS) except Exception as e: print(f"ОШИБКА: не удалось подключиться к {HOST}:{PORT}: {e}", file=sys.stderr) sys.exit(1) sftp = paramiko.SFTPClient.from_transport(t) 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 = stmod.S_ISDIR(e.st_mode) out.append((rel, isdir)) if isdir: out.extend(rlist(full)) return out def same_file(lstat, rstat): """True, если файл на сервере совпадает с локальным (размер + mtime с допуском).""" if rstat.st_size != lstat.st_size: return False rmt = int(rstat.st_mtime) lmt = int(lstat.st_mtime) # Некоторые SFTP-сервера отдают mtime=0 — в этом случае считаем файл изменённым. if rmt <= 0: return False return abs(rmt - lmt) <= MTIME_TOLERANCE 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(os.stat(lpath), rstat): skipped += 1 print(f" = {rel}") continue if not DRYRUN: sftp.put(lpath, rpath) uploaded += 1 print(f" + {rel}") if not DRYRUN: to_delete = remote_files - set(local_files) for rel in sorted(to_delete): try: sftp.remove(os.path.join(REMOTE, rel)) print(f" - {rel}") except Exception as e: print(f" ! {rel} {e}") sftp.close() t.close() print(f"Готово: {uploaded} залито, {skipped} пропущено." if not DRYRUN else f"DRY-RUN завершён: будет залито {uploaded}, пропущено {skipped}.")