From a8d1f286d1f83ef9a2f814a8682d67e01c126873 Mon Sep 17 00:00:00 2001 From: protokey Date: Tue, 29 Sep 2026 20:27:05 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20docker-=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20-=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B2=20~/apps=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 12 +- docker-compose.yaml | 29 + env_example | 2 + src/App/README.md | 19 +- src/App/admin.py | 51 +- src/App/bot/interface.py | 365 ++++++++++++- src/App/bot/texts.py | 255 ++++++++- src/App/migrations/0010_docker_platform.py | 100 ++++ src/App/models.py | 421 +++++++++++---- src/App/platforms.py | 165 ++++++ src/App/services.py | 107 ++-- src/App/tasks.py | 237 ++++++-- src/App/tests_bot.py | 4 +- src/App/tests_platforms.py | 509 ++++++++++++++++++ src/App/uploads.py | 165 ++++++ src/MyPointVPN/settings/project.py | 2 + src/Telegram/README.md | 4 + src/Telegram/api.py | 34 +- src/Telegram/context.py | 36 +- src/Telegram/dispatcher.py | 11 +- src/Telegram/messages.py | 17 + src/Telegram/testing.py | 46 +- src/Telegram/tests.py | 69 ++- src/serverus/README.md | 41 +- src/serverus/__init__.py | 12 + src/serverus/__main__.py | 88 ++- src/serverus/docker.py | 127 +++++ src/serverus/playbooks/app_control.yml | 29 + src/serverus/playbooks/app_deploy.yml | 177 ++++++ src/serverus/playbooks/app_logs.yml | 21 + src/serverus/playbooks/app_remove.yml | 27 + src/serverus/playbooks/app_status.yml | 16 + src/serverus/playbooks/docker.yml | 135 +++++ src/serverus/playbooks/files/app_status.py | 68 +++ src/serverus/playbooks/files/app_sync.py | 76 +++ src/serverus/playbooks/release.yml | 27 +- src/serverus/playbooks/tasks/app_scripts.yml | 13 + src/serverus/playbooks/tasks/caddy_reload.yml | 4 + src/serverus/playbooks/tasks/firewall.yml | 16 +- src/serverus/playbooks/templates/Caddyfile.j2 | 9 + .../playbooks/templates/caddy-compose.yaml.j2 | 23 + src/serverus/tests.py | 172 +++++- 42 files changed, 3509 insertions(+), 232 deletions(-) create mode 100644 src/App/migrations/0010_docker_platform.py create mode 100644 src/App/platforms.py create mode 100644 src/App/tests_platforms.py create mode 100644 src/App/uploads.py create mode 100644 src/serverus/docker.py create mode 100644 src/serverus/playbooks/app_control.yml create mode 100644 src/serverus/playbooks/app_deploy.yml create mode 100644 src/serverus/playbooks/app_logs.yml create mode 100644 src/serverus/playbooks/app_remove.yml create mode 100644 src/serverus/playbooks/app_status.yml create mode 100644 src/serverus/playbooks/docker.yml create mode 100644 src/serverus/playbooks/files/app_status.py create mode 100644 src/serverus/playbooks/files/app_sync.py create mode 100644 src/serverus/playbooks/tasks/app_scripts.yml create mode 100644 src/serverus/playbooks/tasks/caddy_reload.yml create mode 100644 src/serverus/playbooks/templates/Caddyfile.j2 create mode 100644 src/serverus/playbooks/templates/caddy-compose.yaml.j2 diff --git a/Makefile b/Makefile index 10e4428..46dbc3b 100644 --- a/Makefile +++ b/Makefile @@ -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 [ ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @@ -37,6 +37,8 @@ help: @echo "make serverus_cert ip= [domain=] [ca=yes] - сертификат панели на домен или на ip" @echo "make serverus_outbound ip= [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую" @echo "make serverus_release ip= [password=] - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт" + @echo "make serverus_docker ip= password= [local_certs=yes] - настроить свежий сервер и поставить Docker с ingress Caddy" + @echo "make serverus_app ip= action=deploy|status|logs|start|stop|restart|pull|remove [name=] [dir=] [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="; 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= 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= action=deploy|status|... [name=] [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) diff --git a/docker-compose.yaml b/docker-compose.yaml index 0aa9b29..91a6ced 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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; + } + } diff --git a/env_example b/env_example index ac95069..cb9a19b 100644 --- a/env_example +++ b/env_example @@ -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 diff --git a/src/App/README.md b/src/App/README.md index fc11396..93361ff 100644 --- a/src/App/README.md +++ b/src/App/README.md @@ -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//`, 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=`. - `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) diff --git a/src/App/admin.py b/src/App/admin.py index eef87e3..08cb221 100644 --- a/src/App/admin.py +++ b/src/App/admin.py @@ -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='отпечаток') diff --git a/src/App/bot/interface.py b/src/App/bot/interface.py index b4e2c5d..40ce871 100644 --- a/src/App/bot/interface.py +++ b/src/App/bot/interface.py @@ -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) diff --git a/src/App/bot/texts.py b/src/App/bot/texts.py index f8cf1f8..ef23f2b 100644 --- a/src/App/bot/texts.py +++ b/src/App/bot/texts.py @@ -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 = '''Что это за бот? Что бот делает с сервером? Прописывает свой ssh ключ, меняет пароль root, обновляет систему, переносит ssh на случайный порт с входом только по ключу, включает фаервол и ставит нужные сервисы. +Что такое docker-сервер? +Сервер под ваши docker-приложения. Бот настраивает его так же, ставит Docker и Caddy, который выпускает сертификаты для ваших доменов. Приложение - папка ~/apps/<имя>: docker-compose.yaml, .env, caddy.conf для веб-морды и data/<сервис>/ с настройками сервисов. Файлы вы держите у себя и присылаете боту архивом, данные сервисов живут в volumes. + Как забрать сервер назад? Меню сервера - "Вернуть". Бот уберет свои доступы, откатит ssh и фаервол, поставит новый пароль root и пришлет его. Данные на сервере останутся. @@ -75,6 +117,12 @@ HELP_TEXT = '''Добавить сервер 4. по желанию "домен", "прокси" 5. "установить" - установка идет в фоне, по окончании придет уведомление +Docker-сервер +1. "добавить docker-сервер" и ip:пароль +2. "docker-серверы", имя сервера, "новое приложение", имя и архив папки приложения +3. в приложении: "залить апдейт" - файлы поверх текущих (архив или один файл), "перезалить все" - архив целиком, лишнее удаляется. Перед применением бот покажет, что изменится +4. "старт", "стоп", "рестарт", "логи" - для всего приложения или, если прислать имя сервиса, только для него + Управлять сервером "управление сервером" и имя сервера из главного меню. @@ -141,7 +189,7 @@ def point_card(point: Point) -> str: f'Сервер: {escape(point.name)}', 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'{escape(point.name)}: {escape(point.error)}.' +def task_interrupted(obj: Managed) -> str: + return f'{escape(obj.name)}: {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'Проверка {escape(point.name)}: снова все в порядке.' + + +# docker-серверы + +DOCKER_CREDENTIALS_PROMPT = ( + 'отправь данные для подключения к серверу под docker\nip:root_password\n' + 'Сервер будет настроен так же, как сервер точки, и получит Docker и Caddy для веб-приложений.' +) +DOCKER_INSTALL_STARTED = 'Запускаю установку docker-сервера. Отправлю уведомление когда все будет готово.' +PLATFORM_SELECT_PROMPT = 'Пришлите имя docker-сервера' +PLATFORM_NOT_FOUND = 'Docker-сервер не найден. Пришлите имя из списка.' +PLATFORM_NOT_READY = 'Docker-сервер еще не установлен.' +PLATFORM_REINSTALL_PROMPT = '''Сервер будет настроен заново, приложения развернуты с текущими версиями файлов. +Вызывайте после переустановки ОС в панели хостинг провайдера. +Пришлите ip:новый_пароль или нажмите "Использовать старый".''' +PLATFORM_RELEASE_CONFIRM = '''Сервер будет освобожден: случайный пароль root, мои доступы убраны. +Приложения на сервере продолжат работать, но управлять ими из бота будет нельзя. Продолжить?''' +APP_NAME_PROMPT = '''Пришли имя приложения: латиница в нижнем регистре, цифры, - и _. Например site. +Это имя папки ~/apps/<имя> и compose-проекта.''' +APP_NOT_FOUND = 'Нет такого приложения или сервиса. Пришли имя из списка.' +UPLOAD_UPDATE_PROMPT = '''Пришли архив (zip, tar, tar.gz) или один файл документом. +Файлы лягут поверх текущих, остальные останутся. Корень архива - корень приложения.''' +UPLOAD_FULL_PROMPT = '''Пришли архив всей папки приложения (zip, tar, tar.gz) документом. +Файлы, которых нет в архиве, будут удалены. Корень архива - корень приложения: +docker-compose.yaml, .env, caddy.conf, data/<сервис>/.''' +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'{escape(platform.name)}\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} {escape(name)} {escape(details)}' + (f' ({escape(health)})' if health else '') + + +def platform_card(platform: Platform) -> str: + lines = [ + f'Docker-сервер: {escape(platform.name)}', + 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Приложения (проверено {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'{escape(app.name)} {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'Приложение: {escape(app.name)} на {escape(platform.name)}', + 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'Сервис {escape(service)} приложения {escape(app.name)}', + 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'{escape(path)}' 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Нельзя применить:') + lines += [f'- {escape(error)}' for error in upload.errors] + if upload.warnings: + lines.append('\nПредупреждения:') + 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)} - проименован как {escape(platform.name)}, ' + 'готов к работе.' + ) + + +def platform_install_failed(platform: Platform, error: Exception) -> str: + return f'Не удалось настроить {escape(platform.name)}:\n{escape(str(error))}' + + +def platform_released(name: str, address: str, password: str, error: str) -> str: + if error: + return f'{escape(name)}: не удалось вернуть {escape(address)}: {escape(error)}' + return f'Docker-сервер {escape(name)} возвращен.\n{escape(address)} - пароль root {escape(password)}' + + +def deployed(name: str, data: dict) -> str: + lines = [f'{escape(name)}: файлы применены.'] + recreated = data.get('recreated') or [] + if recreated: + lines.append('Пересозданы с новыми настройками: ' + ', '.join(f'{escape(s)}' for s in recreated)) + return '\n'.join(lines) + + +def deploy_failed(name: str, error: Exception) -> str: + return f'{escape(name)}: не удалось применить файлы:\n{escape(str(error))}' + + +def app_controlled(name: str, action: str, service: str) -> str: + target = f'{escape(name)}' + (f' / {escape(service)}' if service else '') + return f'{target}: {ACTION_DONE.get(action, action)}.' + + +def app_removed(name: str, volumes: bool) -> str: + return f'{escape(name)} удалено' + (' вместе с данными.' if volumes else ', volumes остались на сервере.') + + +def app_operation_failed(name: str, error: Exception) -> str: + return f'{escape(name)}: не удалось:\n{escape(str(error))}' + + +def refresh_failed(platform: Platform, error: Exception) -> str: + return f'{escape(platform.name)}: не удалось проверить сервер:\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'{escape(name)}: не удалось получить логи:\n{escape(str(error))}' diff --git a/src/App/migrations/0010_docker_platform.py b/src/App/migrations/0010_docker_platform.py new file mode 100644 index 0000000..b53b3fe --- /dev/null +++ b/src/App/migrations/0010_docker_platform.py @@ -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'), + ), + ] diff --git a/src/App/models.py b/src/App/models.py index 24663c2..0c0b7e1 100644 --- a/src/App/models.py +++ b/src/App/models.py @@ -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//, 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']) diff --git a/src/App/platforms.py b/src/App/platforms.py new file mode 100644 index 0000000..e2fce7e --- /dev/null +++ b/src/App/platforms.py @@ -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) diff --git a/src/App/services.py b/src/App/services.py index 884afed..b8d6e0b 100644 --- a/src/App/services.py +++ b/src/App/services.py @@ -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) diff --git a/src/App/tasks.py b/src/App/tasks.py index 4a7d9f9..6a10b5a 100644 --- a/src/App/tasks.py +++ b/src/App/tasks.py @@ -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), + ) # ежедневная проверка: своя цепочка у каждой точки, живет, пока жива точка diff --git a/src/App/tests_bot.py b/src/App/tests_bot.py index de504d8..a78d0f0 100644 --- a/src/App/tests_bot.py +++ b/src/App/tests_bot.py @@ -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 diff --git a/src/App/tests_platforms.py b/src/App/tests_platforms.py new file mode 100644 index 0000000..9004745 --- /dev/null +++ b/src/App/tests_platforms.py @@ -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 diff --git a/src/App/uploads.py b/src/App/uploads.py new file mode 100644 index 0000000..426c43a --- /dev/null +++ b/src/App/uploads.py @@ -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 diff --git a/src/MyPointVPN/settings/project.py b/src/MyPointVPN/settings/project.py index f19cf77..b955056 100644 --- a/src/MyPointVPN/settings/project.py +++ b/src/MyPointVPN/settings/project.py @@ -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', '') diff --git a/src/Telegram/README.md b/src/Telegram/README.md index 71c4040..0d3cd25 100644 --- a/src/Telegram/README.md +++ b/src/Telegram/README.md @@ -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, чтобы он не повторял то же обновление. diff --git a/src/Telegram/api.py b/src/Telegram/api.py index 5dea1c3..253df8e 100644 --- a/src/Telegram/api.py +++ b/src/Telegram/api.py @@ -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 diff --git a/src/Telegram/context.py b/src/Telegram/context.py index 40582f9..b937d9a 100644 --- a/src/Telegram/context.py +++ b/src/Telegram/context.py @@ -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, + ) diff --git a/src/Telegram/dispatcher.py b/src/Telegram/dispatcher.py index cbb2657..316fc13 100644 --- a/src/Telegram/dispatcher.py +++ b/src/Telegram/dispatcher.py @@ -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, diff --git a/src/Telegram/messages.py b/src/Telegram/messages.py index 2c34d9a..0e89a3f 100644 --- a/src/Telegram/messages.py +++ b/src/Telegram/messages.py @@ -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, + ) diff --git a/src/Telegram/testing.py b/src/Telegram/testing.py index a190349..0524f06 100644 --- a/src/Telegram/testing.py +++ b/src/Telegram/testing.py @@ -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 diff --git a/src/Telegram/tests.py b/src/Telegram/tests.py index d23d63f..8c0e9d2 100644 --- a/src/Telegram/tests.py +++ b/src/Telegram/tests.py @@ -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): diff --git a/src/serverus/README.md b/src/serverus/README.md index f01543a..9bda2f7 100644 --- a/src/serverus/README.md +++ b/src/serverus/README.md @@ -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 `, который через 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//`, 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= action=add|del|stats name= # ключ на па make serverus_cert ip= [domain=] [ca=yes] # сертификат на домен или на ip make serverus_outbound ip= [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую make serverus_release ip= # вернуть сервер +make serverus_docker ip= password= [local_certs=yes] # bootstrap + Docker + ingress +make serverus_app ip= action=deploy name= dir=<папка> # залить приложение из локальной папки +make serverus_app ip= action=status|logs|start|stop|restart|pull|remove [name=] [service=] [volumes=yes] ``` То же без Makefile: `python -m serverus xui --out `, `python -m serverus key add --out ` и `python -m serverus release --out `. @@ -134,3 +170,6 @@ make serverus_release 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 diff --git a/src/serverus/__init__.py b/src/serverus/__init__.py index 88ab118..1180952 100644 --- a/src/serverus/__init__.py +++ b/src/serverus/__init__.py @@ -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', diff --git a/src/serverus/__main__.py b/src/serverus/__main__.py index 26ac411..eaf4bb7 100644 --- a/src/serverus/__main__.py +++ b/src/serverus/__main__.py @@ -5,6 +5,9 @@ python -m serverus cert [] --out [--ca ] python -m serverus outbound [] --out python -m serverus release --out [--password ] + python -m serverus docker --out [--local-certs] + python -m serverus app deploy --out + python -m serverus app status|logs|start|stop|restart|pull|remove [] --out [--service ] [--volumes] Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в /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) diff --git a/src/serverus/docker.py b/src/serverus/docker.py new file mode 100644 index 0000000..6eb3df1 --- /dev/null +++ b/src/serverus/docker.py @@ -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// (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() diff --git a/src/serverus/playbooks/app_control.yml b/src/serverus/playbooks/app_control.yml new file mode 100644 index 0000000..257a5f8 --- /dev/null +++ b/src/serverus/playbooks/app_control.yml @@ -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] }}" diff --git a/src/serverus/playbooks/app_deploy.yml b/src/serverus/playbooks/app_deploy.yml new file mode 100644 index 0000000..dd082a5 --- /dev/null +++ b/src/serverus/playbooks/app_deploy.yml @@ -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 }}" diff --git a/src/serverus/playbooks/app_logs.yml b/src/serverus/playbooks/app_logs.yml new file mode 100644 index 0000000..9ac49e0 --- /dev/null +++ b/src/serverus/playbooks/app_logs.yml @@ -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:] }}" diff --git a/src/serverus/playbooks/app_remove.yml b/src/serverus/playbooks/app_remove.yml new file mode 100644 index 0000000..e4b3c91 --- /dev/null +++ b/src/serverus/playbooks/app_remove.yml @@ -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 diff --git a/src/serverus/playbooks/app_status.yml b/src/serverus/playbooks/app_status.yml new file mode 100644 index 0000000..e74fe9e --- /dev/null +++ b/src/serverus/playbooks/app_status.yml @@ -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 }}" diff --git a/src/serverus/playbooks/docker.yml b/src/serverus/playbooks/docker.yml new file mode 100644 index 0000000..4701492 --- /dev/null +++ b/src/serverus/playbooks/docker.yml @@ -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 diff --git a/src/serverus/playbooks/files/app_status.py b/src/serverus/playbooks/files/app_status.py new file mode 100644 index 0000000..cd1b6b4 --- /dev/null +++ b/src/serverus/playbooks/files/app_status.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Состояние docker-приложений: app_status.py []. + +Сервисы берутся из 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:]) diff --git a/src/serverus/playbooks/files/app_sync.py b/src/serverus/playbooks/files/app_sync.py new file mode 100644 index 0000000..90aae85 --- /dev/null +++ b/src/serverus/playbooks/files/app_sync.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Приводит папку приложения к присланной копии: app_sync.py . + +Файлы переписываются по месту (тот же 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]) diff --git a/src/serverus/playbooks/release.yml b/src/serverus/playbooks/release.yml index 91257c1..0c729c6 100644 --- a/src/serverus/playbooks/release.yml +++ b/src/serverus/playbooks/release.yml @@ -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: diff --git a/src/serverus/playbooks/tasks/app_scripts.yml b/src/serverus/playbooks/tasks/app_scripts.yml new file mode 100644 index 0000000..8b4fa7f --- /dev/null +++ b/src/serverus/playbooks/tasks/app_scripts.yml @@ -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 ] diff --git a/src/serverus/playbooks/tasks/caddy_reload.yml b/src/serverus/playbooks/tasks/caddy_reload.yml new file mode 100644 index 0000000..58cc7d2 --- /dev/null +++ b/src/serverus/playbooks/tasks/caddy_reload.yml @@ -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" diff --git a/src/serverus/playbooks/tasks/firewall.yml b/src/serverus/playbooks/tasks/firewall.yml index 5b83b8c..214e259 100644 --- a/src/serverus/playbooks/tasks/firewall.yml +++ b/src/serverus/playbooks/tasks/firewall.yml @@ -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] diff --git a/src/serverus/playbooks/templates/Caddyfile.j2 b/src/serverus/playbooks/templates/Caddyfile.j2 new file mode 100644 index 0000000..28e7360 --- /dev/null +++ b/src/serverus/playbooks/templates/Caddyfile.j2 @@ -0,0 +1,9 @@ +# {{ ansible_managed }} +{% if local_certs | bool %} +{ + # стенд без публичного адреса: сертификаты от собственного CA Caddy вместо Let's Encrypt + local_certs +} + +{% endif %} +import sites/*.caddy diff --git a/src/serverus/playbooks/templates/caddy-compose.yaml.j2 b/src/serverus/playbooks/templates/caddy-compose.yaml.j2 new file mode 100644 index 0000000..95807c1 --- /dev/null +++ b/src/serverus/playbooks/templates/caddy-compose.yaml.j2 @@ -0,0 +1,23 @@ +# {{ ansible_managed }} +# ingress: единственное приложение, которое публикует порты. Блоки приложений - data/caddy/sites/.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: diff --git a/src/serverus/tests.py b/src/serverus/tests.py index 908cd5c..e1d192c 100644 --- a/src/serverus/tests.py +++ b/src/serverus/tests.py @@ -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