serverus: app_ports.py проверяет опубликованные порты приложения до изменений - по docker compose config (переменные из .env подставлены) против слушающих сокетов сервера и портов контейнеров других приложений. Занятый порт больше не роняет up после замены файлов. Конвенция: только мост; не-HTTP сервисы публикуют свои порты, 80 и 443 - у caddy; bind-mount только своих файлов из data/<сервис>/ с :ro. Описано, что упавший up после синхронизации файлы не откатывает. App: uploads.check предупреждает о 80/443 вместо любых портов и о bind-mount из папки приложения вне data/ или без :ro. docs/apps/rustdesk: приложение (hbbs, hbbr, ключ генерируем сами), genkey.py и check.py - проверка сервера по протоколу RustDesk снаружи. Проверено на стенде Debian 13: деплой, повторный деплой, отказ второго приложения на тех же портах до изменений, перезагрузка сервера. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
9.0 KiB
Python
215 lines
9.0 KiB
Python
"""Разбор присланных боту файлов приложения: архивы, слияние версий, разница и проверка по конвенции.
|
|
|
|
Корень архива - корень приложения. Мусор архиваторов пропускается, пути наружу и ссылки - отказ.
|
|
Конвенция приложения (serverus.DockerHost): docker-compose.yaml, .env, caddy.conf, data/<сервис>/.
|
|
"""
|
|
import io
|
|
import posixpath
|
|
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')
|
|
# порты ingress: остальные приложения публикуют только свои, не-HTTP порты
|
|
INGRESS_PORTS = {80, 443}
|
|
|
|
|
|
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 {}
|
|
taken = sorted(INGRESS_PORTS & {port for entry in _list(service.get('ports')) for port in _host_ports(entry)})
|
|
if taken:
|
|
warnings.append(f'{name}: публикует {", ".join(map(str, taken))}, их занимает caddy, веб-морда - через caddy.conf')
|
|
for source, read_only in _app_mounts(service):
|
|
if not source.startswith(f'{DATA_DIR}/'):
|
|
warnings.append(f'{name}: монтирует {source}, из папки приложения монтируется только {DATA_DIR}/<сервис>/, '
|
|
'состояние - в named volume')
|
|
elif not read_only:
|
|
warnings.append(f'{name}: {source} без :ro, то, что сервис туда запишет, удалит следующая заливка')
|
|
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
|
|
|
|
|
|
def _list(value) -> list:
|
|
return value if isinstance(value, list) else []
|
|
|
|
|
|
def _host_ports(entry) -> set[int]:
|
|
"""Порты хоста из записи ports: "[ip:]хост:контейнер[/протокол]" или длинная форма. Без порта хоста - пусто."""
|
|
if isinstance(entry, dict):
|
|
published = entry.get('published')
|
|
return _port_range(str(published)) if published is not None else set()
|
|
text = str(entry).split('/')[0]
|
|
if ':' not in text:
|
|
return set()
|
|
return _port_range(text.rsplit(':', 1)[0].rsplit(':', 1)[-1])
|
|
|
|
|
|
def _port_range(text: str) -> set[int]:
|
|
"""80 или 8000-8010; переменные и мусор - пусто: порты после подстановки переменных проверяет сервер при деплое."""
|
|
first, _, last = text.partition('-')
|
|
if not first.isdigit() or (last and not last.isdigit()):
|
|
return set()
|
|
return set(range(int(first), int(last or first) + 1))
|
|
|
|
|
|
def _app_mounts(service: dict):
|
|
"""Bind-mount из папки приложения: (путь относительно приложения, только чтение)."""
|
|
for volume in _list(service.get('volumes')):
|
|
if isinstance(volume, dict):
|
|
if volume.get('type') != 'bind':
|
|
continue
|
|
source, read_only = str(volume.get('source', '')), bool(volume.get('read_only'))
|
|
else:
|
|
parts = str(volume).split(':')
|
|
if len(parts) < 2:
|
|
continue
|
|
source, read_only = parts[0], len(parts) > 2 and 'ro' in parts[2].split(',')
|
|
if source.startswith('.'):
|
|
yield posixpath.normpath(source), read_only
|