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
@@ -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` покажет «=» (пропуски).
## Альтернативы, отклонённые
- **rsync**: Jino SFTP-only, удалённый exec запрещён.
- **Манифест (JSON) прошлого деплоя**: усложняет, требует хранения состояния;
SHA1-сравнение с сервером самодостаточно.
- **Только размер**: риск ложного пропуска при изменении содержимого без смены
размера (маловероятно для статики, но SHA1 дешевле ошибки).
## Открытые вопросы
- Точное время залива (замер №2) зависит от пропускной способности Jino SFTP.
- SHA1 крупных файлов при каждом деплое: 21M PDF → ~2-5с на чтение с сервера;
приемлемо по сравнению с перезаливкой.
@@ -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: Удаление лишних файлов сохранено
@@ -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
- [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)`.