Files
dedinit.ru/tools/deploy_sftp.py
T

204 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}.")