204 lines
6.9 KiB
Python
204 lines
6.9 KiB
Python
#!/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}.") |