From f7be5a87b4050c3613656db1d8f0c72fdfb2d4f3 Mon Sep 17 00:00:00 2001 From: Storozhenko Evgeny Date: Tue, 22 Sep 2026 18:40:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D0=BA=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20SFTP-?= =?UTF-8?q?=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20(=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8=D0=B2?= =?UTF-8?q?=D1=88=D0=B8=D0=B5=D1=81=D1=8F=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B?= =?UTF-8?q?,=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B0=D1=83=D1=82=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- .../changes/archive-by-year-month/tasks.md | 2 +- .../deploy-sftp-incremental/.openspec.yaml | 2 + .../changes/deploy-sftp-incremental/design.md | 47 +++++++++++++++ .../deploy-sftp-incremental/proposal.md | 30 ++++++++++ .../specs/deploy-sftp-incremental/spec.md | 50 ++++++++++++++++ .../changes/deploy-sftp-incremental/tasks.md | 7 +++ tools/deploy_sftp.py | 60 +++++++++++++++---- 8 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/deploy-sftp-incremental/.openspec.yaml create mode 100644 openspec/changes/deploy-sftp-incremental/design.md create mode 100644 openspec/changes/deploy-sftp-incremental/proposal.md create mode 100644 openspec/changes/deploy-sftp-incremental/specs/deploy-sftp-incremental/spec.md create mode 100644 openspec/changes/deploy-sftp-incremental/tasks.md diff --git a/.gitignore b/.gitignore index 554dbb5..3c9e1b7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ static/images/favicon/README.MD static/images/favicon/dwarffortress* static/images/favicon/android-chrome* static/images/favicon/site.webmanifest -static/images/favicon/apple-touch-icon.png \ No newline at end of file +static/images/favicon/apple-touch-icon.png +__pycache__/ +*.pyc diff --git a/openspec/changes/archive-by-year-month/tasks.md b/openspec/changes/archive-by-year-month/tasks.md index 765fa4a..4e0b976 100644 --- a/openspec/changes/archive-by-year-month/tasks.md +++ b/openspec/changes/archive-by-year-month/tasks.md @@ -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 после деплоя) \ No newline at end of file diff --git a/openspec/changes/deploy-sftp-incremental/.openspec.yaml b/openspec/changes/deploy-sftp-incremental/.openspec.yaml new file mode 100644 index 0000000..a9eca53 --- /dev/null +++ b/openspec/changes/deploy-sftp-incremental/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 \ No newline at end of file diff --git a/openspec/changes/deploy-sftp-incremental/design.md b/openspec/changes/deploy-sftp-incremental/design.md new file mode 100644 index 0000000..469eb63 --- /dev/null +++ b/openspec/changes/deploy-sftp-incremental/design.md @@ -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` покажет «=» (пропуски). \ No newline at end of file diff --git a/openspec/changes/deploy-sftp-incremental/proposal.md b/openspec/changes/deploy-sftp-incremental/proposal.md new file mode 100644 index 0000000..410b45c --- /dev/null +++ b/openspec/changes/deploy-sftp-incremental/proposal.md @@ -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 запрещён). \ 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 new file mode 100644 index 0000000..a722162 --- /dev/null +++ b/openspec/changes/deploy-sftp-incremental/specs/deploy-sftp-incremental/spec.md @@ -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` удаляется с сервера \ No newline at end of file diff --git a/openspec/changes/deploy-sftp-incremental/tasks.md b/openspec/changes/deploy-sftp-incremental/tasks.md new file mode 100644 index 0000000..e64dc86 --- /dev/null +++ b/openspec/changes/deploy-sftp-incremental/tasks.md @@ -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 \ No newline at end of file diff --git a/tools/deploy_sftp.py b/tools/deploy_sftp.py index e3462d2..7463d7e 100644 --- a/tools/deploy_sftp.py +++ b/tools/deploy_sftp.py @@ -5,8 +5,13 @@ Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён, поэтому заливаем через SFTP с рекурсивным mirror и --delete (как rsync). Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md). + +Инкрементально: файлы, которые уже есть на сервере с тем же размером и mtime +(допуск 300с), пропускаются — заливаются только новые/изменённые. +Таймауты: соединение banner_timeout=30/timeout=30, операция — socket timeout 120с. """ import os +import socket import stat as stmod import sys @@ -23,8 +28,9 @@ 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: +if not PASS and not DRYRUN: print("ОШИБКА: переменная SSHPASS (пароль SFTP) не задана. См. README.md / docs/DEPLOY_INSTRUCTIONS.md", file=sys.stderr) sys.exit(1) @@ -33,8 +39,16 @@ 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.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) @@ -54,6 +68,18 @@ def rlist(path): 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}/" + (" (DRY-RUN)" if DRYRUN else "")) @@ -62,14 +88,11 @@ for root, dirs, files in os.walk(LOCAL): for f in files: local_files.append(os.path.relpath(os.path.join(root, f), LOCAL)) -if not DRYRUN: - remote_entries = rlist(REMOTE) - remote_files = {r for r, d in remote_entries if not d} +remote_entries = rlist(REMOTE) if not DRYRUN else [] +remote_files = {r for r, d in remote_entries if not d} +uploaded = skipped = 0 for rel in sorted(local_files): - if DRYRUN: - print(" +", rel) - continue lpath = os.path.join(LOCAL, rel) rpath = os.path.join(REMOTE, rel) rdir = os.path.dirname(rpath) @@ -85,18 +108,29 @@ for rel in sorted(local_files): sftp.mkdir(cur) except IOError: pass - sftp.put(lpath, rpath) - print(" +", rel) + 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) + 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(" -", rel) + print(f" - {rel}") except Exception as e: - print(" !", rel, e) + print(f" ! {rel} {e}") sftp.close() t.close() -print("Готово." if not DRYRUN else "DRY-RUN завершён: показаны изменения без заливки.") \ No newline at end of file +print(f"Готово: {uploaded} залито, {skipped} пропущено." + if not DRYRUN else f"DRY-RUN завершён: будет залито {uploaded}, пропущено {skipped}.") \ No newline at end of file