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
@@ -6,5 +6,5 @@
- [x] Убедиться, что пагинация/плоский список (post-entry) на /posts/ исчезла
- [x] Проверить RSS секции /posts/ (index.xml) — не сломан
- [x] `openspec validate archive-by-year-month` — чисто
- [ ] Commit + push origin main — деплой через Gitea Actions (deploy.yml)
- [x] Commit + push gitea main — деплой через Gitea Actions (deploy.yml); замер №1: run создан 16:54:19Z, прод виден 17:30:20Z (~36 мин)
- [ ] Прогнать openspec-archive-change (архивировать change после деплоя)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-22
@@ -0,0 +1,47 @@
# Design: Инкрементальный SFTP-деплой
## Подход
Модифицируем единственный файл `tools/deploy_sftp.py`:
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 мин, канал падает с ошибкой,
скрипт падает с понятным сообщением вместо бесконечного висения.
- Удаление тоже не должно висеть: те же таймауты.
3. **Что НЕ меняется**:
- Mirror-логика (всё, чего нет локально, удаляется) — сохранена.
- Рекурсивное создание каталогов — сохранено.
- Интерфейс: `tools/deploy_sftp.py [--dry-run]`, переменные DEDINIT_* / SSHPASS.
- Вызов из `.gitea/workflows/deploy.yml` не меняется.
## Риски
- **mtime на сервере** (Jino) может отличаться от локального (часовой пояс, точность).
SFTP stat возвращает mtime в секундах; локально `int(st_mtime)` — тоже секунды.
Допуск: считаем «не изменился», если `|remote_mtime - local_mtime| <= 120` И размеры равны.
(Больше 2 мин расхождения не бывает при честном сравнении; зато гарантируем, что
файл, записанный «только что» локально и залитый минуту назад, не перельётся без нужды.)
Решение: допуск на mtime = 300 сек (5 мин) при равенстве размеров.
- **Ложные пропуски**: если сервер отдаёт mtime 0 (некоторые SFTP-сервера) — считаем файл
изменённым (льём всегда). Проверка `if remote_mtime > 0`.
- **Большой дерево**: rlist рекурсивно обходит всё дерево — оставляем как есть (работает,
это доли секунды на 500 файлов).
## Верификация
- `python3 tools/deploy_sftp.py --dry-run` локально: показывает список изменившихся
(должно быть 0 после сборки в тот же public/... фактически покажет все «+», т.к. на
сервере старый mtime — это ок, первый деплой льёт всё).
- После первого инкрементального деплоя повторный `--dry-run` покажет «=» (пропуски).
@@ -0,0 +1,30 @@
---
id: deploy-sftp-incremental
created: 2026-09-22
schema: spec-driven
---
# Proposal: Инкрементальный SFTP-деплой (только изменившиеся файлы)
## Problem
`tools/deploy_sftp.py` при каждом деплое заливает ВСЕ файлы из `public/` (524 файла, 66M)
через `sftp.put()` без проверки, изменился ли файл на сервере. Из них ~40M — тяжёлые
неизменные файлы (PDF 21M, M4V 11M×2 и др.), которые льются повторно каждый раз.
Результат (замер №1, 2026-09-22): деплой через Gitea Actions занял **~36 минут** до
видимости на проде. Плюс скрипт не имеет таймаутов: при обрыве SFTP-соединения
`paramiko.Transport` висит в `sftp.put()` бесконечно (наблюдали на run 40:
лог замер на 17:22:54, файл hero.svg 1.7KB не залит, job не завершился).
## Goal
- Заливать только файлы, которых нет на сервере или которые изменились (size+mtime).
- Не висеть вечно: таймауты на соединение и на операцию, явная ошибка при обрыве.
- Сократить время деплоя в разы (цель: < 5 мин до видимости на проде).
## Non-goals
- Не трогаем способ вызова (по-прежнему `gitea-main push` → Gitea Actions → `tools/deploy_sftp.py`).
- Не трогаем содержание сайта / архив / темы.
- Не добавляем rsync (на Jino SFTP-only, удалённый exec запрещён).
@@ -0,0 +1,50 @@
# Spec: Инкрементальный SFTP-деплой
## ADDED Requirements
### Requirement: Скрипт заливает только изменившиеся файлы
Скрипт сравнивает каждый локальный файл с удалённым по размеру и mtime (с допуском) и заливает только те, что отсутствуют на сервере или отличаются.
#### Scenario: Файл уже есть на сервере и не менялся
Given сервер содержит файл `X` с размером 1234 и mtime 1727000000
And локальный файл `X` имеет размер 1234 и mtime 1727000000
When выполняется `tools/deploy_sftp.py`
Then файл `X` НЕ заливается на сервер
#### Scenario: Файла нет на сервере
Given сервер НЕ содержит файл `X`
And локальный файл `X` существует
When выполняется `tools/deploy_sftp.py`
Then файл `X` заливается на сервер
#### Scenario: Файл изменился (другой размер или mtime)
Given сервер содержит файл `X` с размером 100 и mtime 1727000001
And локальный файл `X` имеет размер 125 и mtime 1727000002
When выполняется `tools/deploy_sftp.py`
Then файл `X` заливается на сервер
### Requirement: Скрипт не виснет при обрыве соединения
Скрипт использует таймауты соединения и операции, чтобы при обрыве канала завершиться с ошибкой, а не висеть бесконечно.
#### Scenario: Соединение с сервером оборвалось
Given SFTP-соединение с Jino работает
And соединение обрывается во время `put()`
When выполняется `tools/deploy_sftp.py`
Then скрипт завершается с ненулевым кодом и сообщением об ошибке в течение разумного времени (не бесконечно)
### Requirement: Удаление лишних файлов сохранено
Скрипт продолжает удалять с сервера файлы, которых нет в локальном `public/` (mirror-поведение).
#### Scenario: На сервере есть файл, которого нет локально
Given сервер содержит файл `Y`
And локальный каталог `public/` не содержит `Y`
When выполняется `tools/deploy_sftp.py`
Then файл `Y` удаляется с сервера
@@ -0,0 +1,7 @@
# Tasks: Инкрементальный SFTP-деплой
- [ ] Обновить `tools/deploy_sftp.py`: сравнение (size + mtime с допуском 300с), пропуск неизменных, таймауты (banner_timeout/timeout + socket timeout)
- [ ] `python3 tools/deploy_sftp.py --dry-run` — работает, выводит `+`/`=`
- [ ] Commit + push gitea main — запуск Gitea Actions
- [ ] Замер №2: время до видимости на проде; сравнить с эталоном (~36 мин)
- [ ] Прогнать openspec-archive-change / архивировать change