diff --git a/openspec/changes/deploy-sftp-incremental/design.md b/openspec/changes/deploy-sftp-incremental/design.md index 469eb63..18d65ca 100644 --- a/openspec/changes/deploy-sftp-incremental/design.md +++ b/openspec/changes/deploy-sftp-incremental/design.md @@ -1,47 +1,53 @@ # Design: Инкрементальный SFTP-деплой -## Подход +## Проблема -Модифицируем единственный файл `tools/deploy_sftp.py`: +`tools/deploy_sftp.py` mirror-заливал ВСЕ 524 файла (66M) каждый деплой, +включая неизменные тяжёлые (21M PDF, 11M×2 M4V) — деплой занимал ~36 мин +(замер №1: run создан 16:54:19Z → прод виден 17:30:20Z). Плюс: при обрыве +SFTP-соединения скрипт вис бесконечно (нет таймаутов) — оба раза висел на +`2026/gotosocial-relay-match-by-default/hero.svg`. -1. **Сравнение файлов (size + mtime)**: - - Для каждого локального файла вызываем `sftp.stat(remote_path)`. - - Если stat успешен И `(st_size, int(st_mtime))` совпадает с локальным - `(os.stat(lpath).st_size, int(os.stat(lpath).st_mtime))` — пропускаем (не льём). - - Если stat бросает IOError (нет файла) или не совпадает — заливаем. - - Метрику печатаем: `= пропущено, + залито`. +## Решение -2. **Таймауты против зависания**: - - `paramiko.Transport` создаём с `banner_timeout=30`, `timeout=30`. - - Для каждого `put` используем `paramiko` окно: оборачиваем в `socket.setdefaulttimeout(120)` - (SFTP-канал унаследует) — если операция длится дольше 2 мин, канал падает с ошибкой, - скрипт падает с понятным сообщением вместо бесконечного висения. - - Удаление тоже не должно висеть: те же таймауты. +Единственный файл `tools/deploy_sftp.py`: -3. **Что НЕ меняется**: - - Mirror-логика (всё, чего нет локально, удаляется) — сохранена. - - Рекурсивное создание каталогов — сохранено. - - Интерфейс: `tools/deploy_sftp.py [--dry-run]`, переменные DEDINIT_* / SSHPASS. - - Вызов из `.gitea/workflows/deploy.yml` не меняется. +1. **Инкремент по (size + SHA1)**: для каждого локального файла — `sftp.stat()`; + если удалённый существует и (размер совпадает И SHA1 содержимого совпадает) + — пропуск; иначе `sftp.put()`. + - mtime НЕ используется: в CI (Gitea Actions) checkout ставит свежий mtime + всем файлам → mtime-сравнение бесполезно. + - SHA1 читается потоково с удалённого через `sftp.open()` (1MB чанками) — + корректно для 21M PDF (несколько секунд). + - Для несовпадающих размеров хэш не считается (быстрый путь). -## Риски +2. **Защита от зависаний**: + - `Transport.set_keepalive(15)` — пинги; мёртвое соединение падает само. + - `sftp.get_channel().settimeout(120)` — операция дольше 120с прерывается. + - `with_retry()` — при исключении переподключение (до 3 попыток, sleep 2с), + `put`/`remove` повторяются; после исчерпания — выход с ненулевым кодом. -- **mtime на сервере** (Jino) может отличаться от локального (часовой пояс, точность). - SFTP stat возвращает mtime в секундах; локально `int(st_mtime)` — тоже секунды. - Допуск: считаем «не изменился», если `|remote_mtime - local_mtime| <= 120` И размеры равны. - (Больше 2 мин расхождения не бывает при честном сравнении; зато гарантируем, что - файл, записанный «только что» локально и залитый минуту назад, не перельётся без нужды.) - Решение: допуск на mtime = 300 сек (5 мин) при равенстве размеров. +3. **Mirror-удаление сохранено**: `remote_files - local_files` → `remove()`. -- **Ложные пропуски**: если сервер отдаёт mtime 0 (некоторые SFTP-сервера) — считаем файл - изменённым (льём всегда). Проверка `if remote_mtime > 0`. +4. **CI**: `python3-paramiko` ставится через **apt** (не pip в venv) — не зависит + от PyPI, быстрее (установка случайно занимала 15-20 мин). -- **Большой дерево**: rlist рекурсивно обходит всё дерево — оставляем как есть (работает, - это доли секунды на 500 файлов). +## Почему не mtime -## Верификация +В Gitea Actions `actions/checkout` распаковывает репозиторий с текущим mtime → +все файлы «свежие» относительно сервера → mtime-сравнение никогда не пропускает. +SHA1 — единственный надёжный индикатор неизменности для CI. -- `python3 tools/deploy_sftp.py --dry-run` локально: показывает список изменившихся - (должно быть 0 после сборки в тот же public/... фактически покажет все «+», т.к. на - сервере старый mtime — это ок, первый деплой льёт всё). -- После первого инкрементального деплоя повторный `--dry-run` покажет «=» (пропуски). \ No newline at end of file +## Альтернативы, отклонённые + +- **rsync**: Jino SFTP-only, удалённый exec запрещён. +- **Манифест (JSON) прошлого деплоя**: усложняет, требует хранения состояния; + SHA1-сравнение с сервером самодостаточно. +- **Только размер**: риск ложного пропуска при изменении содержимого без смены + размера (маловероятно для статики, но SHA1 дешевле ошибки). + +## Открытые вопросы + +- Точное время залива (замер №2) зависит от пропускной способности Jino SFTP. +- SHA1 крупных файлов при каждом деплое: 21M PDF → ~2-5с на чтение с сервера; + приемлемо по сравнению с перезаливкой. \ No newline at end of file diff --git a/openspec/changes/deploy-sftp-incremental/specs/deploy-sftp-incremental/spec.md b/openspec/changes/deploy-sftp-incremental/specs/deploy-sftp-incremental/spec.md index a722162..22aa607 100644 --- a/openspec/changes/deploy-sftp-incremental/specs/deploy-sftp-incremental/spec.md +++ b/openspec/changes/deploy-sftp-incremental/specs/deploy-sftp-incremental/spec.md @@ -1,15 +1,15 @@ # Spec: Инкрементальный SFTP-деплой -## ADDED Requirements +## MODIFIED Requirements ### Requirement: Скрипт заливает только изменившиеся файлы -Скрипт сравнивает каждый локальный файл с удалённым по размеру и mtime (с допуском) и заливает только те, что отсутствуют на сервере или отличаются. +Скрипт сравнивает каждый локальный файл с удалённым и заливает только те, что отсутствуют на сервере или отличаются. Сравнение — по размеру и SHA1-хешу содержимого (mtime не используется: в CI после checkout он всегда свежий, что делает mtime-сравнение бесполезным). #### Scenario: Файл уже есть на сервере и не менялся -Given сервер содержит файл `X` с размером 1234 и mtime 1727000000 -And локальный файл `X` имеет размер 1234 и mtime 1727000000 +Given сервер содержит файл `X` с тем же размером и SHA1, что локальный +And локальный файл `X` существует When выполняется `tools/deploy_sftp.py` Then файл `X` НЕ заливается на сервер @@ -20,23 +20,30 @@ And локальный файл `X` существует When выполняется `tools/deploy_sftp.py` Then файл `X` заливается на сервер -#### Scenario: Файл изменился (другой размер или mtime) +#### Scenario: Файл изменился (другой размер) -Given сервер содержит файл `X` с размером 100 и mtime 1727000001 -And локальный файл `X` имеет размер 125 и mtime 1727000002 +Given сервер содержит файл `X` с размером 100 +And локальный файл `X` имеет размер 125 +When выполняется `tools/deploy_sftp.py` +Then файл `X` заливается на сервер + +#### Scenario: Файл изменился (тот же размер, другое содержимое) + +Given сервер содержит файл `X` того же размера, но с другим SHA1 +And локальный файл `X` имеет другой SHA1 When выполняется `tools/deploy_sftp.py` Then файл `X` заливается на сервер ### Requirement: Скрипт не виснет при обрыве соединения -Скрипт использует таймауты соединения и операции, чтобы при обрыве канала завершиться с ошибкой, а не висеть бесконечно. +Скрипт использует keepalive (15с) и таймаут канала (120с), а также переподключение с ретраями (до 3 попыток), чтобы при обрыве канала завершиться с ошибкой или продолжить, а не висеть бесконечно. #### Scenario: Соединение с сервером оборвалось Given SFTP-соединение с Jino работает And соединение обрывается во время `put()` When выполняется `tools/deploy_sftp.py` -Then скрипт завершается с ненулевым кодом и сообщением об ошибке в течение разумного времени (не бесконечно) +Then скрипт переподключается и повторяет операцию; если все попытки исчерпаны — завершается с ненулевым кодом и сообщением об ошибке ### Requirement: Удаление лишних файлов сохранено diff --git a/openspec/changes/deploy-sftp-incremental/tasks.md b/openspec/changes/deploy-sftp-incremental/tasks.md index dc71648..daa19fc 100644 --- a/openspec/changes/deploy-sftp-incremental/tasks.md +++ b/openspec/changes/deploy-sftp-incremental/tasks.md @@ -1,7 +1,20 @@ # Tasks: Инкрементальный SFTP-деплой -- [x] Обновить `tools/deploy_sftp.py`: сравнение (size + mtime с допуском 300с), пропуск неизменных, таймауты (banner_timeout/timeout + socket timeout) -- [x] `python3 tools/deploy_sftp.py --dry-run` — работает, выводит `+`/`=` -- [ ] Commit + push gitea main — запуск Gitea Actions -- [ ] Замер №2: время до видимости на проде; сравнить с эталоном (~36 мин) -- [ ] Прогнать openspec-archive-change / архивировать change \ No newline at end of file +- [x] Обновить `tools/deploy_sftp.py`: инкремент по (size + SHA1), keepalive/таймаут канала, ретраи с переподключением +- [x] Проверить синтаксис (`python3 -m py_compile`) и логику same_file (юнит-тест без сети) +- [x] CI: заменить pip-установку paramiko на apt `python3-paramiko` (не зависит от PyPI) в `.gitea/workflows/deploy.yml` +- [x] Обновить openspec (spec/design/tasks) под SHA1-решение +- [ ] Замерить деплой №2 после запуска run на новом коде: сравнить с эталоном ~36 мин +- [ ] Прогнать openspec-archive-change (из прошлого change) — замер №1 зафиксирован в tasks.md + +## Замеры + +- **Замер №1 (эталон, старый mirror-скрипт)**: run 43 создан 16:54:19Z → прод виден 17:30:20Z = **~36 мин**. Полный job не завершился (завис на мёртвом SFTP). +- **Замер №1b (f7be5a8, mtime-инкремент — НЕ сработал)**: run 41 создан 18:40:54Z → прод обновлён 19:07:07Z = **~27 мин** (ускорение за счёт того, что заливка шла быстрее; mtime-сравнение пропускало 0 файлов — checkout даёт свежий mtime). Job завис на том же `2026/gotosocial-relay-match-by-default/hero.svg`. +- **Замер №2 (SHA1 + apt-paramiko)**: ожидается после освобождения runner'а (run 41 висит, run 42 не может стартовать). + +## Блокеры + +- Run 41 (f7be5a8) висит в Gitea Actions на мёртвом SFTP (нет API-cancel; runner на bigbox недоступен) — новый run (07ba1e4-правки) ждёт освобождения runner'а. По умолчанию Gitea ждёт до ~6ч. +- Gitea не создаёт следующий run по ветке, пока по ней есть active run (наблюдение). +- `socket.setdefaulttimeout(120)` НЕ прерывает зависший put в paramiko (канал создан до вызова) — заменён на `channel.settimeout(120)`. \ No newline at end of file diff --git a/tools/deploy_sftp.py b/tools/deploy_sftp.py index 7463d7e..86431f3 100644 --- a/tools/deploy_sftp.py +++ b/tools/deploy_sftp.py @@ -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}.") \ No newline at end of file