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
@@ -19,7 +19,7 @@ SERVERUS_CA = $(if $(filter yes,$(ca)),--ca $(SERVERUS_CA_DIR))
|
||||
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
|
||||
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
|
||||
|
||||
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker dj_secrets dj_backup dj_restore dj_check_points docker_build docker_up serve_ssh serverus_xui serverus_key serverus_cert serverus_outbound serverus_release
|
||||
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker dj_secrets dj_backup dj_restore dj_check_points docker_build docker_up serve_ssh serverus_xui serverus_key serverus_cert serverus_outbound serverus_release serverus_docker serverus_app
|
||||
|
||||
help:
|
||||
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
|
||||
@@ -37,6 +37,8 @@ help:
|
||||
@echo "make serverus_cert ip=<ip> [domain=<domain>] [ca=yes] - сертификат панели на домен или на ip"
|
||||
@echo "make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую"
|
||||
@echo "make serverus_release ip=<ip> [password=<new_root_password>] - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
|
||||
@echo "make serverus_docker ip=<ip> password=<root_password> [local_certs=yes] - настроить свежий сервер и поставить Docker с ingress Caddy"
|
||||
@echo "make serverus_app ip=<ip> action=deploy|status|logs|start|stop|restart|pull|remove [name=<app>] [dir=<local_app_dir>] [service=<service>] [volumes=yes] - приложение на docker-сервере"
|
||||
|
||||
venv:
|
||||
@test -d $(VENV) || python3 -m venv $(VENV)
|
||||
@@ -142,3 +144,11 @@ serverus_outbound:
|
||||
serverus_release:
|
||||
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip=<ip>"; exit 1; }
|
||||
$(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(password),--password '$(password)')
|
||||
|
||||
serverus_docker:
|
||||
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_docker ip=<ip> password=<root_password>"; exit 1; }
|
||||
$(SERVERUS) docker $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(filter yes,$(local_certs)),--local-certs)
|
||||
|
||||
serverus_app:
|
||||
@test -n "$(ip)" -a -n "$(action)" || { echo "Укажите приложение: make serverus_app ip=<ip> action=deploy|status|... [name=<app>] [dir=<local_app_dir>]"; exit 1; }
|
||||
$(SERVERUS) app $(action) $(name) $(if $(dir),$(abspath $(dir))) --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(service),--service '$(service)') $(if $(filter yes,$(volumes)),--volumes)
|
||||
@@ -25,6 +25,18 @@ services:
|
||||
# - "127.0.0.1:8081:8081"
|
||||
# - "127.0.0.1:8082:8082"
|
||||
|
||||
telegram-files:
|
||||
# файлы, присланные ботам документами: Bot API сервер в --local режиме не отдает их по HTTP, а пишет на диск
|
||||
# с правами 0600 от uid 101. Воркеры nginx работают от того же uid 101. Отдаются только documents/
|
||||
image: docker.local/nginx:1.30.5-alpine-slim
|
||||
configs:
|
||||
- source: telegram-files
|
||||
target: /etc/nginx/conf.d/default.conf
|
||||
volumes:
|
||||
- telegram-bot-api-data:/var/lib/telegram-bot-api:ro
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: docker.local/redis:8.8.3-alpine
|
||||
ports:
|
||||
@@ -75,6 +87,7 @@ services:
|
||||
DB_PORT: "5432"
|
||||
RABBITMQ_ADDRESS: rabbitmq:5672
|
||||
TELEGRAM_API_URL: http://telegram:8081
|
||||
TELEGRAM_FILES_URL: http://telegram-files:8083
|
||||
# Bot API сервер ходит на webhook по имени сервиса сайта, домен не нужен
|
||||
TELEGRAM_WEBHOOK_URL: http://web:8000
|
||||
ALLOWED_HOSTS: web,127.0.0.1,localhost
|
||||
@@ -98,6 +111,8 @@ services:
|
||||
condition: service_started
|
||||
telegram:
|
||||
condition: service_started
|
||||
telegram-files:
|
||||
condition: service_started
|
||||
|
||||
worker:
|
||||
<<: *app
|
||||
@@ -117,3 +132,17 @@ volumes:
|
||||
redis_data:
|
||||
rabbitmq_data:
|
||||
postgres_data:
|
||||
|
||||
configs:
|
||||
telegram-files:
|
||||
content: |
|
||||
server {
|
||||
listen 127.0.0.1:8083;
|
||||
access_log off;
|
||||
location ~ ^/[0-9]+:[A-Za-z0-9_-]+/documents/[^/]+$$ {
|
||||
root /var/lib/telegram-bot-api;
|
||||
}
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ TG_APP_ID=
|
||||
TG_APP_HASH=
|
||||
# Адрес своего Bot API сервера для Django. Пусто - api.telegram.org
|
||||
TELEGRAM_API_URL=http://localhost:8081
|
||||
# Файлы, присланные ботам документами (сервис telegram-files): свой Bot API сервер не отдает их по HTTP
|
||||
TELEGRAM_FILES_URL=http://localhost:8083
|
||||
# Адрес сайта для webhook ботов, как его видит Bot API сервер (он в сети хоста, network_mode: host)
|
||||
TELEGRAM_WEBHOOK_URL=http://localhost:8000
|
||||
# Хосты сайта через запятую, в том числе хост из TELEGRAM_WEBHOOK_URL
|
||||
|
||||
+18
-1
@@ -7,6 +7,7 @@ Django приложение связывающее все части сайта
|
||||
-[ ] Настройки домена и прокси у готовой точки
|
||||
-[x] Клиенты и статистика через API панели 3x-ui
|
||||
-[x] Супервизор долгих задач и ежедневная проверка точек
|
||||
-[x] Docker-серверы: приложения в `~/apps` из бота
|
||||
|
||||
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
|
||||
|
||||
@@ -14,6 +15,11 @@ Django приложение связывающее все части сайта
|
||||
|
||||
- `Point` - точка доступа: сервер с панелью 3x-ui и необязательный сервер маскировки. Владелец - `Telegram.User`. Хранит домен, прокси на выходе и доступ к панели.
|
||||
- `Server` - сервер точки с ролью `panel` или `mask`. Хранит текущие порт ssh, пароль root, ключ и отпечаток ssh, а также пароль root, присланный пользователем при добавлении (`default_root_password`).
|
||||
- `Platform` - docker-сервер: один сервер с ролью `docker`, приложения и снимок их состояния (`status_snapshot`, `status_at`). Владелец - `Telegram.User`.
|
||||
- `DockerApp` - приложение платформы: папка `~/apps/<name>/`, compose-проект `name`. Имя `caddy` занято ingress.
|
||||
- `AppVersion` - полный набор файлов приложения (зашифрован: в нем `.env`), вид (`full`, `update`, `rollback`) и состояние (`draft`, `applied`, `failed`). Текущая версия - последняя примененная, хранятся 10 последних.
|
||||
- `Point` и `Platform` наследуют абстрактную `Managed`: владелец, имя, статус, ошибка и поля долгой задачи. Менеджер `ManagedManager` - `start_task`, `claim_task`, `beat`, `finish_task`, `abort_task`, `unique_name`, запросы `owned_by`, `find_by_name`, `find_by_pk`, `find_last`.
|
||||
- `Server` принадлежит точке или платформе, ровно чему-то одному (`CheckConstraint`).
|
||||
- `Key` - ключ клиента на панели точки: имя (оно же email клиента в панели), `uuid` и `sub_id`. Клиенту выдается `key.subscription_url`, после переустановки ключ восстанавливается с теми же ссылками.
|
||||
|
||||
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес. Маскировка требует домен.
|
||||
@@ -34,12 +40,23 @@ Django приложение связывающее все части сайта
|
||||
- `fields.py` - `EncryptedTextField`: секреты в базе зашифрованы Fernet ключами `FIELD_ENCRYPTION_KEYS` из `.env`.
|
||||
- `management/commands/serve_ssh.py` - ручной вход на сервер: `make serve_ssh ip=<ip>`.
|
||||
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
|
||||
- Долгая задача запускается через `tasks.start(actor, point, status, chat, *args)`: точка получает статус и token задачи, вместе с задачей стартует `supervise`. Задача раз в 30 секунд отмечается в точке (`task_heartbeat`), супервизор раз в минуту проверяет отметку и, если задача молчит 3 минуты (или не начиналась час), снимает ее: статус по `Point.INTERRUPTED`, уведомление в чат. Кончилась задача - кончился и супервизор. Повторная доставка сообщения после падения воркера задачу не повторяет, а снимает.
|
||||
- Долгая задача запускается через `tasks.start(actor, obj, status, chat, *args)` для точки или платформы: запись получает статус и token задачи, вместе с задачей стартует `supervise`. Задача раз в 30 секунд отмечается в записи (`task_heartbeat`), супервизор раз в минуту проверяет отметку и, если задача молчит 3 минуты (или не начиналась час), снимает ее: статус по `INTERRUPTED` модели, уведомление в чат. Кончилась задача - кончился и супервизор. Повторная доставка сообщения после падения воркера задачу не повторяет, а снимает.
|
||||
- `check_point` - ежедневная проверка точки (`services.check_point`): своя цепочка у каждой точки, запускается после установки и командой `check_points` (ее выполняет воркер в docker при старте). Сутки проходятся перескоками по 15 минут: RabbitMQ не держит неподтвержденное отложенное сообщение дольше `consumer_timeout`.
|
||||
- `backups.py` и команды `backup`, `restore` - бэкап базы `pg_dump`/`pg_restore`, `generate_secrets` - секреты для `.env`.
|
||||
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
|
||||
- `bot/texts.py` - кнопки и тексты.
|
||||
|
||||
- `uploads.py` - разбор присланных файлов приложения без Django: zip, tar, tar.gz или один файл, слияние (`merge`), разница (`diff`) и проверка по конвенции (`check`: ошибки не дают применить, предупреждения - нет). Мусор архиваторов пропускается, пути наружу и ссылки - отказ, лимиты 5 Мб архив, 20 Мб распаковано, 500 файлов.
|
||||
- `platforms.py` - сценарии docker-платформы поверх `serverus.DockerHost`: `install_platform`, `reinstall` (приложения разворачиваются заново с текущими версиями), `release_platform`, `create_app`, `prepare_upload`, `prepare_rollback`, `apply`, `control`, `logs`, `remove_app`, `refresh`.
|
||||
|
||||
## Docker-сервер
|
||||
|
||||
Приложение живет в App версиями файлов. "Залить апдейт" сливает присланное с текущей версией, "перезалить все" заменяет ее архивом целиком. В обоих случаях сервер получает полный набор файлов, бот перед применением показывает разницу и предупреждения. Удалить сервис из приложения - залить compose-файл без него: локальная копия пользователя - источник правды, бот compose-файл не правит.
|
||||
|
||||
Операции, которые меняют сервер (установка, применение файлов, старт/стоп, удаление), идут задачами под супервизором по одной: платформа `updating`, пока операция не кончится. Прерванная операция оставляет платформу `ready` с текстом ошибки. Статус и логи - быстрые задачи без занятости (`refresh_platform`, `send_logs`), периодической проверки нет: "проверить" в меню сервера.
|
||||
|
||||
Файлы приходят документом через `Telegram` (`ctx.download_document`), логи уходят файлом (`send_document`).
|
||||
|
||||
## Установка точки
|
||||
|
||||
1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables)
|
||||
|
||||
+47
-4
@@ -3,7 +3,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.contrib.auth.forms import AdminUserCreationForm, UserChangeForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Key, Point, Server, User
|
||||
from .models import AppVersion, DockerApp, Key, Platform, Point, Server, User
|
||||
|
||||
|
||||
class UserCreationForm(AdminUserCreationForm):
|
||||
@@ -55,13 +55,56 @@ class PointAdmin(admin.ModelAdmin):
|
||||
inlines = [ServerInline]
|
||||
|
||||
|
||||
@admin.register(Platform)
|
||||
class PlatformAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'owner', 'status', 'created_at')
|
||||
list_filter = ('status',)
|
||||
search_fields = ('name', 'servers__address')
|
||||
readonly_fields = (
|
||||
'owner', 'name', 'status', 'error', 'status_at', 'status_snapshot', 'task_started_at', 'task_heartbeat',
|
||||
'created_at',
|
||||
)
|
||||
inlines = [ServerInline]
|
||||
|
||||
|
||||
class AppVersionInline(admin.TabularInline):
|
||||
"""Только история: файлы не показываются, в них .env с секретами."""
|
||||
|
||||
model = AppVersion
|
||||
extra = 0
|
||||
fields = ('created_at', 'kind', 'state', 'error')
|
||||
readonly_fields = fields
|
||||
can_delete = False
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(DockerApp)
|
||||
class DockerAppAdmin(admin.ModelAdmin):
|
||||
"""Только просмотр: приложения заливаются и удаляются через бота (platforms), иначе база и сервер разойдутся."""
|
||||
|
||||
list_display = ('name', 'platform', 'created_at')
|
||||
list_filter = ('platform',)
|
||||
search_fields = ('name', 'platform__name')
|
||||
fields = ('name', 'platform', 'created_at')
|
||||
readonly_fields = fields
|
||||
inlines = [AppVersionInline]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(Server)
|
||||
class ServerAdmin(admin.ModelAdmin):
|
||||
list_display = ('address', 'role', 'point', 'ssh_port', 'has_fingerprint')
|
||||
list_display = ('address', 'role', 'point', 'platform', 'ssh_port', 'has_fingerprint')
|
||||
list_filter = ('role',)
|
||||
search_fields = ('address', 'point__name')
|
||||
search_fields = ('address', 'point__name', 'platform__name')
|
||||
exclude = ('private_key',)
|
||||
readonly_fields = ('point', 'role', 'public_key', 'uname')
|
||||
readonly_fields = ('point', 'platform', 'role', 'public_key', 'uname')
|
||||
actions = ('reset_fingerprint',)
|
||||
|
||||
@admin.display(boolean=True, description='отпечаток')
|
||||
|
||||
+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)
|
||||
+249
-6
@@ -1,9 +1,11 @@
|
||||
"""Кнопки и тексты бота. Сообщения размечены HTML, данные пользователя экранируются."""
|
||||
import datetime
|
||||
from html import escape
|
||||
|
||||
from django.utils import timezone
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from ..models import Key, Point, Server
|
||||
from ..models import AppVersion, DockerApp, Key, Managed, Platform, Point, Server
|
||||
|
||||
# главное меню
|
||||
MAIN_MENU = 'главное меню'
|
||||
@@ -39,8 +41,45 @@ USE_OLD = 'Использовать старый'
|
||||
ADD_KEY = 'выпустить ключ'
|
||||
REVOKE_KEY = 'отозвать ключ'
|
||||
|
||||
MAIN_KEYBOARD = [[FAQ, ADD_SERVER, HELP]]
|
||||
MAIN_KEYBOARD_WITH_POINTS = [[FAQ, ADD_SERVER, HELP], [MANAGE]]
|
||||
# docker-серверы
|
||||
ADD_DOCKER = 'добавить docker-сервер'
|
||||
DOCKER_SERVERS = 'docker-серверы'
|
||||
PLATFORM_MENU = 'меню docker-сервера'
|
||||
NEW_APP = 'новое приложение'
|
||||
CHECK = 'проверить'
|
||||
APP_MENU = 'меню приложения'
|
||||
START = 'старт'
|
||||
STOP = 'стоп'
|
||||
RESTART = 'рестарт'
|
||||
LOGS = 'логи'
|
||||
PULL = 'обновить образы'
|
||||
UPLOAD_UPDATE = 'залить апдейт'
|
||||
UPLOAD_FULL = 'перезалить все'
|
||||
ROLLBACK = 'откатить'
|
||||
REMOVE_APP = 'удалить'
|
||||
APPLY = 'Применить'
|
||||
REMOVE_KEEP_DATA = 'удалить, данные оставить'
|
||||
REMOVE_WITH_DATA = 'удалить с данными'
|
||||
|
||||
ACTIONS = {START: 'start', STOP: 'stop', RESTART: 'restart', PULL: 'pull'}
|
||||
ACTION_DONE = {'start': 'запущено', 'stop': 'остановлено', 'restart': 'перезапущено', 'pull': 'образы обновлены'}
|
||||
|
||||
|
||||
def main_keyboard(has_points: bool = False, has_platforms: bool = False) -> list[list[str]]:
|
||||
rows = [[FAQ, ADD_SERVER, HELP]]
|
||||
if has_points:
|
||||
rows.append([MANAGE])
|
||||
rows.append([ADD_DOCKER, DOCKER_SERVERS] if has_platforms else [ADD_DOCKER])
|
||||
return rows
|
||||
|
||||
|
||||
MAIN_KEYBOARD = main_keyboard()
|
||||
MAIN_KEYBOARD_WITH_POINTS = main_keyboard(has_points=True)
|
||||
PLATFORM_KEYBOARD = [[NEW_APP, CHECK], [REINSTALL, RELEASE], [MAIN_MENU]]
|
||||
APP_KEYBOARD = [[START, STOP, RESTART], [LOGS, PULL], [UPLOAD_UPDATE, UPLOAD_FULL], [ROLLBACK, REMOVE_APP], [PLATFORM_MENU]]
|
||||
SERVICE_KEYBOARD = [[START, STOP, RESTART], [LOGS], [APP_MENU]]
|
||||
CONFIRM_KEYBOARD = [[APPLY, CANCEL]]
|
||||
REMOVE_APP_KEYBOARD = [[REMOVE_KEEP_DATA, REMOVE_WITH_DATA], [CANCEL]]
|
||||
DRAFT_KEYBOARD = [[DOMAIN, PROXY, MASK], [CANCEL, INSTALL, SERVER, HELP]]
|
||||
POINT_KEYBOARD = [[RELEASE, REINSTALL], [POINT_DOMAIN, POINT_PROXY], [CLIENTS], [MAIN_MENU]]
|
||||
KEYS_KEYBOARD = [[ADD_KEY, REVOKE_KEY], [SERVER_MENU]]
|
||||
@@ -62,6 +101,9 @@ FAQ_TEXT = '''<b>Что это за бот?</b>
|
||||
<b>Что бот делает с сервером?</b>
|
||||
Прописывает свой ssh ключ, меняет пароль root, обновляет систему, переносит ssh на случайный порт с входом только по ключу, включает фаервол и ставит нужные сервисы.
|
||||
|
||||
<b>Что такое docker-сервер?</b>
|
||||
Сервер под ваши docker-приложения. Бот настраивает его так же, ставит Docker и Caddy, который выпускает сертификаты для ваших доменов. Приложение - папка <code>~/apps/<имя></code>: <code>docker-compose.yaml</code>, <code>.env</code>, <code>caddy.conf</code> для веб-морды и <code>data/<сервис>/</code> с настройками сервисов. Файлы вы держите у себя и присылаете боту архивом, данные сервисов живут в volumes.
|
||||
|
||||
<b>Как забрать сервер назад?</b>
|
||||
Меню сервера - "Вернуть". Бот уберет свои доступы, откатит ssh и фаервол, поставит новый пароль root и пришлет его. Данные на сервере останутся.
|
||||
|
||||
@@ -75,6 +117,12 @@ HELP_TEXT = '''<b>Добавить сервер</b>
|
||||
4. по желанию "домен", "прокси"
|
||||
5. "установить" - установка идет в фоне, по окончании придет уведомление
|
||||
|
||||
<b>Docker-сервер</b>
|
||||
1. "добавить docker-сервер" и <code>ip:пароль</code>
|
||||
2. "docker-серверы", имя сервера, "новое приложение", имя и архив папки приложения
|
||||
3. в приложении: "залить апдейт" - файлы поверх текущих (архив или один файл), "перезалить все" - архив целиком, лишнее удаляется. Перед применением бот покажет, что изменится
|
||||
4. "старт", "стоп", "рестарт", "логи" - для всего приложения или, если прислать имя сервиса, только для него
|
||||
|
||||
<b>Управлять сервером</b>
|
||||
"управление сервером" и имя сервера из главного меню.
|
||||
|
||||
@@ -141,7 +189,7 @@ def point_card(point: Point) -> str:
|
||||
f'Сервер: <code>{escape(point.name)}</code>',
|
||||
f'Состояние: {point.get_status_display()}',
|
||||
]
|
||||
for role in Server.Role:
|
||||
for role in (Server.Role.PANEL, Server.Role.MASK):
|
||||
server = servers.get(role)
|
||||
lines.append(f'{role.label.capitalize()}: {escape(server.address) if server else EMPTY}')
|
||||
lines += [
|
||||
@@ -267,8 +315,8 @@ def change_failed(point: Point, error: Exception) -> str:
|
||||
)
|
||||
|
||||
|
||||
def task_interrupted(point: Point) -> str:
|
||||
return f'<code>{escape(point.name)}</code>: {escape(point.error)}.'
|
||||
def task_interrupted(obj: Managed) -> str:
|
||||
return f'<code>{escape(obj.name)}</code>: {escape(obj.error)}.'
|
||||
|
||||
|
||||
def health_problem(point: Point, problem: str) -> str:
|
||||
@@ -277,3 +325,198 @@ def health_problem(point: Point, problem: str) -> str:
|
||||
|
||||
def health_restored(point: Point) -> str:
|
||||
return f'Проверка <code>{escape(point.name)}</code>: снова все в порядке.'
|
||||
|
||||
|
||||
# docker-серверы
|
||||
|
||||
DOCKER_CREDENTIALS_PROMPT = (
|
||||
'отправь данные для подключения к серверу под docker\n<code>ip:root_password</code>\n'
|
||||
'Сервер будет настроен так же, как сервер точки, и получит Docker и Caddy для веб-приложений.'
|
||||
)
|
||||
DOCKER_INSTALL_STARTED = 'Запускаю установку docker-сервера. Отправлю уведомление когда все будет готово.'
|
||||
PLATFORM_SELECT_PROMPT = 'Пришлите имя docker-сервера'
|
||||
PLATFORM_NOT_FOUND = 'Docker-сервер не найден. Пришлите имя из списка.'
|
||||
PLATFORM_NOT_READY = 'Docker-сервер еще не установлен.'
|
||||
PLATFORM_REINSTALL_PROMPT = '''Сервер будет настроен заново, приложения развернуты с текущими версиями файлов.
|
||||
Вызывайте после переустановки ОС в панели хостинг провайдера.
|
||||
Пришлите <code>ip:новый_пароль</code> или нажмите "Использовать старый".'''
|
||||
PLATFORM_RELEASE_CONFIRM = '''Сервер будет освобожден: случайный пароль root, мои доступы убраны.
|
||||
Приложения на сервере продолжат работать, но управлять ими из бота будет нельзя. Продолжить?'''
|
||||
APP_NAME_PROMPT = '''Пришли имя приложения: латиница в нижнем регистре, цифры, - и _. Например <code>site</code>.
|
||||
Это имя папки <code>~/apps/<имя></code> и compose-проекта.'''
|
||||
APP_NOT_FOUND = 'Нет такого приложения или сервиса. Пришли имя из списка.'
|
||||
UPLOAD_UPDATE_PROMPT = '''Пришли архив (zip, tar, tar.gz) или один файл документом.
|
||||
Файлы лягут поверх текущих, остальные останутся. Корень архива - корень приложения.'''
|
||||
UPLOAD_FULL_PROMPT = '''Пришли архив всей папки приложения (zip, tar, tar.gz) документом.
|
||||
Файлы, которых нет в архиве, будут удалены. Корень архива - корень приложения:
|
||||
<code>docker-compose.yaml</code>, <code>.env</code>, <code>caddy.conf</code>, <code>data/<сервис>/</code>.'''
|
||||
UPLOAD_NOT_DOCUMENT = 'Пришли файл документом или нажми "отмена".'
|
||||
UPLOAD_UNCHANGED = 'Файлы не изменились, применять нечего.'
|
||||
VERSION_GONE = 'Эти файлы уже применены или заменены новыми. Пришлите их заново.'
|
||||
APP_GONE = 'Приложение уже удалено.'
|
||||
APPLY_STARTED = 'Применяю. Отправлю уведомление когда все будет готово.'
|
||||
ROLLBACK_NONE = 'Предыдущей версии нет, откатываться некуда.'
|
||||
APP_NOT_DEPLOYED = 'Приложение еще не залито: "перезалить все".'
|
||||
REMOVE_APP_CONFIRM = '''Приложение будет остановлено, папка и блок Caddy удалены.
|
||||
"удалить с данными" удалит и volumes приложения, восстановить их будет нельзя.'''
|
||||
OPERATION_STARTED = 'Выполняю. Отправлю уведомление когда все будет готово.'
|
||||
CHECK_STARTED = 'Проверяю сервер.'
|
||||
LOGS_STARTED = 'Собираю логи.'
|
||||
NO_SNAPSHOT = 'Состояние неизвестно: нажмите "проверить" в меню docker-сервера.'
|
||||
|
||||
|
||||
def platforms_list(platforms) -> str:
|
||||
return '\n'.join(
|
||||
f'<code>{escape(platform.name)}</code>\t{platform.get_status_display()}\tdocker' for platform in platforms
|
||||
)
|
||||
|
||||
|
||||
def when(moment) -> str:
|
||||
return f'{timezone.localtime(moment):%d.%m %H:%M}' if moment else EMPTY
|
||||
|
||||
|
||||
def service_line(name: str, state: dict) -> str:
|
||||
health = state.get('health')
|
||||
mark = '✅' if state.get('state') == 'running' and health in ('', None, 'healthy') else '⚠️'
|
||||
details = state.get('status') or state.get('state', '')
|
||||
return f'{mark} <code>{escape(name)}</code> {escape(details)}' + (f' ({escape(health)})' if health else '')
|
||||
|
||||
|
||||
def platform_card(platform: Platform) -> str:
|
||||
lines = [
|
||||
f'Docker-сервер: <code>{escape(platform.name)}</code>',
|
||||
f'Состояние: {platform.get_status_display()}',
|
||||
f'Адрес: {escape(platform.server.address)}',
|
||||
]
|
||||
if platform.error:
|
||||
lines.append(f'Ошибка: {escape(platform.error)}')
|
||||
apps = list(platform.apps.all())
|
||||
lines.append(f'\n<b>Приложения</b> (проверено {when(platform.status_at)}):')
|
||||
if not apps:
|
||||
lines.append('Приложений пока нет: "новое приложение".')
|
||||
for app in apps:
|
||||
status = platform.app_status(app.name)
|
||||
services = status.get('services', {})
|
||||
if app.current_version() is None:
|
||||
summary = 'не залито'
|
||||
elif status.get('error'):
|
||||
summary = f'ошибка: {escape(status["error"])}'
|
||||
elif not services:
|
||||
summary = 'нет данных'
|
||||
else:
|
||||
running = sum(1 for state in services.values() if state.get('state') == 'running')
|
||||
summary = f'работает {running} из {len(services)}'
|
||||
lines.append(f'<code>{escape(app.name)}</code> {summary}')
|
||||
if apps:
|
||||
lines.append('\nПришли имя приложения, чтобы управлять им.')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def version_line(version: AppVersion | None) -> str:
|
||||
if version is None:
|
||||
return 'не залито'
|
||||
return f'{when(version.created_at)}, {version.get_kind_display()}'
|
||||
|
||||
|
||||
def app_card(app: DockerApp) -> str:
|
||||
platform = app.platform
|
||||
status = platform.app_status(app.name)
|
||||
lines = [
|
||||
f'Приложение: <code>{escape(app.name)}</code> на <code>{escape(platform.name)}</code>',
|
||||
f'Файлы: {version_line(app.current_version())}',
|
||||
f'Сервисы (проверено {when(platform.status_at)}):',
|
||||
]
|
||||
if status.get('error'):
|
||||
lines.append(f'ошибка: {escape(status["error"])}')
|
||||
services = status.get('services', {})
|
||||
lines += [service_line(name, state) for name, state in sorted(services.items())] or [EMPTY]
|
||||
if services:
|
||||
lines.append('\nПришли имя сервиса, чтобы управлять только им.')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def service_card(app: DockerApp, service: str) -> str:
|
||||
state = app.platform.app_status(app.name).get('services', {}).get(service, {})
|
||||
return '\n'.join([
|
||||
f'Сервис <code>{escape(service)}</code> приложения <code>{escape(app.name)}</code>',
|
||||
service_line(service, state) if state else EMPTY,
|
||||
])
|
||||
|
||||
|
||||
def upload_report(upload) -> str:
|
||||
"""Разница файлов, ошибки и предупреждения присланной версии."""
|
||||
diff = upload.diff
|
||||
lines = []
|
||||
for title, paths in (('Добавлено', diff.added), ('Изменено', diff.changed), ('Удалено', diff.removed)):
|
||||
if paths:
|
||||
shown = ', '.join(f'<code>{escape(path)}</code>' for path in paths[:20])
|
||||
lines.append(f'{title}: {shown}' + (f' и еще {len(paths) - 20}' if len(paths) > 20 else ''))
|
||||
if upload.errors:
|
||||
lines.append('\n<b>Нельзя применить:</b>')
|
||||
lines += [f'- {escape(error)}' for error in upload.errors]
|
||||
if upload.warnings:
|
||||
lines.append('\n<b>Предупреждения:</b>')
|
||||
lines += [f'- {escape(warning)}' for warning in upload.warnings]
|
||||
if upload.version:
|
||||
lines.append('\nПрименить?')
|
||||
elif upload.errors:
|
||||
lines.append('\nИсправь и пришли файлы заново.')
|
||||
return '\n'.join(lines) or UPLOAD_UNCHANGED
|
||||
|
||||
|
||||
def upload_failed(error: Exception) -> str:
|
||||
return f'Не удалось разобрать файл: {escape(str(error))}'
|
||||
|
||||
|
||||
def platform_installed(platform: Platform) -> str:
|
||||
return (
|
||||
f'docker-сервер {escape(platform.server.address)} - проименован как <code>{escape(platform.name)}</code>, '
|
||||
'готов к работе.'
|
||||
)
|
||||
|
||||
|
||||
def platform_install_failed(platform: Platform, error: Exception) -> str:
|
||||
return f'Не удалось настроить <code>{escape(platform.name)}</code>:\n{escape(str(error))}'
|
||||
|
||||
|
||||
def platform_released(name: str, address: str, password: str, error: str) -> str:
|
||||
if error:
|
||||
return f'<code>{escape(name)}</code>: не удалось вернуть {escape(address)}: {escape(error)}'
|
||||
return f'Docker-сервер <code>{escape(name)}</code> возвращен.\n{escape(address)} - пароль root <code>{escape(password)}</code>'
|
||||
|
||||
|
||||
def deployed(name: str, data: dict) -> str:
|
||||
lines = [f'<code>{escape(name)}</code>: файлы применены.']
|
||||
recreated = data.get('recreated') or []
|
||||
if recreated:
|
||||
lines.append('Пересозданы с новыми настройками: ' + ', '.join(f'<code>{escape(s)}</code>' for s in recreated))
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def deploy_failed(name: str, error: Exception) -> str:
|
||||
return f'<code>{escape(name)}</code>: не удалось применить файлы:\n{escape(str(error))}'
|
||||
|
||||
|
||||
def app_controlled(name: str, action: str, service: str) -> str:
|
||||
target = f'<code>{escape(name)}</code>' + (f' / <code>{escape(service)}</code>' if service else '')
|
||||
return f'{target}: {ACTION_DONE.get(action, action)}.'
|
||||
|
||||
|
||||
def app_removed(name: str, volumes: bool) -> str:
|
||||
return f'<code>{escape(name)}</code> удалено' + (' вместе с данными.' if volumes else ', volumes остались на сервере.')
|
||||
|
||||
|
||||
def app_operation_failed(name: str, error: Exception) -> str:
|
||||
return f'<code>{escape(name)}</code>: не удалось:\n{escape(str(error))}'
|
||||
|
||||
|
||||
def refresh_failed(platform: Platform, error: Exception) -> str:
|
||||
return f'<code>{escape(platform.name)}</code>: не удалось проверить сервер:\n{escape(str(error))}'
|
||||
|
||||
|
||||
def logs_caption(name: str, service: str) -> str:
|
||||
return f'Логи {name}' + (f' / {service}' if service else '')
|
||||
|
||||
|
||||
def logs_failed(name: str, error: Exception) -> str:
|
||||
return f'<code>{escape(name)}</code>: не удалось получить логи:\n{escape(str(error))}'
|
||||
@@ -0,0 +1,100 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-29 08:24
|
||||
|
||||
import App.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('App', '0009_point_task_supervisor'),
|
||||
('Telegram', '0003_bot_webhook'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DockerApp',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=64)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'приложение',
|
||||
'verbose_name_plural': 'приложения',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='point',
|
||||
name='owner',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='Telegram.user'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='server',
|
||||
name='point',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='servers', to='App.point'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='server',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('panel', 'панель'), ('mask', 'маскировка'), ('docker', 'docker')], max_length=8),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AppVersion',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('files', App.fields.EncryptedTextField(help_text='base64(zlib(json {путь: base64}))')),
|
||||
('kind', models.CharField(choices=[('full', 'перезалито все'), ('update', 'апдейт'), ('rollback', 'откат')], max_length=8)),
|
||||
('state', models.CharField(choices=[('draft', 'черновик'), ('applied', 'применена'), ('failed', 'не применилась')], default='draft', max_length=8)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='App.dockerapp')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'версия приложения',
|
||||
'verbose_name_plural': 'версии приложений',
|
||||
'ordering': ['-pk'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Platform',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=64, unique=True)),
|
||||
('status', models.CharField(choices=[('installing', 'устанавливается'), ('updating', 'настраивается'), ('ready', 'активен'), ('error', 'ошибка'), ('releasing', 'возвращается')], default='installing', max_length=16)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('task_token', models.CharField(blank=True, editable=False, max_length=32)),
|
||||
('task_started_at', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
('task_heartbeat', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('status_snapshot', models.JSONField(blank=True, default=dict, editable=False, help_text='Последний DockerHost.status(): состояние приложений')),
|
||||
('status_at', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='Telegram.user')),
|
||||
('task_chat', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='Telegram.chat')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'docker-сервер',
|
||||
'verbose_name_plural': 'docker-серверы',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='dockerapp',
|
||||
name='platform',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='apps', to='App.platform'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='server',
|
||||
name='platform',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='servers', to='App.platform'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='server',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('platform__isnull', True), ('point__isnull', False)), models.Q(('platform__isnull', False), ('point__isnull', True)), _connector='OR'), name='app_server_one_parent'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='dockerapp',
|
||||
constraint=models.UniqueConstraint(fields=('platform', 'name'), name='app_dockerapp_name_unique'),
|
||||
),
|
||||
]
|
||||
+319
-102
@@ -1,12 +1,15 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.contrib.auth.base_user import BaseUserManager
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from serverus import CF_WARP, Host, generate_password, generate_port, generate_username
|
||||
from serverus import APP_NAME_RE, CF_WARP, Host, generate_password, generate_port, generate_username
|
||||
|
||||
from .fields import EncryptedTextField
|
||||
|
||||
@@ -68,29 +71,131 @@ class Credentials:
|
||||
# find_* возвращают None, и это отражено в типе. Вызывающая сторона видит по имени и типу, нужна ли проверка на None.
|
||||
|
||||
|
||||
class PointQuerySet(models.QuerySet):
|
||||
def owned_by(self, owner) -> 'PointQuerySet':
|
||||
"""Точки владельца от старых к новым."""
|
||||
class ManagedQuerySet(models.QuerySet):
|
||||
def owned_by(self, owner) -> 'ManagedQuerySet':
|
||||
"""Записи владельца от старых к новым."""
|
||||
return self.filter(owner=owner).order_by('created_at')
|
||||
|
||||
def find_by_pk(self, pk) -> 'Point | None':
|
||||
def find_by_pk(self, pk):
|
||||
return self.filter(pk=pk).first()
|
||||
|
||||
def find_by_name(self, name: str) -> 'Point | None':
|
||||
def find_by_name(self, name: str):
|
||||
return self.filter(name=name).first()
|
||||
|
||||
def find_last(self) -> 'Point | None':
|
||||
"""Последняя созданная точка."""
|
||||
def find_last(self):
|
||||
"""Последняя созданная запись."""
|
||||
return self.order_by('created_at').last()
|
||||
|
||||
def is_name_taken(self, name: str) -> bool:
|
||||
return self.filter(name=name).exists()
|
||||
|
||||
def busy(self) -> 'PointQuerySet':
|
||||
return self.filter(status__in=Point.BUSY_STATUSES)
|
||||
def busy(self) -> 'ManagedQuerySet':
|
||||
return self.filter(status__in=self.model.BUSY_STATUSES)
|
||||
|
||||
|
||||
class PointManager(models.Manager.from_queryset(PointQuerySet)):
|
||||
class ManagedManager(models.Manager.from_queryset(ManagedQuerySet)):
|
||||
"""Долгая задача записи: token связывает запись, задачу и ее супервизора (App.tasks)."""
|
||||
|
||||
def start_task(self, obj: 'Managed', status: str, token: str, chat=None):
|
||||
"""Запись занята задачей token. chat - куда сообщить, если задача прервется."""
|
||||
obj.status = status
|
||||
obj.error = ''
|
||||
obj.task_token = token
|
||||
obj.task_chat = chat
|
||||
obj.task_started_at = None
|
||||
obj.task_heartbeat = timezone.now()
|
||||
obj.save(update_fields=['status', 'error', 'task_token', 'task_chat', 'task_started_at', 'task_heartbeat'])
|
||||
|
||||
def claim_task(self, pk, token: str) -> bool:
|
||||
"""Задача token начала работу. False - запись уже не ее или это повтор после падения воркера."""
|
||||
now = timezone.now()
|
||||
return bool(self.filter(pk=pk, task_token=token, task_started_at__isnull=True)
|
||||
.update(task_started_at=now, task_heartbeat=now))
|
||||
|
||||
def beat(self, pk, token: str):
|
||||
"""Задача token жива."""
|
||||
self.filter(pk=pk, task_token=token).update(task_heartbeat=timezone.now())
|
||||
|
||||
def finish_task(self, pk, token: str):
|
||||
"""Задача token закончилась, статус она выставила сама."""
|
||||
self.filter(pk=pk, task_token=token).update(**Managed.NO_TASK)
|
||||
|
||||
def abort_task(self, pk, token: str) -> bool:
|
||||
"""Задача token прервалась: статус по INTERRUPTED. False - задача уже снята или успела выставить итог."""
|
||||
with transaction.atomic():
|
||||
obj = self.select_for_update().filter(pk=pk, task_token=token).first()
|
||||
if obj is None:
|
||||
return False
|
||||
if obj.status not in self.model.INTERRUPTED:
|
||||
# сценарий закончился, упало то, что после него (например уведомление): итог уже в статусе
|
||||
self.filter(pk=pk).update(**Managed.NO_TASK)
|
||||
return False
|
||||
status, error = self.model.INTERRUPTED[obj.status]
|
||||
self.filter(pk=pk).update(status=status, error=error, **Managed.NO_TASK)
|
||||
return True
|
||||
|
||||
def unique_name(self, prefix: str) -> str:
|
||||
while True:
|
||||
name = f'{prefix}-{secrets.token_hex(3)}'
|
||||
if not self.is_name_taken(name):
|
||||
return name
|
||||
|
||||
|
||||
class Managed(models.Model):
|
||||
"""Запись, которой управляют долгие задачи (App.tasks): точка доступа или docker-сервер.
|
||||
|
||||
Пока запись занята задачей (BUSY_STATUSES), с ней ничего не сделать. Прерванная задача оставляет статус
|
||||
по INTERRUPTED.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
INSTALLING = 'installing', 'устанавливается'
|
||||
UPDATING = 'updating', 'настраивается'
|
||||
READY = 'ready', 'активен'
|
||||
ERROR = 'error', 'ошибка'
|
||||
RELEASING = 'releasing', 'возвращается'
|
||||
|
||||
BUSY_STATUSES = (Status.INSTALLING, Status.UPDATING, Status.RELEASING)
|
||||
# статус и ошибка, если задача прервалась: упал воркер, вышел лимит времени
|
||||
INTERRUPTED: dict[str, tuple[str, str]] = {}
|
||||
NO_TASK = {'task_token': '', 'task_chat': None, 'task_started_at': None, 'task_heartbeat': None}
|
||||
|
||||
owner = models.ForeignKey('Telegram.User', on_delete=models.PROTECT, related_name='%(class)ss')
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.INSTALLING)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
# долгая задача и ее супервизор, см. App.tasks
|
||||
task_token = models.CharField(max_length=32, blank=True, editable=False)
|
||||
task_chat = models.ForeignKey(
|
||||
'Telegram.Chat', on_delete=models.SET_NULL, null=True, blank=True, editable=False, related_name='+',
|
||||
)
|
||||
task_started_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
task_heartbeat = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def is_busy(self) -> bool:
|
||||
return self.status in self.BUSY_STATUSES
|
||||
|
||||
def set_status(self, status: str, error: str = ''):
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.save(update_fields=['status', 'error'])
|
||||
|
||||
|
||||
class PointQuerySet(ManagedQuerySet):
|
||||
pass
|
||||
|
||||
|
||||
class PointManager(ManagedManager.from_queryset(PointQuerySet)):
|
||||
def create_point(
|
||||
self, owner, panel: Credentials, mask: Credentials | None = None, *, domain: str = '', outbound_proxy: str = '',
|
||||
) -> 'Point':
|
||||
@@ -102,7 +207,7 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
|
||||
with transaction.atomic():
|
||||
point = self.create(
|
||||
owner=owner,
|
||||
name=self._unique_name(),
|
||||
name=self.unique_name('point'),
|
||||
domain=domain,
|
||||
outbound_proxy=outbound_proxy,
|
||||
panel_username=generate_username(),
|
||||
@@ -117,54 +222,8 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
|
||||
Server.objects.create_server(point, Server.Role.MASK, mask)
|
||||
return point
|
||||
|
||||
# долгая задача точки: token связывает точку, задачу и ее супервизора (App.tasks)
|
||||
|
||||
def start_task(self, point: 'Point', status: str, token: str, chat=None):
|
||||
"""Точка занята задачей token. chat - куда сообщить, если задача прервется."""
|
||||
point.status = status
|
||||
point.error = ''
|
||||
point.task_token = token
|
||||
point.task_chat = chat
|
||||
point.task_started_at = None
|
||||
point.task_heartbeat = timezone.now()
|
||||
point.save(update_fields=['status', 'error', 'task_token', 'task_chat', 'task_started_at', 'task_heartbeat'])
|
||||
|
||||
def claim_task(self, pk, token: str) -> bool:
|
||||
"""Задача token начала работу. False - точка уже не ее или это повтор после падения воркера."""
|
||||
now = timezone.now()
|
||||
return bool(self.filter(pk=pk, task_token=token, task_started_at__isnull=True)
|
||||
.update(task_started_at=now, task_heartbeat=now))
|
||||
|
||||
def beat(self, pk, token: str):
|
||||
"""Задача token жива."""
|
||||
self.filter(pk=pk, task_token=token).update(task_heartbeat=timezone.now())
|
||||
|
||||
def finish_task(self, pk, token: str):
|
||||
"""Задача token закончилась, статус она выставила сама."""
|
||||
self.filter(pk=pk, task_token=token).update(**Point.NO_TASK)
|
||||
|
||||
def abort_task(self, pk, token: str) -> bool:
|
||||
"""Задача token прервалась: статус по INTERRUPTED. False - задача уже снята или успела выставить итог."""
|
||||
with transaction.atomic():
|
||||
point = self.select_for_update().filter(pk=pk, task_token=token).first()
|
||||
if point is None:
|
||||
return False
|
||||
if point.status not in Point.INTERRUPTED:
|
||||
# сценарий закончился, упало то, что после него (например уведомление): итог уже в статусе
|
||||
self.filter(pk=pk).update(**Point.NO_TASK)
|
||||
return False
|
||||
status, error = Point.INTERRUPTED[point.status]
|
||||
self.filter(pk=pk).update(status=status, error=error, **Point.NO_TASK)
|
||||
return True
|
||||
|
||||
def _unique_name(self) -> str:
|
||||
while True:
|
||||
name = f'point-{secrets.token_hex(3)}'
|
||||
if not self.is_name_taken(name):
|
||||
return name
|
||||
|
||||
|
||||
class Point(models.Model):
|
||||
class Point(Managed):
|
||||
"""Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним.
|
||||
|
||||
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
|
||||
@@ -174,27 +233,12 @@ class Point(models.Model):
|
||||
|
||||
CF_WARP = CF_WARP
|
||||
|
||||
class Status(models.TextChoices):
|
||||
INSTALLING = 'installing', 'устанавливается'
|
||||
UPDATING = 'updating', 'настраивается'
|
||||
READY = 'ready', 'активен'
|
||||
ERROR = 'error', 'ошибка'
|
||||
RELEASING = 'releasing', 'возвращается'
|
||||
|
||||
BUSY_STATUSES = (Status.INSTALLING, Status.UPDATING, Status.RELEASING)
|
||||
# статус и ошибка, если задача прервалась: упал воркер, вышел лимит времени
|
||||
INTERRUPTED = {
|
||||
Status.INSTALLING: (Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
|
||||
Managed.Status.INSTALLING: (Managed.Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
|
||||
# домен и прокси serverus применяет в самом конце, прежние настройки почти наверняка на месте
|
||||
Status.UPDATING: (Status.READY, 'смена настройки прервалась, настройки прежние'),
|
||||
Status.RELEASING: (Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
|
||||
Managed.Status.UPDATING: (Managed.Status.READY, 'смена настройки прервалась, настройки прежние'),
|
||||
Managed.Status.RELEASING: (Managed.Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
|
||||
}
|
||||
NO_TASK = {'task_token': '', 'task_chat': None, 'task_started_at': None, 'task_heartbeat': None}
|
||||
|
||||
owner = models.ForeignKey('Telegram.User', on_delete=models.PROTECT, related_name='points')
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.INSTALLING)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
domain = models.CharField(max_length=253, blank=True)
|
||||
outbound_proxy = EncryptedTextField(blank=True, help_text=f'protocol://user:password@host:port или {CF_WARP}')
|
||||
@@ -208,34 +252,17 @@ class Point(models.Model):
|
||||
sub_port = models.PositiveIntegerField(help_text='Порт подписки панели')
|
||||
sub_path = models.CharField(max_length=64, help_text='Путь подписки без слешей')
|
||||
|
||||
# долгая задача (установка, смена настройки, возврат) и ее супервизор, см. App.tasks
|
||||
task_token = models.CharField(max_length=32, blank=True, editable=False)
|
||||
task_chat = models.ForeignKey(
|
||||
'Telegram.Chat', on_delete=models.SET_NULL, null=True, blank=True, editable=False, related_name='+',
|
||||
)
|
||||
task_started_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
task_heartbeat = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
|
||||
# ежедневная проверка, см. App.tasks.check_point
|
||||
check_token = models.CharField(max_length=32, blank=True, editable=False)
|
||||
checked_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
health_error = models.TextField(blank=True, editable=False, help_text='Что не так по последней проверке')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = PointManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'точка доступа'
|
||||
verbose_name_plural = 'точки доступа'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def is_busy(self) -> bool:
|
||||
return self.status in self.BUSY_STATUSES
|
||||
|
||||
@property
|
||||
def panel_host(self) -> str:
|
||||
"""Адрес панели для https и подписки: домен, без него - ip панели. По ssh ходим только по ip."""
|
||||
@@ -247,11 +274,6 @@ class Point(models.Model):
|
||||
def subscription_url(self, sub_id: str) -> str:
|
||||
return f'https://{self.panel_host}:{self.sub_port}/{self.sub_path}/{sub_id}'
|
||||
|
||||
def set_status(self, status: str, error: str = ''):
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.save(update_fields=['status', 'error'])
|
||||
|
||||
def server(self, role: str) -> 'Server':
|
||||
"""Сервер с ролью role. Бросает Server.DoesNotExist, если его нет."""
|
||||
return self.servers.get(role=role)
|
||||
@@ -294,19 +316,35 @@ class ServerManager(models.Manager.from_queryset(ServerQuerySet)):
|
||||
default_root_password=credentials.password,
|
||||
)
|
||||
|
||||
def create_docker_server(self, platform: 'Platform', credentials: Credentials) -> 'Server':
|
||||
"""Сервер docker-платформы. Вызывать через Server.objects, обычно в составе Platform.objects.create_platform."""
|
||||
return self.create(
|
||||
platform=platform,
|
||||
role=Server.Role.DOCKER,
|
||||
address=credentials.address,
|
||||
root_password=credentials.password,
|
||||
default_root_password=credentials.password,
|
||||
)
|
||||
|
||||
|
||||
class Server(models.Model):
|
||||
"""Сервер под управлением системы. Поля ssh отражают текущее состояние сервера.
|
||||
|
||||
Создание: Server.objects.create_server, обычно в составе Point.objects.create_point. Поиск: методы ServerQuerySet,
|
||||
Принадлежит точке доступа (роли panel, mask) или docker-платформе (роль docker), ровно чему-то одному.
|
||||
Создание: Server.objects.create_server, обычно в составе Point.objects.create_point, и
|
||||
Server.objects.create_docker_server в составе Platform.objects.create_platform. Поиск: методы ServerQuerySet,
|
||||
в том числе в пределах точки: point.servers.find_by_address(address).
|
||||
"""
|
||||
|
||||
class Role(models.TextChoices):
|
||||
PANEL = 'panel', 'панель'
|
||||
MASK = 'mask', 'маскировка'
|
||||
DOCKER = 'docker', 'docker'
|
||||
|
||||
point = models.ForeignKey(Point, on_delete=models.CASCADE, related_name='servers')
|
||||
point = models.ForeignKey(Point, on_delete=models.CASCADE, null=True, blank=True, related_name='servers')
|
||||
platform = models.ForeignKey(
|
||||
'Platform', on_delete=models.CASCADE, null=True, blank=True, related_name='servers',
|
||||
)
|
||||
role = models.CharField(max_length=8, choices=Role.choices)
|
||||
address = models.GenericIPAddressField(unique=True)
|
||||
ssh_port = models.PositiveIntegerField(default=22)
|
||||
@@ -325,7 +363,14 @@ class Server(models.Model):
|
||||
class Meta:
|
||||
verbose_name = 'сервер'
|
||||
verbose_name_plural = 'серверы'
|
||||
constraints = [models.UniqueConstraint(fields=['point', 'role'], name='app_server_role_unique')]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['point', 'role'], name='app_server_role_unique'),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(point__isnull=False, platform__isnull=True)
|
||||
| models.Q(point__isnull=True, platform__isnull=False),
|
||||
name='app_server_one_parent',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.address} ({self.get_role_display()})'
|
||||
@@ -378,3 +423,175 @@ class Key(models.Model):
|
||||
@property
|
||||
def subscription_url(self) -> str:
|
||||
return self.point.subscription_url(self.sub_id)
|
||||
|
||||
|
||||
class PlatformQuerySet(ManagedQuerySet):
|
||||
pass
|
||||
|
||||
|
||||
class PlatformManager(ManagedManager.from_queryset(PlatformQuerySet)):
|
||||
def create_platform(self, owner, credentials: Credentials) -> 'Platform':
|
||||
"""Создает docker-платформу вместе с ее сервером. Вызывать через Platform.objects. Имя генерируется."""
|
||||
with transaction.atomic():
|
||||
platform = self.create(owner=owner, name=self.unique_name('docker'))
|
||||
Server.objects.create_docker_server(platform, credentials)
|
||||
return platform
|
||||
|
||||
|
||||
class Platform(Managed):
|
||||
"""Docker-сервер: приложения в ~/apps под управлением бота (serverus.DockerHost).
|
||||
|
||||
Операции с сервером идут по одной: пока платформа занята задачей, с ней ничего не сделать.
|
||||
Создание: Platform.objects.create_platform. Поиск: методы PlatformQuerySet. Удаление: platform.delete()
|
||||
удаляет и сервер, и приложения.
|
||||
"""
|
||||
|
||||
INTERRUPTED = {
|
||||
Managed.Status.INSTALLING: (Managed.Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
|
||||
Managed.Status.UPDATING: (Managed.Status.READY, 'операция прервалась, проверьте состояние кнопкой "Проверить"'),
|
||||
Managed.Status.RELEASING: (Managed.Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
|
||||
}
|
||||
|
||||
status_snapshot = models.JSONField(
|
||||
default=dict, blank=True, editable=False, help_text='Последний DockerHost.status(): состояние приложений',
|
||||
)
|
||||
status_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
|
||||
objects = PlatformManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'docker-сервер'
|
||||
verbose_name_plural = 'docker-серверы'
|
||||
|
||||
@property
|
||||
def server(self) -> Server:
|
||||
"""Сервер платформы. Есть всегда: create_platform создает его вместе с платформой."""
|
||||
return self.servers.get(role=Server.Role.DOCKER)
|
||||
|
||||
def app_status(self, name: str) -> dict:
|
||||
"""Состояние приложения из последнего снимка: {'services': {...}, 'error': ''}, пусто - в снимке его нет."""
|
||||
return self.status_snapshot.get('apps', {}).get(name, {})
|
||||
|
||||
def save_snapshot(self, snapshot: dict):
|
||||
self.status_snapshot = snapshot
|
||||
self.status_at = timezone.now()
|
||||
self.save(update_fields=['status_snapshot', 'status_at'])
|
||||
|
||||
|
||||
class DockerAppQuerySet(models.QuerySet):
|
||||
def find_by_name(self, name: str) -> 'DockerApp | None':
|
||||
return self.filter(name=name).first()
|
||||
|
||||
def is_name_taken(self, name: str) -> bool:
|
||||
return self.filter(name=name).exists()
|
||||
|
||||
|
||||
class DockerApp(models.Model):
|
||||
"""Приложение на docker-платформе: папка ~/apps/<name>/, compose-проект name.
|
||||
|
||||
Файлы приложения хранятся версиями (AppVersion): текущая - последняя примененная. Сервер всегда получает полный
|
||||
набор файлов текущей версии. Создание и удаление: App.platforms. Поиск: platform.apps.find_by_name(name).
|
||||
"""
|
||||
|
||||
NAME_RE = APP_NAME_RE
|
||||
|
||||
platform = models.ForeignKey(Platform, on_delete=models.CASCADE, related_name='apps')
|
||||
name = models.CharField(max_length=64)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = DockerAppQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'приложение'
|
||||
verbose_name_plural = 'приложения'
|
||||
ordering = ['name']
|
||||
constraints = [models.UniqueConstraint(fields=['platform', 'name'], name='app_dockerapp_name_unique')]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.platform})'
|
||||
|
||||
def current_version(self) -> 'AppVersion | None':
|
||||
return self.versions.applied().first()
|
||||
|
||||
def previous_version(self) -> 'AppVersion | None':
|
||||
"""Версия перед текущей: на нее откатывается приложение."""
|
||||
return self.versions.applied()[1:2].first()
|
||||
|
||||
|
||||
class AppVersionQuerySet(models.QuerySet):
|
||||
def applied(self) -> 'AppVersionQuerySet':
|
||||
"""Примененные версии от новых к старым."""
|
||||
return self.filter(state=AppVersion.State.APPLIED).order_by('-pk')
|
||||
|
||||
def find_by_pk(self, pk) -> 'AppVersion | None':
|
||||
return self.filter(pk=pk).first()
|
||||
|
||||
|
||||
class AppVersionManager(models.Manager.from_queryset(AppVersionQuerySet)):
|
||||
def create_draft(self, app: DockerApp, files: dict[str, bytes], kind: str) -> 'AppVersion':
|
||||
"""Черновик версии: файлы, которые пользователь прислал и еще не применил. Прежние черновики удаляются."""
|
||||
with transaction.atomic():
|
||||
app.versions.filter(state=AppVersion.State.DRAFT).delete()
|
||||
return self.create(app=app, files=AppVersion.encode(files), kind=kind)
|
||||
|
||||
|
||||
class AppVersion(models.Model):
|
||||
"""Полный набор файлов приложения. Файлы хранятся зашифрованными: в них .env с секретами.
|
||||
|
||||
Черновик (draft) становится примененной версией (applied) или неудачной (failed). Хранятся KEEP последних
|
||||
примененных версий. Создание: AppVersion.objects.create_draft, дальше App.platforms.
|
||||
"""
|
||||
|
||||
KEEP = 10
|
||||
|
||||
class Kind(models.TextChoices):
|
||||
FULL = 'full', 'перезалито все'
|
||||
UPDATE = 'update', 'апдейт'
|
||||
ROLLBACK = 'rollback', 'откат'
|
||||
|
||||
class State(models.TextChoices):
|
||||
DRAFT = 'draft', 'черновик'
|
||||
APPLIED = 'applied', 'применена'
|
||||
FAILED = 'failed', 'не применилась'
|
||||
|
||||
app = models.ForeignKey(DockerApp, on_delete=models.CASCADE, related_name='versions')
|
||||
files = EncryptedTextField(help_text='base64(zlib(json {путь: base64}))')
|
||||
kind = models.CharField(max_length=8, choices=Kind.choices)
|
||||
state = models.CharField(max_length=8, choices=State.choices, default=State.DRAFT)
|
||||
error = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = AppVersionManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'версия приложения'
|
||||
verbose_name_plural = 'версии приложений'
|
||||
ordering = ['-pk']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.app} #{self.pk} ({self.get_state_display()})'
|
||||
|
||||
@staticmethod
|
||||
def encode(files: dict[str, bytes]) -> str:
|
||||
data = {path: base64.b64encode(content).decode() for path, content in sorted(files.items())}
|
||||
return base64.b64encode(zlib.compress(json.dumps(data).encode())).decode()
|
||||
|
||||
def get_files(self) -> dict[str, bytes]:
|
||||
data = json.loads(zlib.decompress(base64.b64decode(self.files)))
|
||||
return {path: base64.b64decode(content) for path, content in data.items()}
|
||||
|
||||
def mark_applied(self):
|
||||
"""Версия применена: старые примененные версии сверх KEEP и неудачные попытки удаляются."""
|
||||
with transaction.atomic():
|
||||
self.state = self.State.APPLIED
|
||||
self.error = ''
|
||||
self.save(update_fields=['state', 'error'])
|
||||
versions = self.app.versions
|
||||
versions.filter(state=self.State.FAILED).delete()
|
||||
stale = versions.applied().values_list('pk', flat=True)[self.KEEP:]
|
||||
versions.filter(pk__in=list(stale)).delete()
|
||||
|
||||
def mark_failed(self, error: str):
|
||||
self.state = self.State.FAILED
|
||||
self.error = error
|
||||
self.save(update_fields=['state', 'error'])
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Сценарии docker-платформы поверх serverus.DockerHost: установка, приложения и их версии, возврат сервера.
|
||||
|
||||
Файлы приложения живут в App версиями (AppVersion): апдейт сливается с текущей версией здесь, а сервер всегда
|
||||
получает полный набор файлов. Операции, которые меняют сервер, выполняются задачами (App.tasks) по одной.
|
||||
"""
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import serverus
|
||||
|
||||
from . import uploads
|
||||
from .models import AppVersion, DockerApp, Platform
|
||||
from .services import (
|
||||
ProvisionError, bootstrap_server, check_result, installing, local_ca, release_server, reset_for_reinstall, updating,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Upload:
|
||||
"""Итог разбора присланных файлов. version - черновик для применения, None - применять нечего или нельзя."""
|
||||
version: AppVersion | None
|
||||
diff: uploads.Diff
|
||||
errors: list[str]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def docker(platform: Platform) -> serverus.DockerHost:
|
||||
return serverus.DockerHost(platform.server.host())
|
||||
|
||||
|
||||
def install_platform(platform: Platform):
|
||||
"""bootstrap сервера, Docker и ingress, затем приложения с текущими версиями (после переустановки ОС)."""
|
||||
with installing(platform):
|
||||
server = platform.server
|
||||
bootstrap_server(server)
|
||||
check_result(server, docker(platform).install(local_certs=local_ca() is not None))
|
||||
server.uname = check_result(server, serverus.Server(server.host()).facts()).data.get('uname', '')
|
||||
server.save(update_fields=['uname'])
|
||||
for app in platform.apps.all():
|
||||
version = app.current_version()
|
||||
if version:
|
||||
check_result(server, docker(platform).deploy(app.name, version.get_files()))
|
||||
_refresh_after(platform)
|
||||
|
||||
|
||||
def reinstall(platform: Platform, password: str | None = None):
|
||||
"""Повторная установка после переустановки ОС у провайдера. password - новый пароль root от провайдера,
|
||||
без него - присланный при добавлении сервера. Приложения разворачиваются заново с текущими версиями."""
|
||||
reset_for_reinstall(platform.server, password)
|
||||
install_platform(platform)
|
||||
|
||||
|
||||
def release_platform(platform: Platform) -> tuple[str, str]:
|
||||
"""Возвращает сервер владельцу: (новый пароль root, ошибка). Приложения на сервере продолжают работать.
|
||||
|
||||
Платформа удаляется из базы вместе с версиями приложений, только если сервер возвращен.
|
||||
"""
|
||||
server = platform.server
|
||||
error = release_server(server)
|
||||
if error:
|
||||
platform.set_status(Platform.Status.ERROR, f'{server.address}: {error}')
|
||||
return '', error
|
||||
password = server.root_password
|
||||
platform.delete()
|
||||
return password, ''
|
||||
|
||||
|
||||
def create_app(platform: Platform, name: str) -> DockerApp:
|
||||
"""Новое приложение без файлов. ValueError - имя не подходит, занято или зарезервировано."""
|
||||
if not DockerApp.NAME_RE.match(name):
|
||||
raise ValueError('имя приложения: латиница в нижнем регистре, цифры, - и _, до 64 символов')
|
||||
if name == serverus.INGRESS or platform.apps.is_name_taken(name):
|
||||
raise ValueError(f'приложение {name} уже есть')
|
||||
return DockerApp.objects.create(platform=platform, name=name)
|
||||
|
||||
|
||||
def discard_new_app(app: DockerApp):
|
||||
"""Отмена создания: приложение, которое ни разу не применялось, удаляется."""
|
||||
if app.current_version() is None:
|
||||
app.delete()
|
||||
|
||||
|
||||
def prepare_upload(app: DockerApp, filename: str, content: bytes, kind: str) -> Upload:
|
||||
"""Разбирает присланный файл в черновик версии. kind - AppVersion.Kind.UPDATE (поверх текущих файлов)
|
||||
или FULL (архив целиком, отсутствующее удаляется). uploads.UploadError - файл не разобрать."""
|
||||
new = uploads.read(filename, content)
|
||||
if kind == AppVersion.Kind.FULL and not uploads.is_archive(filename):
|
||||
raise uploads.UploadError('перезалить все можно только архивом: zip, tar, tar.gz')
|
||||
current = app.current_version()
|
||||
old = current.get_files() if current else {}
|
||||
files = uploads.merge(old, new) if kind == AppVersion.Kind.UPDATE else new
|
||||
return _draft(app, old, files, kind)
|
||||
|
||||
|
||||
def prepare_rollback(app: DockerApp) -> Upload:
|
||||
"""Черновик отката на предыдущую версию. ValueError - откатываться некуда."""
|
||||
previous = app.previous_version()
|
||||
if previous is None:
|
||||
raise ValueError('предыдущей версии нет')
|
||||
return _draft(app, app.current_version().get_files(), previous.get_files(), AppVersion.Kind.ROLLBACK)
|
||||
|
||||
|
||||
def _draft(app: DockerApp, old: dict[str, bytes], files: dict[str, bytes], kind: str) -> Upload:
|
||||
errors, warnings = uploads.check(files)
|
||||
diff = uploads.diff(old, files)
|
||||
version = None
|
||||
if not errors and (diff or app.current_version() is None):
|
||||
version = AppVersion.objects.create_draft(app, files, kind)
|
||||
return Upload(version, diff, errors, warnings)
|
||||
|
||||
|
||||
def apply(version: AppVersion) -> dict:
|
||||
"""Разворачивает черновик на сервере. Возвращает data DockerHost.deploy: changed, removed, recreated, services.
|
||||
|
||||
Ошибка - версия отмечается неудачной, на сервере остается прежняя (compose-файл проверяется до изменений).
|
||||
"""
|
||||
app = version.app
|
||||
platform = app.platform
|
||||
with updating(platform):
|
||||
try:
|
||||
result = check_result(platform.server, docker(platform).deploy(app.name, version.get_files()))
|
||||
except ProvisionError as error:
|
||||
version.mark_failed(str(error))
|
||||
raise
|
||||
version.mark_applied()
|
||||
_refresh_after(platform)
|
||||
return result.data
|
||||
|
||||
|
||||
def control(app: DockerApp, action: str, service: str = ''):
|
||||
"""start, stop, restart или pull приложения или одного сервиса."""
|
||||
platform = app.platform
|
||||
with updating(platform):
|
||||
check_result(platform.server, docker(platform).control(app.name, action, service))
|
||||
_refresh_after(platform)
|
||||
|
||||
|
||||
def remove_app(app: DockerApp, volumes: bool = False):
|
||||
"""Удаляет приложение с сервера (volumes=True - вместе с данными) и из базы."""
|
||||
platform = app.platform
|
||||
with updating(platform):
|
||||
check_result(platform.server, docker(platform).remove(app.name, volumes))
|
||||
app.delete()
|
||||
_refresh_after(platform)
|
||||
|
||||
|
||||
def logs(app: DockerApp, service: str = '', lines: int = 500) -> str:
|
||||
return check_result(app.platform.server, docker(app.platform).logs(app.name, service, lines)).data.get('logs', '')
|
||||
|
||||
|
||||
def refresh(platform: Platform) -> dict:
|
||||
"""Снимок состояния приложений с сервера в platform.status_snapshot."""
|
||||
snapshot = check_result(platform.server, docker(platform).status()).data
|
||||
platform.save_snapshot(snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _refresh_after(platform: Platform):
|
||||
"""Снимок после операции: операция уже прошла, недоступный сервер ее не отменяет."""
|
||||
try:
|
||||
refresh(platform)
|
||||
except ProvisionError:
|
||||
logger.warning('Platform %s: status refresh failed', platform, exc_info=True)
|
||||
+61
-46
@@ -7,7 +7,7 @@ from contextlib import contextmanager
|
||||
import serverus
|
||||
from django.conf import settings
|
||||
|
||||
from .models import KEY_NAME_RE, Key, Point, Server
|
||||
from .models import KEY_NAME_RE, Key, Managed, Point, Server
|
||||
|
||||
|
||||
class ProvisionError(Exception):
|
||||
@@ -16,7 +16,7 @@ class ProvisionError(Exception):
|
||||
|
||||
def install_point(point: Point):
|
||||
"""Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели."""
|
||||
with _installing(point):
|
||||
with installing(point):
|
||||
for server in point.servers.all():
|
||||
provision(server)
|
||||
restore_keys(point)
|
||||
@@ -29,12 +29,9 @@ def reinstall(point: Point, address: str | None = None, password: str | None = N
|
||||
password - новый пароль root от провайдера, без него используется пароль, присланный при добавлении сервера.
|
||||
"""
|
||||
servers = [point.servers.get(address=address)] if address else point.servers.all()
|
||||
with _installing(point):
|
||||
with installing(point):
|
||||
for server in servers:
|
||||
server.known_hosts = ''
|
||||
server.ssh_port = 22
|
||||
server.root_password = password or server.default_root_password
|
||||
server.save()
|
||||
reset_for_reinstall(server, password)
|
||||
provision(server)
|
||||
# панель доступна по домену только через маскировку, поэтому после всех серверов
|
||||
restore_keys(point)
|
||||
@@ -47,22 +44,11 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""
|
||||
passwords, errors = {}, {}
|
||||
for server in point.servers.all():
|
||||
if not server.private_key:
|
||||
# возвращен прошлой попыткой: ключа на сервере больше нет, пароль уже новый
|
||||
passwords[server.address] = server.root_password
|
||||
continue
|
||||
password = serverus.generate_password()
|
||||
result = serverus.Server(server.host()).release(password)
|
||||
if result.ok:
|
||||
# release убирает ключ и возвращает ssh на 22 порт
|
||||
server.root_password = password
|
||||
server.ssh_port = 22
|
||||
server.private_key = ''
|
||||
server.public_key = ''
|
||||
server.save(update_fields=['root_password', 'ssh_port', 'private_key', 'public_key'])
|
||||
passwords[server.address] = password
|
||||
error = release_server(server)
|
||||
if error:
|
||||
errors[server.address] = error
|
||||
else:
|
||||
errors[server.address] = result.error
|
||||
passwords[server.address] = server.root_password
|
||||
if errors:
|
||||
point.set_status(Point.Status.ERROR, '\n'.join(f'{address}: {error}' for address, error in errors.items()))
|
||||
else:
|
||||
@@ -77,8 +63,8 @@ def set_domain(point: Point, domain: str):
|
||||
"""
|
||||
if not domain and point.mask:
|
||||
raise ValueError('маскировка работает только с доменом')
|
||||
with _updating(point):
|
||||
_check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca()))
|
||||
with updating(point):
|
||||
check_result(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca()))
|
||||
point.domain = domain
|
||||
point.save(update_fields=['domain'])
|
||||
|
||||
@@ -87,8 +73,8 @@ def set_outbound_proxy(point: Point, proxy: str):
|
||||
"""Меняет исходящий прокси готовой точки: protocol://user:password@host:port, Point.CF_WARP или '' - напрямую."""
|
||||
if proxy and proxy != Point.CF_WARP:
|
||||
serverus.parse_proxy(proxy)
|
||||
with _updating(point):
|
||||
_check(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy))
|
||||
with updating(point):
|
||||
check_result(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy))
|
||||
point.outbound_proxy = proxy
|
||||
point.save(update_fields=['outbound_proxy'])
|
||||
|
||||
@@ -182,16 +168,45 @@ def xui_api(point: Point) -> serverus.XuiApi:
|
||||
|
||||
def provision(server: Server):
|
||||
"""Базовая настройка сервера и специализация по его роли."""
|
||||
_bootstrap(server)
|
||||
bootstrap_server(server)
|
||||
if server.role == Server.Role.PANEL:
|
||||
_install_panel(server)
|
||||
else:
|
||||
_redirect_to_panel(server)
|
||||
server.uname = _check(server, serverus.Server(server.host()).facts()).data.get('uname', '')
|
||||
server.uname = check_result(server, serverus.Server(server.host()).facts()).data.get('uname', '')
|
||||
server.save(update_fields=['uname'])
|
||||
|
||||
|
||||
def _bootstrap(server: Server):
|
||||
def reset_for_reinstall(server: Server, password: str | None = None):
|
||||
"""Сервер переустановлен у провайдера: отпечаток новый, ssh на 22 порту, пароль от провайдера.
|
||||
|
||||
password - новый пароль root, без него - присланный при добавлении сервера.
|
||||
"""
|
||||
server.known_hosts = ''
|
||||
server.ssh_port = 22
|
||||
server.root_password = password or server.default_root_password
|
||||
server.save()
|
||||
|
||||
|
||||
def release_server(server: Server) -> str:
|
||||
"""Возвращает сервер владельцу с новым паролем root (server.root_password). Возвращает ошибку или ''."""
|
||||
if not server.private_key:
|
||||
# возвращен прошлой попыткой: ключа на сервере больше нет, пароль уже новый
|
||||
return ''
|
||||
password = serverus.generate_password()
|
||||
result = serverus.Server(server.host()).release(password)
|
||||
if not result.ok:
|
||||
return result.error
|
||||
# release убирает ключ и возвращает ssh на 22 порт
|
||||
server.root_password = password
|
||||
server.ssh_port = 22
|
||||
server.private_key = ''
|
||||
server.public_key = ''
|
||||
server.save(update_fields=['root_password', 'ssh_port', 'private_key', 'public_key'])
|
||||
return ''
|
||||
|
||||
|
||||
def bootstrap_server(server: Server):
|
||||
if not server.private_key:
|
||||
keys = serverus.generate_keypair(f'mypoint-{server.address}')
|
||||
server.private_key, server.public_key = keys.private_key, keys.public_key
|
||||
@@ -199,7 +214,7 @@ def _bootstrap(server: Server):
|
||||
new_password = serverus.generate_password()
|
||||
ssh_port = serverus.generate_ssh_port()
|
||||
# при ошибке остается старый пароль, но ключ уже может быть прописан: повторный запуск войдет по ключу
|
||||
_check(server, serverus.Server(server.host()).bootstrap(server.root_password, server.public_key, new_password, ssh_port))
|
||||
check_result(server, serverus.Server(server.host()).bootstrap(server.root_password, server.public_key, new_password, ssh_port))
|
||||
server.root_password = new_password
|
||||
server.ssh_port = ssh_port
|
||||
server.save(update_fields=['root_password', 'ssh_port'])
|
||||
@@ -208,7 +223,7 @@ def _bootstrap(server: Server):
|
||||
def _install_panel(server: Server):
|
||||
point = server.point
|
||||
panel = serverus.XuiPanel(server.host())
|
||||
result = _check(server, panel.install(
|
||||
result = check_result(server, panel.install(
|
||||
username=point.panel_username,
|
||||
password=point.panel_password,
|
||||
panel_port=point.panel_port,
|
||||
@@ -220,21 +235,21 @@ def _install_panel(server: Server):
|
||||
))
|
||||
point.panel_api_token = result.data.get('api_token', '')
|
||||
point.save(update_fields=['panel_api_token'])
|
||||
result = _check(server, panel.create_inbound())
|
||||
result = check_result(server, panel.create_inbound())
|
||||
point.panel_inbound_id = result.data.get('inbound_id')
|
||||
point.save(update_fields=['panel_inbound_id'])
|
||||
if point.outbound_proxy:
|
||||
_check(server, panel.set_outbound(point.outbound_proxy))
|
||||
check_result(server, panel.set_outbound(point.outbound_proxy))
|
||||
|
||||
|
||||
def _redirect_to_panel(server: Server):
|
||||
point = server.point
|
||||
# 80 - выпуск и продление сертификата (HTTP-01), 443 - подключения клиентов
|
||||
ports = (80, 443, point.panel_port, point.sub_port)
|
||||
_check(server, serverus.RedirectProxy(server.host()).redirect(point.panel.address, ports))
|
||||
check_result(server, serverus.RedirectProxy(server.host()).redirect(point.panel.address, ports))
|
||||
|
||||
|
||||
def _check(server: Server, result: serverus.Result) -> serverus.Result:
|
||||
def check_result(server: Server, result: serverus.Result) -> serverus.Result:
|
||||
if result.known_hosts and result.known_hosts != server.known_hosts:
|
||||
server.known_hosts = result.known_hosts
|
||||
server.save(update_fields=['known_hosts'])
|
||||
@@ -244,28 +259,28 @@ def _check(server: Server, result: serverus.Result) -> serverus.Result:
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _installing(point: Point):
|
||||
def installing(obj: Managed):
|
||||
"""Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки."""
|
||||
point.set_status(Point.Status.INSTALLING)
|
||||
obj.set_status(Managed.Status.INSTALLING)
|
||||
try:
|
||||
yield
|
||||
except Exception as error:
|
||||
point.set_status(Point.Status.ERROR, str(error))
|
||||
obj.set_status(Managed.Status.ERROR, str(error))
|
||||
raise
|
||||
point.set_status(Point.Status.READY)
|
||||
obj.set_status(Managed.Status.READY)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _updating(point: Point):
|
||||
"""Статус UPDATING на время смены настройки готовой точки, затем READY.
|
||||
def updating(obj: Managed):
|
||||
"""Статус UPDATING на время изменения готовой записи, затем READY.
|
||||
|
||||
При ошибке точка тоже остается READY с текстом ошибки: serverus меняет сертификат и прокси только после
|
||||
успешного выпуска и проверки, так что панель работает с прежними настройками.
|
||||
При ошибке запись тоже остается READY с текстом ошибки: serverus меняет сертификат и прокси точки только после
|
||||
успешного выпуска и проверки, приложение docker-платформы - только после проверки compose-файла.
|
||||
"""
|
||||
point.set_status(Point.Status.UPDATING)
|
||||
obj.set_status(Managed.Status.UPDATING)
|
||||
try:
|
||||
yield
|
||||
except Exception as error:
|
||||
point.set_status(Point.Status.READY, str(error))
|
||||
obj.set_status(Managed.Status.READY, str(error))
|
||||
raise
|
||||
point.set_status(Point.Status.READY)
|
||||
obj.set_status(Managed.Status.READY)
|
||||
+189
-48
@@ -1,9 +1,10 @@
|
||||
"""Фоновые задачи: долгие операции с серверами, их супервизор и ежедневная проверка точек.
|
||||
|
||||
Долгая задача запускается через start(): точка получает статус и token задачи, вместе с задачей запускается
|
||||
супервизор. Пока задача работает, она раз в HEARTBEAT_SECONDS отмечается в точке. Супервизор просыпается
|
||||
раз в минуту, пока задача не закончится, и снимает ее, если отметки прекратились: воркер упал, его убили,
|
||||
сообщение потерялось. Точка получает статус по Point.INTERRUPTED, пользователь - уведомление.
|
||||
Долгая задача запускается через start() для точки доступа или docker-платформы (Managed): запись получает статус
|
||||
и token задачи, вместе с задачей запускается супервизор. Пока задача работает, она раз в HEARTBEAT_SECONDS
|
||||
отмечается в записи. Супервизор просыпается раз в минуту, пока задача не закончится, и снимает ее, если отметки
|
||||
прекратились: воркер упал, его убили, сообщение потерялось. Запись получает статус по INTERRUPTED своей модели,
|
||||
пользователь - уведомление.
|
||||
|
||||
Повторная доставка сообщения (RabbitMQ возвращает в очередь сообщение упавшего воркера) задачу не повторяет,
|
||||
а снимает: повторять половину работы с серверами опасно, пользователь сам решит, переустанавливать ли.
|
||||
@@ -15,19 +16,22 @@ import threading
|
||||
import time
|
||||
|
||||
import dramatiq
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
from Telegram.messages import send
|
||||
from Telegram.messages import send, send_document
|
||||
from Telegram.models import Chat
|
||||
|
||||
from . import fields, services
|
||||
from . import fields, platforms, services
|
||||
from .bot import texts
|
||||
from .models import Point
|
||||
from .models import AppVersion, DockerApp, Managed, Platform, Point
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# установка обновляет систему и ставит панель, это долго
|
||||
TIME_LIMIT_MS = 60 * 60 * 1000
|
||||
# статус и логи docker-платформы: один короткий плейбук
|
||||
QUICK_TIME_LIMIT_MS = 5 * 60 * 1000
|
||||
HEARTBEAT_SECONDS = 30
|
||||
SUPERVISE_DELAY_MS = 60 * 1000
|
||||
# без отметок дольше - задача мертва
|
||||
@@ -49,73 +53,86 @@ def _notify(chat_id: int | None, text: str, keyboard=None):
|
||||
|
||||
# долгие задачи и супервизор
|
||||
|
||||
def start(actor, point: Point, status: str, chat, *args):
|
||||
"""Запускает долгую задачу точки вместе с супервизором. chat - куда сообщить о результате."""
|
||||
def _model(label: str) -> type[Managed]:
|
||||
return apps.get_model(label)
|
||||
|
||||
|
||||
def _menu_keyboard(obj: Managed):
|
||||
return [[texts.PLATFORM_MENU if isinstance(obj, Platform) else texts.SERVER_MENU]]
|
||||
|
||||
|
||||
def start(actor, obj: Managed, status: str, chat, *args):
|
||||
"""Запускает долгую задачу записи вместе с супервизором. chat - куда сообщить о результате.
|
||||
actor получает (pk, token, *args)."""
|
||||
model = type(obj)
|
||||
token = secrets.token_hex(16)
|
||||
Point.objects.start_task(point, status, token, chat)
|
||||
actor.send(point.pk, token, *args)
|
||||
logger.info('Point %s: %s queued, task %s', point, actor.actor_name, token[:8])
|
||||
supervise.send_with_options(args=(point.pk, token), delay=SUPERVISE_DELAY_MS)
|
||||
model.objects.start_task(obj, status, token, chat)
|
||||
actor.send(obj.pk, token, *args)
|
||||
logger.info('%s %s: %s queued, task %s', model.__name__, obj, actor.actor_name, token[:8])
|
||||
supervise.send_with_options(args=(model._meta.label, obj.pk, token), delay=SUPERVISE_DELAY_MS)
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=3)
|
||||
def supervise(point_id: int, token: str):
|
||||
point = Point.objects.filter(pk=point_id, task_token=token).first()
|
||||
if point is None:
|
||||
def supervise(label: str, pk: int, token: str):
|
||||
model = _model(label)
|
||||
obj = model.objects.filter(pk=pk, task_token=token).first()
|
||||
if obj is None:
|
||||
# задача закончилась, супервизор больше не нужен
|
||||
return
|
||||
limit = TASK_STALE if point.task_started_at else QUEUE_TIMEOUT
|
||||
if timezone.now() - point.task_heartbeat > limit:
|
||||
logger.error('Point %s task is stuck: no heartbeat since %s', point, point.task_heartbeat)
|
||||
_abort(point_id, token)
|
||||
limit = TASK_STALE if obj.task_started_at else QUEUE_TIMEOUT
|
||||
if timezone.now() - obj.task_heartbeat > limit:
|
||||
logger.error('%s %s task is stuck: no heartbeat since %s', model.__name__, obj, obj.task_heartbeat)
|
||||
_abort(model, pk, token)
|
||||
return
|
||||
logger.debug('Point %s task %s alive, last heartbeat %s', point, token[:8], point.task_heartbeat)
|
||||
supervise.send_with_options(args=(point_id, token), delay=SUPERVISE_DELAY_MS)
|
||||
logger.debug('%s %s task %s alive, last heartbeat %s', model.__name__, obj, token[:8], obj.task_heartbeat)
|
||||
supervise.send_with_options(args=(label, pk, token), delay=SUPERVISE_DELAY_MS)
|
||||
|
||||
|
||||
def _run(what: str, point_id: int, token: str, work):
|
||||
"""Выполняет work(point) как задачу token: с отметками для супервизора и снятием, если она прервется.
|
||||
def _run(model: type[Managed], what: str, pk: int, token: str, work):
|
||||
"""Выполняет work(obj) как задачу token: с отметками для супервизора и снятием, если она прервется.
|
||||
what - название задачи для лога."""
|
||||
if not Point.objects.claim_task(point_id, token):
|
||||
# точка удалена, задачу уже снял супервизор или это повторная доставка после падения воркера
|
||||
logger.warning('Point %s: %s task %s is not claimable, dropped', point_id, what, token[:8])
|
||||
_abort(point_id, token)
|
||||
name = model.__name__
|
||||
if not model.objects.claim_task(pk, token):
|
||||
# запись удалена, задачу уже снял супервизор или это повторная доставка после падения воркера
|
||||
logger.warning('%s %s: %s task %s is not claimable, dropped', name, pk, what, token[:8])
|
||||
_abort(model, pk, token)
|
||||
return
|
||||
logger.info('Point %s: %s started, task %s', point_id, what, token[:8])
|
||||
logger.info('%s %s: %s started, task %s', name, pk, what, token[:8])
|
||||
started = time.monotonic()
|
||||
stop = threading.Event()
|
||||
heartbeat = threading.Thread(target=_heartbeat, args=(point_id, token, stop), daemon=True)
|
||||
heartbeat = threading.Thread(target=_heartbeat, args=(model, pk, token, stop), daemon=True)
|
||||
heartbeat.start()
|
||||
try:
|
||||
work(Point.objects.get(pk=point_id))
|
||||
work(model.objects.get(pk=pk))
|
||||
except BaseException:
|
||||
# лимит времени dramatiq и остановка воркера - не Exception, сценарий их не ловит
|
||||
logger.exception('Point %s task interrupted', point_id)
|
||||
_abort(point_id, token)
|
||||
logger.exception('%s %s task interrupted', name, pk)
|
||||
_abort(model, pk, token)
|
||||
raise
|
||||
finally:
|
||||
stop.set()
|
||||
heartbeat.join()
|
||||
Point.objects.finish_task(point_id, token)
|
||||
point = Point.objects.filter(pk=point_id).first()
|
||||
model.objects.finish_task(pk, token)
|
||||
obj = model.objects.filter(pk=pk).first()
|
||||
logger.info(
|
||||
'Point %s: %s finished in %.0fs, %s', point_id, what, time.monotonic() - started,
|
||||
f'status {point.status}' + (f': {point.error}' if point.error else '') if point else 'point deleted',
|
||||
'%s %s: %s finished in %.0fs, %s', name, pk, what, time.monotonic() - started,
|
||||
f'status {obj.status}' + (f': {obj.error}' if obj.error else '') if obj else 'deleted',
|
||||
)
|
||||
|
||||
|
||||
def _heartbeat(point_id: int, token: str, stop: threading.Event):
|
||||
def _heartbeat(model: type[Managed], pk: int, token: str, stop: threading.Event):
|
||||
try:
|
||||
while not stop.wait(HEARTBEAT_SECONDS):
|
||||
Point.objects.beat(point_id, token)
|
||||
model.objects.beat(pk, token)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _abort(point_id: int, token: str):
|
||||
chat_id = Point.objects.filter(pk=point_id).values_list('task_chat', flat=True).first()
|
||||
if Point.objects.abort_task(point_id, token):
|
||||
_notify(chat_id, texts.task_interrupted(Point.objects.get(pk=point_id)), keyboard=[[texts.SERVER_MENU]])
|
||||
def _abort(model: type[Managed], pk: int, token: str):
|
||||
chat_id = model.objects.filter(pk=pk).values_list('task_chat', flat=True).first()
|
||||
if model.objects.abort_task(pk, token):
|
||||
obj = model.objects.get(pk=pk)
|
||||
_notify(chat_id, texts.task_interrupted(obj), keyboard=_menu_keyboard(obj))
|
||||
|
||||
|
||||
def _install(point: Point, operation, *args):
|
||||
@@ -150,29 +167,153 @@ def _release(point: Point):
|
||||
# повтор не нужен: пользователь сам решает, переустанавливать ли после ошибки
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def install_point(point_id: int, token: str):
|
||||
_run('install', point_id, token, lambda point: _install(point, services.install_point))
|
||||
_run(Point, 'install', point_id, token, lambda point: _install(point, services.install_point))
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def reinstall_point(point_id: int, token: str, address: str | None = None, password: str | None = None):
|
||||
_run('reinstall', point_id, token, lambda point: _install(point, services.reinstall, address, password))
|
||||
_run(Point, 'reinstall', point_id, token, lambda point: _install(point, services.reinstall, address, password))
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def release_point(point_id: int, token: str):
|
||||
_run('release', point_id, token, _release)
|
||||
_run(Point, 'release', point_id, token, _release)
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def set_domain(point_id: int, token: str, domain: str):
|
||||
_run('set_domain', point_id, token, lambda point: _change(point, services.set_domain, domain, texts.domain_changed))
|
||||
_run(Point, 'set_domain', point_id, token, lambda point: _change(point, services.set_domain, domain, texts.domain_changed))
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def set_outbound_proxy(point_id: int, token: str, encrypted_proxy: str):
|
||||
"""encrypted_proxy - fields.encrypt(proxy): в прокси логин и пароль, а сообщения брокера лежат на диске."""
|
||||
proxy = fields.decrypt(encrypted_proxy) if encrypted_proxy else ''
|
||||
_run('set_outbound_proxy', point_id, token, lambda point: _change(point, services.set_outbound_proxy, proxy, texts.proxy_changed))
|
||||
_run(Point, 'set_outbound_proxy', point_id, token, lambda point: _change(point, services.set_outbound_proxy, proxy, texts.proxy_changed))
|
||||
|
||||
|
||||
# docker-платформа: операции с сервером идут по одной, пока платформа занята задачей
|
||||
|
||||
def _platform_operation(platform: Platform, what: str, operation, done, failed):
|
||||
"""operation(platform) и уведомление: done(результат) или failed(ошибка)."""
|
||||
chat_id = platform.task_chat_id
|
||||
try:
|
||||
result = operation(platform)
|
||||
except Exception as error:
|
||||
logger.exception('Platform %s %s failed', platform, what)
|
||||
_notify(chat_id, failed(error), keyboard=[[texts.PLATFORM_MENU]])
|
||||
return
|
||||
_notify(chat_id, done(result), keyboard=[[texts.PLATFORM_MENU]])
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def install_platform(platform_id: int, token: str):
|
||||
_run(Platform, 'install', platform_id, token, lambda platform: _platform_operation(
|
||||
platform, 'install', platforms.install_platform,
|
||||
lambda _: texts.platform_installed(platform), lambda error: texts.platform_install_failed(platform, error),
|
||||
))
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def reinstall_platform(platform_id: int, token: str, encrypted_password: str = ''):
|
||||
"""encrypted_password - fields.encrypt(пароль root от провайдера), пусто - присланный при добавлении."""
|
||||
password = fields.decrypt(encrypted_password) if encrypted_password else None
|
||||
_run(Platform, 'reinstall', platform_id, token, lambda platform: _platform_operation(
|
||||
platform, 'reinstall', lambda platform: platforms.reinstall(platform, password),
|
||||
lambda _: texts.platform_installed(platform), lambda error: texts.platform_install_failed(platform, error),
|
||||
))
|
||||
|
||||
|
||||
def _release_platform(platform: Platform):
|
||||
chat_id, name, address = platform.task_chat_id, platform.name, platform.server.address
|
||||
password, error = platforms.release_platform(platform)
|
||||
_notify(chat_id, texts.platform_released(name, address, password, error))
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def release_platform(platform_id: int, token: str):
|
||||
_run(Platform, 'release', platform_id, token, _release_platform)
|
||||
|
||||
|
||||
def _deploy(platform: Platform, version_id: int):
|
||||
version = AppVersion.objects.filter(pk=version_id, app__platform=platform, state=AppVersion.State.DRAFT).first()
|
||||
if version is None:
|
||||
_notify(platform.task_chat_id, texts.VERSION_GONE, keyboard=[[texts.PLATFORM_MENU]])
|
||||
return
|
||||
name = version.app.name
|
||||
_platform_operation(
|
||||
platform, 'deploy', lambda _: platforms.apply(version),
|
||||
lambda data: texts.deployed(name, data), lambda error: texts.deploy_failed(name, error),
|
||||
)
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def deploy_version(platform_id: int, token: str, version_id: int):
|
||||
_run(Platform, 'deploy', platform_id, token, lambda platform: _deploy(platform, version_id))
|
||||
|
||||
|
||||
def _app_operation(platform: Platform, app_id: int, what: str, operation, done):
|
||||
app = platform.apps.filter(pk=app_id).first()
|
||||
if app is None:
|
||||
_notify(platform.task_chat_id, texts.APP_GONE, keyboard=[[texts.PLATFORM_MENU]])
|
||||
return
|
||||
name = app.name
|
||||
_platform_operation(
|
||||
platform, what, lambda _: operation(app),
|
||||
lambda _: done(name), lambda error: texts.app_operation_failed(name, error),
|
||||
)
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def control_app(platform_id: int, token: str, app_id: int, action: str, service: str = ''):
|
||||
_run(Platform, action, platform_id, token, lambda platform: _app_operation(
|
||||
platform, app_id, action, lambda app: platforms.control(app, action, service),
|
||||
lambda name: texts.app_controlled(name, action, service),
|
||||
))
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS)
|
||||
def remove_app(platform_id: int, token: str, app_id: int, volumes: bool = False):
|
||||
_run(Platform, 'remove', platform_id, token, lambda platform: _app_operation(
|
||||
platform, app_id, 'remove', lambda app: platforms.remove_app(app, volumes),
|
||||
lambda name: texts.app_removed(name, volumes),
|
||||
))
|
||||
|
||||
|
||||
# быстрые операции без занятости: только читают сервер
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=QUICK_TIME_LIMIT_MS)
|
||||
def refresh_platform(platform_id: int, chat_id: int):
|
||||
"""Снимок состояния приложений и карточка платформы в чат."""
|
||||
platform = Platform.objects.find_by_pk(platform_id)
|
||||
if platform is None:
|
||||
return
|
||||
try:
|
||||
platforms.refresh(platform)
|
||||
except Exception as error:
|
||||
logger.exception('Platform %s status refresh failed', platform)
|
||||
_notify(chat_id, texts.refresh_failed(platform, error))
|
||||
return
|
||||
_notify(chat_id, texts.platform_card(platform))
|
||||
|
||||
|
||||
@dramatiq.actor(max_retries=0, time_limit=QUICK_TIME_LIMIT_MS)
|
||||
def send_logs(app_id: int, service: str, chat_id: int):
|
||||
"""Логи приложения или сервиса файлом в чат."""
|
||||
app = DockerApp.objects.filter(pk=app_id).select_related('platform').first()
|
||||
if app is None:
|
||||
return
|
||||
try:
|
||||
content = platforms.logs(app, service)
|
||||
except Exception as error:
|
||||
logger.exception('App %s logs failed', app)
|
||||
_notify(chat_id, texts.logs_failed(app.name, error))
|
||||
return
|
||||
stamp = timezone.now().strftime('%Y%m%d-%H%M%S')
|
||||
filename = f"{app.name}{'-' + service if service else ''}-{stamp}.log"
|
||||
send_document(
|
||||
Chat.objects.get(pk=chat_id), filename, content.encode() or b'(empty)\n', caption=texts.logs_caption(app.name, service),
|
||||
)
|
||||
|
||||
|
||||
# ежедневная проверка: своя цепочка у каждой точки, живет, пока жива точка
|
||||
|
||||
@@ -392,12 +392,12 @@ class SupervisorTests(TestCase):
|
||||
def start(self, actor=tasks.install_point, status=Point.Status.INSTALLING, *args):
|
||||
with mock.patch.object(actor, 'send') as send, mock.patch.object(tasks.supervise, 'send_with_options') as sup:
|
||||
tasks.start(actor, self.point, status, self.chat, *args)
|
||||
sup.assert_called_once_with(args=(self.point.pk, send.call_args.args[1]), delay=tasks.SUPERVISE_DELAY_MS)
|
||||
sup.assert_called_once_with(args=('App.Point', self.point.pk, send.call_args.args[1]), delay=tasks.SUPERVISE_DELAY_MS)
|
||||
return send.call_args.args
|
||||
|
||||
def supervise(self, token):
|
||||
with mock.patch.object(tasks.supervise, 'send_with_options') as again:
|
||||
tasks.supervise.fn(self.point.pk, token)
|
||||
tasks.supervise.fn('App.Point', self.point.pk, token)
|
||||
self.point.refresh_from_db()
|
||||
return again.called
|
||||
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import io
|
||||
import tarfile
|
||||
import zipfile
|
||||
from unittest import mock
|
||||
|
||||
import serverus
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from Telegram import interfaces
|
||||
from Telegram.models import Bot, Chat, User as TelegramUser
|
||||
from Telegram.testing import Conversation, FakeApi
|
||||
|
||||
from . import platforms, tasks, uploads
|
||||
from .bot import texts
|
||||
from .bot.interface import MyPointInterface
|
||||
from .models import AppVersion, Credentials, DockerApp, Platform, Server
|
||||
from .tests_services import FakeRunner, create_point, patch_runner
|
||||
|
||||
COMPOSE = b'''services:
|
||||
web:
|
||||
image: nginx
|
||||
restart: unless-stopped
|
||||
volumes: [ "./data/web:/etc/nginx/conf.d:ro" ]
|
||||
'''
|
||||
SNAPSHOT = {'apps': {'site': {'services': {'web': {'state': 'running', 'health': '', 'status': 'Up 1 hour'}}, 'error': ''}}}
|
||||
|
||||
|
||||
def make_zip(files: dict[str, bytes], symlink: str = '') -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, 'w') as archive:
|
||||
for path, content in files.items():
|
||||
archive.writestr(path, content)
|
||||
if symlink:
|
||||
info = zipfile.ZipInfo(symlink)
|
||||
info.external_attr = 0o120777 << 16
|
||||
archive.writestr(info, '/etc/passwd')
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def make_tar(files: dict[str, bytes], symlink: str = '') -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode='w:gz') as archive:
|
||||
for path, content in files.items():
|
||||
info = tarfile.TarInfo(path)
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
if symlink:
|
||||
info = tarfile.TarInfo(symlink)
|
||||
info.type = tarfile.SYMTYPE
|
||||
info.linkname = '/etc/passwd'
|
||||
archive.addfile(info)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
class DockerRunner(FakeRunner):
|
||||
"""FakeRunner с ответами плейбуков docker-платформы. Запоминает файлы, ушедшие в deploy."""
|
||||
|
||||
def __init__(self, fail=None, snapshot=None):
|
||||
super().__init__(fail=fail)
|
||||
self.deployed = {}
|
||||
self.snapshot = SNAPSHOT if snapshot is None else snapshot
|
||||
|
||||
def __call__(self, playbook, host, extravars):
|
||||
result = super().__call__(playbook, host, extravars)
|
||||
if playbook == 'app_deploy':
|
||||
with tarfile.open(extravars['archive']) as archive:
|
||||
self.deployed[extravars['app']] = {
|
||||
member.name: archive.extractfile(member).read() for member in archive.getmembers()
|
||||
}
|
||||
if not result.ok:
|
||||
return result
|
||||
data = {
|
||||
'app_status': self.snapshot,
|
||||
'app_deploy': {'changed': [], 'removed': [], 'recreated': ['web'], 'services': ['web']},
|
||||
'app_logs': {'logs': 'line 1\nline 2'},
|
||||
}.get(playbook)
|
||||
if data is None:
|
||||
return result
|
||||
return serverus.Result(ok=True, status='successful', data=data, known_hosts=result.known_hosts)
|
||||
|
||||
|
||||
def create_platform(owner=None) -> Platform:
|
||||
owner = owner or TelegramUser.objects.create(tg_id=1)
|
||||
return Platform.objects.create_platform(owner, Credentials('10.0.1.1', 'docker-pw'))
|
||||
|
||||
|
||||
def ready_platform(owner=None) -> Platform:
|
||||
platform = create_platform(owner)
|
||||
server = platform.server
|
||||
server.private_key = 'key'
|
||||
server.save()
|
||||
platform.set_status(Platform.Status.READY)
|
||||
return platform
|
||||
|
||||
|
||||
def deployed_app(platform: Platform, name='site', files=None) -> DockerApp:
|
||||
app = DockerApp.objects.create(platform=platform, name=name)
|
||||
version = AppVersion.objects.create_draft(app, files or {'docker-compose.yaml': COMPOSE}, AppVersion.Kind.FULL)
|
||||
version.mark_applied()
|
||||
return app
|
||||
|
||||
|
||||
class UploadsTests(TestCase):
|
||||
def test_zip_and_tar(self):
|
||||
files = {'docker-compose.yaml': COMPOSE, 'data/web/default.conf': b'server {}'}
|
||||
self.assertEqual(uploads.read('site.zip', make_zip(files)), files)
|
||||
self.assertEqual(uploads.read('site.tar.gz', make_tar(files)), files)
|
||||
self.assertEqual(uploads.read('site.tgz', make_tar(files)), files)
|
||||
|
||||
def test_single_file(self):
|
||||
self.assertEqual(uploads.read('docker-compose.yaml', COMPOSE), {'docker-compose.yaml': COMPOSE})
|
||||
self.assertFalse(uploads.is_archive('docker-compose.yaml'))
|
||||
|
||||
def test_skips_junk(self):
|
||||
archive = make_zip({
|
||||
'.env': b'A=1', '__MACOSX/._x': b'', 'data/.DS_Store': b'', 'data/web/._a.conf': b'', 'Thumbs.db': b'',
|
||||
})
|
||||
self.assertEqual(uploads.read('a.zip', archive), {'.env': b'A=1'})
|
||||
|
||||
def test_rejects_unsafe_paths_and_links(self):
|
||||
for name, content in (
|
||||
('a.zip', make_zip({'../etc/passwd': b''})),
|
||||
('a.zip', make_zip({'/etc/passwd': b''})),
|
||||
('a.zip', make_zip({'.env': b''}, symlink='data/link')),
|
||||
('a.tar.gz', make_tar({'data/../../x': b''})),
|
||||
('a.tar.gz', make_tar({'.env': b''}, symlink='data/link')),
|
||||
('a.zip', b'not a zip'),
|
||||
('a.zip', make_zip({})),
|
||||
):
|
||||
with self.subTest(name=name), self.assertRaises(uploads.UploadError):
|
||||
uploads.read(name, content)
|
||||
|
||||
def test_limits(self):
|
||||
with mock.patch.object(uploads, 'FILES_MAX', 2), self.assertRaisesRegex(uploads.UploadError, 'файлов'):
|
||||
uploads.read('a.zip', make_zip({'a': b'', 'b': b'', 'c': b''}))
|
||||
with mock.patch.object(uploads, 'UNPACKED_MAX', 10), self.assertRaisesRegex(uploads.UploadError, 'Мб'):
|
||||
uploads.read('a.tar.gz', make_tar({'a': b'x' * 11}))
|
||||
with mock.patch.object(uploads, 'ARCHIVE_MAX', 10), self.assertRaises(uploads.UploadError):
|
||||
uploads.read('a.zip', b'x' * 11)
|
||||
|
||||
def test_merge_and_diff(self):
|
||||
old = {'a': b'1', 'b': b'2', 'c': b'3'}
|
||||
new = uploads.merge(old, {'b': b'22', 'd': b'4'})
|
||||
self.assertEqual(new, {'a': b'1', 'b': b'22', 'c': b'3', 'd': b'4'})
|
||||
diff = uploads.diff(old, {'b': b'22', 'd': b'4'})
|
||||
self.assertEqual((diff.added, diff.changed, diff.removed), (['d'], ['b'], ['a', 'c']))
|
||||
self.assertFalse(uploads.diff(old, old))
|
||||
|
||||
def test_check(self):
|
||||
self.assertEqual(uploads.check({'compose.yaml': COMPOSE})[0], ['нет docker-compose.yaml в корне (compose.yaml нужно переименовать)'])
|
||||
self.assertIn('не разбирается', uploads.check({'docker-compose.yaml': b'services: [x'})[0][0])
|
||||
self.assertEqual(uploads.check({'docker-compose.yaml': b'x: 1'})[0], ['в docker-compose.yaml нет services'])
|
||||
errors, warnings = uploads.check({
|
||||
'docker-compose.yaml': b'services:\n db:\n image: postgres\n ports: ["5432:5432"]\n',
|
||||
'data/web/x.conf': b'', 'notes.txt': b'', '.env': b'', 'caddy.conf': b'',
|
||||
})
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [
|
||||
'db: публикует порты, наружу должен смотреть только caddy',
|
||||
'db: нет restart, после перезагрузки сервера не поднимется',
|
||||
'data/web/: нет сервиса web',
|
||||
'вне конвенции: notes.txt',
|
||||
])
|
||||
self.assertEqual(uploads.check({'docker-compose.yaml': COMPOSE, 'data/web/a.conf': b''}), ([], []))
|
||||
|
||||
|
||||
class ModelsTests(TestCase):
|
||||
def test_create_platform(self):
|
||||
platform = create_platform()
|
||||
self.assertTrue(platform.name.startswith('docker-'))
|
||||
self.assertEqual(platform.status, Platform.Status.INSTALLING)
|
||||
self.assertEqual((platform.server.address, platform.server.role), ('10.0.1.1', Server.Role.DOCKER))
|
||||
self.assertEqual(platform.server.default_root_password, 'docker-pw')
|
||||
self.assertEqual(Platform.objects.owned_by(platform.owner).find_last(), platform)
|
||||
self.assertEqual(list(platform.owner.platforms.all()), [platform])
|
||||
|
||||
def test_server_has_one_parent(self):
|
||||
platform = create_platform()
|
||||
point = create_point(TelegramUser.objects.create(tg_id=2), mask=None)
|
||||
server = platform.server
|
||||
server.point = point
|
||||
with self.assertRaises(IntegrityError):
|
||||
server.save()
|
||||
|
||||
def test_point_card_skips_docker_role(self):
|
||||
point = create_point(mask=None)
|
||||
self.assertNotIn('Docker', texts.point_card(point))
|
||||
|
||||
def test_versions(self):
|
||||
app = DockerApp.objects.create(platform=create_platform(), name='site')
|
||||
self.assertIsNone(app.current_version())
|
||||
files = {'docker-compose.yaml': COMPOSE, '.env': b'SECRET=1', 'data/web/bin': bytes(range(256))}
|
||||
first = AppVersion.objects.create_draft(app, files, AppVersion.Kind.FULL)
|
||||
self.assertEqual(first.get_files(), files)
|
||||
self.assertNotIn(b'SECRET', AppVersion.objects.filter(pk=first.pk).values_list('files', flat=True)[0].encode())
|
||||
first.mark_applied()
|
||||
versions = []
|
||||
for index in range(AppVersion.KEEP + 1):
|
||||
version = AppVersion.objects.create_draft(app, {'docker-compose.yaml': b'%d' % index}, AppVersion.Kind.UPDATE)
|
||||
version.mark_applied()
|
||||
versions.append(version)
|
||||
self.assertEqual(app.current_version(), versions[-1])
|
||||
self.assertEqual(app.previous_version(), versions[-2])
|
||||
self.assertEqual(app.versions.applied().count(), AppVersion.KEEP)
|
||||
self.assertFalse(app.versions.filter(pk=first.pk).exists())
|
||||
|
||||
def test_new_draft_replaces_old_draft(self):
|
||||
app = DockerApp.objects.create(platform=create_platform(), name='site')
|
||||
AppVersion.objects.create_draft(app, {'a': b''}, AppVersion.Kind.FULL)
|
||||
AppVersion.objects.create_draft(app, {'b': b''}, AppVersion.Kind.FULL)
|
||||
self.assertEqual(app.versions.count(), 1)
|
||||
|
||||
|
||||
class PlatformScenarioTests(TestCase):
|
||||
def test_install(self):
|
||||
platform = create_platform()
|
||||
runner = DockerRunner()
|
||||
with patch_runner(runner):
|
||||
platforms.install_platform(platform)
|
||||
platform.refresh_from_db()
|
||||
self.assertEqual(platform.status, Platform.Status.READY)
|
||||
self.assertEqual(runner.playbooks('10.0.1.1'), ['bootstrap', 'docker', 'facts', 'app_status'])
|
||||
self.assertEqual(runner.call('docker', '10.0.1.1')[1]['local_certs'], False)
|
||||
self.assertEqual(platform.server.uname, 'Linux test')
|
||||
self.assertEqual(platform.status_snapshot, SNAPSHOT)
|
||||
|
||||
def test_install_failure(self):
|
||||
platform = create_platform()
|
||||
with patch_runner(DockerRunner(fail='docker')), self.assertRaises(platforms.ProvisionError):
|
||||
platforms.install_platform(platform)
|
||||
platform.refresh_from_db()
|
||||
self.assertEqual((platform.status, platform.error), (Platform.Status.ERROR, '10.0.1.1: boom'))
|
||||
|
||||
def test_reinstall_redeploys_apps(self):
|
||||
platform = ready_platform()
|
||||
deployed_app(platform)
|
||||
runner = DockerRunner()
|
||||
with patch_runner(runner):
|
||||
platforms.reinstall(platform, 'new-pw')
|
||||
self.assertEqual(runner.playbooks('10.0.1.1'), ['bootstrap', 'docker', 'facts', 'app_deploy', 'app_status'])
|
||||
self.assertEqual(runner.call('bootstrap', '10.0.1.1')[1]['login_password'], 'new-pw')
|
||||
self.assertEqual(runner.deployed['site'], {'docker-compose.yaml': COMPOSE})
|
||||
|
||||
def test_release(self):
|
||||
platform = ready_platform()
|
||||
deployed_app(platform)
|
||||
with patch_runner(DockerRunner()):
|
||||
password, error = platforms.release_platform(platform)
|
||||
self.assertTrue(password)
|
||||
self.assertEqual(error, '')
|
||||
self.assertFalse(Platform.objects.exists() or DockerApp.objects.exists() or Server.objects.exists())
|
||||
|
||||
def test_create_app(self):
|
||||
platform = ready_platform()
|
||||
self.assertEqual(platforms.create_app(platform, 'site').name, 'site')
|
||||
for name in ('site', 'caddy', 'Site', '../x', ''):
|
||||
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||
platforms.create_app(platform, name)
|
||||
|
||||
def test_update_merges_and_full_replaces(self):
|
||||
app = deployed_app(ready_platform(), files={'docker-compose.yaml': COMPOSE, 'data/web/a.conf': b'a'})
|
||||
upload = platforms.prepare_upload(app, 'b.conf', b'b', AppVersion.Kind.UPDATE)
|
||||
self.assertEqual((upload.errors, upload.diff.added), ([], ['b.conf']))
|
||||
self.assertEqual(upload.version.get_files(), {'docker-compose.yaml': COMPOSE, 'data/web/a.conf': b'a', 'b.conf': b'b'})
|
||||
upload = platforms.prepare_upload(app, 'app.zip', make_zip({'docker-compose.yaml': COMPOSE}), AppVersion.Kind.FULL)
|
||||
self.assertEqual(upload.diff.removed, ['data/web/a.conf'])
|
||||
self.assertEqual(app.versions.filter(state=AppVersion.State.DRAFT).count(), 1)
|
||||
|
||||
def test_upload_without_changes_or_with_errors(self):
|
||||
app = deployed_app(ready_platform())
|
||||
upload = platforms.prepare_upload(app, 'docker-compose.yaml', COMPOSE, AppVersion.Kind.UPDATE)
|
||||
self.assertIsNone(upload.version)
|
||||
self.assertFalse(upload.diff)
|
||||
upload = platforms.prepare_upload(app, 'docker-compose.yaml', b'x: 1', AppVersion.Kind.UPDATE)
|
||||
self.assertIsNone(upload.version)
|
||||
self.assertTrue(upload.errors)
|
||||
with self.assertRaisesRegex(uploads.UploadError, 'только архивом'):
|
||||
platforms.prepare_upload(app, 'docker-compose.yaml', COMPOSE, AppVersion.Kind.FULL)
|
||||
|
||||
def test_apply(self):
|
||||
platform = ready_platform()
|
||||
app = DockerApp.objects.create(platform=platform, name='site')
|
||||
upload = platforms.prepare_upload(app, 'app.tar.gz', make_tar({'docker-compose.yaml': COMPOSE}), AppVersion.Kind.FULL)
|
||||
runner = DockerRunner()
|
||||
with patch_runner(runner):
|
||||
data = platforms.apply(upload.version)
|
||||
self.assertEqual(data['recreated'], ['web'])
|
||||
self.assertEqual(runner.deployed['site'], {'docker-compose.yaml': COMPOSE})
|
||||
self.assertEqual(app.current_version(), upload.version)
|
||||
platform.refresh_from_db()
|
||||
self.assertEqual((platform.status, platform.status_snapshot), (Platform.Status.READY, SNAPSHOT))
|
||||
|
||||
def test_apply_failure_keeps_previous_version(self):
|
||||
platform = ready_platform()
|
||||
app = deployed_app(platform)
|
||||
current = app.current_version()
|
||||
upload = platforms.prepare_upload(app, 'data/web/a.conf', b'a', AppVersion.Kind.UPDATE)
|
||||
with patch_runner(DockerRunner(fail='app_deploy')), self.assertRaises(platforms.ProvisionError):
|
||||
platforms.apply(upload.version)
|
||||
upload.version.refresh_from_db()
|
||||
self.assertEqual(upload.version.state, AppVersion.State.FAILED)
|
||||
self.assertEqual(app.current_version(), current)
|
||||
platform.refresh_from_db()
|
||||
self.assertEqual((platform.status, platform.error), (Platform.Status.READY, '10.0.1.1: boom'))
|
||||
|
||||
def test_rollback(self):
|
||||
app = deployed_app(ready_platform())
|
||||
with self.assertRaises(ValueError):
|
||||
platforms.prepare_rollback(app)
|
||||
version = platforms.prepare_upload(app, 'data/web/a.conf', b'a', AppVersion.Kind.UPDATE).version
|
||||
version.mark_applied()
|
||||
upload = platforms.prepare_rollback(app)
|
||||
self.assertEqual(upload.diff.removed, ['data/web/a.conf'])
|
||||
self.assertEqual(upload.version.kind, AppVersion.Kind.ROLLBACK)
|
||||
|
||||
def test_control_logs_remove(self):
|
||||
platform = ready_platform()
|
||||
app = deployed_app(platform)
|
||||
runner = DockerRunner()
|
||||
with patch_runner(runner):
|
||||
platforms.control(app, 'restart', 'web')
|
||||
self.assertEqual(platforms.logs(app, 'web'), 'line 1\nline 2')
|
||||
platforms.remove_app(app, volumes=True)
|
||||
self.assertEqual(runner.call('app_control', '10.0.1.1')[1], {
|
||||
'apps_dir': '/root/apps', 'app': 'site', 'action': 'restart', 'service': 'web',
|
||||
})
|
||||
self.assertTrue(runner.call('app_remove', '10.0.1.1')[1]['volumes'])
|
||||
self.assertFalse(DockerApp.objects.exists())
|
||||
|
||||
def test_refresh_failure_after_operation_is_not_an_error(self):
|
||||
platform = ready_platform()
|
||||
app = deployed_app(platform)
|
||||
with patch_runner(DockerRunner(fail='app_status')), self.assertLogs('App.platforms', 'WARNING'):
|
||||
platforms.control(app, 'stop')
|
||||
platform.refresh_from_db()
|
||||
self.assertEqual((platform.status, platform.error), (Platform.Status.READY, ''))
|
||||
|
||||
|
||||
class PlatformTasksTests(TestCase):
|
||||
def setUp(self):
|
||||
bot = Bot.objects.create(token='1:test')
|
||||
owner = TelegramUser.objects.create(tg_id=1)
|
||||
self.chat = Chat.objects.create(bot=bot, chat_id=1, type='private', user=owner)
|
||||
self.platform = ready_platform(owner)
|
||||
self.api = FakeApi()
|
||||
patcher = mock.patch.object(Bot, 'api', return_value=self.api)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def run_task(self, actor, *args, status=Platform.Status.UPDATING, runner=None):
|
||||
self.platform.refresh_from_db()
|
||||
with mock.patch.object(actor, 'send') as send, mock.patch.object(tasks.supervise, 'send_with_options') as sup:
|
||||
tasks.start(actor, self.platform, status, self.chat, *args)
|
||||
self.assertEqual(sup.call_args.kwargs['args'][0], 'App.Platform')
|
||||
with patch_runner(runner or DockerRunner()):
|
||||
actor.fn(*send.call_args.args)
|
||||
return self.api.last_text
|
||||
|
||||
def test_install(self):
|
||||
text = self.run_task(tasks.install_platform, status=Platform.Status.INSTALLING)
|
||||
self.assertIn('готов к работе', text)
|
||||
self.assertEqual(self.api.last_keyboard, [[texts.PLATFORM_MENU]])
|
||||
|
||||
def test_deploy(self):
|
||||
app = DockerApp.objects.create(platform=self.platform, name='site')
|
||||
version = AppVersion.objects.create_draft(app, {'docker-compose.yaml': COMPOSE}, AppVersion.Kind.FULL)
|
||||
text = self.run_task(tasks.deploy_version, version.pk)
|
||||
self.assertIn('файлы применены', text)
|
||||
self.assertIn('web', text)
|
||||
with self.assertLogs('App.tasks', 'ERROR'):
|
||||
version = AppVersion.objects.create_draft(app, {'docker-compose.yaml': b'x'}, AppVersion.Kind.FULL)
|
||||
text = self.run_task(tasks.deploy_version, version.pk, runner=DockerRunner(fail='app_deploy'))
|
||||
self.assertIn('не удалось применить', text)
|
||||
self.assertIn('уже применены', self.run_task(tasks.deploy_version, version.pk))
|
||||
|
||||
def test_control_and_remove(self):
|
||||
app = deployed_app(self.platform)
|
||||
self.assertIn('перезапущено', self.run_task(tasks.control_app, app.pk, 'restart', 'web'))
|
||||
self.assertIn('вместе с данными', self.run_task(tasks.remove_app, app.pk, True))
|
||||
self.assertIn('уже удалено', self.run_task(tasks.remove_app, app.pk, True))
|
||||
|
||||
def test_release(self):
|
||||
text = self.run_task(tasks.release_platform, status=Platform.Status.RELEASING)
|
||||
self.assertIn('10.0.1.1 - пароль root', text)
|
||||
self.assertFalse(Platform.objects.exists())
|
||||
|
||||
def test_refresh_and_logs(self):
|
||||
app = deployed_app(self.platform)
|
||||
with patch_runner(DockerRunner()):
|
||||
tasks.refresh_platform.fn(self.platform.pk, self.chat.pk)
|
||||
self.assertIn('работает 1 из 1', self.api.last_text)
|
||||
tasks.send_logs.fn(app.pk, 'web', self.chat.pk)
|
||||
document = self.api.documents[-1]
|
||||
self.assertTrue(document.file_name.startswith('site-web-'))
|
||||
self.assertEqual(document.content, b'line 1\nline 2')
|
||||
with patch_runner(DockerRunner(fail='app_status')), self.assertLogs('App.tasks', 'ERROR'):
|
||||
tasks.refresh_platform.fn(self.platform.pk, self.chat.pk)
|
||||
self.assertIn('не удалось проверить', self.api.last_text)
|
||||
|
||||
|
||||
class PlatformBotTests(TestCase):
|
||||
def setUp(self):
|
||||
self.bot = Bot.objects.create(token='1:test', interface=interfaces.key(MyPointInterface))
|
||||
self.chat = Conversation(self.bot)
|
||||
self.api = self.chat.api
|
||||
self.say('/start')
|
||||
self.owner = TelegramUser.objects.get()
|
||||
for actor in (tasks.install_platform, tasks.deploy_version, tasks.control_app, tasks.remove_app,
|
||||
tasks.refresh_platform, tasks.send_logs, tasks.supervise):
|
||||
patcher = mock.patch.object(actor, 'send' if actor is not tasks.supervise else 'send_with_options')
|
||||
setattr(self, actor.actor_name, patcher.start())
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def say(self, text):
|
||||
return self.chat.say(text)
|
||||
|
||||
def open_platform(self) -> Platform:
|
||||
platform = ready_platform(self.owner)
|
||||
platform.save_snapshot(SNAPSHOT)
|
||||
self.say(texts.DOCKER_SERVERS)
|
||||
self.assertIn(platform.name, self.say(platform.name))
|
||||
return platform
|
||||
|
||||
def test_add_docker_server(self):
|
||||
self.assertIn('ip:root_password', self.say(texts.ADD_DOCKER))
|
||||
self.assertEqual(self.say('10.0.1.1:secret'), texts.DOCKER_INSTALL_STARTED)
|
||||
self.assertEqual(len(self.api.deleted), 1)
|
||||
platform = Platform.objects.get()
|
||||
self.assertEqual(platform.status, Platform.Status.INSTALLING)
|
||||
self.install_platform.assert_called_once()
|
||||
self.assertEqual(self.api.last_keyboard, texts.main_keyboard(has_platforms=True))
|
||||
self.assertEqual(self.say(texts.ADD_DOCKER) and self.say('10.0.1.1:x'), texts.ADDRESS_TAKEN.format(address='10.0.1.1'))
|
||||
self.assertIn(platform.name, self.say(texts.MAIN_MENU))
|
||||
|
||||
def test_new_app_upload_and_apply(self):
|
||||
platform = self.open_platform()
|
||||
self.say(texts.NEW_APP)
|
||||
self.assertIn('уже есть', self.say('caddy'))
|
||||
self.assertIn('архив всей папки', self.say('site'))
|
||||
self.assertEqual(self.say('text'), texts.UPLOAD_NOT_DOCUMENT)
|
||||
reply = self.chat.send_document('site.zip', make_zip({'docker-compose.yaml': COMPOSE, 'data/web/a.conf': b'a'}))
|
||||
self.assertIn('Добавлено', reply)
|
||||
self.assertIn('Применить?', reply)
|
||||
self.assertEqual(self.say(texts.APPLY), texts.APPLY_STARTED)
|
||||
version = AppVersion.objects.get()
|
||||
self.assertEqual(self.deploy_version.call_args.args[2], version.pk)
|
||||
platform.refresh_from_db()
|
||||
self.assertEqual(platform.status, Platform.Status.UPDATING)
|
||||
# занятый сервер не принимает новые операции
|
||||
self.assertIn('Дождитесь', self.say(texts.RESTART))
|
||||
|
||||
def test_cancel_new_app_discards_it(self):
|
||||
self.open_platform()
|
||||
self.say(texts.NEW_APP)
|
||||
self.say('site')
|
||||
self.chat.send_document('site.zip', make_zip({'docker-compose.yaml': COMPOSE}))
|
||||
self.assertIn('Приложений пока нет', self.say(texts.CANCEL))
|
||||
self.assertFalse(DockerApp.objects.exists() or AppVersion.objects.exists())
|
||||
|
||||
def test_update_errors_and_unchanged(self):
|
||||
platform = self.open_platform()
|
||||
deployed_app(platform)
|
||||
self.assertIn('Up 1 hour', self.say('site'))
|
||||
self.say(texts.UPLOAD_UPDATE)
|
||||
reply = self.chat.send_document('docker-compose.yaml', b'x: 1')
|
||||
self.assertIn('Нельзя применить', reply)
|
||||
# без изменений - сообщение и назад к приложению
|
||||
self.assertIn('Up 1 hour', self.chat.send_document('docker-compose.yaml', COMPOSE))
|
||||
self.assertEqual(self.api.sent[-2].text, texts.UPLOAD_UNCHANGED)
|
||||
self.say(texts.UPLOAD_FULL)
|
||||
self.assertIn('только архивом', self.chat.send_document('docker-compose.yaml', COMPOSE))
|
||||
|
||||
def test_service_control_and_logs(self):
|
||||
platform = self.open_platform()
|
||||
app = deployed_app(platform)
|
||||
self.say('site')
|
||||
self.assertEqual(self.say('ghost'), texts.APP_NOT_FOUND)
|
||||
self.assertIn('Up 1 hour', self.say('web'))
|
||||
self.assertEqual(self.api.last_keyboard, texts.SERVICE_KEYBOARD)
|
||||
self.assertEqual(self.say(texts.LOGS), texts.LOGS_STARTED)
|
||||
self.assertEqual(self.send_logs.call_args.args, (app.pk, 'web', self.chat_pk()))
|
||||
self.assertEqual(self.say(texts.STOP), texts.OPERATION_STARTED)
|
||||
self.assertEqual(self.control_app.call_args.args[2:], (app.pk, 'stop', 'web'))
|
||||
|
||||
def test_remove_app(self):
|
||||
platform = self.open_platform()
|
||||
app = deployed_app(platform)
|
||||
self.say('site')
|
||||
self.assertEqual(self.say(texts.REMOVE_APP), texts.REMOVE_APP_CONFIRM)
|
||||
self.assertIn('site', self.say(texts.CANCEL))
|
||||
self.say(texts.REMOVE_APP)
|
||||
self.assertEqual(self.say(texts.REMOVE_WITH_DATA), texts.OPERATION_STARTED)
|
||||
self.assertEqual(self.remove_app.call_args.args[2:], (app.pk, True))
|
||||
|
||||
def test_check_and_rollback(self):
|
||||
platform = self.open_platform()
|
||||
deployed_app(platform)
|
||||
self.assertEqual(self.say(texts.CHECK), texts.CHECK_STARTED)
|
||||
self.refresh_platform.assert_called_once_with(platform.pk, self.chat_pk())
|
||||
self.say('site')
|
||||
self.assertEqual(self.say(texts.ROLLBACK), texts.ROLLBACK_NONE)
|
||||
|
||||
def test_other_users_platform_is_hidden(self):
|
||||
platform = ready_platform(TelegramUser.objects.create(tg_id=99))
|
||||
self.say(texts.DOCKER_SERVERS)
|
||||
self.assertEqual(self.say(platform.name), texts.PLATFORM_NOT_FOUND)
|
||||
|
||||
def chat_pk(self):
|
||||
return Chat.objects.get().pk
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Разбор присланных боту файлов приложения: архивы, слияние версий, разница и проверка по конвенции.
|
||||
|
||||
Корень архива - корень приложения. Мусор архиваторов пропускается, пути наружу и ссылки - отказ.
|
||||
Конвенция приложения (serverus.DockerHost): docker-compose.yaml, .env, caddy.conf, data/<сервис>/.
|
||||
"""
|
||||
import io
|
||||
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')
|
||||
|
||||
|
||||
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 {}
|
||||
if service.get('ports'):
|
||||
warnings.append(f'{name}: публикует порты, наружу должен смотреть только caddy')
|
||||
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
|
||||
@@ -38,6 +38,8 @@ INSTALLED_APPS += [
|
||||
|
||||
# Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org
|
||||
TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '')
|
||||
# Раздача файлов своего Bot API сервера (сервис telegram-files): в --local режиме он пишет их на диск
|
||||
TELEGRAM_FILES_URL = os.environ.get('TELEGRAM_FILES_URL', '')
|
||||
# Адрес этого сайта, как его видит Bot API сервер: на него ставится webhook ботов
|
||||
TELEGRAM_WEBHOOK_URL = os.environ.get('TELEGRAM_WEBHOOK_URL', '')
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ class Shop(Interface):
|
||||
|
||||
Порядок разбора сообщения: команда, кнопка текущего состояния (`@text(..., state=...)`), кнопка, состояние диалога, `fallback`.
|
||||
|
||||
Файлы: `ctx.document` - присланный документ (`file_id`, `name`, `size`, `mime_type`) или `None`, `ctx.text` у документа - подпись. `ctx.download_document(max_size)` возвращает содержимое, `api.FileTooLarge` - файл больше `max_size`, `api.ApiError` - не скачался. Документ ловит обработчик состояния (`@state`). `ctx.reply_document(filename, content, caption)` и `messages.send_document(chat, ...)` отправляют файл документом.
|
||||
|
||||
`ctx.delete_message()` удаляет входящее сообщение из чата, например с паролем. Модуль с интерфейсом нужно импортировать при старте, например в `AppConfig.ready()`.
|
||||
|
||||
## Запуск
|
||||
@@ -69,6 +71,8 @@ class Shop(Interface):
|
||||
3. Отдельный процесс не нужен: обновления принимает сайт. Смена интерфейса применяется без перезапуска.
|
||||
|
||||
`TELEGRAM_API_URL` направляет бота на свой Bot API сервер (сервис `telegram` в `docker-compose.yaml`).
|
||||
`TELEGRAM_FILES_URL` - раздача файлов своего Bot API сервера (сервис `telegram-files`). В `--local` режиме сервер не отдает файлы по HTTP, а пишет их в свою папку (`api.WORK_DIR`) с правами 0600 от uid 101. Рядом с ним работает nginx, воркеры которого тоже от uid 101: он отдает только `documents/` и слушает только локальный адрес.
|
||||
|
||||
`TELEGRAM_WEBHOOK_URL` - адрес сайта, как его видит Bot API сервер, к нему дописывается путь webhook. Сервер в docker запущен с `--local` (`TELEGRAM_LOCAL`), поэтому webhook может быть на http и на любом порту. Хост из этого адреса нужно добавить в `ALLOWED_HOSTS`.
|
||||
|
||||
Состояние webhook (адрес, очередь, последняя ошибка доставки) видно на странице бота в админке. Ошибка обработчика пишется в `last_error`, а телеграму отвечаем 200, чтобы он не повторял то же обновление.
|
||||
+33
-1
@@ -9,17 +9,24 @@ def configure_api_server():
|
||||
if uses_own_server():
|
||||
url = settings.TELEGRAM_API_URL.rstrip('/')
|
||||
apihelper.API_URL = url + '/bot{0}/{1}'
|
||||
apihelper.FILE_URL = url + '/file/bot{0}/{1}'
|
||||
# в --local режиме сервер файлы не отдает, их раздает TELEGRAM_FILES_URL по пути внутри WORK_DIR
|
||||
apihelper.FILE_URL = getattr(settings, 'TELEGRAM_FILES_URL', '').rstrip('/') + '{1}'
|
||||
|
||||
|
||||
CLOUD_API_URL = 'https://api.telegram.org'
|
||||
ALLOWED_UPDATES = ['message', 'my_chat_member']
|
||||
# папка данных своего Bot API сервера (TELEGRAM_WORK_DIR образа): в --local режиме getFile отдает путь внутри нее
|
||||
WORK_DIR = '/var/lib/telegram-bot-api'
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FileTooLarge(ApiError):
|
||||
pass
|
||||
|
||||
|
||||
def uses_own_server() -> bool:
|
||||
return bool(getattr(settings, 'TELEGRAM_API_URL', ''))
|
||||
|
||||
@@ -34,3 +41,28 @@ def log_out_from_cloud(token: str):
|
||||
|
||||
def make_api(token: str) -> telebot.TeleBot:
|
||||
return telebot.TeleBot(token, threaded=False)
|
||||
|
||||
|
||||
def download_file(api: telebot.TeleBot, file_id: str, max_size: int) -> bytes:
|
||||
"""Содержимое файла из сообщения. FileTooLarge - больше max_size байт, ApiError - не удалось скачать.
|
||||
|
||||
Свой Bot API сервер в --local режиме файлы по HTTP не отдает: getFile возвращает путь на его диске,
|
||||
а раздает их TELEGRAM_FILES_URL (nginx рядом с сервером, см. configure_api_server).
|
||||
"""
|
||||
try:
|
||||
file = api.get_file(file_id)
|
||||
if file.file_size and file.file_size > max_size:
|
||||
raise FileTooLarge(f'file is larger than {max_size} bytes')
|
||||
path = file.file_path
|
||||
if uses_own_server():
|
||||
if not path.startswith(WORK_DIR + '/'):
|
||||
raise ApiError('unexpected file path from Bot API server')
|
||||
path = path[len(WORK_DIR):]
|
||||
content = api.download_file(path)
|
||||
except ApiError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ApiError(f'download: {error}') from error
|
||||
if len(content) > max_size:
|
||||
raise FileTooLarge(f'file is larger than {max_size} bytes')
|
||||
return content
|
||||
+35
-1
@@ -1,9 +1,21 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from telebot import types
|
||||
|
||||
from . import api as bot_api
|
||||
from . import interfaces, messages
|
||||
from .models import Bot, Chat, User
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Document:
|
||||
"""Файл, присланный документом."""
|
||||
file_id: str
|
||||
name: str
|
||||
size: int
|
||||
mime_type: str
|
||||
|
||||
|
||||
class Context:
|
||||
"""Всё, что нужно обработчику интерфейса: входящее сообщение, собеседник, состояние диалога и ответ."""
|
||||
|
||||
@@ -16,7 +28,21 @@ class Context:
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.message.text or ''
|
||||
"""Текст сообщения, у документа - подпись."""
|
||||
return self.message.text or self.message.caption or ''
|
||||
|
||||
@property
|
||||
def document(self) -> Document | None:
|
||||
document = self.message.document
|
||||
if document is None:
|
||||
return None
|
||||
return Document(document.file_id, document.file_name or '', document.file_size or 0, document.mime_type or '')
|
||||
|
||||
def download_document(self, max_size: int) -> bytes:
|
||||
"""Содержимое присланного документа. api.FileTooLarge - больше max_size байт, api.ApiError - не скачался."""
|
||||
if self.document is None:
|
||||
raise ValueError('message has no document')
|
||||
return bot_api.download_file(self.api, self.document.file_id, max_size)
|
||||
|
||||
@property
|
||||
def command(self) -> str | None:
|
||||
@@ -65,3 +91,11 @@ class Context:
|
||||
|
||||
def reply(self, text: str, keyboard: messages.Keyboard | None = None, parse_mode: str | None = None):
|
||||
return messages.send(self.chat, text, keyboard=keyboard, parse_mode=parse_mode, api=self.api)
|
||||
|
||||
def reply_document(
|
||||
self, filename: str, content: bytes, caption: str = '', keyboard: messages.Keyboard | None = None,
|
||||
parse_mode: str | None = None,
|
||||
):
|
||||
return messages.send_document(
|
||||
self.chat, filename, content, caption=caption, keyboard=keyboard, parse_mode=parse_mode, api=self.api,
|
||||
)
|
||||
@@ -33,7 +33,7 @@ def _handle_message(api, bot: Bot, message: types.Message):
|
||||
user = _save_user(message.from_user)
|
||||
chat = _save_chat(bot, message.chat, user)
|
||||
Message.objects.create(
|
||||
chat=chat, user=user, direction=Message.Direction.IN, message_id=message.message_id, text=message.text or '',
|
||||
chat=chat, user=user, direction=Message.Direction.IN, message_id=message.message_id, text=_message_text(message),
|
||||
)
|
||||
|
||||
interface = bot.get_interface()
|
||||
@@ -44,6 +44,15 @@ def _handle_message(api, bot: Bot, message: types.Message):
|
||||
chat.save(update_fields=['state', 'data'])
|
||||
|
||||
|
||||
def _message_text(message: types.Message) -> str:
|
||||
"""Текст для истории: у документа - подпись или имя файла."""
|
||||
if message.text:
|
||||
return message.text
|
||||
if message.document:
|
||||
return message.caption or message.document.file_name or ''
|
||||
return message.caption or ''
|
||||
|
||||
|
||||
def _save_user(tg_user: types.User) -> User:
|
||||
user, _ = User.objects.update_or_create(
|
||||
tg_id=tg_user.id,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import io
|
||||
|
||||
from telebot import types
|
||||
|
||||
from .models import Chat, Message
|
||||
@@ -22,3 +24,18 @@ def send(chat: Chat, text: str, keyboard: Keyboard | None = None, parse_mode: st
|
||||
api = api or chat.bot.api()
|
||||
sent = api.send_message(chat.chat_id, text, reply_markup=build_keyboard(keyboard), parse_mode=parse_mode)
|
||||
return Message.objects.create(chat=chat, direction=Message.Direction.OUT, message_id=sent.message_id, text=text)
|
||||
|
||||
|
||||
def send_document(
|
||||
chat: Chat, filename: str, content: bytes, caption: str = '', keyboard: Keyboard | None = None,
|
||||
parse_mode: str | None = None, api=None,
|
||||
) -> Message:
|
||||
"""Отправляет файл документом и сохраняет в историю подпись или имя файла."""
|
||||
api = api or chat.bot.api()
|
||||
sent = api.send_document(
|
||||
chat.chat_id, types.InputFile(io.BytesIO(content), file_name=filename), caption=caption or None,
|
||||
reply_markup=build_keyboard(keyboard), parse_mode=parse_mode,
|
||||
)
|
||||
return Message.objects.create(
|
||||
chat=chat, direction=Message.Direction.OUT, message_id=sent.message_id, text=caption or filename,
|
||||
)
|
||||
+45
-1
@@ -8,11 +8,32 @@ CHAT_ID = 42
|
||||
|
||||
|
||||
class FakeApi:
|
||||
"""Подменяет TeleBot: запоминает отправленные и удаленные сообщения."""
|
||||
"""Подменяет TeleBot: запоминает отправленные и удаленные сообщения, отдает присланные документы."""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.deleted = []
|
||||
self.documents = []
|
||||
self.files = {}
|
||||
"""file_id -> содержимое присланного документа."""
|
||||
|
||||
def get_file(self, file_id):
|
||||
from .api import WORK_DIR, uses_own_server
|
||||
|
||||
# свой сервер отдает путь на своем диске, облако - относительный
|
||||
path = f'{WORK_DIR}/1:token/documents/{file_id}' if uses_own_server() else f'documents/{file_id}'
|
||||
return SimpleNamespace(file_id=file_id, file_path=path, file_size=len(self.files[file_id]))
|
||||
|
||||
def download_file(self, file_path):
|
||||
return self.files[file_path.rsplit('/', 1)[-1]]
|
||||
|
||||
def send_document(self, chat_id, document, caption=None, reply_markup=None, parse_mode=None):
|
||||
content = document.file.read()
|
||||
self.documents.append(SimpleNamespace(
|
||||
chat_id=chat_id, file_name=document.file_name, content=content, caption=caption, reply_markup=reply_markup,
|
||||
))
|
||||
self.sent.append(SimpleNamespace(chat_id=chat_id, text=caption, reply_markup=reply_markup, parse_mode=parse_mode))
|
||||
return SimpleNamespace(message_id=len(self.sent))
|
||||
|
||||
def send_message(self, chat_id, text, reply_markup=None, parse_mode=None):
|
||||
self.sent.append(SimpleNamespace(chat_id=chat_id, text=text, reply_markup=reply_markup, parse_mode=parse_mode))
|
||||
@@ -51,6 +72,19 @@ def message_update_json(update_id: int, text: str, chat_type: str = 'private', u
|
||||
}
|
||||
|
||||
|
||||
def document_update_json(
|
||||
update_id: int, file_id: str, file_name: str, size: int, caption: str = '', user_id: int = USER_ID,
|
||||
) -> dict:
|
||||
"""Обновление с документом в том виде, в каком его присылает Bot API."""
|
||||
update = message_update_json(update_id, '', user_id=user_id)
|
||||
message = update['message']
|
||||
del message['text']
|
||||
message['document'] = {'file_id': file_id, 'file_unique_id': file_id, 'file_name': file_name, 'file_size': size}
|
||||
if caption:
|
||||
message['caption'] = caption
|
||||
return update
|
||||
|
||||
|
||||
class Conversation:
|
||||
"""Диалог пользователя с ботом: say() отправляет сообщение и возвращает ответ бота."""
|
||||
|
||||
@@ -68,3 +102,13 @@ class Conversation:
|
||||
sent_before = len(self.api.sent)
|
||||
self._handle_update(self.api, self.bot, message_update(self._update_id, text, user_id=self.user_id))
|
||||
return self.api.last_text if len(self.api.sent) > sent_before else None
|
||||
|
||||
def send_document(self, file_name: str, content: bytes, caption: str = '') -> str | None:
|
||||
"""Присылает боту документ и возвращает ответ бота."""
|
||||
self._update_id += 1
|
||||
file_id = f'file{self._update_id}'
|
||||
self.api.files[file_id] = content
|
||||
sent_before = len(self.api.sent)
|
||||
update = document_update_json(self._update_id, file_id, file_name, len(content), caption, self.user_id)
|
||||
self._handle_update(self.api, self.bot, types.Update.de_json(update))
|
||||
return self.api.last_text if len(self.api.sent) > sent_before else None
|
||||
+68
-1
@@ -5,11 +5,12 @@ from unittest import mock
|
||||
from django.test import TestCase, override_settings
|
||||
from telebot import types
|
||||
|
||||
from . import api as bot_api
|
||||
from . import interfaces
|
||||
from .dispatcher import handle_update
|
||||
from .interfaces import Interface, command, register, state, text
|
||||
from .models import Bot, Chat, Message, User
|
||||
from .testing import FakeApi, message_update, message_update_json
|
||||
from .testing import Conversation, FakeApi, message_update, message_update_json
|
||||
|
||||
|
||||
@register
|
||||
@@ -42,6 +43,22 @@ class EchoInterface(Interface):
|
||||
ctx.state = ''
|
||||
ctx.reply(f'hi {ctx.text}')
|
||||
|
||||
@text('upload')
|
||||
def ask_file(self, ctx):
|
||||
ctx.state = 'await_file'
|
||||
ctx.reply('file?')
|
||||
|
||||
@state('await_file')
|
||||
def file(self, ctx):
|
||||
if ctx.document is None:
|
||||
return ctx.reply('not a file')
|
||||
try:
|
||||
content = ctx.download_document(max_size=10)
|
||||
except bot_api.FileTooLarge:
|
||||
return ctx.reply('too large')
|
||||
ctx.state = ''
|
||||
ctx.reply_document(f'echo-{ctx.document.name}', content, caption=f'got {ctx.text}')
|
||||
|
||||
def fallback(self, ctx):
|
||||
ctx.reply(f'echo {ctx.text}')
|
||||
|
||||
@@ -128,6 +145,56 @@ class DispatcherTests(TestCase):
|
||||
self.assertEqual(Chat.objects.count(), 1)
|
||||
|
||||
|
||||
class DocumentTests(TestCase):
|
||||
def setUp(self):
|
||||
self.bot = Bot.objects.create(token='1:test', interface=interfaces.key(EchoInterface))
|
||||
self.conversation = Conversation(self.bot)
|
||||
self.conversation.say('upload')
|
||||
|
||||
def check_echo(self):
|
||||
self.assertEqual(self.conversation.send_document('a.zip', b'zip', caption='caption'), 'got caption')
|
||||
document = self.conversation.api.documents[-1]
|
||||
self.assertEqual((document.file_name, document.content), ('echo-a.zip', b'zip'))
|
||||
self.assertEqual(
|
||||
list(Message.objects.order_by('pk').values_list('direction', 'text'))[-2:],
|
||||
[('in', 'caption'), ('out', 'got caption')],
|
||||
)
|
||||
|
||||
@override_settings(TELEGRAM_API_URL='')
|
||||
def test_cloud_document(self):
|
||||
self.check_echo()
|
||||
|
||||
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081')
|
||||
def test_own_server_document(self):
|
||||
self.check_echo()
|
||||
|
||||
def test_too_large(self):
|
||||
self.assertEqual(self.conversation.send_document('big.zip', b'x' * 11), 'too large')
|
||||
self.assertEqual(Message.objects.filter(direction='in').order_by('pk').last().text, 'big.zip')
|
||||
|
||||
def test_text_in_file_state(self):
|
||||
self.assertEqual(self.conversation.say('hello'), 'not a file')
|
||||
|
||||
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081')
|
||||
def test_own_server_rejects_foreign_path(self):
|
||||
api = mock.Mock()
|
||||
api.get_file.return_value = SimpleNamespace(file_path='/etc/passwd', file_size=1)
|
||||
with self.assertRaises(bot_api.ApiError):
|
||||
bot_api.download_file(api, 'id', 10)
|
||||
api.download_file.assert_not_called()
|
||||
|
||||
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081', TELEGRAM_FILES_URL='http://127.0.0.1:8083/')
|
||||
def test_own_server_file_url(self):
|
||||
from telebot import apihelper
|
||||
|
||||
with mock.patch.object(apihelper, 'API_URL', None), mock.patch.object(apihelper, 'FILE_URL', None):
|
||||
bot_api.configure_api_server()
|
||||
self.assertEqual(
|
||||
apihelper.FILE_URL.format('1:t', '/1:t/documents/file_0.zip'),
|
||||
'http://127.0.0.1:8083/1:t/documents/file_0.zip',
|
||||
)
|
||||
|
||||
|
||||
@override_settings(TELEGRAM_WEBHOOK_URL='http://localhost:8000/')
|
||||
class PublishTests(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
+40
-1
@@ -54,7 +54,7 @@ server.release(generate_password())
|
||||
Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны.
|
||||
|
||||
- `bootstrap` - проверяет Debian 12 или 13, прописывает ключ, меняет пароль root, обновляет систему (если что-то обновилось - перезагружает в конце), включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ.
|
||||
- `release` - ставит новый пароль root, сбрасывает nftables, убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
|
||||
- `release` - ставит новый пароль root, возвращает конфиг nftables Debian по умолчанию (все разрешено, nftables остается включенным, Docker перезапускается, чтобы вернуть свои правила), убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
|
||||
- `facts` - `uname -a` и версия ОС.
|
||||
|
||||
Специализации - наследники `Server`:
|
||||
@@ -65,6 +65,39 @@ server.release(generate_password())
|
||||
- `XuiPanel(host).create_inbound(remark='main', target='')` - подключение vless + xhttp + reality на 443, uTLS firefox, сброс трафика ежемесячно. Цель reality подбирает сканер панели (TLS 1.3, h2, X25519, валидный сертификат, минимальная задержка) из `target` или из своих кандидатов. Ключи x25519 генерирует панель, shortId и путь xhttp - serverus. Существующее подключение с тем же `remark` не трогает. Возвращает `data`: `inbound_id`, `target`, `server_name`, `public_key`, `short_ids`, `path`.
|
||||
- `RedirectProxy(host).redirect(target_ip, ports)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip> <ports>`, который через nftables перенаправляет tcp-порты `ports` на те же порты target_ip с masquerade и MSS clamping. Точка передает 80, 443, порт панели и порт подписки; ssh остается на самом сервере, скрипт откажется перенаправлять его порт. Правила - `/etc/nftables.d/serverus-redirect.nft`, подключаются include из `/etc/nftables.conf`. Заодно включает bbr, fq и увеличенные буферы и таблицу conntrack.
|
||||
|
||||
- `DockerHost(host).install(local_certs=False)` - после `bootstrap`: фаервол (ssh, 80, 443/tcp+udp, транзитный трафик пропускается - его фильтрует Docker), Docker из репозитория Docker, логи контейнеров с ротацией (`log-driver: local`), автоматические обновления безопасности (`unattended-upgrades`), папка приложений `APPS_DIR` (`/root/apps`), сеть `web` и встроенное приложение `INGRESS` (`caddy`, образ `CADDY_IMAGE` закреплен, обновлять вручную). `local_certs` - сертификаты собственного CA Caddy вместо Let's Encrypt, для стенда.
|
||||
|
||||
## Docker-приложения
|
||||
|
||||
Приложение - папка `/root/apps/<name>/`, compose-проект с именем `name`:
|
||||
|
||||
```
|
||||
docker-compose.yaml COMPOSE_FILE, обязателен
|
||||
.env ENV_FILE, секреты, на сервере 0600
|
||||
caddy.conf CADDY_FILE, необязательно: блок Caddy для веб-морды
|
||||
data/<сервис>/ DATA_DIR, настройки сервиса, имя папки = имя сервиса в compose
|
||||
```
|
||||
|
||||
Сервисы ничего не пишут в папку приложения, состояние - только в named volumes. Поэтому папка целиком повторяет присланные файлы, лишние файлы удаляются. Порты публикует только `caddy`; остальные сервисы общаются через docker-сеть, веб-морды подключаются к внешней сети `web`, а их домены описываются в `caddy.conf`:
|
||||
|
||||
```caddyfile
|
||||
app.example.com {
|
||||
reverse_proxy site-web-1:8080
|
||||
}
|
||||
```
|
||||
|
||||
Caddy выпускает сертификат для каждого домена из блоков сам, домен должен указывать на сервер.
|
||||
|
||||
- `deploy(name, files)` - `files` - полный набор файлов `{путь: bytes}`. Compose-файл и `caddy.conf` (`caddy validate` на копии конфигурации Caddy с новым блоком: ошибки и дубли доменов) проверяются до изменений: ошибка - приложение не тронуто. Файлы обновляются по месту (тот же inode, смонтированный файл остается тем же файлом), затем `docker compose up -d --remove-orphans`: compose сам пересоздает сервисы, у которых изменилась конфигурация, в том числе из-за `.env`. Сервисы, у которых изменилось что-то в `data/<сервис>/`, пересоздаются отдельно. Изменился `caddy.conf` - Caddy перезагружается; не принял блок - прежний возвращается, `deploy` падает с ошибкой Caddy. Возвращает `data`: `changed`, `removed` (пути), `recreated`, `services`.
|
||||
- `status()` - все приложения: сервисы из compose-файла, сверенные с `docker ps -a`. Сервис без контейнера - `missing`. `data`: `{'apps': {app: {'services': {service: {'state', 'health', 'status'}}, 'error': ''}}}`.
|
||||
- `control(name, action, service='')` - `start` (`up -d`), `stop`, `restart`, `pull` (новые образы и `up -d`) приложения или одного сервиса.
|
||||
- `logs(name, service='', lines=500)` - `data['logs']`, до 1 Мб.
|
||||
- `remove(name, volumes=False)` - контейнеры, папка и блок Caddy; `volumes=True` - вместе с данными.
|
||||
|
||||
Контейнерам нужен `restart: unless-stopped`: при изменении конфига nftables перезапускается и стирает правила Docker, Docker перезапускается следом, чтобы их вернуть. Без изменений конфига nftables не перезапускается.
|
||||
|
||||
Скрипты на сервере (`playbooks/files/app_sync.py`, `app_status.py`) копируются в `/root/apps/.serverus/` на каждом запуске.
|
||||
|
||||
Порты и логины: `generate_port(exclude=...)` - порт сервиса из 10000-20000 (ssh - 20000-60000), `generate_username()` - 6 символов.
|
||||
|
||||
Плейбуки ходят в API панели с самого сервера на `127.0.0.1` со своим токеном `serverus`, который перевыпускается на каждом запуске. Токен `api_token`, отданный вызывающей стороне, при этом не меняется.
|
||||
@@ -106,6 +139,9 @@ make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на па
|
||||
make serverus_cert ip=<ip> [domain=<domain>] [ca=yes] # сертификат на домен или на ip
|
||||
make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую
|
||||
make serverus_release ip=<ip> # вернуть сервер
|
||||
make serverus_docker ip=<ip> password=<root_password> [local_certs=yes] # bootstrap + Docker + ingress
|
||||
make serverus_app ip=<ip> action=deploy name=<app> dir=<папка> # залить приложение из локальной папки
|
||||
make serverus_app ip=<ip> action=status|logs|start|stop|restart|pull|remove [name=<app>] [service=<service>] [volumes=yes]
|
||||
```
|
||||
|
||||
То же без Makefile: `python -m serverus xui <ip> <password> --out <dir>`, `python -m serverus key add <name> --out <dir>` и `python -m serverus release --out <dir>`.
|
||||
@@ -134,3 +170,6 @@ make serverus_release ip=<ip> # вернуть се
|
||||
- [x] `release` на локальном стенде
|
||||
- [ ] проверить на сервере с публичным адресом: сертификат Let's Encrypt на домен и на ip (продление короткого сертификата по cron), `release`, Debian 12
|
||||
- [x] выпуск и отзыв ключей в боте поверх `XuiApi`
|
||||
- [x] `DockerHost`: плейбуки приложений проверены на локальном Docker (deploy, выборочное пересоздание, отказ на битом compose, откат блока Caddy, статус, логи, удаление)
|
||||
- [x] `DockerHost` на стенде (Debian 13): установка, https через `local_certs`, выборочное пересоздание, отказ битого `caddy.conf` и дубля домена до изменений, удаление сервиса заливкой, `release` с работающими приложениями, повторный bootstrap сервера с Docker
|
||||
- [ ] `DockerHost` на сервере с публичным адресом: сертификаты Let's Encrypt через Caddy
|
||||
@@ -1,6 +1,9 @@
|
||||
from .credentials import (
|
||||
KeyPair, generate_keypair, generate_password, generate_port, generate_ssh_port, generate_username,
|
||||
)
|
||||
from .docker import (
|
||||
APP_NAME_RE, CADDY_FILE, CADDY_IMAGE, COMPOSE_FILE, DATA_DIR, ENV_FILE, INGRESS, SERVICE_NAME_RE, DockerHost,
|
||||
)
|
||||
from .host import Host
|
||||
from .local_ca import Certificate, LocalCA
|
||||
from .proxy import RedirectProxy
|
||||
@@ -10,13 +13,22 @@ from .xui import CF_WARP, XUI_VERSION, XuiPanel, parse_proxy
|
||||
from .xui_api import XuiApi, XuiApiError
|
||||
|
||||
__all__ = [
|
||||
'APP_NAME_RE',
|
||||
'CADDY_FILE',
|
||||
'CADDY_IMAGE',
|
||||
'CF_WARP',
|
||||
'COMPOSE_FILE',
|
||||
'Certificate',
|
||||
'DATA_DIR',
|
||||
'DockerHost',
|
||||
'ENV_FILE',
|
||||
'Host',
|
||||
'INGRESS',
|
||||
'KeyPair',
|
||||
'LocalCA',
|
||||
'RedirectProxy',
|
||||
'Result',
|
||||
'SERVICE_NAME_RE',
|
||||
'Server',
|
||||
'XUI_VERSION',
|
||||
'XuiApi',
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
python -m serverus cert [<domain>] --out <dir> [--ca <dir>]
|
||||
python -m serverus outbound [<proxy>] --out <dir>
|
||||
python -m serverus release --out <dir> [--password <new_root_password>]
|
||||
python -m serverus docker <ip> <root_password> --out <dir> [--local-certs]
|
||||
python -m serverus app deploy <name> <app_dir> --out <dir>
|
||||
python -m serverus app status|logs|start|stop|restart|pull|remove [<name>] --out <dir> [--service <service>] [--volumes]
|
||||
|
||||
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
|
||||
bootstrap меняет пароль и порт ssh, без этого файла доступ к серверу можно потерять.
|
||||
@@ -15,7 +18,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import (
|
||||
Host, LocalCA, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
|
||||
DockerHost, Host, LocalCA, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
|
||||
generate_ssh_port, generate_username,
|
||||
)
|
||||
|
||||
@@ -53,7 +56,25 @@ def main(argv=None) -> int:
|
||||
release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
|
||||
release.add_argument('--password', default='', help='новый пароль root, без него случайный')
|
||||
|
||||
docker = commands.add_parser('docker', help='bootstrap свежего сервера и установка Docker с ingress Caddy')
|
||||
docker.add_argument('address')
|
||||
docker.add_argument('password', help='текущий пароль root')
|
||||
docker.add_argument('--out', type=Path, required=True, help='папка для доступов к серверу')
|
||||
docker.add_argument('--local-certs', action='store_true', help='сертификаты своего CA Caddy: для стенда без публичного адреса')
|
||||
|
||||
app = commands.add_parser('app', help='приложение на сервере от команды docker')
|
||||
app.add_argument('action', choices=['deploy', 'status', 'logs', 'start', 'stop', 'restart', 'pull', 'remove'])
|
||||
app.add_argument('name', nargs='?', default='', help='имя приложения, для status не нужно')
|
||||
app.add_argument('app_dir', nargs='?', type=Path, help='для deploy: локальная папка приложения')
|
||||
app.add_argument('--out', type=Path, required=True, help='папка с доступами от команды docker')
|
||||
app.add_argument('--service', default='', help='один сервис приложения')
|
||||
app.add_argument('--volumes', action='store_true', help='remove: удалить и данные')
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == 'docker':
|
||||
return install_docker(args.address, args.password, args.out, args.local_certs)
|
||||
if args.command == 'app':
|
||||
return manage_app(args.action, args.name, args.app_dir, args.out, args.service, args.volumes)
|
||||
if args.command == 'xui':
|
||||
return install_xui(args.address, args.password, args.out, args.domain, not args.no_ssl, args.ca)
|
||||
if args.command == 'key':
|
||||
@@ -133,6 +154,71 @@ def install_panel(access: dict, out: Path, ca_dir: Path | None) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def install_docker(address: str, password: str, out: Path, local_certs: bool) -> int:
|
||||
access_file = out / ACCESS_FILE
|
||||
access = json.loads(access_file.read_text()) if access_file.exists() else {}
|
||||
if access.get('bootstrapped'):
|
||||
# повторный запуск после ошибки установки: сервер уже на ключе и новом порту
|
||||
print('bootstrap: skipped, already done')
|
||||
else:
|
||||
keys = generate_keypair()
|
||||
access = {
|
||||
'address': address,
|
||||
'ssh_port': generate_ssh_port(),
|
||||
'root_password': generate_password(),
|
||||
'private_key': keys.private_key,
|
||||
'known_hosts': '',
|
||||
}
|
||||
save(out, access)
|
||||
result = Server(Host(address, private_key=keys.private_key)).bootstrap(
|
||||
password, keys.public_key, access['root_password'], access['ssh_port'],
|
||||
)
|
||||
access['known_hosts'] = result.known_hosts
|
||||
access['bootstrapped'] = result.ok
|
||||
save(out, access)
|
||||
report('bootstrap', result)
|
||||
if not result.ok:
|
||||
return 1
|
||||
result = DockerHost(load_host(access)).install(local_certs=local_certs)
|
||||
report('docker', result)
|
||||
if not result.ok:
|
||||
return 1
|
||||
print(f"ssh: ssh -i {out / KEY_FILE} -p {access['ssh_port']} root@{access['address']}")
|
||||
return 0
|
||||
|
||||
|
||||
def manage_app(action: str, name: str, app_dir: Path | None, out: Path, service: str, volumes: bool) -> int:
|
||||
access = json.loads((out / ACCESS_FILE).read_text())
|
||||
docker = DockerHost(load_host(access))
|
||||
try:
|
||||
if action == 'deploy':
|
||||
if app_dir is None or not app_dir.is_dir():
|
||||
print('deploy: укажите локальную папку приложения', file=sys.stderr)
|
||||
return 1
|
||||
files = {
|
||||
path.relative_to(app_dir).as_posix(): path.read_bytes()
|
||||
for path in sorted(app_dir.rglob('*')) if path.is_file()
|
||||
}
|
||||
result = docker.deploy(name, files)
|
||||
elif action == 'status':
|
||||
result = docker.status()
|
||||
elif action == 'logs':
|
||||
result = docker.logs(name, service)
|
||||
elif action == 'remove':
|
||||
result = docker.remove(name, volumes)
|
||||
else:
|
||||
result = docker.control(name, action, service)
|
||||
except ValueError as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
report(action, result)
|
||||
if action == 'logs':
|
||||
print(result.data.get('logs', ''))
|
||||
elif result.data:
|
||||
print(json.dumps(result.data, indent=2, ensure_ascii=False))
|
||||
return 0 if result.ok else 1
|
||||
|
||||
|
||||
def manage_key(action: str, name: str, out: Path) -> int:
|
||||
access = json.loads((out / ACCESS_FILE).read_text())
|
||||
api = XuiApi(panel_url(access), access['api_token'], ca_file=access.get('ca_file') or None)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import io
|
||||
import re
|
||||
import tarfile
|
||||
import tempfile
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from .runner import Result
|
||||
from .server import Server
|
||||
|
||||
CADDY_IMAGE = 'caddy:2.10.2-alpine'
|
||||
"""Образ Caddy на docker-сервере. Обновлять вручную после проверки."""
|
||||
INGRESS = 'caddy'
|
||||
"""Встроенное приложение: единственное, которое публикует порты (80, 443). Заливать и удалять его нельзя."""
|
||||
APPS_DIR = '/root/apps'
|
||||
COMPOSE_FILE = 'docker-compose.yaml'
|
||||
CADDY_FILE = 'caddy.conf'
|
||||
"""Блок Caddy приложения с веб-мордой: лежит в корне приложения, на сервере подключается к ingress."""
|
||||
DATA_DIR = 'data'
|
||||
"""Настройки сервисов: data/<сервис>/. Изменения в них пересоздают только этот сервис."""
|
||||
ENV_FILE = '.env'
|
||||
|
||||
APP_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
|
||||
SERVICE_NAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$')
|
||||
ACTIONS = ('start', 'stop', 'restart', 'pull')
|
||||
LOG_LINES_MAX = 5000
|
||||
|
||||
|
||||
class DockerHost(Server):
|
||||
"""Сервер для docker-приложений.
|
||||
|
||||
Приложение - папка APPS_DIR/<name>/ (compose-проект с именем name): COMPOSE_FILE, ENV_FILE, CADDY_FILE (необязательно)
|
||||
и DATA_DIR/<сервис>/ с настройками сервисов. Сервисы ничего не пишут в папку приложения, состояние - в named volumes,
|
||||
поэтому папка целиком повторяет присланные файлы.
|
||||
"""
|
||||
|
||||
def install(self, local_certs: bool = False) -> Result:
|
||||
"""Ставит Docker и ingress на сервер после bootstrap.
|
||||
|
||||
Открывает 80 и 443 (tcp, udp для http/3), логи контейнеров ротируются, обновления безопасности ставятся сами.
|
||||
Создает APPS_DIR, сеть web и приложение INGRESS: Caddy, который подключает caddy.conf приложений и сам выпускает
|
||||
для их доменов сертификаты Let's Encrypt. local_certs - сертификаты от собственного CA Caddy, для стенда без
|
||||
публичного адреса.
|
||||
"""
|
||||
return self._run('docker', caddy_image=CADDY_IMAGE, local_certs=local_certs)
|
||||
|
||||
def deploy(self, name: str, files: Mapping[str, bytes]) -> Result:
|
||||
"""Приводит приложение name к files ({путь: содержимое}, полный набор файлов) и запускает его.
|
||||
|
||||
Сначала проверяет compose-файл: ошибка - на сервере ничего не изменилось. Затем обновляет файлы по месту,
|
||||
лишние удаляет, делает docker compose up -d --remove-orphans (compose сам пересоздает сервисы с изменившейся
|
||||
конфигурацией) и пересоздает сервисы, у которых изменилось что-то в DATA_DIR/<сервис>/. Изменился CADDY_FILE -
|
||||
перезагружает Caddy; не принял - прежний блок возвращается, deploy завершается ошибкой.
|
||||
Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...]}.
|
||||
"""
|
||||
_check_app(name)
|
||||
if name == INGRESS:
|
||||
raise ValueError(f'{INGRESS} is managed by install')
|
||||
paths = [_check_path(path) for path in files]
|
||||
if COMPOSE_FILE not in paths:
|
||||
raise ValueError(f'{COMPOSE_FILE} is required')
|
||||
with tempfile.TemporaryDirectory(prefix='serverus-app-') as directory:
|
||||
archive = Path(directory) / f'{name}.tar.gz'
|
||||
archive.write_bytes(_pack(dict(zip(paths, files.values()))))
|
||||
return self._run('app_deploy', app=name, archive=str(archive))
|
||||
|
||||
def status(self) -> Result:
|
||||
"""Состояние всех приложений: сервисы из compose-файла, сверенные с docker ps -a.
|
||||
|
||||
Возвращает data: {'apps': {app: {'services': {service: {'state', 'health', 'status'}}, 'error': ''}}}.
|
||||
state - running, exited, restarting... или missing, если контейнера сервиса нет.
|
||||
error - текст, если compose-файл приложения не читается.
|
||||
"""
|
||||
return self._run('app_status')
|
||||
|
||||
def control(self, name: str, action: str, service: str = '') -> Result:
|
||||
"""start (up -d), stop, restart или pull (новые образы и up -d) приложения или одного его сервиса."""
|
||||
_check_app(name)
|
||||
if action not in ACTIONS:
|
||||
raise ValueError(f'action must be one of {ACTIONS}')
|
||||
return self._run('app_control', app=name, action=action, service=_check_service(service))
|
||||
|
||||
def logs(self, name: str, service: str = '', lines: int = 500) -> Result:
|
||||
"""Последние lines строк логов приложения или одного сервиса. Возвращает data: {'logs': текст}."""
|
||||
_check_app(name)
|
||||
lines = max(1, min(int(lines), LOG_LINES_MAX))
|
||||
return self._run('app_logs', app=name, service=_check_service(service), lines=lines)
|
||||
|
||||
def remove(self, name: str, volumes: bool = False) -> Result:
|
||||
"""Останавливает и удаляет приложение: контейнеры, папку и блок Caddy. volumes=True - вместе с данными."""
|
||||
_check_app(name)
|
||||
if name == INGRESS:
|
||||
raise ValueError(f'{INGRESS} is managed by install')
|
||||
return self._run('app_remove', app=name, volumes=volumes)
|
||||
|
||||
def _run(self, playbook: str, **extravars) -> Result:
|
||||
return self.run(playbook, apps_dir=APPS_DIR, **extravars)
|
||||
|
||||
|
||||
def _check_app(name: str):
|
||||
if not APP_NAME_RE.match(name):
|
||||
raise ValueError('app name: a-z, 0-9, - and _, up to 64 characters')
|
||||
|
||||
|
||||
def _check_service(name: str) -> str:
|
||||
if name and not SERVICE_NAME_RE.match(name):
|
||||
raise ValueError('service name: a-z, A-Z, 0-9, ., - and _, up to 64 characters')
|
||||
return name
|
||||
|
||||
|
||||
def _check_path(path: str) -> str:
|
||||
"""Относительный путь внутри приложения без . и .."""
|
||||
pure = PurePosixPath(path)
|
||||
if not path or pure.is_absolute() or any(part in ('', '.', '..') for part in path.split('/')):
|
||||
raise ValueError(f'bad path: {path!r}')
|
||||
return str(pure)
|
||||
|
||||
|
||||
def _pack(files: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode='w:gz') as tar:
|
||||
for path, content in sorted(files.items()):
|
||||
info = tarfile.TarInfo(path)
|
||||
info.size = len(content)
|
||||
info.mode = 0o600 if PurePosixPath(path).name == ENV_FILE else 0o644
|
||||
tar.addfile(info, io.BytesIO(content))
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,29 @@
|
||||
# extravars: apps_dir, app, action (start, stop, restart, pull), service (optional, без него - все приложение)
|
||||
- name: Control app
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
app_dir: "{{ apps_dir }}/{{ app }}"
|
||||
services: "{{ [service] if service | default('') else [] }}"
|
||||
commands:
|
||||
start: [ [ up, -d ] ]
|
||||
stop: [ [ stop ] ]
|
||||
restart: [ [ restart ] ]
|
||||
pull: [ [ pull ], [ up, -d ] ]
|
||||
tasks:
|
||||
- name: Check app
|
||||
ansible.builtin.stat:
|
||||
path: "{{ app_dir }}/docker-compose.yaml"
|
||||
register: compose_file
|
||||
|
||||
- name: Require app
|
||||
ansible.builtin.assert:
|
||||
that: compose_file.stat.exists
|
||||
fail_msg: "app {{ app }} not found"
|
||||
quiet: true
|
||||
|
||||
- name: Run docker compose
|
||||
ansible.builtin.command:
|
||||
argv: "{{ ['docker', 'compose'] + item + services }}"
|
||||
chdir: "{{ app_dir }}"
|
||||
loop: "{{ commands[action] }}"
|
||||
@@ -0,0 +1,177 @@
|
||||
# extravars: apps_dir, app, archive (tar.gz на управляющей машине: полный набор файлов приложения)
|
||||
#
|
||||
# 1. Файлы распаковываются во временную папку, compose-файл и caddy.conf (caddy validate на копии конфигурации
|
||||
# с новым блоком) проверяются там: ошибка - приложение не тронуто.
|
||||
# 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются.
|
||||
# 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией.
|
||||
# 4. Сервисы, у которых изменилось что-то в data/<сервис>/, пересоздаются: конфиги они читают при старте.
|
||||
# У нового приложения - нет: up только что создал все контейнеры.
|
||||
# 5. caddy.conf приложения становится блоком ingress. Caddy все же не принял - прежний блок возвращается, ошибка.
|
||||
- name: Deploy app
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
app_dir: "{{ apps_dir }}/{{ app }}"
|
||||
stage: "{{ apps_dir }}/.staging/{{ app }}"
|
||||
snippet: "{{ apps_dir }}/caddy/data/caddy/sites/{{ app }}.caddy"
|
||||
caddy_check: "{{ apps_dir }}/caddy/data/caddy/.check"
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/app_scripts.yml
|
||||
|
||||
- name: Deploy
|
||||
block:
|
||||
- name: Clean staging
|
||||
ansible.builtin.file:
|
||||
path: "{{ stage }}"
|
||||
state: absent
|
||||
|
||||
- name: Create staging
|
||||
ansible.builtin.file:
|
||||
path: "{{ stage }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Unpack files
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ archive }}"
|
||||
dest: "{{ stage }}"
|
||||
|
||||
- name: Validate compose file
|
||||
ansible.builtin.command: docker compose -p {{ app | quote }} config -q
|
||||
args:
|
||||
chdir: "{{ stage }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Check new caddy.conf
|
||||
ansible.builtin.stat:
|
||||
path: "{{ stage }}/caddy.conf"
|
||||
register: new_caddy_conf
|
||||
|
||||
# копия конфигурации Caddy с новым блоком вместо прежнего: дубли доменов и ошибки видны до изменений
|
||||
- name: Validate caddy.conf
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
rm -rf {{ caddy_check | quote }}
|
||||
mkdir -p {{ caddy_check | quote }}/sites
|
||||
cp {{ apps_dir | quote }}/caddy/data/caddy/Caddyfile {{ caddy_check | quote }}/
|
||||
find {{ apps_dir | quote }}/caddy/data/caddy/sites -maxdepth 1 -name '*.caddy' ! -name {{ (app + '.caddy') | quote }} \
|
||||
-exec cp {} {{ caddy_check | quote }}/sites/ \;
|
||||
cp {{ stage | quote }}/caddy.conf {{ caddy_check | quote }}/sites/{{ (app + '.caddy') | quote }}
|
||||
cd {{ apps_dir | quote }}/caddy
|
||||
# ошибку показываем без информационных строк Caddy
|
||||
rc=0
|
||||
out=$(docker compose exec -T caddy caddy validate --config /etc/caddy/.check/Caddyfile --adapter caddyfile 2>&1) || rc=$?
|
||||
printf '%s\n' "$out" | grep -v '"level":"info"' >&2 || true
|
||||
exit $rc
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: caddy_validate
|
||||
changed_when: false
|
||||
when: new_caddy_conf.stat.exists
|
||||
|
||||
- name: Sync files
|
||||
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_sync.py {{ stage | quote }} {{ app_dir | quote }}
|
||||
register: sync_out
|
||||
changed_when: sync_out.rc == 0 and ((sync_out.stdout | from_json).changed + (sync_out.stdout | from_json).removed) | length > 0
|
||||
always:
|
||||
- name: Remove staging
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop: [ "{{ stage }}", "{{ caddy_check }}" ]
|
||||
|
||||
- name: Start app
|
||||
ansible.builtin.command: docker compose up -d --remove-orphans
|
||||
args:
|
||||
chdir: "{{ app_dir }}"
|
||||
|
||||
- name: Read services
|
||||
ansible.builtin.command: docker compose config --services
|
||||
args:
|
||||
chdir: "{{ app_dir }}"
|
||||
register: services_out
|
||||
changed_when: false
|
||||
|
||||
- name: Find services with changed settings
|
||||
ansible.builtin.set_fact:
|
||||
sync: "{{ sync_out.stdout | from_json }}"
|
||||
services: "{{ services_out.stdout_lines }}"
|
||||
# новое приложение: up только что создал все контейнеры с присланными настройками
|
||||
recreate: >-
|
||||
{{ [] if (sync_out.stdout | from_json).created else
|
||||
((sync_out.stdout | from_json).changed + (sync_out.stdout | from_json).removed)
|
||||
| select('match', '^data/[^/]+/') | map('regex_replace', '^data/([^/]+)/.*$', '\1')
|
||||
| unique | intersect(services_out.stdout_lines) | list }}
|
||||
|
||||
- name: Recreate services with changed settings
|
||||
ansible.builtin.command:
|
||||
argv: [ docker, compose, up, -d, --force-recreate, --no-deps, "{{ item }}" ]
|
||||
chdir: "{{ app_dir }}"
|
||||
loop: "{{ recreate }}"
|
||||
|
||||
- name: Check caddy.conf
|
||||
ansible.builtin.stat:
|
||||
path: "{{ app_dir }}/caddy.conf"
|
||||
register: caddy_conf
|
||||
|
||||
- name: Check current Caddy block
|
||||
ansible.builtin.stat:
|
||||
path: "{{ snippet }}"
|
||||
register: old_snippet
|
||||
|
||||
- name: Save current Caddy block
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ snippet }}"
|
||||
register: old_snippet_content
|
||||
when: old_snippet.stat.exists
|
||||
|
||||
- name: Update Caddy block
|
||||
block:
|
||||
- name: Install Caddy block
|
||||
ansible.builtin.copy:
|
||||
src: "{{ app_dir }}/caddy.conf"
|
||||
dest: "{{ snippet }}"
|
||||
remote_src: true
|
||||
mode: "0644"
|
||||
register: snippet_copy
|
||||
when: caddy_conf.stat.exists
|
||||
|
||||
- name: Remove Caddy block
|
||||
ansible.builtin.file:
|
||||
path: "{{ snippet }}"
|
||||
state: absent
|
||||
register: snippet_remove
|
||||
when: not caddy_conf.stat.exists
|
||||
|
||||
- ansible.builtin.include_tasks: tasks/caddy_reload.yml
|
||||
when: snippet_copy.changed or snippet_remove.changed
|
||||
rescue:
|
||||
- name: Restore previous Caddy block
|
||||
ansible.builtin.copy:
|
||||
content: "{{ old_snippet_content.content | b64decode }}"
|
||||
dest: "{{ snippet }}"
|
||||
mode: "0644"
|
||||
when: old_snippet.stat.exists
|
||||
|
||||
- name: Remove rejected Caddy block
|
||||
ansible.builtin.file:
|
||||
path: "{{ snippet }}"
|
||||
state: absent
|
||||
when: not old_snippet.stat.exists
|
||||
|
||||
- ansible.builtin.include_tasks: tasks/caddy_reload.yml
|
||||
|
||||
- name: Report rejected caddy.conf
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
app is deployed, but Caddy rejected caddy.conf, previous block is kept:
|
||||
{{ (ansible_failed_result.stderr | default(ansible_failed_result.msg | default(''))) | trim | truncate(800) }}
|
||||
|
||||
- name: Return result
|
||||
ansible.builtin.set_stats:
|
||||
aggregate: false
|
||||
data:
|
||||
changed: "{{ sync.changed }}"
|
||||
removed: "{{ sync.removed }}"
|
||||
recreated: "{{ recreate }}"
|
||||
services: "{{ services }}"
|
||||
@@ -0,0 +1,21 @@
|
||||
# extravars: apps_dir, app, service (optional), lines
|
||||
- name: App logs
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
app_dir: "{{ apps_dir }}/{{ app }}"
|
||||
# лог уходит через события ansible: больше мегабайта не нужно
|
||||
logs_limit: 1000000
|
||||
tasks:
|
||||
- name: Read logs
|
||||
ansible.builtin.command:
|
||||
argv: "{{ ['docker', 'compose', 'logs', '--no-color', '--timestamps', '--tail', lines | string] + ([service] if service | default('') else []) }}"
|
||||
chdir: "{{ app_dir }}"
|
||||
register: logs_out
|
||||
changed_when: false
|
||||
|
||||
- name: Return logs
|
||||
ansible.builtin.set_stats:
|
||||
aggregate: false
|
||||
data:
|
||||
logs: "{{ logs_out.stdout[-logs_limit:] }}"
|
||||
@@ -0,0 +1,27 @@
|
||||
# extravars: apps_dir, app, volumes (true - удалить и данные)
|
||||
- name: Remove app
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
vars:
|
||||
app_dir: "{{ apps_dir }}/{{ app }}"
|
||||
snippet: "{{ apps_dir }}/caddy/data/caddy/sites/{{ app }}.caddy"
|
||||
tasks:
|
||||
# по имени проекта: контейнеры удаляются, даже если папки уже нет
|
||||
- name: Stop and remove containers
|
||||
ansible.builtin.command:
|
||||
argv: "{{ ['docker', 'compose', '-p', app, 'down', '--remove-orphans'] + (['--volumes'] if volumes | bool else []) }}"
|
||||
chdir: "{{ apps_dir }}"
|
||||
|
||||
- name: Remove app directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ app_dir }}"
|
||||
state: absent
|
||||
|
||||
- name: Remove Caddy block
|
||||
ansible.builtin.file:
|
||||
path: "{{ snippet }}"
|
||||
state: absent
|
||||
register: snippet_remove
|
||||
|
||||
- ansible.builtin.include_tasks: tasks/caddy_reload.yml
|
||||
when: snippet_remove.changed
|
||||
@@ -0,0 +1,16 @@
|
||||
# extravars: apps_dir
|
||||
- name: Apps status
|
||||
hosts: target
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/app_scripts.yml
|
||||
|
||||
- name: Read status
|
||||
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_status.py {{ apps_dir | quote }}
|
||||
register: status_out
|
||||
changed_when: false
|
||||
|
||||
- name: Return status
|
||||
ansible.builtin.set_stats:
|
||||
aggregate: false
|
||||
data: "{{ status_out.stdout | from_json }}"
|
||||
@@ -0,0 +1,135 @@
|
||||
# extravars: apps_dir, caddy_image, local_certs
|
||||
#
|
||||
# Docker из репозитория Docker, логи контейнеров с ротацией, автоматические обновления безопасности, папка приложений,
|
||||
# сеть web и ingress: приложение caddy, единственное, которое публикует порты. Caddy подключает блоки приложений
|
||||
# из data/caddy/sites/ и сам выпускает сертификаты для их доменов. local_certs - сертификаты своего CA Caddy.
|
||||
# Фаервол настраивается первым: nftables при старте стирает все правила, Docker ставит свои поверх.
|
||||
- name: Install Docker platform
|
||||
hosts: target
|
||||
gather_facts: true
|
||||
vars:
|
||||
caddy_dir: "{{ apps_dir }}/caddy"
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/firewall.yml
|
||||
vars:
|
||||
firewall_tcp_ports: [ "{{ ansible_port }}", 80, 443 ]
|
||||
firewall_udp_ports: [ 443 ]
|
||||
# трафик контейнеров идет через forward: его фильтрует Docker
|
||||
firewall_forward: true
|
||||
|
||||
- name: Install prerequisites
|
||||
ansible.builtin.apt:
|
||||
name: [ ca-certificates, curl, python3, unattended-upgrades ]
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Enable automatic security updates
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/apt.conf.d/20auto-upgrades
|
||||
mode: "0644"
|
||||
content: |
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
|
||||
- name: Create keyrings directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/apt/keyrings
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Download Docker key
|
||||
ansible.builtin.get_url:
|
||||
url: https://download.docker.com/linux/debian/gpg
|
||||
dest: /etc/apt/keyrings/docker.asc
|
||||
mode: "0644"
|
||||
|
||||
- name: Add Docker repository
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/sources.list.d/docker.sources
|
||||
mode: "0644"
|
||||
content: |
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/debian
|
||||
Suites: {{ ansible_facts.distribution_release }}
|
||||
Components: stable
|
||||
Architectures: {{ 'arm64' if ansible_facts.architecture == 'aarch64' else 'amd64' }}
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
|
||||
- name: Create Docker config directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/docker
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Configure Docker daemon
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/docker/daemon.json
|
||||
mode: "0644"
|
||||
content: |
|
||||
{
|
||||
"log-driver": "local",
|
||||
"log-opts": {"max-size": "10m", "max-file": "3"}
|
||||
}
|
||||
register: docker_daemon
|
||||
|
||||
- name: Install Docker
|
||||
ansible.builtin.apt:
|
||||
name: [ docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin ]
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
# после частых перезапусков (фаервол, release) systemd может держать Docker остановленным по лимиту запусков
|
||||
- name: Reset Docker start limit
|
||||
ansible.builtin.command: systemctl reset-failed docker.service
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Start Docker
|
||||
ansible.builtin.systemd_service:
|
||||
name: docker
|
||||
enabled: true
|
||||
state: "{{ 'restarted' if docker_daemon.changed else 'started' }}"
|
||||
|
||||
- name: Create apps directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ apps_dir }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Create web network
|
||||
ansible.builtin.shell: docker network inspect web >/dev/null 2>&1 || { docker network create web && echo created; }
|
||||
register: web_network
|
||||
changed_when: "'created' in web_network.stdout"
|
||||
|
||||
- name: Create ingress directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ caddy_dir }}/data/caddy/sites"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Write ingress compose file
|
||||
ansible.builtin.template:
|
||||
src: templates/caddy-compose.yaml.j2
|
||||
dest: "{{ caddy_dir }}/docker-compose.yaml"
|
||||
mode: "0600"
|
||||
|
||||
# Caddyfile смонтирован папкой: файл, замененный целиком, контейнер увидел бы только после пересоздания
|
||||
- name: Write Caddyfile
|
||||
ansible.builtin.template:
|
||||
src: templates/Caddyfile.j2
|
||||
dest: "{{ caddy_dir }}/data/caddy/Caddyfile"
|
||||
mode: "0644"
|
||||
register: caddyfile
|
||||
|
||||
- name: Start ingress
|
||||
ansible.builtin.command: docker compose up -d --remove-orphans
|
||||
args:
|
||||
chdir: "{{ caddy_dir }}"
|
||||
register: caddy_up
|
||||
changed_when: "'Started' in caddy_up.stderr or 'Recreated' in caddy_up.stderr"
|
||||
|
||||
- name: Reload Caddyfile
|
||||
ansible.builtin.command: docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
args:
|
||||
chdir: "{{ caddy_dir }}"
|
||||
when: caddyfile.changed and not caddy_up.changed
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Состояние docker-приложений: app_status.py <apps_dir> [<app>].
|
||||
|
||||
Сервисы берутся из compose-файла и сверяются с docker compose ps -a: описан, но контейнера нет - state missing.
|
||||
Контейнеры, которых нет в compose-файле (остались от прошлой версии), тоже попадают в список.
|
||||
Печатает JSON: {"apps": {app: {"services": {service: {"state", "health", "status"}}, "error": ""}}}.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
COMPOSE_FILE = 'docker-compose.yaml'
|
||||
|
||||
|
||||
def compose(app_dir, *args):
|
||||
result = subprocess.run(
|
||||
['docker', 'compose', *args], cwd=app_dir, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or result.stdout).strip()[-500:])
|
||||
return result.stdout
|
||||
|
||||
|
||||
def containers(output):
|
||||
"""ps --format json: старый compose печатает массив, новый - объект на строку."""
|
||||
output = output.strip()
|
||||
if not output:
|
||||
return []
|
||||
if output.startswith('['):
|
||||
return json.loads(output)
|
||||
return [json.loads(line) for line in output.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def app_status(app_dir):
|
||||
services = {
|
||||
name: {'state': 'missing', 'health': '', 'status': ''}
|
||||
for name in compose(app_dir, 'config', '--services').split()
|
||||
}
|
||||
for container in containers(compose(app_dir, 'ps', '-a', '--format', 'json')):
|
||||
state = {
|
||||
'state': container.get('State', ''),
|
||||
'health': container.get('Health', ''),
|
||||
'status': container.get('Status', ''),
|
||||
}
|
||||
name = container.get('Service', '')
|
||||
# несколько реплик: показываем проблемную
|
||||
if services.get(name, {}).get('state') in (None, 'missing', 'running'):
|
||||
services[name] = state
|
||||
return services
|
||||
|
||||
|
||||
def main(apps_dir, only=''):
|
||||
apps = {}
|
||||
names = [only] if only else sorted(os.listdir(apps_dir)) if os.path.isdir(apps_dir) else []
|
||||
for name in names:
|
||||
app_dir = os.path.join(apps_dir, name)
|
||||
if name.startswith('.') or not os.path.isfile(os.path.join(app_dir, COMPOSE_FILE)):
|
||||
continue
|
||||
try:
|
||||
apps[name] = {'services': app_status(app_dir), 'error': ''}
|
||||
except (RuntimeError, ValueError, subprocess.TimeoutExpired) as error:
|
||||
apps[name] = {'services': {}, 'error': str(error)}
|
||||
print(json.dumps({'apps': apps}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(*sys.argv[1:])
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Приводит папку приложения к присланной копии: app_sync.py <stage> <app_dir>.
|
||||
|
||||
Файлы переписываются по месту (тот же inode): смонтированный в контейнер файл остается тем же файлом.
|
||||
Лишние файлы удаляются, пустые папки за ними тоже.
|
||||
Печатает JSON: {"changed": [...], "removed": [...], "created": true, если папки приложения не было}.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
|
||||
|
||||
def files(root):
|
||||
result = set()
|
||||
for directory, _, names in os.walk(root):
|
||||
for name in names:
|
||||
result.add(os.path.relpath(os.path.join(directory, name), root))
|
||||
return result
|
||||
|
||||
|
||||
def same(src, dst):
|
||||
if not os.path.isfile(dst) or os.path.islink(dst):
|
||||
return False
|
||||
if stat.S_IMODE(os.stat(src).st_mode) != stat.S_IMODE(os.stat(dst).st_mode):
|
||||
return False
|
||||
with open(src, 'rb') as a, open(dst, 'rb') as b:
|
||||
return a.read() == b.read()
|
||||
|
||||
|
||||
def clear(path):
|
||||
"""Убирает то, что мешает положить файл: папку или ссылку на его месте."""
|
||||
if os.path.islink(path) or (os.path.exists(path) and not os.path.isdir(path)):
|
||||
os.remove(path)
|
||||
elif os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
def ensure_dir(app_dir, relative):
|
||||
"""Создает папки до файла; файл или ссылка на месте папки удаляется."""
|
||||
current = app_dir
|
||||
for part in relative.split(os.sep)[:-1]:
|
||||
current = os.path.join(current, part)
|
||||
if os.path.islink(current) or (os.path.exists(current) and not os.path.isdir(current)):
|
||||
os.remove(current)
|
||||
os.makedirs(current, exist_ok=True)
|
||||
|
||||
|
||||
def main(stage, app_dir):
|
||||
created = not os.path.isdir(app_dir)
|
||||
os.makedirs(app_dir, mode=0o700, exist_ok=True)
|
||||
new, old = files(stage), files(app_dir)
|
||||
changed, removed = [], []
|
||||
for relative in sorted(old - new):
|
||||
os.remove(os.path.join(app_dir, relative))
|
||||
removed.append(relative)
|
||||
for relative in sorted(new):
|
||||
src, dst = os.path.join(stage, relative), os.path.join(app_dir, relative)
|
||||
if same(src, dst):
|
||||
continue
|
||||
ensure_dir(app_dir, relative)
|
||||
if os.path.isdir(dst) or os.path.islink(dst):
|
||||
clear(dst)
|
||||
with open(src, 'rb') as source, open(dst, 'wb') as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
os.chmod(dst, stat.S_IMODE(os.stat(src).st_mode))
|
||||
changed.append(relative)
|
||||
for directory, _, _ in os.walk(app_dir, topdown=False):
|
||||
if directory != app_dir and not os.listdir(directory):
|
||||
os.rmdir(directory)
|
||||
print(json.dumps({'changed': changed, 'removed': removed, 'created': created}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
@@ -5,19 +5,40 @@
|
||||
tasks:
|
||||
- ansible.builtin.import_tasks: tasks/root_password.yml
|
||||
|
||||
# конфиг Debian по умолчанию: все разрешено. nftables остается включенным
|
||||
- name: Restore default nftables config
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/nftables.conf
|
||||
mode: "0755"
|
||||
content: |
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
|
||||
- name: Flush and disable nftables
|
||||
table inet filter {
|
||||
chain input {
|
||||
type filter hook input priority filter;
|
||||
}
|
||||
chain forward {
|
||||
type filter hook forward priority filter;
|
||||
}
|
||||
chain output {
|
||||
type filter hook output priority filter;
|
||||
}
|
||||
}
|
||||
|
||||
- name: Apply default nftables rules
|
||||
ansible.builtin.systemd_service:
|
||||
name: nftables
|
||||
enabled: false
|
||||
state: stopped
|
||||
state: restarted
|
||||
|
||||
# flush ruleset стер и правила Docker, приложения на сервере остаются работать
|
||||
- name: Restore Docker rules
|
||||
ansible.builtin.shell: systemctl reset-failed docker.service 2>/dev/null; systemctl try-restart docker
|
||||
register: docker_restart
|
||||
changed_when: false
|
||||
# rc 5 - Docker не установлен
|
||||
failed_when: docker_restart.rc not in [0, 5]
|
||||
|
||||
- name: Remove traffic redirect
|
||||
ansible.builtin.file:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Скрипты приложений на сервере: копируются на каждом запуске, чтобы соответствовать версии serverus
|
||||
- name: Create serverus scripts directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ apps_dir }}/.serverus"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Install app scripts
|
||||
ansible.builtin.copy:
|
||||
src: "files/{{ item }}"
|
||||
dest: "{{ apps_dir }}/.serverus/{{ item }}"
|
||||
mode: "0755"
|
||||
loop: [ app_sync.py, app_status.py ]
|
||||
@@ -0,0 +1,4 @@
|
||||
- name: Reload Caddy
|
||||
ansible.builtin.command: docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
args:
|
||||
chdir: "{{ apps_dir }}/caddy"
|
||||
@@ -1,4 +1,8 @@
|
||||
# vars: firewall_tcp_ports, firewall_udp_ports (optional), firewall_forward (optional, пропускать транзитный трафик)
|
||||
#
|
||||
# Конфиг начинается с flush ruleset: при загрузке nftables стартует раньше Docker, и Docker добавляет свои правила
|
||||
# поверх. Перезапуск nftables на работающем сервере стирает правила Docker, поэтому nftables перезапускается только
|
||||
# при изменении конфига, а Docker следом восстанавливает свои правила (контейнеры с restart поднимутся сами).
|
||||
- name: Install nftables
|
||||
ansible.builtin.apt:
|
||||
name: nftables
|
||||
@@ -10,9 +14,19 @@
|
||||
dest: /etc/nftables.conf
|
||||
mode: "0755"
|
||||
validate: /usr/sbin/nft -c -f %s
|
||||
register: firewall_config
|
||||
|
||||
- name: Apply nftables
|
||||
ansible.builtin.systemd_service:
|
||||
name: nftables
|
||||
enabled: true
|
||||
state: restarted
|
||||
state: "{{ 'restarted' if firewall_config.changed else 'started' }}"
|
||||
|
||||
# bootstrap меняет конфиг дважды подряд: reset-failed сбрасывает счетчик запусков, иначе systemd откажет по лимиту
|
||||
- name: Restore Docker rules
|
||||
ansible.builtin.shell: systemctl reset-failed docker.service 2>/dev/null; systemctl try-restart docker
|
||||
when: firewall_config.changed
|
||||
register: docker_restart
|
||||
changed_when: false
|
||||
# rc 5 - Docker не установлен
|
||||
failed_when: docker_restart.rc not in [0, 5]
|
||||
@@ -0,0 +1,9 @@
|
||||
# {{ ansible_managed }}
|
||||
{% if local_certs | bool %}
|
||||
{
|
||||
# стенд без публичного адреса: сертификаты от собственного CA Caddy вместо Let's Encrypt
|
||||
local_certs
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
import sites/*.caddy
|
||||
@@ -0,0 +1,23 @@
|
||||
# {{ ansible_managed }}
|
||||
# ingress: единственное приложение, которое публикует порты. Блоки приложений - data/caddy/sites/<app>.caddy
|
||||
services:
|
||||
caddy:
|
||||
image: {{ caddy_image }}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./data/caddy:/etc/caddy:ro
|
||||
- data:/data
|
||||
- config:/config
|
||||
networks: [ web ]
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
data:
|
||||
config:
|
||||
+171
-1
@@ -1,13 +1,19 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import urllib.error
|
||||
from unittest import mock
|
||||
|
||||
from . import (
|
||||
CF_WARP, XUI_VERSION, Host, LocalCA, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
|
||||
CADDY_IMAGE, CF_WARP, XUI_VERSION, DockerHost, Host, LocalCA, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
|
||||
generate_ssh_port, generate_username, parse_proxy, run_playbook,
|
||||
)
|
||||
from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE
|
||||
@@ -154,6 +160,170 @@ class XuiPanelTests(unittest.TestCase):
|
||||
int(short_id, 16)
|
||||
|
||||
|
||||
class RecordingRunner(FakeRunner):
|
||||
"""Запоминает и архив deploy: после запуска временная папка с ним удаляется."""
|
||||
|
||||
def __call__(self, playbook, host, extravars):
|
||||
if 'archive' in extravars:
|
||||
with tarfile.open(extravars['archive']) as tar:
|
||||
self.files = {
|
||||
member.name: (tar.extractfile(member).read(), member.mode) for member in tar.getmembers()
|
||||
}
|
||||
return super().__call__(playbook, host, extravars)
|
||||
|
||||
|
||||
class DockerHostTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runner = RecordingRunner()
|
||||
self.docker = DockerHost(Host('1.2.3.4'), runner=self.runner)
|
||||
|
||||
def test_install(self):
|
||||
self.docker.install(local_certs=True)
|
||||
self.assertEqual(self.runner.calls[0][0::2], ('docker', {
|
||||
'apps_dir': '/root/apps', 'caddy_image': CADDY_IMAGE, 'local_certs': True,
|
||||
}))
|
||||
|
||||
def test_deploy_packs_files(self):
|
||||
self.docker.deploy('site', {'docker-compose.yaml': b'services: {}', '.env': b'A=1', 'data/nginx/n.conf': b'x'})
|
||||
playbook, _, extravars = self.runner.calls[0]
|
||||
self.assertEqual((playbook, extravars['app'], extravars['apps_dir']), ('app_deploy', 'site', '/root/apps'))
|
||||
self.assertFalse(os.path.exists(extravars['archive']))
|
||||
self.assertEqual(self.runner.files, {
|
||||
'.env': (b'A=1', 0o600),
|
||||
'data/nginx/n.conf': (b'x', 0o644),
|
||||
'docker-compose.yaml': (b'services: {}', 0o644),
|
||||
})
|
||||
|
||||
def test_deploy_rejects_bad_input(self):
|
||||
compose = {'docker-compose.yaml': b''}
|
||||
for name, files in (
|
||||
('Site', compose), ('../x', compose), ('caddy', compose), ('site', {'compose.yaml': b''}),
|
||||
('site', {**compose, '../etc/passwd': b''}), ('site', {**compose, '/etc/passwd': b''}),
|
||||
('site', {**compose, 'data/./x': b''}),
|
||||
):
|
||||
with self.subTest(name=name, files=list(files)), self.assertRaises(ValueError):
|
||||
self.docker.deploy(name, files)
|
||||
self.assertEqual(self.runner.calls, [])
|
||||
|
||||
def test_operations(self):
|
||||
self.docker.status()
|
||||
self.docker.control('site', 'restart', 'nginx')
|
||||
self.docker.logs('site', lines=10**6)
|
||||
self.docker.remove('site', volumes=True)
|
||||
self.assertEqual([(call[0], call[2]) for call in self.runner.calls], [
|
||||
('app_status', {'apps_dir': '/root/apps'}),
|
||||
('app_control', {'apps_dir': '/root/apps', 'app': 'site', 'action': 'restart', 'service': 'nginx'}),
|
||||
('app_logs', {'apps_dir': '/root/apps', 'app': 'site', 'service': '', 'lines': 5000}),
|
||||
('app_remove', {'apps_dir': '/root/apps', 'app': 'site', 'volumes': True}),
|
||||
])
|
||||
|
||||
def test_control_rejects_bad_input(self):
|
||||
for args in (('site', 'kill'), ('site', 'stop', 'a b'), ('caddy!', 'stop')):
|
||||
with self.subTest(args=args), self.assertRaises(ValueError):
|
||||
self.docker.control(*args)
|
||||
with self.assertRaises(ValueError):
|
||||
self.docker.remove('caddy')
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent / 'playbooks' / 'files'
|
||||
|
||||
|
||||
class AppSyncTests(unittest.TestCase):
|
||||
def sync(self, stage, app_dir):
|
||||
out = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / 'app_sync.py'), str(stage), str(app_dir)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
def write(self, root, files):
|
||||
for path, content in files.items():
|
||||
target = root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content)
|
||||
|
||||
def test_sync_in_place(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
stage, app_dir = Path(directory, 'stage'), Path(directory, 'app')
|
||||
self.write(stage, {'docker-compose.yaml': 'a', 'data/nginx/n.conf': 'n', 'data/old/o.conf': 'o'})
|
||||
self.assertEqual(self.sync(stage, app_dir), {
|
||||
'changed': ['data/nginx/n.conf', 'data/old/o.conf', 'docker-compose.yaml'], 'removed': [], 'created': True,
|
||||
})
|
||||
inode = (app_dir / 'data/nginx/n.conf').stat().st_ino
|
||||
for path in stage.rglob('*'):
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
self.write(stage, {'docker-compose.yaml': 'a', 'data/nginx/n.conf': 'new'})
|
||||
self.assertEqual(self.sync(stage, app_dir), {
|
||||
'changed': ['data/nginx/n.conf'], 'removed': ['data/old/o.conf'], 'created': False,
|
||||
})
|
||||
self.assertEqual((app_dir / 'data/nginx/n.conf').read_text(), 'new')
|
||||
# тот же inode: смонтированный в контейнер файл остается тем же файлом
|
||||
self.assertEqual((app_dir / 'data/nginx/n.conf').stat().st_ino, inode)
|
||||
self.assertFalse((app_dir / 'data/old').exists())
|
||||
self.assertEqual(self.sync(stage, app_dir), {'changed': [], 'removed': [], 'created': False})
|
||||
|
||||
def test_sync_mode_change(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
stage, app_dir = Path(directory, 'stage'), Path(directory, 'app')
|
||||
self.write(stage, {'.env': 'A=1'})
|
||||
self.sync(stage, app_dir)
|
||||
os.chmod(stage / '.env', 0o600)
|
||||
self.assertEqual(self.sync(stage, app_dir)['changed'], ['.env'])
|
||||
self.assertEqual((app_dir / '.env').stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_sync_replaces_directory_with_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
stage, app_dir = Path(directory, 'stage'), Path(directory, 'app')
|
||||
self.write(app_dir, {'data/x/y': 'old'})
|
||||
self.write(stage, {'data/x': 'file'})
|
||||
self.assertEqual(self.sync(stage, app_dir), {'changed': ['data/x'], 'removed': ['data/x/y'], 'created': False})
|
||||
self.assertEqual((app_dir / 'data/x').read_text(), 'file')
|
||||
|
||||
|
||||
class AppStatusTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
spec = importlib.util.spec_from_file_location('app_status', SCRIPTS / 'app_status.py')
|
||||
self.module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(self.module)
|
||||
|
||||
def test_containers_formats(self):
|
||||
container = {'Service': 'web', 'State': 'running'}
|
||||
self.assertEqual(self.module.containers(json.dumps([container])), [container])
|
||||
self.assertEqual(self.module.containers(json.dumps(container) + '\n' + json.dumps(container)), [container] * 2)
|
||||
self.assertEqual(self.module.containers(''), [])
|
||||
|
||||
def test_services_compared_with_containers(self):
|
||||
outputs = {
|
||||
('config', '--services'): 'web\ndb\n',
|
||||
('ps', '-a', '--format', 'json'): '\n'.join(json.dumps(c) for c in (
|
||||
{'Service': 'web', 'State': 'running', 'Health': 'healthy', 'Status': 'Up 1 hour'},
|
||||
{'Service': 'web', 'State': 'exited', 'Health': '', 'Status': 'Exited (1)'},
|
||||
{'Service': 'old', 'State': 'running', 'Health': '', 'Status': 'Up'},
|
||||
)),
|
||||
}
|
||||
with mock.patch.object(self.module, 'compose', lambda app_dir, *args: outputs[args]):
|
||||
status = self.module.app_status('/apps/site')
|
||||
self.assertEqual(status, {
|
||||
'web': {'state': 'exited', 'health': '', 'status': 'Exited (1)'},
|
||||
'db': {'state': 'missing', 'health': '', 'status': ''},
|
||||
'old': {'state': 'running', 'health': '', 'status': 'Up'},
|
||||
})
|
||||
|
||||
|
||||
class PlaybookSyntaxTests(unittest.TestCase):
|
||||
def test_docker_playbooks(self):
|
||||
playbooks = Path(__file__).resolve().parent / 'playbooks'
|
||||
ansible = Path(sys.executable).parent / 'ansible-playbook'
|
||||
for name in ('docker', 'app_deploy', 'app_status', 'app_control', 'app_logs', 'app_remove', 'release'):
|
||||
with self.subTest(playbook=name):
|
||||
out = subprocess.run(
|
||||
[str(ansible), '--syntax-check', '-i', 'localhost,', f'{name}.yml'],
|
||||
cwd=playbooks, capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
|
||||
|
||||
class LocalCATests(unittest.TestCase):
|
||||
def test_ca_is_created_once_and_verifies_certificates(self):
|
||||
from cryptography import x509
|
||||
|
||||
Reference in new issue
Block a user