openspec: архив changes series-taxonomy-and-header, archive-by-year-month, deploy-sftp-incremental (2026-09-25)
deploy-dedinit / Build & SFTP Deploy (push) Successful in 7m13s
deploy-dedinit / Build & SFTP Deploy (push) Successful in 7m13s
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-22
|
||||
@@ -0,0 +1,53 @@
|
||||
# Design: Инкрементальный SFTP-деплой
|
||||
|
||||
## Проблема
|
||||
|
||||
`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`.
|
||||
|
||||
## Решение
|
||||
|
||||
Единственный файл `tools/deploy_sftp.py`:
|
||||
|
||||
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` повторяются; после исчерпания — выход с ненулевым кодом.
|
||||
|
||||
3. **Mirror-удаление сохранено**: `remote_files - local_files` → `remove()`.
|
||||
|
||||
4. **CI**: `python3-paramiko` ставится через **apt** (не pip в venv) — не зависит
|
||||
от PyPI, быстрее (установка случайно занимала 15-20 мин).
|
||||
|
||||
## Почему не mtime
|
||||
|
||||
В Gitea Actions `actions/checkout` распаковывает репозиторий с текущим mtime →
|
||||
все файлы «свежие» относительно сервера → mtime-сравнение никогда не пропускает.
|
||||
SHA1 — единственный надёжный индикатор неизменности для CI.
|
||||
|
||||
## Альтернативы, отклонённые
|
||||
|
||||
- **rsync**: Jino SFTP-only, удалённый exec запрещён.
|
||||
- **Манифест (JSON) прошлого деплоя**: усложняет, требует хранения состояния;
|
||||
SHA1-сравнение с сервером самодостаточно.
|
||||
- **Только размер**: риск ложного пропуска при изменении содержимого без смены
|
||||
размера (маловероятно для статики, но SHA1 дешевле ошибки).
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Точное время залива (замер №2) зависит от пропускной способности Jino SFTP.
|
||||
- SHA1 крупных файлов при каждом деплое: 21M PDF → ~2-5с на чтение с сервера;
|
||||
приемлемо по сравнению с перезаливкой.
|
||||
@@ -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 запрещён).
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Spec: Инкрементальный SFTP-деплой
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Скрипт заливает только изменившиеся файлы
|
||||
|
||||
Скрипт сравнивает каждый локальный файл с удалённым и заливает только те, что отсутствуют на сервере или отличаются. Сравнение — по размеру и SHA1-хешу содержимого (mtime не используется: в CI после checkout он всегда свежий, что делает mtime-сравнение бесполезным).
|
||||
|
||||
#### Scenario: Файл уже есть на сервере и не менялся
|
||||
|
||||
Given сервер содержит файл `X` с тем же размером и SHA1, что локальный
|
||||
And локальный файл `X` существует
|
||||
When выполняется `tools/deploy_sftp.py`
|
||||
Then файл `X` НЕ заливается на сервер
|
||||
|
||||
#### Scenario: Файла нет на сервере
|
||||
|
||||
Given сервер НЕ содержит файл `X`
|
||||
And локальный файл `X` существует
|
||||
When выполняется `tools/deploy_sftp.py`
|
||||
Then файл `X` заливается на сервер
|
||||
|
||||
#### Scenario: Файл изменился (другой размер)
|
||||
|
||||
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 скрипт переподключается и повторяет операцию; если все попытки исчерпаны — завершается с ненулевым кодом и сообщением об ошибке
|
||||
|
||||
### Requirement: Удаление лишних файлов сохранено
|
||||
|
||||
Скрипт продолжает удалять с сервера файлы, которых нет в локальном `public/` (mirror-поведение).
|
||||
|
||||
#### Scenario: На сервере есть файл, которого нет локально
|
||||
|
||||
Given сервер содержит файл `Y`
|
||||
And локальный каталог `public/` не содержит `Y`
|
||||
When выполняется `tools/deploy_sftp.py`
|
||||
Then файл `Y` удаляется с сервера
|
||||
@@ -0,0 +1,20 @@
|
||||
# Tasks: Инкрементальный SFTP-деплой
|
||||
|
||||
- [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-решение
|
||||
- [x] Замерить деплой №2 после запуска run на новом коде: сравнить с эталоном ~36 мин
|
||||
- [x] Прогнать 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)**: run 43 (92b7094) — SUCCESS за **12.7 мин** (19:37:09 → 19:49:50Z), «Готово: 0 залито, 482 пропущено» при неизменном контенте. Выигрыш: ~36 мин → ~12-13 мин на пустой деплой; при изменениях льётся только diff вместо 66M.
|
||||
|
||||
## Блокеры
|
||||
|
||||
- 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)`.
|
||||
Reference in New Issue
Block a user