"""Разбор присланных боту файлов приложения: архивы, слияние версий, разница и проверка по конвенции. Корень архива - корень приложения. Мусор архиваторов пропускается, пути наружу и ссылки - отказ. Конвенция приложения (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