feat: инкрементальный SFTP-деплой (только изменившиеся файлы, таймауты)
deploy-dedinit / Build & SFTP Deploy (push) Successful in 43m42s

This commit is contained in:
2026-09-22 18:40:46 +00:00
parent 65457becf2
commit f7be5a87b4
8 changed files with 187 additions and 15 deletions
+47 -13
View File
@@ -5,8 +5,13 @@
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
@@ -23,8 +28,9 @@ 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:
if not PASS and not DRYRUN:
print("ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md", file=sys.stderr)
sys.exit(1)
@@ -33,8 +39,16 @@ 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.connect(username=USER, password=PASS)
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)
@@ -54,6 +68,18 @@ def rlist(path):
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 ""))
@@ -62,14 +88,11 @@ for root, dirs, files in os.walk(LOCAL):
for f in files:
local_files.append(os.path.relpath(os.path.join(root, f), LOCAL))
if not DRYRUN:
remote_entries = rlist(REMOTE)
remote_files = {r for r, d in remote_entries if not d}
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):
if DRYRUN:
print(" +", rel)
continue
lpath = os.path.join(LOCAL, rel)
rpath = os.path.join(REMOTE, rel)
rdir = os.path.dirname(rpath)
@@ -85,18 +108,29 @@ for rel in sorted(local_files):
sftp.mkdir(cur)
except IOError:
pass
sftp.put(lpath, rpath)
print(" +", rel)
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(" -", rel)
print(f" - {rel}")
except Exception as e:
print(" !", rel, e)
print(f" ! {rel} {e}")
sftp.close()
t.close()
print("Готово." if not DRYRUN else "DRY-RUN завершён: показаны изменения без заливки.")
print(f"Готово: {uploaded} залито, {skipped} пропущено."
if not DRYRUN else f"DRY-RUN завершён: будет залито {uploaded}, пропущено {skipped}.")