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>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-09-29 20:27:05 +04:00
1 parent 65c19ea1cd
commit a8d1f286d1
42 files changed
+3509 -232

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
"""Разбор присланных боту файлов приложения: архивы, слияние версий, разница и проверка по конвенции.
Корень архива - корень приложения. Мусор архиваторов пропускается, пути наружу и ссылки - отказ.
Конвенция приложения (serverus.DockerHost): docker-compose.yaml, .env, caddy.conf, data/<сервис>/.
"""
import io
import stat
import tarfile
import zipfile
from dataclasses import dataclass
from pathlib import PurePosixPath
import yaml
from serverus import CADDY_FILE, COMPOSE_FILE, DATA_DIR, ENV_FILE
ARCHIVE_MAX = 5 * 1024 * 1024
UNPACKED_MAX = 20 * 1024 * 1024
FILES_MAX = 500
JUNK_NAMES = {'.DS_Store', 'Thumbs.db', 'desktop.ini'}
JUNK_DIRS = {'__MACOSX'}
ARCHIVE_SUFFIXES = ('.zip', '.tar', '.tar.gz', '.tgz')
# так compose-файл могли назвать по привычке
OTHER_COMPOSE_NAMES = ('docker-compose.yml', 'compose.yaml', 'compose.yml')
class UploadError(ValueError):
pass
@dataclass(frozen=True)
class Diff:
added: list[str]
changed: list[str]
removed: list[str]
def __bool__(self):
return bool(self.added or self.changed or self.removed)
def is_archive(filename: str) -> bool:
return filename.lower().endswith(ARCHIVE_SUFFIXES)
def read(filename: str, content: bytes) -> dict[str, bytes]:
"""Файлы из присланного документа: {путь: содержимое}. Не архив - один файл в корне приложения.
UploadError - архив не читается, выходит за лимиты или содержит ссылки и пути за пределы приложения.
"""
if len(content) > ARCHIVE_MAX:
raise UploadError(f'файл больше {ARCHIVE_MAX // 1024 // 1024} Мб')
lower = filename.lower()
try:
if lower.endswith('.zip'):
files = _read_zip(content)
elif is_archive(lower):
files = _read_tar(content)
else:
files = {_clean_path(filename): content}
except (zipfile.BadZipFile, tarfile.TarError, EOFError, OSError) as error:
raise UploadError(f'архив не читается: {error}') from error
if not files:
raise UploadError('в архиве нет файлов')
return files
def _read_zip(content: bytes) -> dict[str, bytes]:
files, total = {}, 0
with zipfile.ZipFile(io.BytesIO(content)) as archive:
for info in archive.infolist():
if info.is_dir():
continue
# тип файла есть не у всех архиваторов: без него это обычный файл
file_type = stat.S_IFMT(info.external_attr >> 16)
if file_type and file_type != stat.S_IFREG:
raise UploadError(f'{info.filename}: ссылки и специальные файлы не поддерживаются')
path = _clean_path(info.filename)
if _is_junk(path):
continue
# zipfile читает не больше размера из заголовка, так что лимит по заголовку честный
total = _count(files, total, info.file_size)
files[path] = archive.read(info)
return files
def _read_tar(content: bytes) -> dict[str, bytes]:
files, total = {}, 0
with tarfile.open(fileobj=io.BytesIO(content), mode='r:*') as archive:
for member in archive:
if member.isdir():
continue
if not member.isfile():
raise UploadError(f'{member.name}: ссылки и специальные файлы не поддерживаются')
path = _clean_path(member.name)
if _is_junk(path):
continue
total = _count(files, total, member.size)
files[path] = archive.extractfile(member).read()
return files
def _count(files: dict, total: int, size: int) -> int:
if len(files) >= FILES_MAX:
raise UploadError(f'больше {FILES_MAX} файлов')
total += size
if total > UNPACKED_MAX:
raise UploadError(f'распакованные файлы больше {UNPACKED_MAX // 1024 // 1024} Мб')
return total
def _clean_path(path: str) -> str:
path = path.replace('\\', '/')
while path.startswith('./'):
path = path[2:]
parts = path.split('/')
if not path or path.startswith('/') or any(part in ('', '.', '..') for part in parts):
raise UploadError(f'недопустимый путь: {path!r}')
return str(PurePosixPath(path))
def _is_junk(path: str) -> bool:
parts = path.split('/')
return parts[-1] in JUNK_NAMES or parts[-1].startswith('._') or any(part in JUNK_DIRS for part in parts)
def merge(old: dict[str, bytes], new: dict[str, bytes]) -> dict[str, bytes]:
"""Апдейт: новые файлы поверх текущих, остальные остаются."""
return {**old, **new}
def diff(old: dict[str, bytes], new: dict[str, bytes]) -> Diff:
return Diff(
added=sorted(new.keys() - old.keys()),
changed=sorted(path for path in new.keys() & old.keys() if new[path] != old[path]),
removed=sorted(old.keys() - new.keys()),
)
def check(files: dict[str, bytes]) -> tuple[list[str], list[str]]:
"""Проверка полного набора файлов приложения: (ошибки, предупреждения). С ошибками применять нельзя."""
errors, warnings = [], []
if COMPOSE_FILE not in files:
other = next((name for name in OTHER_COMPOSE_NAMES if name in files), None)
hint = f' ({other} нужно переименовать)' if other else ''
return [f'нет {COMPOSE_FILE} в корне{hint}'], warnings
try:
compose = yaml.safe_load(files[COMPOSE_FILE])
except yaml.YAMLError as error:
return [f'{COMPOSE_FILE} не разбирается: {error}'], warnings
services = compose.get('services') if isinstance(compose, dict) else None
if not isinstance(services, dict) or not services:
return [f'в {COMPOSE_FILE} нет services'], warnings
for name, service in services.items():
service = service if isinstance(service, dict) else {}
if service.get('ports'):
warnings.append(f'{name}: публикует порты, наружу должен смотреть только caddy')
if not service.get('restart'):
warnings.append(f'{name}: нет restart, после перезагрузки сервера не поднимется')
folders = {path.split('/')[1] for path in files if path.startswith(f'{DATA_DIR}/') and path.count('/') >= 2}
for folder in sorted(folders - set(map(str, services))):
warnings.append(f'{DATA_DIR}/{folder}/: нет сервиса {folder}')
known = {COMPOSE_FILE, ENV_FILE, CADDY_FILE}
stray = sorted(path for path in files if path not in known and not path.startswith(f'{DATA_DIR}/'))
if stray:
warnings.append('вне конвенции: ' + ', '.join(stray[:10]) + (' ...' if len(stray) > 10 else ''))
return errors, warnings