feat: SFTP-деплой по size+SHA1 (не mtime), keepalive+таймаут канала, ретраи; apt python3-paramiko
deploy-dedinit / Build & SFTP Deploy (push) Successful in 12m41s

This commit is contained in:
2026-09-22 19:27:36 +00:00
parent 07ba1e427d
commit 92b7094fc7
4 changed files with 182 additions and 88 deletions
+107 -39
View File
@@ -1,19 +1,29 @@
#!/usr/bin/env python3
"""Deploy public/ -> /dedinit.ru on Jino (SFTP-only, password auth).
Используется командой `make deploy` (единственный способ деплоя сайта).
Единственный способ деплоя сайта (Gitea Actions / локально).
Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён,
поэтому заливаем через SFTP с рекурсивным mirror и --delete (как rsync).
поэтому mirror-заливка через SFTP с удалением лишнего (как rsync --delete).
Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md).
Инкрементально: файлы, которые уже есть на сервере с тем же размером и mtime
(допуск 300с), пропускаются — заливаются только новые/изменённые.
Таймауты: соединение banner_timeout=30/timeout=30, операция — socket timeout 120с.
Инкрементально: файл пропускается, если на сервере уже есть файл того же
размера И с тем же 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 socket
import stat as stmod
import sys
import time
try:
import paramiko
@@ -28,7 +38,6 @@ 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)
@@ -39,17 +48,31 @@ 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:
def connect():
t = paramiko.Transport((HOST, PORT))
t.banner_timeout = 30
t.set_keepalive(15)
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)
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):
@@ -61,25 +84,74 @@ def rlist(path):
for e in entries:
full = os.path.join(path, e.filename)
rel = os.path.relpath(full, REMOTE)
isdir = stmod.S_ISDIR(e.st_mode)
isdir = e.st_mode and (e.st_mode & 0o170000) == 0o040000
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
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 ""))
@@ -112,25 +184,21 @@ for rel in sorted(local_files):
rstat = sftp.stat(rpath)
except IOError:
rstat = None
if rstat is not None and same_file(os.stat(lpath), rstat):
if rstat is not None and same_file(lpath, rstat):
skipped += 1
print(f" = {rel}")
continue
if not DRYRUN:
sftp.put(lpath, rpath)
with_retry(sftp.put, lpath, rpath, desc=f"put {rel}")
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}")
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}")
sftp.close()
t.close()
disconnect(t, sftp)
print(f"Готово: {uploaded} залито, {skipped} пропущено."
if not DRYRUN else f"DRY-RUN завершён: будет залито {uploaded}, пропущено {skipped}.")