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>
767 lines
26 KiB
Python
767 lines
26 KiB
Python
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, platforms, services, tasks, uploads
|
|
from ..models import AppVersion, Credentials, DockerApp, Platform, Point, Server
|
|
from . import texts
|
|
|
|
# состояния диалога
|
|
DRAFT = 'draft'
|
|
DRAFT_SERVER = 'draft.server'
|
|
DRAFT_MASK = 'draft.mask'
|
|
DRAFT_DOMAIN = 'draft.domain'
|
|
DRAFT_PROXY = 'draft.proxy'
|
|
SELECT = 'select'
|
|
POINT = 'point'
|
|
POINT_RELEASE = 'point.release'
|
|
POINT_REINSTALL = 'point.reinstall'
|
|
POINT_DOMAIN_EDIT = 'point.domain'
|
|
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}$')
|
|
HTML = 'HTML'
|
|
|
|
|
|
def parse_credentials(value: str) -> Credentials | None:
|
|
"""'ip:password' -> Credentials. Пароль может содержать двоеточия."""
|
|
address, _, password = value.strip().partition(':')
|
|
try:
|
|
address = str(ipaddress.ip_address(address))
|
|
except ValueError:
|
|
return None
|
|
return Credentials(address, password) if password else None
|
|
|
|
|
|
def is_domain(value: str) -> bool:
|
|
return bool(DOMAIN_RE.match(value))
|
|
|
|
|
|
def is_proxy_url(value: str) -> bool:
|
|
try:
|
|
serverus.parse_proxy(value)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
@register
|
|
class MyPointInterface(Interface):
|
|
title = 'MyPoint VPN'
|
|
|
|
# главное меню
|
|
|
|
@command('start')
|
|
@text(texts.MAIN_MENU, texts.CANCEL)
|
|
def main_menu(self, ctx):
|
|
ctx.reset()
|
|
points = list(Point.objects.owned_by(ctx.user))
|
|
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):
|
|
ctx.reply(texts.FAQ_TEXT, parse_mode=HTML)
|
|
|
|
@text(texts.HELP)
|
|
def help(self, ctx):
|
|
ctx.reply(texts.HELP_TEXT, parse_mode=HTML)
|
|
|
|
def fallback(self, ctx):
|
|
ctx.reply(texts.FALLBACK)
|
|
|
|
# добавление сервера: черновик живет в данных чата до "установить"
|
|
|
|
@text(texts.ADD_SERVER)
|
|
def add_server(self, ctx):
|
|
ctx.reset()
|
|
ctx.data['draft'] = {}
|
|
self.show_draft(ctx)
|
|
|
|
def show_draft(self, ctx):
|
|
ctx.state = DRAFT
|
|
ctx.reply(texts.draft_card(ctx.data['draft']), keyboard=texts.DRAFT_KEYBOARD, parse_mode=HTML)
|
|
|
|
@text(texts.SERVER, state=DRAFT)
|
|
def ask_server(self, ctx):
|
|
self.ask(ctx, DRAFT_SERVER, texts.CREDENTIALS_PROMPT)
|
|
|
|
@text(texts.MASK, state=DRAFT)
|
|
def ask_mask(self, ctx):
|
|
self.ask(ctx, DRAFT_MASK, texts.CREDENTIALS_PROMPT, [[texts.CANCEL, texts.DELETE]])
|
|
|
|
@text(texts.DELETE, state=DRAFT_MASK)
|
|
def delete_mask(self, ctx):
|
|
ctx.data['draft'].pop('mask', None)
|
|
self.show_draft(ctx)
|
|
|
|
@text(texts.DOMAIN, state=DRAFT)
|
|
def ask_domain(self, ctx):
|
|
self.ask(ctx, DRAFT_DOMAIN, texts.DOMAIN_PROMPT, [[texts.CANCEL, texts.DELETE]])
|
|
|
|
@text(texts.PROXY, state=DRAFT)
|
|
def ask_proxy(self, ctx):
|
|
self.ask(ctx, DRAFT_PROXY, texts.PROXY_PROMPT, [[texts.CF_WARP, texts.CANCEL, texts.DELETE]])
|
|
|
|
def ask(self, ctx, new_state: str, prompt: str, keyboard=texts.CANCEL_KEYBOARD):
|
|
ctx.state = new_state
|
|
ctx.reply(prompt, keyboard=keyboard, parse_mode=HTML)
|
|
|
|
@text(texts.CANCEL, state=DRAFT_SERVER)
|
|
@text(texts.CANCEL, state=DRAFT_MASK)
|
|
@text(texts.CANCEL, state=DRAFT_DOMAIN)
|
|
@text(texts.CANCEL, state=DRAFT_PROXY)
|
|
def back_to_draft(self, ctx):
|
|
self.show_draft(ctx)
|
|
|
|
@state(DRAFT_SERVER, DRAFT_MASK)
|
|
def set_credentials(self, ctx):
|
|
# в сообщении пароль root, в чате ему не место
|
|
ctx.delete_message()
|
|
credentials = parse_credentials(ctx.text)
|
|
if credentials is None:
|
|
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
|
|
draft = ctx.data['draft']
|
|
key = DRAFT_CREDENTIALS[ctx.state]
|
|
other = draft.get('mask' if key == 'server' else 'server', {})
|
|
if other.get('address') == credentials.address:
|
|
return ctx.reply(texts.ADDRESS_DUPLICATE)
|
|
if Server.objects.is_address_taken(credentials.address):
|
|
return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
|
|
draft[key] = {'address': credentials.address, 'password': credentials.password}
|
|
self.show_draft(ctx)
|
|
|
|
@state(DRAFT_DOMAIN)
|
|
def set_domain(self, ctx):
|
|
domain = ctx.text.strip().lower().rstrip('.')
|
|
if not is_domain(domain):
|
|
return ctx.reply(texts.DOMAIN_INVALID)
|
|
ctx.data['draft']['domain'] = domain
|
|
self.show_draft(ctx)
|
|
|
|
@text(texts.DELETE, state=DRAFT_DOMAIN)
|
|
def delete_domain(self, ctx):
|
|
ctx.data['draft']['domain'] = ''
|
|
self.show_draft(ctx)
|
|
|
|
@state(DRAFT_PROXY)
|
|
def set_proxy(self, ctx):
|
|
# в прокси может быть пароль
|
|
ctx.delete_message()
|
|
proxy = ctx.text.strip()
|
|
if not is_proxy_url(proxy):
|
|
return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML)
|
|
ctx.data['draft']['proxy'] = proxy
|
|
self.show_draft(ctx)
|
|
|
|
@text(texts.CF_WARP, state=DRAFT_PROXY)
|
|
def set_cf_warp(self, ctx):
|
|
ctx.data['draft']['proxy'] = Point.CF_WARP
|
|
self.show_draft(ctx)
|
|
|
|
@text(texts.DELETE, state=DRAFT_PROXY)
|
|
def delete_proxy(self, ctx):
|
|
ctx.data['draft']['proxy'] = ''
|
|
self.show_draft(ctx)
|
|
|
|
@text(texts.INSTALL, state=DRAFT)
|
|
def install(self, ctx):
|
|
draft = ctx.data['draft']
|
|
if not draft.get('server'):
|
|
return ctx.reply(texts.DRAFT_INCOMPLETE)
|
|
if draft.get('mask') and not draft.get('domain'):
|
|
return ctx.reply(texts.MASK_WITHOUT_DOMAIN)
|
|
point = Point.objects.create_point(
|
|
ctx.user,
|
|
panel=Credentials(**draft['server']),
|
|
mask=Credentials(**draft['mask']) if draft.get('mask') else None,
|
|
domain=draft.get('domain', ''),
|
|
outbound_proxy=draft.get('proxy', ''),
|
|
)
|
|
tasks.start(tasks.install_point, point, Point.Status.INSTALLING, ctx.chat)
|
|
ctx.reset()
|
|
ctx.reply(texts.INSTALL_STARTED, keyboard=self.main_keyboard(ctx))
|
|
|
|
# управление сервером
|
|
|
|
@text(texts.MANAGE)
|
|
def ask_point(self, ctx):
|
|
ctx.reset()
|
|
points = Point.objects.owned_by(ctx.user)
|
|
self.ask(ctx, SELECT, f'{texts.SELECT_PROMPT}\n{texts.points_list(points)}')
|
|
|
|
@state(SELECT)
|
|
def select_point(self, ctx):
|
|
point = Point.objects.owned_by(ctx.user).find_by_name(ctx.text.strip())
|
|
if point is None:
|
|
return ctx.reply(texts.POINT_NOT_FOUND)
|
|
self.show_point(ctx, point)
|
|
|
|
@text(texts.SERVER_MENU)
|
|
def server_menu(self, ctx):
|
|
"""Кнопка из уведомления: последняя открытая точка или последняя созданная."""
|
|
point = self.current_point(ctx) or Point.objects.owned_by(ctx.user).find_last()
|
|
if point is None:
|
|
return self.main_menu(ctx)
|
|
self.show_point(ctx, point)
|
|
|
|
def current_point(self, ctx) -> Point | None:
|
|
point_id = ctx.data.get('point')
|
|
return Point.objects.owned_by(ctx.user).find_by_pk(point_id) if point_id else None
|
|
|
|
def show_point(self, ctx, point: Point):
|
|
ctx.reset()
|
|
ctx.state = POINT
|
|
ctx.data['point'] = point.pk
|
|
ctx.reply(texts.point_card(point), keyboard=texts.POINT_KEYBOARD, parse_mode=HTML)
|
|
|
|
def idle_point(self, ctx) -> Point | None:
|
|
"""Текущая точка, если с ней можно работать. Иначе отвечает пользователю и возвращает None."""
|
|
point = self.current_point(ctx)
|
|
if point is None:
|
|
self.main_menu(ctx)
|
|
elif point.is_busy:
|
|
ctx.reply(texts.POINT_BUSY.format(status=point.get_status_display()))
|
|
return None
|
|
return point
|
|
|
|
@text(texts.RELEASE, state=POINT)
|
|
def ask_release(self, ctx):
|
|
if self.idle_point(ctx):
|
|
self.ask(ctx, POINT_RELEASE, texts.RELEASE_CONFIRM, [[texts.YES, texts.NO]])
|
|
|
|
@text(texts.YES, state=POINT_RELEASE)
|
|
def release(self, ctx):
|
|
point = self.idle_point(ctx)
|
|
if point is None:
|
|
return
|
|
tasks.start(tasks.release_point, point, Point.Status.RELEASING, ctx.chat)
|
|
ctx.reset()
|
|
ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx))
|
|
|
|
@text(texts.REINSTALL, state=POINT)
|
|
def ask_reinstall(self, ctx):
|
|
if self.idle_point(ctx):
|
|
self.ask(ctx, POINT_REINSTALL, texts.REINSTALL_PROMPT, [[texts.CANCEL, texts.USE_OLD]])
|
|
|
|
@state(POINT_REINSTALL)
|
|
def reinstall_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)
|
|
point = self.idle_point(ctx)
|
|
if point is None:
|
|
return
|
|
if point.servers.find_by_address(credentials.address) is None:
|
|
return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
|
|
self.start_reinstall(ctx, point, credentials.address, credentials.password)
|
|
|
|
@text(texts.USE_OLD, state=POINT_REINSTALL)
|
|
def reinstall_with_old_passwords(self, ctx):
|
|
point = self.idle_point(ctx)
|
|
if point:
|
|
self.start_reinstall(ctx, point)
|
|
|
|
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=self.main_keyboard(ctx))
|
|
|
|
@text(texts.NO, state=POINT_RELEASE)
|
|
@text(texts.CANCEL, state=POINT_RELEASE)
|
|
@text(texts.CANCEL, state=POINT_REINSTALL)
|
|
@text(texts.CANCEL, state=POINT_DOMAIN_EDIT)
|
|
@text(texts.CANCEL, state=POINT_PROXY_EDIT)
|
|
def back_to_point(self, ctx):
|
|
point = self.current_point(ctx)
|
|
if point is None:
|
|
return self.main_menu(ctx)
|
|
self.show_point(ctx, point)
|
|
|
|
# домен и прокси готовой точки: применяются в фоне, панель тем временем работает с прежними настройками
|
|
|
|
@text(texts.POINT_DOMAIN, state=POINT)
|
|
def ask_point_domain(self, ctx):
|
|
point = self.ready_point(ctx)
|
|
if point:
|
|
keyboard = texts.CANCEL_KEYBOARD if point.mask else [[texts.CANCEL, texts.DELETE]]
|
|
self.ask(ctx, POINT_DOMAIN_EDIT, texts.domain_prompt(point), keyboard)
|
|
|
|
@state(POINT_DOMAIN_EDIT)
|
|
def change_domain(self, ctx):
|
|
domain = ctx.text.strip().lower().rstrip('.')
|
|
if not is_domain(domain):
|
|
return ctx.reply(texts.DOMAIN_INVALID)
|
|
point = self.ready_point(ctx)
|
|
if point:
|
|
self.start_change(ctx, point, tasks.set_domain, domain)
|
|
|
|
@text(texts.DELETE, state=POINT_DOMAIN_EDIT)
|
|
def delete_point_domain(self, ctx):
|
|
point = self.ready_point(ctx)
|
|
if point is None:
|
|
return
|
|
if point.mask:
|
|
return ctx.reply(texts.DOMAIN_REQUIRED_BY_MASK)
|
|
if not point.domain:
|
|
return self.show_point(ctx, point)
|
|
self.start_change(ctx, point, tasks.set_domain, '')
|
|
|
|
@text(texts.POINT_PROXY, state=POINT)
|
|
def ask_point_proxy(self, ctx):
|
|
point = self.ready_point(ctx)
|
|
if point:
|
|
self.ask(ctx, POINT_PROXY_EDIT, texts.proxy_prompt(point), [[texts.CF_WARP, texts.CANCEL, texts.DELETE]])
|
|
|
|
@state(POINT_PROXY_EDIT)
|
|
def change_proxy(self, ctx):
|
|
# в прокси может быть пароль
|
|
ctx.delete_message()
|
|
proxy = ctx.text.strip()
|
|
if not is_proxy_url(proxy):
|
|
return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML)
|
|
self.start_proxy_change(ctx, proxy)
|
|
|
|
@text(texts.CF_WARP, state=POINT_PROXY_EDIT)
|
|
def change_proxy_to_warp(self, ctx):
|
|
self.start_proxy_change(ctx, Point.CF_WARP)
|
|
|
|
@text(texts.DELETE, state=POINT_PROXY_EDIT)
|
|
def delete_point_proxy(self, ctx):
|
|
self.start_proxy_change(ctx, '')
|
|
|
|
def start_proxy_change(self, ctx, proxy: str):
|
|
point = self.ready_point(ctx)
|
|
if point:
|
|
self.start_change(ctx, point, tasks.set_outbound_proxy, fields.encrypt(proxy) if proxy else '')
|
|
|
|
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=self.main_keyboard(ctx))
|
|
|
|
# ключи
|
|
|
|
def ready_point(self, ctx) -> Point | None:
|
|
"""Текущая точка, если на ее панели можно выпускать ключи. Иначе отвечает пользователю и возвращает None."""
|
|
point = self.idle_point(ctx)
|
|
if point is not None and (point.status != Point.Status.READY or not point.panel_inbound_id):
|
|
ctx.reply(texts.POINT_NOT_READY)
|
|
return None
|
|
return point
|
|
|
|
@text(texts.CLIENTS, state=POINT)
|
|
@text(texts.CLIENTS, state=POINT_KEYS)
|
|
@text(texts.CANCEL, state=POINT_KEY_ADD)
|
|
@text(texts.CANCEL, state=POINT_KEY_REVOKE)
|
|
def show_keys(self, ctx):
|
|
point = self.ready_point(ctx)
|
|
if point is None:
|
|
return
|
|
ctx.state = POINT_KEYS
|
|
keys = [(key, services.key_traffic(key)) for key in point.keys.all()]
|
|
ctx.reply(texts.keys_list(point, keys), keyboard=texts.KEYS_KEYBOARD, parse_mode=HTML)
|
|
|
|
@state(POINT_KEYS)
|
|
def show_key(self, ctx):
|
|
point = self.ready_point(ctx)
|
|
if point is None:
|
|
return
|
|
key = point.keys.find_by_name(ctx.text.strip().lower())
|
|
if key is None:
|
|
return ctx.reply(texts.KEY_NOT_FOUND)
|
|
ctx.reply(texts.key_card(key, services.key_traffic(key)), parse_mode=HTML)
|
|
|
|
@text(texts.ADD_KEY, state=POINT_KEYS)
|
|
def ask_key_name(self, ctx):
|
|
if self.ready_point(ctx):
|
|
self.ask(ctx, POINT_KEY_ADD, texts.KEY_NAME_PROMPT)
|
|
|
|
@state(POINT_KEY_ADD)
|
|
def add_key(self, ctx):
|
|
point = self.ready_point(ctx)
|
|
if point is None:
|
|
return
|
|
try:
|
|
key = services.issue_key(point, ctx.text.strip().lower())
|
|
except ValueError as error:
|
|
return ctx.reply(str(error))
|
|
except serverus.XuiApiError as error:
|
|
return ctx.reply(texts.PANEL_ERROR.format(error=error))
|
|
ctx.state = POINT_KEYS
|
|
ctx.reply(texts.key_card(key, None), keyboard=texts.KEYS_KEYBOARD, parse_mode=HTML)
|
|
|
|
@text(texts.REVOKE_KEY, state=POINT_KEYS)
|
|
def ask_revoke_key(self, ctx):
|
|
if self.ready_point(ctx):
|
|
self.ask(ctx, POINT_KEY_REVOKE, texts.KEY_REVOKE_PROMPT)
|
|
|
|
@state(POINT_KEY_REVOKE)
|
|
def revoke_key(self, ctx):
|
|
point = self.ready_point(ctx)
|
|
if point is None:
|
|
return
|
|
key = point.keys.find_by_name(ctx.text.strip().lower())
|
|
if key is None:
|
|
return ctx.reply(texts.KEY_NOT_FOUND)
|
|
try:
|
|
services.revoke_key(key)
|
|
except serverus.XuiApiError as error:
|
|
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)
|