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:
1 parent
65c19ea1cd
commit
a8d1f286d1
42 files changed
+3509
-232
No files matched your search
+355
-10
@@ -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)
|
||||
Reference in new issue
Block a user