feat: SFTP-деплой по size+SHA1 (не mtime), keepalive+таймаут канала, ретраи; apt python3-paramiko
deploy-dedinit / Build & SFTP Deploy (push) Successful in 12m41s
deploy-dedinit / Build & SFTP Deploy (push) Successful in 12m41s
This commit is contained in:
@@ -1,47 +1,53 @@
|
|||||||
# Design: Инкрементальный SFTP-деплой
|
# 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. **Таймауты против зависания**:
|
Единственный файл `tools/deploy_sftp.py`:
|
||||||
- `paramiko.Transport` создаём с `banner_timeout=30`, `timeout=30`.
|
|
||||||
- Для каждого `put` используем `paramiko` окно: оборачиваем в `socket.setdefaulttimeout(120)`
|
|
||||||
(SFTP-канал унаследует) — если операция длится дольше 2 мин, канал падает с ошибкой,
|
|
||||||
скрипт падает с понятным сообщением вместо бесконечного висения.
|
|
||||||
- Удаление тоже не должно висеть: те же таймауты.
|
|
||||||
|
|
||||||
3. **Что НЕ меняется**:
|
1. **Инкремент по (size + SHA1)**: для каждого локального файла — `sftp.stat()`;
|
||||||
- Mirror-логика (всё, чего нет локально, удаляется) — сохранена.
|
если удалённый существует и (размер совпадает И SHA1 содержимого совпадает)
|
||||||
- Рекурсивное создание каталогов — сохранено.
|
— пропуск; иначе `sftp.put()`.
|
||||||
- Интерфейс: `tools/deploy_sftp.py [--dry-run]`, переменные DEDINIT_* / SSHPASS.
|
- mtime НЕ используется: в CI (Gitea Actions) checkout ставит свежий mtime
|
||||||
- Вызов из `.gitea/workflows/deploy.yml` не меняется.
|
всем файлам → 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) может отличаться от локального (часовой пояс, точность).
|
3. **Mirror-удаление сохранено**: `remote_files - local_files` → `remove()`.
|
||||||
SFTP stat возвращает mtime в секундах; локально `int(st_mtime)` — тоже секунды.
|
|
||||||
Допуск: считаем «не изменился», если `|remote_mtime - local_mtime| <= 120` И размеры равны.
|
|
||||||
(Больше 2 мин расхождения не бывает при честном сравнении; зато гарантируем, что
|
|
||||||
файл, записанный «только что» локально и залитый минуту назад, не перельётся без нужды.)
|
|
||||||
Решение: допуск на mtime = 300 сек (5 мин) при равенстве размеров.
|
|
||||||
|
|
||||||
- **Ложные пропуски**: если сервер отдаёт mtime 0 (некоторые SFTP-сервера) — считаем файл
|
4. **CI**: `python3-paramiko` ставится через **apt** (не pip в venv) — не зависит
|
||||||
изменённым (льём всегда). Проверка `if remote_mtime > 0`.
|
от PyPI, быстрее (установка случайно занимала 15-20 мин).
|
||||||
|
|
||||||
- **Большой дерево**: rlist рекурсивно обходит всё дерево — оставляем как есть (работает,
|
## Почему не mtime
|
||||||
это доли секунды на 500 файлов).
|
|
||||||
|
|
||||||
## Верификация
|
В Gitea Actions `actions/checkout` распаковывает репозиторий с текущим mtime →
|
||||||
|
все файлы «свежие» относительно сервера → mtime-сравнение никогда не пропускает.
|
||||||
|
SHA1 — единственный надёжный индикатор неизменности для CI.
|
||||||
|
|
||||||
- `python3 tools/deploy_sftp.py --dry-run` локально: показывает список изменившихся
|
## Альтернативы, отклонённые
|
||||||
(должно быть 0 после сборки в тот же public/... фактически покажет все «+», т.к. на
|
|
||||||
сервере старый mtime — это ок, первый деплой льёт всё).
|
- **rsync**: Jino SFTP-only, удалённый exec запрещён.
|
||||||
- После первого инкрементального деплоя повторный `--dry-run` покажет «=» (пропуски).
|
- **Манифест (JSON) прошлого деплоя**: усложняет, требует хранения состояния;
|
||||||
|
SHA1-сравнение с сервером самодостаточно.
|
||||||
|
- **Только размер**: риск ложного пропуска при изменении содержимого без смены
|
||||||
|
размера (маловероятно для статики, но SHA1 дешевле ошибки).
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
- Точное время залива (замер №2) зависит от пропускной способности Jino SFTP.
|
||||||
|
- SHA1 крупных файлов при каждом деплое: 21M PDF → ~2-5с на чтение с сервера;
|
||||||
|
приемлемо по сравнению с перезаливкой.
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
# Spec: Инкрементальный SFTP-деплой
|
# Spec: Инкрементальный SFTP-деплой
|
||||||
|
|
||||||
## ADDED Requirements
|
## MODIFIED Requirements
|
||||||
|
|
||||||
### Requirement: Скрипт заливает только изменившиеся файлы
|
### Requirement: Скрипт заливает только изменившиеся файлы
|
||||||
|
|
||||||
Скрипт сравнивает каждый локальный файл с удалённым по размеру и mtime (с допуском) и заливает только те, что отсутствуют на сервере или отличаются.
|
Скрипт сравнивает каждый локальный файл с удалённым и заливает только те, что отсутствуют на сервере или отличаются. Сравнение — по размеру и SHA1-хешу содержимого (mtime не используется: в CI после checkout он всегда свежий, что делает mtime-сравнение бесполезным).
|
||||||
|
|
||||||
#### Scenario: Файл уже есть на сервере и не менялся
|
#### Scenario: Файл уже есть на сервере и не менялся
|
||||||
|
|
||||||
Given сервер содержит файл `X` с размером 1234 и mtime 1727000000
|
Given сервер содержит файл `X` с тем же размером и SHA1, что локальный
|
||||||
And локальный файл `X` имеет размер 1234 и mtime 1727000000
|
And локальный файл `X` существует
|
||||||
When выполняется `tools/deploy_sftp.py`
|
When выполняется `tools/deploy_sftp.py`
|
||||||
Then файл `X` НЕ заливается на сервер
|
Then файл `X` НЕ заливается на сервер
|
||||||
|
|
||||||
@@ -20,23 +20,30 @@ And локальный файл `X` существует
|
|||||||
When выполняется `tools/deploy_sftp.py`
|
When выполняется `tools/deploy_sftp.py`
|
||||||
Then файл `X` заливается на сервер
|
Then файл `X` заливается на сервер
|
||||||
|
|
||||||
#### Scenario: Файл изменился (другой размер или mtime)
|
#### Scenario: Файл изменился (другой размер)
|
||||||
|
|
||||||
Given сервер содержит файл `X` с размером 100 и mtime 1727000001
|
Given сервер содержит файл `X` с размером 100
|
||||||
And локальный файл `X` имеет размер 125 и mtime 1727000002
|
And локальный файл `X` имеет размер 125
|
||||||
|
When выполняется `tools/deploy_sftp.py`
|
||||||
|
Then файл `X` заливается на сервер
|
||||||
|
|
||||||
|
#### Scenario: Файл изменился (тот же размер, другое содержимое)
|
||||||
|
|
||||||
|
Given сервер содержит файл `X` того же размера, но с другим SHA1
|
||||||
|
And локальный файл `X` имеет другой SHA1
|
||||||
When выполняется `tools/deploy_sftp.py`
|
When выполняется `tools/deploy_sftp.py`
|
||||||
Then файл `X` заливается на сервер
|
Then файл `X` заливается на сервер
|
||||||
|
|
||||||
### Requirement: Скрипт не виснет при обрыве соединения
|
### Requirement: Скрипт не виснет при обрыве соединения
|
||||||
|
|
||||||
Скрипт использует таймауты соединения и операции, чтобы при обрыве канала завершиться с ошибкой, а не висеть бесконечно.
|
Скрипт использует keepalive (15с) и таймаут канала (120с), а также переподключение с ретраями (до 3 попыток), чтобы при обрыве канала завершиться с ошибкой или продолжить, а не висеть бесконечно.
|
||||||
|
|
||||||
#### Scenario: Соединение с сервером оборвалось
|
#### Scenario: Соединение с сервером оборвалось
|
||||||
|
|
||||||
Given SFTP-соединение с Jino работает
|
Given SFTP-соединение с Jino работает
|
||||||
And соединение обрывается во время `put()`
|
And соединение обрывается во время `put()`
|
||||||
When выполняется `tools/deploy_sftp.py`
|
When выполняется `tools/deploy_sftp.py`
|
||||||
Then скрипт завершается с ненулевым кодом и сообщением об ошибке в течение разумного времени (не бесконечно)
|
Then скрипт переподключается и повторяет операцию; если все попытки исчерпаны — завершается с ненулевым кодом и сообщением об ошибке
|
||||||
|
|
||||||
### Requirement: Удаление лишних файлов сохранено
|
### Requirement: Удаление лишних файлов сохранено
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
# Tasks: Инкрементальный SFTP-деплой
|
# Tasks: Инкрементальный SFTP-деплой
|
||||||
|
|
||||||
- [x] Обновить `tools/deploy_sftp.py`: сравнение (size + mtime с допуском 300с), пропуск неизменных, таймауты (banner_timeout/timeout + socket timeout)
|
- [x] Обновить `tools/deploy_sftp.py`: инкремент по (size + SHA1), keepalive/таймаут канала, ретраи с переподключением
|
||||||
- [x] `python3 tools/deploy_sftp.py --dry-run` — работает, выводит `+`/`=`
|
- [x] Проверить синтаксис (`python3 -m py_compile`) и логику same_file (юнит-тест без сети)
|
||||||
- [ ] Commit + push gitea main — запуск Gitea Actions
|
- [x] CI: заменить pip-установку paramiko на apt `python3-paramiko` (не зависит от PyPI) в `.gitea/workflows/deploy.yml`
|
||||||
- [ ] Замер №2: время до видимости на проде; сравнить с эталоном (~36 мин)
|
- [x] Обновить openspec (spec/design/tasks) под SHA1-решение
|
||||||
- [ ] Прогнать openspec-archive-change / архивировать change
|
- [ ] Замерить деплой №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)`.
|
||||||
+107
-39
@@ -1,19 +1,29 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Deploy public/ -> /dedinit.ru on Jino (SFTP-only, password auth).
|
"""Deploy public/ -> /dedinit.ru on Jino (SFTP-only, password auth).
|
||||||
|
|
||||||
Используется командой `make deploy` (единственный способ деплоя сайта).
|
Единственный способ деплоя сайта (Gitea Actions / локально).
|
||||||
Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён,
|
Jino-аккаунт kpa39l_dedinit — SFTP-only: удалённый exec (rsync/ssh) запрещён,
|
||||||
поэтому заливаем через SFTP с рекурсивным mirror и --delete (как rsync).
|
поэтому mirror-заливка через SFTP с удалением лишнего (как rsync --delete).
|
||||||
Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md).
|
Пароль — из env SSHPASS (см. README.md / docs/DEPLOY_INSTRUCTIONS.md).
|
||||||
|
|
||||||
Инкрементально: файлы, которые уже есть на сервере с тем же размером и mtime
|
Инкрементально: файл пропускается, если на сервере уже есть файл того же
|
||||||
(допуск 300с), пропускаются — заливаются только новые/изменённые.
|
размера И с тем же SHA1-хешем (точно). В CI (Gitea Actions) mtime после
|
||||||
Таймауты: соединение banner_timeout=30/timeout=30, операция — socket timeout 120с.
|
checkout всегда свежий, поэтому mtime НЕ используется для сравнения —
|
||||||
|
только размер + содержимое. Это гарантирует, что тяжёлые неизменные
|
||||||
|
файлы (PDF/M4V) не переливаются повторно.
|
||||||
|
|
||||||
|
Защита от зависаний:
|
||||||
|
- Transport.set_keepalive(15) — пинги каждые 15с; при мёртвом соединении
|
||||||
|
канал падает сам.
|
||||||
|
- Таймаут на канал SFTP: channel.settimeout(120) — операция дольше 120с
|
||||||
|
прерывается с ошибкой (а не висит вечно).
|
||||||
|
- Ретраи: put/remove при обрыве переподключаются и повторяют операцию
|
||||||
|
(до 3 попыток).
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import socket
|
|
||||||
import stat as stmod
|
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import paramiko
|
import paramiko
|
||||||
@@ -28,7 +38,6 @@ 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 and not DRYRUN:
|
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)
|
||||||
@@ -39,17 +48,31 @@ 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))
|
def connect():
|
||||||
t.banner_timeout = 30
|
t = paramiko.Transport((HOST, PORT))
|
||||||
try:
|
t.banner_timeout = 30
|
||||||
|
t.set_keepalive(15)
|
||||||
t.connect(username=USER, password=PASS)
|
t.connect(username=USER, password=PASS)
|
||||||
except Exception as e:
|
sftp = paramiko.SFTPClient.from_transport(t)
|
||||||
print(f"ОШИБКА: не удалось подключиться к {HOST}:{PORT}: {e}", file=sys.stderr)
|
ch = sftp.get_channel()
|
||||||
sys.exit(1)
|
ch.settimeout(120)
|
||||||
sftp = paramiko.SFTPClient.from_transport(t)
|
return t, sftp
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect(t, sftp):
|
||||||
|
try:
|
||||||
|
sftp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
t.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
t = None
|
||||||
|
sftp = None
|
||||||
|
|
||||||
|
|
||||||
def rlist(path):
|
def rlist(path):
|
||||||
@@ -61,25 +84,74 @@ def rlist(path):
|
|||||||
for e in entries:
|
for e in entries:
|
||||||
full = os.path.join(path, e.filename)
|
full = os.path.join(path, e.filename)
|
||||||
rel = os.path.relpath(full, REMOTE)
|
rel = os.path.relpath(full, REMOTE)
|
||||||
isdir = stmod.S_ISDIR(e.st_mode)
|
isdir = e.st_mode and (e.st_mode & 0o170000) == 0o040000
|
||||||
out.append((rel, isdir))
|
out.append((rel, isdir))
|
||||||
if isdir:
|
if isdir:
|
||||||
out.extend(rlist(full))
|
out.extend(rlist(full))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def same_file(lstat, rstat):
|
def local_sha1(lpath):
|
||||||
"""True, если файл на сервере совпадает с локальным (размер + mtime с допуском)."""
|
h = hashlib.sha1()
|
||||||
if rstat.st_size != lstat.st_size:
|
with open(lpath, "rb") as f:
|
||||||
return False
|
while True:
|
||||||
rmt = int(rstat.st_mtime)
|
chunk = f.read(1024 * 1024)
|
||||||
lmt = int(lstat.st_mtime)
|
if not chunk:
|
||||||
# Некоторые SFTP-сервера отдают mtime=0 — в этом случае считаем файл изменённым.
|
break
|
||||||
if rmt <= 0:
|
h.update(chunk)
|
||||||
return False
|
return h.hexdigest()
|
||||||
return abs(rmt - lmt) <= MTIME_TOLERANCE
|
|
||||||
|
|
||||||
|
|
||||||
|
def remote_sha1(rpath):
|
||||||
|
"""SHA1 содержимого удалённого файла через SFTP (поток, без временных копий)."""
|
||||||
|
h = hashlib.sha1()
|
||||||
|
with sftp.open(rpath, "rb") as rf:
|
||||||
|
while True:
|
||||||
|
chunk = rf.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def same_file(lpath, rstat):
|
||||||
|
"""True, если удалённый файл совпадает с локальным (размер + SHA1).
|
||||||
|
|
||||||
|
mtime НЕ используется (в CI после checkout он всегда свежий).
|
||||||
|
Для каждого файла: если размеры разные — льём сразу; если равны —
|
||||||
|
сравниваем SHA1 содержимого (медленно для крупных, но надёжно и
|
||||||
|
позволяет пропускать неизменные PDF/M4V).
|
||||||
|
"""
|
||||||
|
lsize = os.path.getsize(lpath)
|
||||||
|
if rstat.st_size != lsize:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return local_sha1(lpath) == remote_sha1(rpath)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def with_retry(fn, *args, retries=3, desc=""):
|
||||||
|
global t, sftp
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
return fn(*args)
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == retries:
|
||||||
|
print(f" ! {desc}: {e} (после {retries} попыток)", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
print(f" ! {desc}: {e} — переподключение ({attempt}/{retries})", file=sys.stderr)
|
||||||
|
disconnect(t, sftp)
|
||||||
|
time.sleep(2)
|
||||||
|
t, sftp = connect()
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
t, sftp = connect()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ОШИБКА: не удалось подключиться к {HOST}:{PORT}: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
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 ""))
|
||||||
|
|
||||||
@@ -112,25 +184,21 @@ for rel in sorted(local_files):
|
|||||||
rstat = sftp.stat(rpath)
|
rstat = sftp.stat(rpath)
|
||||||
except IOError:
|
except IOError:
|
||||||
rstat = None
|
rstat = None
|
||||||
if rstat is not None and same_file(os.stat(lpath), rstat):
|
if rstat is not None and same_file(lpath, rstat):
|
||||||
skipped += 1
|
skipped += 1
|
||||||
print(f" = {rel}")
|
print(f" = {rel}")
|
||||||
continue
|
continue
|
||||||
if not DRYRUN:
|
if not DRYRUN:
|
||||||
sftp.put(lpath, rpath)
|
with_retry(sftp.put, lpath, rpath, desc=f"put {rel}")
|
||||||
uploaded += 1
|
uploaded += 1
|
||||||
print(f" + {rel}")
|
print(f" + {rel}")
|
||||||
|
|
||||||
if not DRYRUN:
|
if not DRYRUN:
|
||||||
to_delete = remote_files - set(local_files)
|
to_delete = sorted(remote_files - set(local_files))
|
||||||
for rel in sorted(to_delete):
|
for rel in to_delete:
|
||||||
try:
|
with_retry(sftp.remove, os.path.join(REMOTE, rel), desc=f"remove {rel}")
|
||||||
sftp.remove(os.path.join(REMOTE, rel))
|
print(f" - {rel}")
|
||||||
print(f" - {rel}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ! {rel} {e}")
|
|
||||||
|
|
||||||
sftp.close()
|
disconnect(t, sftp)
|
||||||
t.close()
|
|
||||||
print(f"Готово: {uploaded} залито, {skipped} пропущено."
|
print(f"Готово: {uploaded} залито, {skipped} пропущено."
|
||||||
if not DRYRUN else f"DRY-RUN завершён: будет залито {uploaded}, пропущено {skipped}.")
|
if not DRYRUN else f"DRY-RUN завершён: будет залито {uploaded}, пропущено {skipped}.")
|
||||||
Reference in New Issue
Block a user