feat: инкрементальный SFTP-деплой (только изменившиеся файлы, таймауты)
deploy-dedinit / Build & SFTP Deploy (push) Successful in 43m42s
deploy-dedinit / Build & SFTP Deploy (push) Successful in 43m42s
This commit is contained in:
@@ -13,3 +13,5 @@ static/images/favicon/dwarffortress*
|
|||||||
static/images/favicon/android-chrome*
|
static/images/favicon/android-chrome*
|
||||||
static/images/favicon/site.webmanifest
|
static/images/favicon/site.webmanifest
|
||||||
static/images/favicon/apple-touch-icon.png
|
static/images/favicon/apple-touch-icon.png
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
- [x] Убедиться, что пагинация/плоский список (post-entry) на /posts/ исчезла
|
- [x] Убедиться, что пагинация/плоский список (post-entry) на /posts/ исчезла
|
||||||
- [x] Проверить RSS секции /posts/ (index.xml) — не сломан
|
- [x] Проверить RSS секции /posts/ (index.xml) — не сломан
|
||||||
- [x] `openspec validate archive-by-year-month` — чисто
|
- [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 после деплоя)
|
- [ ] Прогнать 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
|
||||||
+46
-12
@@ -5,8 +5,13 @@
|
|||||||
Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён,
|
Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён,
|
||||||
поэтому заливаем через SFTP с рекурсивным mirror и --delete (как rsync).
|
поэтому заливаем через SFTP с рекурсивным mirror и --delete (как rsync).
|
||||||
Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md).
|
Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md).
|
||||||
|
|
||||||
|
Инкрементально: файлы, которые уже есть на сервере с тем же размером и mtime
|
||||||
|
(допуск 300с), пропускаются — заливаются только новые/изменённые.
|
||||||
|
Таймауты: соединение banner_timeout=30/timeout=30, операция — socket timeout 120с.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import stat as stmod
|
import stat as stmod
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -23,8 +28,9 @@ PASS = os.environ.get("SSHPASS", "")
|
|||||||
LOCAL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "public")
|
LOCAL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "public")
|
||||||
REMOTE = os.environ.get("DEDINIT_REMOTE", "/dedinit.ru")
|
REMOTE = os.environ.get("DEDINIT_REMOTE", "/dedinit.ru")
|
||||||
DRYRUN = "--dry-run" in sys.argv
|
DRYRUN = "--dry-run" in sys.argv
|
||||||
|
MTIME_TOLERANCE = int(os.environ.get("DEDINIT_MTIME_TOLERANCE", "300"))
|
||||||
|
|
||||||
if not PASS:
|
if not PASS and not DRYRUN:
|
||||||
print("ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md", file=sys.stderr)
|
print("ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -33,8 +39,16 @@ if not os.path.isdir(LOCAL):
|
|||||||
print(f"ОШИБКА: нет каталога {LOCAL}. Сначала: make build", file=sys.stderr)
|
print(f"ОШИБКА: нет каталога {LOCAL}. Сначала: make build", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Защита от бесконечного зависания при обрыве соединения.
|
||||||
|
socket.setdefaulttimeout(120)
|
||||||
|
|
||||||
t = paramiko.Transport((HOST, PORT))
|
t = paramiko.Transport((HOST, PORT))
|
||||||
t.connect(username=USER, password=PASS)
|
t.banner_timeout = 30
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +68,18 @@ def rlist(path):
|
|||||||
return out
|
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
|
||||||
|
|
||||||
|
|
||||||
print(f"Копирую {LOCAL} -> sftp://{USER}@{HOST}:{PORT}{REMOTE}/"
|
print(f"Копирую {LOCAL} -> sftp://{USER}@{HOST}:{PORT}{REMOTE}/"
|
||||||
+ (" (DRY-RUN)" if DRYRUN else ""))
|
+ (" (DRY-RUN)" if DRYRUN else ""))
|
||||||
|
|
||||||
@@ -62,14 +88,11 @@ for root, dirs, files in os.walk(LOCAL):
|
|||||||
for f in files:
|
for f in files:
|
||||||
local_files.append(os.path.relpath(os.path.join(root, f), LOCAL))
|
local_files.append(os.path.relpath(os.path.join(root, f), LOCAL))
|
||||||
|
|
||||||
if not DRYRUN:
|
remote_entries = rlist(REMOTE) if not DRYRUN else []
|
||||||
remote_entries = rlist(REMOTE)
|
remote_files = {r for r, d in remote_entries if not d}
|
||||||
remote_files = {r for r, d in remote_entries if not d}
|
|
||||||
|
|
||||||
|
uploaded = skipped = 0
|
||||||
for rel in sorted(local_files):
|
for rel in sorted(local_files):
|
||||||
if DRYRUN:
|
|
||||||
print(" +", rel)
|
|
||||||
continue
|
|
||||||
lpath = os.path.join(LOCAL, rel)
|
lpath = os.path.join(LOCAL, rel)
|
||||||
rpath = os.path.join(REMOTE, rel)
|
rpath = os.path.join(REMOTE, rel)
|
||||||
rdir = os.path.dirname(rpath)
|
rdir = os.path.dirname(rpath)
|
||||||
@@ -85,18 +108,29 @@ for rel in sorted(local_files):
|
|||||||
sftp.mkdir(cur)
|
sftp.mkdir(cur)
|
||||||
except IOError:
|
except IOError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
rstat = sftp.stat(rpath)
|
||||||
|
except IOError:
|
||||||
|
rstat = None
|
||||||
|
if rstat is not None and same_file(os.stat(lpath), rstat):
|
||||||
|
skipped += 1
|
||||||
|
print(f" = {rel}")
|
||||||
|
continue
|
||||||
|
if not DRYRUN:
|
||||||
sftp.put(lpath, rpath)
|
sftp.put(lpath, rpath)
|
||||||
print(" +", rel)
|
uploaded += 1
|
||||||
|
print(f" + {rel}")
|
||||||
|
|
||||||
if not DRYRUN:
|
if not DRYRUN:
|
||||||
to_delete = remote_files - set(local_files)
|
to_delete = remote_files - set(local_files)
|
||||||
for rel in sorted(to_delete):
|
for rel in sorted(to_delete):
|
||||||
try:
|
try:
|
||||||
sftp.remove(os.path.join(REMOTE, rel))
|
sftp.remove(os.path.join(REMOTE, rel))
|
||||||
print(" -", rel)
|
print(f" - {rel}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(" !", rel, e)
|
print(f" ! {rel} {e}")
|
||||||
|
|
||||||
sftp.close()
|
sftp.close()
|
||||||
t.close()
|
t.close()
|
||||||
print("Готово." if not DRYRUN else "DRY-RUN завершён: показаны изменения без заливки.")
|
print(f"Готово: {uploaded} залито, {skipped} пропущено."
|
||||||
|
if not DRYRUN else f"DRY-RUN завершён: будет залито {uploaded}, пропущено {skipped}.")
|
||||||
Reference in New Issue
Block a user