Files
mypointvpn/src/serverus/playbooks/files/app_sync.py
T
protokeyandClaude Opus 5.5 a8d1f286d1 feat: docker-серверы - приложения в ~/apps под управлением бота
serverus: DockerHost - установка Docker и ingress Caddy, deploy приложения
с проверкой compose и caddy.conf до изменений и пересозданием только
изменившихся сервисов, status, control, logs, remove. nftables
перезапускается только при изменении конфига, Docker следом
восстанавливает свои правила; release восстанавливает конфиг Debian
вместо остановки nftables.

Telegram: прием документов и отправка файлов, раздача файлов своего
Bot API сервера через nginx (telegram-files).

App: общая модель Managed для Point и Platform, DockerApp и версии
файлов AppVersion, разбор архивов (uploads), сценарии платформы
(platforms), задачи и меню docker-серверов в боте.

Проверено на стенде Debian 13 с local_certs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:27:05 +04:00

77 lines
2.7 KiB
Python

#!/usr/bin/env python3
"""Приводит папку приложения к присланной копии: app_sync.py <stage> <app_dir>.
Файлы переписываются по месту (тот же inode): смонтированный в контейнер файл остается тем же файлом.
Лишние файлы удаляются, пустые папки за ними тоже.
Печатает JSON: {"changed": [...], "removed": [...], "created": true, если папки приложения не было}.
"""
import json
import os
import shutil
import stat
import sys
def files(root):
result = set()
for directory, _, names in os.walk(root):
for name in names:
result.add(os.path.relpath(os.path.join(directory, name), root))
return result
def same(src, dst):
if not os.path.isfile(dst) or os.path.islink(dst):
return False
if stat.S_IMODE(os.stat(src).st_mode) != stat.S_IMODE(os.stat(dst).st_mode):
return False
with open(src, 'rb') as a, open(dst, 'rb') as b:
return a.read() == b.read()
def clear(path):
"""Убирает то, что мешает положить файл: папку или ссылку на его месте."""
if os.path.islink(path) or (os.path.exists(path) and not os.path.isdir(path)):
os.remove(path)
elif os.path.isdir(path):
shutil.rmtree(path)
def ensure_dir(app_dir, relative):
"""Создает папки до файла; файл или ссылка на месте папки удаляется."""
current = app_dir
for part in relative.split(os.sep)[:-1]:
current = os.path.join(current, part)
if os.path.islink(current) or (os.path.exists(current) and not os.path.isdir(current)):
os.remove(current)
os.makedirs(current, exist_ok=True)
def main(stage, app_dir):
created = not os.path.isdir(app_dir)
os.makedirs(app_dir, mode=0o700, exist_ok=True)
new, old = files(stage), files(app_dir)
changed, removed = [], []
for relative in sorted(old - new):
os.remove(os.path.join(app_dir, relative))
removed.append(relative)
for relative in sorted(new):
src, dst = os.path.join(stage, relative), os.path.join(app_dir, relative)
if same(src, dst):
continue
ensure_dir(app_dir, relative)
if os.path.isdir(dst) or os.path.islink(dst):
clear(dst)
with open(src, 'rb') as source, open(dst, 'wb') as target:
shutil.copyfileobj(source, target)
os.chmod(dst, stat.S_IMODE(os.stat(src).st_mode))
changed.append(relative)
for directory, _, _ in os.walk(app_dir, topdown=False):
if directory != app_dir and not os.listdir(directory):
os.rmdir(directory)
print(json.dumps({'changed': changed, 'removed': removed, 'created': created}))
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])