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

+355 -10
View File
@@ -2,10 +2,11 @@ import ipaddress
import re
import serverus
from Telegram import api as bot_api
from Telegram.interfaces import Interface, command, register, state, text
from .. import fields, services, tasks
from ..models import Credentials, Point, Server
from .. import fields, platforms, services, tasks, uploads
from ..models import AppVersion, Credentials, DockerApp, Platform, Point, Server
from . import texts
# состояния диалога
@@ -23,6 +24,17 @@ POINT_PROXY_EDIT = 'point.proxy'
POINT_KEYS = 'point.keys'
POINT_KEY_ADD = 'point.keys.add'
POINT_KEY_REVOKE = 'point.keys.revoke'
DOCKER_ADD = 'docker.add'
DOCKER_SELECT = 'docker.select'
PLATFORM = 'platform'
PLATFORM_RELEASE = 'platform.release'
PLATFORM_REINSTALL = 'platform.reinstall'
APP_NEW = 'platform.app.new'
APP = 'app'
APP_UPLOAD = 'app.upload'
APP_CONFIRM = 'app.confirm'
APP_REMOVE = 'app.remove'
SERVICE = 'app.service'
DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'}
DOMAIN_RE = re.compile(r'^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
@@ -62,10 +74,15 @@ class MyPointInterface(Interface):
def main_menu(self, ctx):
ctx.reset()
points = list(Point.objects.owned_by(ctx.user))
if points:
ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML)
else:
ctx.reply(texts.GREETING, keyboard=texts.MAIN_KEYBOARD)
platform_list = list(Platform.objects.owned_by(ctx.user))
lines = [texts.points_list(points)] if points else []
lines += [texts.platforms_list(platform_list)] if platform_list else []
ctx.reply('\n'.join(lines) or texts.GREETING, keyboard=self.main_keyboard(ctx), parse_mode=HTML)
def main_keyboard(self, ctx):
return texts.main_keyboard(
Point.objects.owned_by(ctx.user).exists(), Platform.objects.owned_by(ctx.user).exists(),
)
@text(texts.FAQ)
def faq(self, ctx):
@@ -188,7 +205,7 @@ class MyPointInterface(Interface):
)
tasks.start(tasks.install_point, point, Point.Status.INSTALLING, ctx.chat)
ctx.reset()
ctx.reply(texts.INSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.INSTALL_STARTED, keyboard=self.main_keyboard(ctx))
# управление сервером
@@ -245,7 +262,7 @@ class MyPointInterface(Interface):
return
tasks.start(tasks.release_point, point, Point.Status.RELEASING, ctx.chat)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.REINSTALL, state=POINT)
def ask_reinstall(self, ctx):
@@ -274,7 +291,7 @@ class MyPointInterface(Interface):
def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None):
tasks.start(tasks.reinstall_point, point, Point.Status.INSTALLING, ctx.chat, address, password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.NO, state=POINT_RELEASE)
@text(texts.CANCEL, state=POINT_RELEASE)
@@ -347,7 +364,7 @@ class MyPointInterface(Interface):
def start_change(self, ctx, point: Point, actor, value: str):
tasks.start(actor, point, Point.Status.UPDATING, ctx.chat, value)
ctx.reset()
ctx.reply(texts.CHANGE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.CHANGE_STARTED, keyboard=self.main_keyboard(ctx))
# ключи
@@ -419,3 +436,331 @@ class MyPointInterface(Interface):
return ctx.reply(texts.PANEL_ERROR.format(error=error))
ctx.reply(texts.KEY_REVOKED.format(name=key.name))
self.show_keys(ctx)
# docker-серверы
@text(texts.ADD_DOCKER)
def ask_docker_server(self, ctx):
ctx.reset()
self.ask(ctx, DOCKER_ADD, texts.DOCKER_CREDENTIALS_PROMPT)
@text(texts.CANCEL, state=DOCKER_ADD)
@text(texts.CANCEL, state=DOCKER_SELECT)
def cancel_docker(self, ctx):
self.main_menu(ctx)
@state(DOCKER_ADD)
def add_docker_server(self, ctx):
# в сообщении пароль root, в чате ему не место
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
if Server.objects.is_address_taken(credentials.address):
return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
platform = Platform.objects.create_platform(ctx.user, credentials)
tasks.start(tasks.install_platform, platform, Platform.Status.INSTALLING, ctx.chat)
ctx.reset()
ctx.reply(texts.DOCKER_INSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.DOCKER_SERVERS)
def ask_platform(self, ctx):
ctx.reset()
platform_list = Platform.objects.owned_by(ctx.user)
self.ask(ctx, DOCKER_SELECT, f'{texts.PLATFORM_SELECT_PROMPT}\n{texts.platforms_list(platform_list)}')
@state(DOCKER_SELECT)
def select_platform(self, ctx):
platform = Platform.objects.owned_by(ctx.user).find_by_name(ctx.text.strip())
if platform is None:
return ctx.reply(texts.PLATFORM_NOT_FOUND)
self.show_platform(ctx, platform)
@text(texts.PLATFORM_MENU)
def platform_menu(self, ctx):
"""Кнопка из уведомления: последний открытый docker-сервер или последний созданный."""
platform = self.current_platform(ctx) or Platform.objects.owned_by(ctx.user).find_last()
if platform is None:
return self.main_menu(ctx)
self.show_platform(ctx, platform)
def current_platform(self, ctx) -> Platform | None:
platform_id = ctx.data.get('platform')
return Platform.objects.owned_by(ctx.user).find_by_pk(platform_id) if platform_id else None
def show_platform(self, ctx, platform: Platform):
ctx.reset()
ctx.state = PLATFORM
ctx.data['platform'] = platform.pk
ctx.reply(texts.platform_card(platform), keyboard=texts.PLATFORM_KEYBOARD, parse_mode=HTML)
def idle_platform(self, ctx, ready: bool = True) -> Platform | None:
"""Текущий docker-сервер, если с ним можно работать. Иначе отвечает пользователю и возвращает None.
ready - сервер должен быть установлен."""
platform = self.current_platform(ctx)
if platform is None:
self.main_menu(ctx)
elif platform.is_busy:
ctx.reply(texts.POINT_BUSY.format(status=platform.get_status_display()))
return None
elif ready and platform.status != Platform.Status.READY:
ctx.reply(texts.PLATFORM_NOT_READY)
return None
return platform
@text(texts.CHECK, state=PLATFORM)
def check_platform(self, ctx):
platform = self.idle_platform(ctx)
if platform:
tasks.refresh_platform.send(platform.pk, ctx.chat.pk)
ctx.reply(texts.CHECK_STARTED)
@text(texts.RELEASE, state=PLATFORM)
def ask_platform_release(self, ctx):
if self.idle_platform(ctx, ready=False):
self.ask(ctx, PLATFORM_RELEASE, texts.PLATFORM_RELEASE_CONFIRM, [[texts.YES, texts.NO]])
@text(texts.YES, state=PLATFORM_RELEASE)
def release_platform(self, ctx):
platform = self.idle_platform(ctx, ready=False)
if platform is None:
return
tasks.start(tasks.release_platform, platform, Platform.Status.RELEASING, ctx.chat)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.REINSTALL, state=PLATFORM)
def ask_platform_reinstall(self, ctx):
if self.idle_platform(ctx, ready=False):
self.ask(ctx, PLATFORM_REINSTALL, texts.PLATFORM_REINSTALL_PROMPT, [[texts.CANCEL, texts.USE_OLD]])
@state(PLATFORM_REINSTALL)
def reinstall_platform_with_password(self, ctx):
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
platform = self.idle_platform(ctx, ready=False)
if platform is None:
return
if platform.server.address != credentials.address:
return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
self.start_platform_reinstall(ctx, platform, fields.encrypt(credentials.password))
@text(texts.USE_OLD, state=PLATFORM_REINSTALL)
def reinstall_platform_with_old_password(self, ctx):
platform = self.idle_platform(ctx, ready=False)
if platform:
self.start_platform_reinstall(ctx, platform, '')
def start_platform_reinstall(self, ctx, platform: Platform, encrypted_password: str):
tasks.start(tasks.reinstall_platform, platform, Platform.Status.INSTALLING, ctx.chat, encrypted_password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.NO, state=PLATFORM_RELEASE)
@text(texts.CANCEL, state=PLATFORM_RELEASE)
@text(texts.CANCEL, state=PLATFORM_REINSTALL)
@text(texts.CANCEL, state=APP_NEW)
def back_to_platform(self, ctx):
platform = self.current_platform(ctx)
if platform is None:
return self.main_menu(ctx)
self.show_platform(ctx, platform)
# приложения
@state(PLATFORM)
def select_app(self, ctx):
platform = self.current_platform(ctx)
if platform is None:
return self.main_menu(ctx)
app = platform.apps.find_by_name(ctx.text.strip())
if app is None:
return ctx.reply(texts.APP_NOT_FOUND)
self.show_app(ctx, app)
def current_app(self, ctx) -> DockerApp | None:
platform = self.current_platform(ctx)
app_id = ctx.data.get('app')
return platform.apps.filter(pk=app_id).first() if platform and app_id else None
def show_app(self, ctx, app: DockerApp):
ctx.state = APP
ctx.data['app'] = app.pk
ctx.data.pop('service', None)
ctx.reply(texts.app_card(app), keyboard=texts.APP_KEYBOARD, parse_mode=HTML)
@text(texts.APP_MENU)
@text(texts.CANCEL, state=APP_REMOVE)
def app_menu(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
self.show_app(ctx, app)
@text(texts.NEW_APP, state=PLATFORM)
def ask_app_name(self, ctx):
if self.idle_platform(ctx):
self.ask(ctx, APP_NEW, texts.APP_NAME_PROMPT)
@state(APP_NEW)
def create_app(self, ctx):
platform = self.idle_platform(ctx)
if platform is None:
return
try:
app = platforms.create_app(platform, ctx.text.strip())
except ValueError as error:
return ctx.reply(str(error))
ctx.data['app'] = app.pk
ctx.data['new_app'] = True
self.ask_upload(ctx, AppVersion.Kind.FULL)
@text(texts.UPLOAD_UPDATE, state=APP)
def ask_update(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
if app.current_version() is None:
return ctx.reply(texts.APP_NOT_DEPLOYED)
self.ask_upload(ctx, AppVersion.Kind.UPDATE)
@text(texts.UPLOAD_FULL, state=APP)
def ask_full(self, ctx):
if self.current_app(ctx) is None:
return self.platform_menu(ctx)
self.ask_upload(ctx, AppVersion.Kind.FULL)
def ask_upload(self, ctx, kind: str):
ctx.data['kind'] = kind
prompt = texts.UPLOAD_UPDATE_PROMPT if kind == AppVersion.Kind.UPDATE else texts.UPLOAD_FULL_PROMPT
self.ask(ctx, APP_UPLOAD, prompt)
@state(APP_UPLOAD)
def receive_upload(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
document = ctx.document
if document is None:
return ctx.reply(texts.UPLOAD_NOT_DOCUMENT)
try:
content = ctx.download_document(uploads.ARCHIVE_MAX)
upload = platforms.prepare_upload(app, document.name, content, ctx.data.get('kind', AppVersion.Kind.UPDATE))
except (bot_api.ApiError, uploads.UploadError) as error:
return ctx.reply(texts.upload_failed(error), parse_mode=HTML)
self.show_upload(ctx, upload)
def show_upload(self, ctx, upload: platforms.Upload):
if upload.version is None:
# ошибки - ждем исправленные файлы, без изменений - назад к приложению
ctx.reply(texts.upload_report(upload), parse_mode=HTML)
if not upload.errors:
self.app_menu(ctx)
return
ctx.state = APP_CONFIRM
ctx.data['version'] = upload.version.pk
ctx.reply(texts.upload_report(upload), keyboard=texts.CONFIRM_KEYBOARD, parse_mode=HTML)
@text(texts.CANCEL, state=APP_UPLOAD)
@text(texts.CANCEL, state=APP_CONFIRM)
def cancel_upload(self, ctx):
app = self.current_app(ctx)
version_id = ctx.data.pop('version', None)
if app and version_id:
app.versions.filter(pk=version_id, state=AppVersion.State.DRAFT).delete()
if app and ctx.data.pop('new_app', False):
platforms.discard_new_app(app)
return self.platform_menu(ctx)
self.app_menu(ctx)
@text(texts.APPLY, state=APP_CONFIRM)
def apply_upload(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
version = app.versions.filter(pk=ctx.data.get('version'), state=AppVersion.State.DRAFT).first()
if version is None:
ctx.reply(texts.VERSION_GONE)
return self.app_menu(ctx)
tasks.start(tasks.deploy_version, platform, Platform.Status.UPDATING, ctx.chat, version.pk)
ctx.data.pop('version', None)
ctx.data.pop('new_app', None)
ctx.state = APP
ctx.reply(texts.APPLY_STARTED, keyboard=texts.APP_KEYBOARD)
@text(texts.ROLLBACK, state=APP)
def rollback(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
try:
upload = platforms.prepare_rollback(app)
except ValueError:
return ctx.reply(texts.ROLLBACK_NONE)
self.show_upload(ctx, upload)
# управление приложением и сервисом
@text(texts.START, state=APP)
@text(texts.STOP, state=APP)
@text(texts.RESTART, state=APP)
@text(texts.PULL, state=APP)
@text(texts.START, state=SERVICE)
@text(texts.STOP, state=SERVICE)
@text(texts.RESTART, state=SERVICE)
def control_app(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
if app.current_version() is None:
return ctx.reply(texts.APP_NOT_DEPLOYED)
service = ctx.data.get('service', '') if ctx.state == SERVICE else ''
tasks.start(tasks.control_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, texts.ACTIONS[ctx.text], service)
ctx.reply(texts.OPERATION_STARTED)
@text(texts.LOGS, state=APP)
@text(texts.LOGS, state=SERVICE)
def send_logs(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
service = ctx.data.get('service', '') if ctx.state == SERVICE else ''
tasks.send_logs.send(app.pk, service, ctx.chat.pk)
ctx.reply(texts.LOGS_STARTED)
@state(APP)
def select_service(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
service = ctx.text.strip()
services = app.platform.app_status(app.name).get('services', {})
if not services:
return ctx.reply(texts.NO_SNAPSHOT)
if service not in services:
return ctx.reply(texts.APP_NOT_FOUND)
ctx.state = SERVICE
ctx.data['service'] = service
ctx.reply(texts.service_card(app, service), keyboard=texts.SERVICE_KEYBOARD, parse_mode=HTML)
@text(texts.REMOVE_APP, state=APP)
def ask_remove_app(self, ctx):
if self.idle_platform(ctx) and self.current_app(ctx):
self.ask(ctx, APP_REMOVE, texts.REMOVE_APP_CONFIRM, texts.REMOVE_APP_KEYBOARD)
@text(texts.REMOVE_KEEP_DATA, state=APP_REMOVE)
@text(texts.REMOVE_WITH_DATA, state=APP_REMOVE)
def remove_app(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
volumes = ctx.text == texts.REMOVE_WITH_DATA
tasks.start(tasks.remove_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, volumes)
ctx.data.pop('app', None)
ctx.state = PLATFORM
ctx.reply(texts.OPERATION_STARTED, keyboard=texts.PLATFORM_KEYBOARD)