feat: публикация портов не-HTTP сервисами, проверка портов до деплоя, пример RustDesk
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>
This commit is contained in:
1 parent
a8d1f286d1
commit
fd4b05097a
16 files changed
+526
-13
No files matched your search
+51
-2
@@ -4,6 +4,7 @@
|
||||
Конвенция приложения (serverus.DockerHost): docker-compose.yaml, .env, caddy.conf, data/<сервис>/.
|
||||
"""
|
||||
import io
|
||||
import posixpath
|
||||
import stat
|
||||
import tarfile
|
||||
import zipfile
|
||||
@@ -21,6 +22,8 @@ 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):
|
||||
@@ -151,8 +154,15 @@ def check(files: dict[str, bytes]) -> tuple[list[str], list[str]]:
|
||||
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')
|
||||
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}
|
||||
@@ -163,3 +173,42 @@ def check(files: dict[str, bytes]) -> tuple[list[str], list[str]]:
|
||||
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
|
||||
Reference in new issue
Block a user