feat: docker-серверы - приложения в ~/apps под управлением бота

serverus: DockerHost - установка Docker и ingress Caddy, deploy приложения
с проверкой compose и caddy.conf до изменений и пересозданием только
изменившихся сервисов, status, control, logs, remove. nftables
перезапускается только при изменении конфига, Docker следом
восстанавливает свои правила; release восстанавливает конфиг Debian
вместо остановки nftables.

Telegram: прием документов и отправка файлов, раздача файлов своего
Bot API сервера через nginx (telegram-files).

App: общая модель Managed для Point и Platform, DockerApp и версии
файлов AppVersion, разбор архивов (uploads), сценарии платформы
(platforms), задачи и меню docker-серверов в боте.

Проверено на стенде Debian 13 с local_certs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-09-29 20:27:05 +04:00
1 parent 65c19ea1cd
commit a8d1f286d1
42 files changed
+3509 -232

No files matched your search

+11 -1
View File
@@ -19,7 +19,7 @@ SERVERUS_CA = $(if $(filter yes,$(ca)),--ca $(SERVERUS_CA_DIR))
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker dj_secrets dj_backup dj_restore dj_check_points docker_build docker_up serve_ssh serverus_xui serverus_key serverus_cert serverus_outbound serverus_release
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker dj_secrets dj_backup dj_restore dj_check_points docker_build docker_up serve_ssh serverus_xui serverus_key serverus_cert serverus_outbound serverus_release serverus_docker serverus_app
help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@@ -37,6 +37,8 @@ help:
@echo "make serverus_cert ip=<ip> [domain=<domain>] [ca=yes] - сертификат панели на домен или на ip"
@echo "make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую"
@echo "make serverus_release ip=<ip> [password=<new_root_password>] - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
@echo "make serverus_docker ip=<ip> password=<root_password> [local_certs=yes] - настроить свежий сервер и поставить Docker с ingress Caddy"
@echo "make serverus_app ip=<ip> action=deploy|status|logs|start|stop|restart|pull|remove [name=<app>] [dir=<local_app_dir>] [service=<service>] [volumes=yes] - приложение на docker-сервере"
venv:
@test -d $(VENV) || python3 -m venv $(VENV)
@@ -142,3 +144,11 @@ serverus_outbound:
serverus_release:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip=<ip>"; exit 1; }
$(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(password),--password '$(password)')
serverus_docker:
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_docker ip=<ip> password=<root_password>"; exit 1; }
$(SERVERUS) docker $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(filter yes,$(local_certs)),--local-certs)
serverus_app:
@test -n "$(ip)" -a -n "$(action)" || { echo "Укажите приложение: make serverus_app ip=<ip> action=deploy|status|... [name=<app>] [dir=<local_app_dir>]"; exit 1; }
$(SERVERUS) app $(action) $(name) $(if $(dir),$(abspath $(dir))) --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(service),--service '$(service)') $(if $(filter yes,$(volumes)),--volumes)
+29
View File
@@ -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;
}
}
+2
View File
@@ -3,6 +3,8 @@ TG_APP_ID=
TG_APP_HASH=
# Адрес своего Bot API сервера для Django. Пусто - api.telegram.org
TELEGRAM_API_URL=http://localhost:8081
# Файлы, присланные ботам документами (сервис telegram-files): свой Bot API сервер не отдает их по HTTP
TELEGRAM_FILES_URL=http://localhost:8083
# Адрес сайта для webhook ботов, как его видит Bot API сервер (он в сети хоста, network_mode: host)
TELEGRAM_WEBHOOK_URL=http://localhost:8000
# Хосты сайта через запятую, в том числе хост из TELEGRAM_WEBHOOK_URL
+18 -1
View File
@@ -7,6 +7,7 @@ Django приложение связывающее все части сайта
-[ ] Настройки домена и прокси у готовой точки
-[x] Клиенты и статистика через API панели 3x-ui
-[x] Супервизор долгих задач и ежедневная проверка точек
-[x] Docker-серверы: приложения в `~/apps` из бота
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
@@ -14,6 +15,11 @@ Django приложение связывающее все части сайта
- `Point` - точка доступа: сервер с панелью 3x-ui и необязательный сервер маскировки. Владелец - `Telegram.User`. Хранит домен, прокси на выходе и доступ к панели.
- `Server` - сервер точки с ролью `panel` или `mask`. Хранит текущие порт ssh, пароль root, ключ и отпечаток ssh, а также пароль root, присланный пользователем при добавлении (`default_root_password`).
- `Platform` - docker-сервер: один сервер с ролью `docker`, приложения и снимок их состояния (`status_snapshot`, `status_at`). Владелец - `Telegram.User`.
- `DockerApp` - приложение платформы: папка `~/apps/<name>/`, compose-проект `name`. Имя `caddy` занято ingress.
- `AppVersion` - полный набор файлов приложения (зашифрован: в нем `.env`), вид (`full`, `update`, `rollback`) и состояние (`draft`, `applied`, `failed`). Текущая версия - последняя примененная, хранятся 10 последних.
- `Point` и `Platform` наследуют абстрактную `Managed`: владелец, имя, статус, ошибка и поля долгой задачи. Менеджер `ManagedManager` - `start_task`, `claim_task`, `beat`, `finish_task`, `abort_task`, `unique_name`, запросы `owned_by`, `find_by_name`, `find_by_pk`, `find_last`.
- `Server` принадлежит точке или платформе, ровно чему-то одному (`CheckConstraint`).
- `Key` - ключ клиента на панели точки: имя (оно же email клиента в панели), `uuid` и `sub_id`. Клиенту выдается `key.subscription_url`, после переустановки ключ восстанавливается с теми же ссылками.
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес. Маскировка требует домен.
@@ -34,12 +40,23 @@ Django приложение связывающее все части сайта
- `fields.py` - `EncryptedTextField`: секреты в базе зашифрованы Fernet ключами `FIELD_ENCRYPTION_KEYS` из `.env`.
- `management/commands/serve_ssh.py` - ручной вход на сервер: `make serve_ssh ip=<ip>`.
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
- Долгая задача запускается через `tasks.start(actor, point, status, chat, *args)`: точка получает статус и token задачи, вместе с задачей стартует `supervise`. Задача раз в 30 секунд отмечается в точке (`task_heartbeat`), супервизор раз в минуту проверяет отметку и, если задача молчит 3 минуты (или не начиналась час), снимает ее: статус по `Point.INTERRUPTED`, уведомление в чат. Кончилась задача - кончился и супервизор. Повторная доставка сообщения после падения воркера задачу не повторяет, а снимает.
- Долгая задача запускается через `tasks.start(actor, obj, status, chat, *args)` для точки или платформы: запись получает статус и token задачи, вместе с задачей стартует `supervise`. Задача раз в 30 секунд отмечается в записи (`task_heartbeat`), супервизор раз в минуту проверяет отметку и, если задача молчит 3 минуты (или не начиналась час), снимает ее: статус по `INTERRUPTED` модели, уведомление в чат. Кончилась задача - кончился и супервизор. Повторная доставка сообщения после падения воркера задачу не повторяет, а снимает.
- `check_point` - ежедневная проверка точки (`services.check_point`): своя цепочка у каждой точки, запускается после установки и командой `check_points` (ее выполняет воркер в docker при старте). Сутки проходятся перескоками по 15 минут: RabbitMQ не держит неподтвержденное отложенное сообщение дольше `consumer_timeout`.
- `backups.py` и команды `backup`, `restore` - бэкап базы `pg_dump`/`pg_restore`, `generate_secrets` - секреты для `.env`.
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
- `bot/texts.py` - кнопки и тексты.
- `uploads.py` - разбор присланных файлов приложения без Django: zip, tar, tar.gz или один файл, слияние (`merge`), разница (`diff`) и проверка по конвенции (`check`: ошибки не дают применить, предупреждения - нет). Мусор архиваторов пропускается, пути наружу и ссылки - отказ, лимиты 5 Мб архив, 20 Мб распаковано, 500 файлов.
- `platforms.py` - сценарии docker-платформы поверх `serverus.DockerHost`: `install_platform`, `reinstall` (приложения разворачиваются заново с текущими версиями), `release_platform`, `create_app`, `prepare_upload`, `prepare_rollback`, `apply`, `control`, `logs`, `remove_app`, `refresh`.
## Docker-сервер
Приложение живет в App версиями файлов. "Залить апдейт" сливает присланное с текущей версией, "перезалить все" заменяет ее архивом целиком. В обоих случаях сервер получает полный набор файлов, бот перед применением показывает разницу и предупреждения. Удалить сервис из приложения - залить compose-файл без него: локальная копия пользователя - источник правды, бот compose-файл не правит.
Операции, которые меняют сервер (установка, применение файлов, старт/стоп, удаление), идут задачами под супервизором по одной: платформа `updating`, пока операция не кончится. Прерванная операция оставляет платформу `ready` с текстом ошибки. Статус и логи - быстрые задачи без занятости (`refresh_platform`, `send_logs`), периодической проверки нет: "проверить" в меню сервера.
Файлы приходят документом через `Telegram` (`ctx.download_document`), логи уходят файлом (`send_document`).
## Установка точки
1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables)
+47 -4
View File
@@ -3,7 +3,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import AdminUserCreationForm, UserChangeForm
from django.utils.translation import gettext_lazy as _
from .models import Key, Point, Server, User
from .models import AppVersion, DockerApp, Key, Platform, Point, Server, User
class UserCreationForm(AdminUserCreationForm):
@@ -55,13 +55,56 @@ class PointAdmin(admin.ModelAdmin):
inlines = [ServerInline]
@admin.register(Platform)
class PlatformAdmin(admin.ModelAdmin):
list_display = ('name', 'owner', 'status', 'created_at')
list_filter = ('status',)
search_fields = ('name', 'servers__address')
readonly_fields = (
'owner', 'name', 'status', 'error', 'status_at', 'status_snapshot', 'task_started_at', 'task_heartbeat',
'created_at',
)
inlines = [ServerInline]
class AppVersionInline(admin.TabularInline):
"""Только история: файлы не показываются, в них .env с секретами."""
model = AppVersion
extra = 0
fields = ('created_at', 'kind', 'state', 'error')
readonly_fields = fields
can_delete = False
def has_add_permission(self, request, obj=None):
return False
@admin.register(DockerApp)
class DockerAppAdmin(admin.ModelAdmin):
"""Только просмотр: приложения заливаются и удаляются через бота (platforms), иначе база и сервер разойдутся."""
list_display = ('name', 'platform', 'created_at')
list_filter = ('platform',)
search_fields = ('name', 'platform__name')
fields = ('name', 'platform', 'created_at')
readonly_fields = fields
inlines = [AppVersionInline]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(Server)
class ServerAdmin(admin.ModelAdmin):
list_display = ('address', 'role', 'point', 'ssh_port', 'has_fingerprint')
list_display = ('address', 'role', 'point', 'platform', 'ssh_port', 'has_fingerprint')
list_filter = ('role',)
search_fields = ('address', 'point__name')
search_fields = ('address', 'point__name', 'platform__name')
exclude = ('private_key',)
readonly_fields = ('point', 'role', 'public_key', 'uname')
readonly_fields = ('point', 'platform', 'role', 'public_key', 'uname')
actions = ('reset_fingerprint',)
@admin.display(boolean=True, description='отпечаток')
+355 -10
View File
@@ -2,10 +2,11 @@ import ipaddress
import re
import serverus
from Telegram import api as bot_api
from Telegram.interfaces import Interface, command, register, state, text
from .. import fields, services, tasks
from ..models import Credentials, Point, Server
from .. import fields, platforms, services, tasks, uploads
from ..models import AppVersion, Credentials, DockerApp, Platform, Point, Server
from . import texts
# состояния диалога
@@ -23,6 +24,17 @@ POINT_PROXY_EDIT = 'point.proxy'
POINT_KEYS = 'point.keys'
POINT_KEY_ADD = 'point.keys.add'
POINT_KEY_REVOKE = 'point.keys.revoke'
DOCKER_ADD = 'docker.add'
DOCKER_SELECT = 'docker.select'
PLATFORM = 'platform'
PLATFORM_RELEASE = 'platform.release'
PLATFORM_REINSTALL = 'platform.reinstall'
APP_NEW = 'platform.app.new'
APP = 'app'
APP_UPLOAD = 'app.upload'
APP_CONFIRM = 'app.confirm'
APP_REMOVE = 'app.remove'
SERVICE = 'app.service'
DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'}
DOMAIN_RE = re.compile(r'^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
@@ -62,10 +74,15 @@ class MyPointInterface(Interface):
def main_menu(self, ctx):
ctx.reset()
points = list(Point.objects.owned_by(ctx.user))
if points:
ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML)
else:
ctx.reply(texts.GREETING, keyboard=texts.MAIN_KEYBOARD)
platform_list = list(Platform.objects.owned_by(ctx.user))
lines = [texts.points_list(points)] if points else []
lines += [texts.platforms_list(platform_list)] if platform_list else []
ctx.reply('\n'.join(lines) or texts.GREETING, keyboard=self.main_keyboard(ctx), parse_mode=HTML)
def main_keyboard(self, ctx):
return texts.main_keyboard(
Point.objects.owned_by(ctx.user).exists(), Platform.objects.owned_by(ctx.user).exists(),
)
@text(texts.FAQ)
def faq(self, ctx):
@@ -188,7 +205,7 @@ class MyPointInterface(Interface):
)
tasks.start(tasks.install_point, point, Point.Status.INSTALLING, ctx.chat)
ctx.reset()
ctx.reply(texts.INSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.INSTALL_STARTED, keyboard=self.main_keyboard(ctx))
# управление сервером
@@ -245,7 +262,7 @@ class MyPointInterface(Interface):
return
tasks.start(tasks.release_point, point, Point.Status.RELEASING, ctx.chat)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.REINSTALL, state=POINT)
def ask_reinstall(self, ctx):
@@ -274,7 +291,7 @@ class MyPointInterface(Interface):
def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None):
tasks.start(tasks.reinstall_point, point, Point.Status.INSTALLING, ctx.chat, address, password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.NO, state=POINT_RELEASE)
@text(texts.CANCEL, state=POINT_RELEASE)
@@ -347,7 +364,7 @@ class MyPointInterface(Interface):
def start_change(self, ctx, point: Point, actor, value: str):
tasks.start(actor, point, Point.Status.UPDATING, ctx.chat, value)
ctx.reset()
ctx.reply(texts.CHANGE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
ctx.reply(texts.CHANGE_STARTED, keyboard=self.main_keyboard(ctx))
# ключи
@@ -419,3 +436,331 @@ class MyPointInterface(Interface):
return ctx.reply(texts.PANEL_ERROR.format(error=error))
ctx.reply(texts.KEY_REVOKED.format(name=key.name))
self.show_keys(ctx)
# docker-серверы
@text(texts.ADD_DOCKER)
def ask_docker_server(self, ctx):
ctx.reset()
self.ask(ctx, DOCKER_ADD, texts.DOCKER_CREDENTIALS_PROMPT)
@text(texts.CANCEL, state=DOCKER_ADD)
@text(texts.CANCEL, state=DOCKER_SELECT)
def cancel_docker(self, ctx):
self.main_menu(ctx)
@state(DOCKER_ADD)
def add_docker_server(self, ctx):
# в сообщении пароль root, в чате ему не место
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
if Server.objects.is_address_taken(credentials.address):
return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
platform = Platform.objects.create_platform(ctx.user, credentials)
tasks.start(tasks.install_platform, platform, Platform.Status.INSTALLING, ctx.chat)
ctx.reset()
ctx.reply(texts.DOCKER_INSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.DOCKER_SERVERS)
def ask_platform(self, ctx):
ctx.reset()
platform_list = Platform.objects.owned_by(ctx.user)
self.ask(ctx, DOCKER_SELECT, f'{texts.PLATFORM_SELECT_PROMPT}\n{texts.platforms_list(platform_list)}')
@state(DOCKER_SELECT)
def select_platform(self, ctx):
platform = Platform.objects.owned_by(ctx.user).find_by_name(ctx.text.strip())
if platform is None:
return ctx.reply(texts.PLATFORM_NOT_FOUND)
self.show_platform(ctx, platform)
@text(texts.PLATFORM_MENU)
def platform_menu(self, ctx):
"""Кнопка из уведомления: последний открытый docker-сервер или последний созданный."""
platform = self.current_platform(ctx) or Platform.objects.owned_by(ctx.user).find_last()
if platform is None:
return self.main_menu(ctx)
self.show_platform(ctx, platform)
def current_platform(self, ctx) -> Platform | None:
platform_id = ctx.data.get('platform')
return Platform.objects.owned_by(ctx.user).find_by_pk(platform_id) if platform_id else None
def show_platform(self, ctx, platform: Platform):
ctx.reset()
ctx.state = PLATFORM
ctx.data['platform'] = platform.pk
ctx.reply(texts.platform_card(platform), keyboard=texts.PLATFORM_KEYBOARD, parse_mode=HTML)
def idle_platform(self, ctx, ready: bool = True) -> Platform | None:
"""Текущий docker-сервер, если с ним можно работать. Иначе отвечает пользователю и возвращает None.
ready - сервер должен быть установлен."""
platform = self.current_platform(ctx)
if platform is None:
self.main_menu(ctx)
elif platform.is_busy:
ctx.reply(texts.POINT_BUSY.format(status=platform.get_status_display()))
return None
elif ready and platform.status != Platform.Status.READY:
ctx.reply(texts.PLATFORM_NOT_READY)
return None
return platform
@text(texts.CHECK, state=PLATFORM)
def check_platform(self, ctx):
platform = self.idle_platform(ctx)
if platform:
tasks.refresh_platform.send(platform.pk, ctx.chat.pk)
ctx.reply(texts.CHECK_STARTED)
@text(texts.RELEASE, state=PLATFORM)
def ask_platform_release(self, ctx):
if self.idle_platform(ctx, ready=False):
self.ask(ctx, PLATFORM_RELEASE, texts.PLATFORM_RELEASE_CONFIRM, [[texts.YES, texts.NO]])
@text(texts.YES, state=PLATFORM_RELEASE)
def release_platform(self, ctx):
platform = self.idle_platform(ctx, ready=False)
if platform is None:
return
tasks.start(tasks.release_platform, platform, Platform.Status.RELEASING, ctx.chat)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.REINSTALL, state=PLATFORM)
def ask_platform_reinstall(self, ctx):
if self.idle_platform(ctx, ready=False):
self.ask(ctx, PLATFORM_REINSTALL, texts.PLATFORM_REINSTALL_PROMPT, [[texts.CANCEL, texts.USE_OLD]])
@state(PLATFORM_REINSTALL)
def reinstall_platform_with_password(self, ctx):
ctx.delete_message()
credentials = parse_credentials(ctx.text)
if credentials is None:
return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML)
platform = self.idle_platform(ctx, ready=False)
if platform is None:
return
if platform.server.address != credentials.address:
return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
self.start_platform_reinstall(ctx, platform, fields.encrypt(credentials.password))
@text(texts.USE_OLD, state=PLATFORM_REINSTALL)
def reinstall_platform_with_old_password(self, ctx):
platform = self.idle_platform(ctx, ready=False)
if platform:
self.start_platform_reinstall(ctx, platform, '')
def start_platform_reinstall(self, ctx, platform: Platform, encrypted_password: str):
tasks.start(tasks.reinstall_platform, platform, Platform.Status.INSTALLING, ctx.chat, encrypted_password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx))
@text(texts.NO, state=PLATFORM_RELEASE)
@text(texts.CANCEL, state=PLATFORM_RELEASE)
@text(texts.CANCEL, state=PLATFORM_REINSTALL)
@text(texts.CANCEL, state=APP_NEW)
def back_to_platform(self, ctx):
platform = self.current_platform(ctx)
if platform is None:
return self.main_menu(ctx)
self.show_platform(ctx, platform)
# приложения
@state(PLATFORM)
def select_app(self, ctx):
platform = self.current_platform(ctx)
if platform is None:
return self.main_menu(ctx)
app = platform.apps.find_by_name(ctx.text.strip())
if app is None:
return ctx.reply(texts.APP_NOT_FOUND)
self.show_app(ctx, app)
def current_app(self, ctx) -> DockerApp | None:
platform = self.current_platform(ctx)
app_id = ctx.data.get('app')
return platform.apps.filter(pk=app_id).first() if platform and app_id else None
def show_app(self, ctx, app: DockerApp):
ctx.state = APP
ctx.data['app'] = app.pk
ctx.data.pop('service', None)
ctx.reply(texts.app_card(app), keyboard=texts.APP_KEYBOARD, parse_mode=HTML)
@text(texts.APP_MENU)
@text(texts.CANCEL, state=APP_REMOVE)
def app_menu(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
self.show_app(ctx, app)
@text(texts.NEW_APP, state=PLATFORM)
def ask_app_name(self, ctx):
if self.idle_platform(ctx):
self.ask(ctx, APP_NEW, texts.APP_NAME_PROMPT)
@state(APP_NEW)
def create_app(self, ctx):
platform = self.idle_platform(ctx)
if platform is None:
return
try:
app = platforms.create_app(platform, ctx.text.strip())
except ValueError as error:
return ctx.reply(str(error))
ctx.data['app'] = app.pk
ctx.data['new_app'] = True
self.ask_upload(ctx, AppVersion.Kind.FULL)
@text(texts.UPLOAD_UPDATE, state=APP)
def ask_update(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
if app.current_version() is None:
return ctx.reply(texts.APP_NOT_DEPLOYED)
self.ask_upload(ctx, AppVersion.Kind.UPDATE)
@text(texts.UPLOAD_FULL, state=APP)
def ask_full(self, ctx):
if self.current_app(ctx) is None:
return self.platform_menu(ctx)
self.ask_upload(ctx, AppVersion.Kind.FULL)
def ask_upload(self, ctx, kind: str):
ctx.data['kind'] = kind
prompt = texts.UPLOAD_UPDATE_PROMPT if kind == AppVersion.Kind.UPDATE else texts.UPLOAD_FULL_PROMPT
self.ask(ctx, APP_UPLOAD, prompt)
@state(APP_UPLOAD)
def receive_upload(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
document = ctx.document
if document is None:
return ctx.reply(texts.UPLOAD_NOT_DOCUMENT)
try:
content = ctx.download_document(uploads.ARCHIVE_MAX)
upload = platforms.prepare_upload(app, document.name, content, ctx.data.get('kind', AppVersion.Kind.UPDATE))
except (bot_api.ApiError, uploads.UploadError) as error:
return ctx.reply(texts.upload_failed(error), parse_mode=HTML)
self.show_upload(ctx, upload)
def show_upload(self, ctx, upload: platforms.Upload):
if upload.version is None:
# ошибки - ждем исправленные файлы, без изменений - назад к приложению
ctx.reply(texts.upload_report(upload), parse_mode=HTML)
if not upload.errors:
self.app_menu(ctx)
return
ctx.state = APP_CONFIRM
ctx.data['version'] = upload.version.pk
ctx.reply(texts.upload_report(upload), keyboard=texts.CONFIRM_KEYBOARD, parse_mode=HTML)
@text(texts.CANCEL, state=APP_UPLOAD)
@text(texts.CANCEL, state=APP_CONFIRM)
def cancel_upload(self, ctx):
app = self.current_app(ctx)
version_id = ctx.data.pop('version', None)
if app and version_id:
app.versions.filter(pk=version_id, state=AppVersion.State.DRAFT).delete()
if app and ctx.data.pop('new_app', False):
platforms.discard_new_app(app)
return self.platform_menu(ctx)
self.app_menu(ctx)
@text(texts.APPLY, state=APP_CONFIRM)
def apply_upload(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
version = app.versions.filter(pk=ctx.data.get('version'), state=AppVersion.State.DRAFT).first()
if version is None:
ctx.reply(texts.VERSION_GONE)
return self.app_menu(ctx)
tasks.start(tasks.deploy_version, platform, Platform.Status.UPDATING, ctx.chat, version.pk)
ctx.data.pop('version', None)
ctx.data.pop('new_app', None)
ctx.state = APP
ctx.reply(texts.APPLY_STARTED, keyboard=texts.APP_KEYBOARD)
@text(texts.ROLLBACK, state=APP)
def rollback(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
try:
upload = platforms.prepare_rollback(app)
except ValueError:
return ctx.reply(texts.ROLLBACK_NONE)
self.show_upload(ctx, upload)
# управление приложением и сервисом
@text(texts.START, state=APP)
@text(texts.STOP, state=APP)
@text(texts.RESTART, state=APP)
@text(texts.PULL, state=APP)
@text(texts.START, state=SERVICE)
@text(texts.STOP, state=SERVICE)
@text(texts.RESTART, state=SERVICE)
def control_app(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
if app.current_version() is None:
return ctx.reply(texts.APP_NOT_DEPLOYED)
service = ctx.data.get('service', '') if ctx.state == SERVICE else ''
tasks.start(tasks.control_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, texts.ACTIONS[ctx.text], service)
ctx.reply(texts.OPERATION_STARTED)
@text(texts.LOGS, state=APP)
@text(texts.LOGS, state=SERVICE)
def send_logs(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
service = ctx.data.get('service', '') if ctx.state == SERVICE else ''
tasks.send_logs.send(app.pk, service, ctx.chat.pk)
ctx.reply(texts.LOGS_STARTED)
@state(APP)
def select_service(self, ctx):
app = self.current_app(ctx)
if app is None:
return self.platform_menu(ctx)
service = ctx.text.strip()
services = app.platform.app_status(app.name).get('services', {})
if not services:
return ctx.reply(texts.NO_SNAPSHOT)
if service not in services:
return ctx.reply(texts.APP_NOT_FOUND)
ctx.state = SERVICE
ctx.data['service'] = service
ctx.reply(texts.service_card(app, service), keyboard=texts.SERVICE_KEYBOARD, parse_mode=HTML)
@text(texts.REMOVE_APP, state=APP)
def ask_remove_app(self, ctx):
if self.idle_platform(ctx) and self.current_app(ctx):
self.ask(ctx, APP_REMOVE, texts.REMOVE_APP_CONFIRM, texts.REMOVE_APP_KEYBOARD)
@text(texts.REMOVE_KEEP_DATA, state=APP_REMOVE)
@text(texts.REMOVE_WITH_DATA, state=APP_REMOVE)
def remove_app(self, ctx):
platform = self.idle_platform(ctx)
app = self.current_app(ctx)
if platform is None or app is None:
return
volumes = ctx.text == texts.REMOVE_WITH_DATA
tasks.start(tasks.remove_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, volumes)
ctx.data.pop('app', None)
ctx.state = PLATFORM
ctx.reply(texts.OPERATION_STARTED, keyboard=texts.PLATFORM_KEYBOARD)
+249 -6
View File
@@ -1,9 +1,11 @@
"""Кнопки и тексты бота. Сообщения размечены HTML, данные пользователя экранируются."""
import datetime
from html import escape
from django.utils import timezone
from urllib.parse import urlsplit, urlunsplit
from ..models import Key, Point, Server
from ..models import AppVersion, DockerApp, Key, Managed, Platform, Point, Server
# главное меню
MAIN_MENU = 'главное меню'
@@ -39,8 +41,45 @@ USE_OLD = 'Использовать старый'
ADD_KEY = 'выпустить ключ'
REVOKE_KEY = 'отозвать ключ'
MAIN_KEYBOARD = [[FAQ, ADD_SERVER, HELP]]
MAIN_KEYBOARD_WITH_POINTS = [[FAQ, ADD_SERVER, HELP], [MANAGE]]
# docker-серверы
ADD_DOCKER = 'добавить docker-сервер'
DOCKER_SERVERS = 'docker-серверы'
PLATFORM_MENU = 'меню docker-сервера'
NEW_APP = 'новое приложение'
CHECK = 'проверить'
APP_MENU = 'меню приложения'
START = 'старт'
STOP = 'стоп'
RESTART = 'рестарт'
LOGS = 'логи'
PULL = 'обновить образы'
UPLOAD_UPDATE = 'залить апдейт'
UPLOAD_FULL = 'перезалить все'
ROLLBACK = 'откатить'
REMOVE_APP = 'удалить'
APPLY = 'Применить'
REMOVE_KEEP_DATA = 'удалить, данные оставить'
REMOVE_WITH_DATA = 'удалить с данными'
ACTIONS = {START: 'start', STOP: 'stop', RESTART: 'restart', PULL: 'pull'}
ACTION_DONE = {'start': 'запущено', 'stop': 'остановлено', 'restart': 'перезапущено', 'pull': 'образы обновлены'}
def main_keyboard(has_points: bool = False, has_platforms: bool = False) -> list[list[str]]:
rows = [[FAQ, ADD_SERVER, HELP]]
if has_points:
rows.append([MANAGE])
rows.append([ADD_DOCKER, DOCKER_SERVERS] if has_platforms else [ADD_DOCKER])
return rows
MAIN_KEYBOARD = main_keyboard()
MAIN_KEYBOARD_WITH_POINTS = main_keyboard(has_points=True)
PLATFORM_KEYBOARD = [[NEW_APP, CHECK], [REINSTALL, RELEASE], [MAIN_MENU]]
APP_KEYBOARD = [[START, STOP, RESTART], [LOGS, PULL], [UPLOAD_UPDATE, UPLOAD_FULL], [ROLLBACK, REMOVE_APP], [PLATFORM_MENU]]
SERVICE_KEYBOARD = [[START, STOP, RESTART], [LOGS], [APP_MENU]]
CONFIRM_KEYBOARD = [[APPLY, CANCEL]]
REMOVE_APP_KEYBOARD = [[REMOVE_KEEP_DATA, REMOVE_WITH_DATA], [CANCEL]]
DRAFT_KEYBOARD = [[DOMAIN, PROXY, MASK], [CANCEL, INSTALL, SERVER, HELP]]
POINT_KEYBOARD = [[RELEASE, REINSTALL], [POINT_DOMAIN, POINT_PROXY], [CLIENTS], [MAIN_MENU]]
KEYS_KEYBOARD = [[ADD_KEY, REVOKE_KEY], [SERVER_MENU]]
@@ -62,6 +101,9 @@ FAQ_TEXT = '''<b>Что это за бот?</b>
<b>Что бот делает с сервером?</b>
Прописывает свой ssh ключ, меняет пароль root, обновляет систему, переносит ssh на случайный порт с входом только по ключу, включает фаервол и ставит нужные сервисы.
<b>Что такое docker-сервер?</b>
Сервер под ваши docker-приложения. Бот настраивает его так же, ставит Docker и Caddy, который выпускает сертификаты для ваших доменов. Приложение - папка <code>~/apps/&lt;имя&gt;</code>: <code>docker-compose.yaml</code>, <code>.env</code>, <code>caddy.conf</code> для веб-морды и <code>data/&lt;сервис&gt;/</code> с настройками сервисов. Файлы вы держите у себя и присылаете боту архивом, данные сервисов живут в volumes.
<b>Как забрать сервер назад?</b>
Меню сервера - "Вернуть". Бот уберет свои доступы, откатит ssh и фаервол, поставит новый пароль root и пришлет его. Данные на сервере останутся.
@@ -75,6 +117,12 @@ HELP_TEXT = '''<b>Добавить сервер</b>
4. по желанию "домен", "прокси"
5. "установить" - установка идет в фоне, по окончании придет уведомление
<b>Docker-сервер</b>
1. "добавить docker-сервер" и <code>ip:пароль</code>
2. "docker-серверы", имя сервера, "новое приложение", имя и архив папки приложения
3. в приложении: "залить апдейт" - файлы поверх текущих (архив или один файл), "перезалить все" - архив целиком, лишнее удаляется. Перед применением бот покажет, что изменится
4. "старт", "стоп", "рестарт", "логи" - для всего приложения или, если прислать имя сервиса, только для него
<b>Управлять сервером</b>
"управление сервером" и имя сервера из главного меню.
@@ -141,7 +189,7 @@ def point_card(point: Point) -> str:
f'Сервер: <code>{escape(point.name)}</code>',
f'Состояние: {point.get_status_display()}',
]
for role in Server.Role:
for role in (Server.Role.PANEL, Server.Role.MASK):
server = servers.get(role)
lines.append(f'{role.label.capitalize()}: {escape(server.address) if server else EMPTY}')
lines += [
@@ -267,8 +315,8 @@ def change_failed(point: Point, error: Exception) -> str:
)
def task_interrupted(point: Point) -> str:
return f'<code>{escape(point.name)}</code>: {escape(point.error)}.'
def task_interrupted(obj: Managed) -> str:
return f'<code>{escape(obj.name)}</code>: {escape(obj.error)}.'
def health_problem(point: Point, problem: str) -> str:
@@ -277,3 +325,198 @@ def health_problem(point: Point, problem: str) -> str:
def health_restored(point: Point) -> str:
return f'Проверка <code>{escape(point.name)}</code>: снова все в порядке.'
# docker-серверы
DOCKER_CREDENTIALS_PROMPT = (
'отправь данные для подключения к серверу под docker\n<code>ip:root_password</code>\n'
'Сервер будет настроен так же, как сервер точки, и получит Docker и Caddy для веб-приложений.'
)
DOCKER_INSTALL_STARTED = 'Запускаю установку docker-сервера. Отправлю уведомление когда все будет готово.'
PLATFORM_SELECT_PROMPT = 'Пришлите имя docker-сервера'
PLATFORM_NOT_FOUND = 'Docker-сервер не найден. Пришлите имя из списка.'
PLATFORM_NOT_READY = 'Docker-сервер еще не установлен.'
PLATFORM_REINSTALL_PROMPT = '''Сервер будет настроен заново, приложения развернуты с текущими версиями файлов.
Вызывайте после переустановки ОС в панели хостинг провайдера.
Пришлите <code>ip:новый_пароль</code> или нажмите "Использовать старый".'''
PLATFORM_RELEASE_CONFIRM = '''Сервер будет освобожден: случайный пароль root, мои доступы убраны.
Приложения на сервере продолжат работать, но управлять ими из бота будет нельзя. Продолжить?'''
APP_NAME_PROMPT = '''Пришли имя приложения: латиница в нижнем регистре, цифры, - и _. Например <code>site</code>.
Это имя папки <code>~/apps/&lt;имя&gt;</code> и compose-проекта.'''
APP_NOT_FOUND = 'Нет такого приложения или сервиса. Пришли имя из списка.'
UPLOAD_UPDATE_PROMPT = '''Пришли архив (zip, tar, tar.gz) или один файл документом.
Файлы лягут поверх текущих, остальные останутся. Корень архива - корень приложения.'''
UPLOAD_FULL_PROMPT = '''Пришли архив всей папки приложения (zip, tar, tar.gz) документом.
Файлы, которых нет в архиве, будут удалены. Корень архива - корень приложения:
<code>docker-compose.yaml</code>, <code>.env</code>, <code>caddy.conf</code>, <code>data/&lt;сервис&gt;/</code>.'''
UPLOAD_NOT_DOCUMENT = 'Пришли файл документом или нажми "отмена".'
UPLOAD_UNCHANGED = 'Файлы не изменились, применять нечего.'
VERSION_GONE = 'Эти файлы уже применены или заменены новыми. Пришлите их заново.'
APP_GONE = 'Приложение уже удалено.'
APPLY_STARTED = 'Применяю. Отправлю уведомление когда все будет готово.'
ROLLBACK_NONE = 'Предыдущей версии нет, откатываться некуда.'
APP_NOT_DEPLOYED = 'Приложение еще не залито: "перезалить все".'
REMOVE_APP_CONFIRM = '''Приложение будет остановлено, папка и блок Caddy удалены.
"удалить с данными" удалит и volumes приложения, восстановить их будет нельзя.'''
OPERATION_STARTED = 'Выполняю. Отправлю уведомление когда все будет готово.'
CHECK_STARTED = 'Проверяю сервер.'
LOGS_STARTED = 'Собираю логи.'
NO_SNAPSHOT = 'Состояние неизвестно: нажмите "проверить" в меню docker-сервера.'
def platforms_list(platforms) -> str:
return '\n'.join(
f'<code>{escape(platform.name)}</code>\t{platform.get_status_display()}\tdocker' for platform in platforms
)
def when(moment) -> str:
return f'{timezone.localtime(moment):%d.%m %H:%M}' if moment else EMPTY
def service_line(name: str, state: dict) -> str:
health = state.get('health')
mark = '✅' if state.get('state') == 'running' and health in ('', None, 'healthy') else '⚠️'
details = state.get('status') or state.get('state', '')
return f'{mark} <code>{escape(name)}</code> {escape(details)}' + (f' ({escape(health)})' if health else '')
def platform_card(platform: Platform) -> str:
lines = [
f'Docker-сервер: <code>{escape(platform.name)}</code>',
f'Состояние: {platform.get_status_display()}',
f'Адрес: {escape(platform.server.address)}',
]
if platform.error:
lines.append(f'Ошибка: {escape(platform.error)}')
apps = list(platform.apps.all())
lines.append(f'\n<b>Приложения</b> (проверено {when(platform.status_at)}):')
if not apps:
lines.append('Приложений пока нет: "новое приложение".')
for app in apps:
status = platform.app_status(app.name)
services = status.get('services', {})
if app.current_version() is None:
summary = 'не залито'
elif status.get('error'):
summary = f'ошибка: {escape(status["error"])}'
elif not services:
summary = 'нет данных'
else:
running = sum(1 for state in services.values() if state.get('state') == 'running')
summary = f'работает {running} из {len(services)}'
lines.append(f'<code>{escape(app.name)}</code> {summary}')
if apps:
lines.append('\nПришли имя приложения, чтобы управлять им.')
return '\n'.join(lines)
def version_line(version: AppVersion | None) -> str:
if version is None:
return 'не залито'
return f'{when(version.created_at)}, {version.get_kind_display()}'
def app_card(app: DockerApp) -> str:
platform = app.platform
status = platform.app_status(app.name)
lines = [
f'Приложение: <code>{escape(app.name)}</code> на <code>{escape(platform.name)}</code>',
f'Файлы: {version_line(app.current_version())}',
f'Сервисы (проверено {when(platform.status_at)}):',
]
if status.get('error'):
lines.append(f'ошибка: {escape(status["error"])}')
services = status.get('services', {})
lines += [service_line(name, state) for name, state in sorted(services.items())] or [EMPTY]
if services:
lines.append('\nПришли имя сервиса, чтобы управлять только им.')
return '\n'.join(lines)
def service_card(app: DockerApp, service: str) -> str:
state = app.platform.app_status(app.name).get('services', {}).get(service, {})
return '\n'.join([
f'Сервис <code>{escape(service)}</code> приложения <code>{escape(app.name)}</code>',
service_line(service, state) if state else EMPTY,
])
def upload_report(upload) -> str:
"""Разница файлов, ошибки и предупреждения присланной версии."""
diff = upload.diff
lines = []
for title, paths in (('Добавлено', diff.added), ('Изменено', diff.changed), ('Удалено', diff.removed)):
if paths:
shown = ', '.join(f'<code>{escape(path)}</code>' for path in paths[:20])
lines.append(f'{title}: {shown}' + (f' и еще {len(paths) - 20}' if len(paths) > 20 else ''))
if upload.errors:
lines.append('\n<b>Нельзя применить:</b>')
lines += [f'- {escape(error)}' for error in upload.errors]
if upload.warnings:
lines.append('\n<b>Предупреждения:</b>')
lines += [f'- {escape(warning)}' for warning in upload.warnings]
if upload.version:
lines.append('\nПрименить?')
elif upload.errors:
lines.append('\nИсправь и пришли файлы заново.')
return '\n'.join(lines) or UPLOAD_UNCHANGED
def upload_failed(error: Exception) -> str:
return f'Не удалось разобрать файл: {escape(str(error))}'
def platform_installed(platform: Platform) -> str:
return (
f'docker-сервер {escape(platform.server.address)} - проименован как <code>{escape(platform.name)}</code>, '
'готов к работе.'
)
def platform_install_failed(platform: Platform, error: Exception) -> str:
return f'Не удалось настроить <code>{escape(platform.name)}</code>:\n{escape(str(error))}'
def platform_released(name: str, address: str, password: str, error: str) -> str:
if error:
return f'<code>{escape(name)}</code>: не удалось вернуть {escape(address)}: {escape(error)}'
return f'Docker-сервер <code>{escape(name)}</code> возвращен.\n{escape(address)} - пароль root <code>{escape(password)}</code>'
def deployed(name: str, data: dict) -> str:
lines = [f'<code>{escape(name)}</code>: файлы применены.']
recreated = data.get('recreated') or []
if recreated:
lines.append('Пересозданы с новыми настройками: ' + ', '.join(f'<code>{escape(s)}</code>' for s in recreated))
return '\n'.join(lines)
def deploy_failed(name: str, error: Exception) -> str:
return f'<code>{escape(name)}</code>: не удалось применить файлы:\n{escape(str(error))}'
def app_controlled(name: str, action: str, service: str) -> str:
target = f'<code>{escape(name)}</code>' + (f' / <code>{escape(service)}</code>' if service else '')
return f'{target}: {ACTION_DONE.get(action, action)}.'
def app_removed(name: str, volumes: bool) -> str:
return f'<code>{escape(name)}</code> удалено' + (' вместе с данными.' if volumes else ', volumes остались на сервере.')
def app_operation_failed(name: str, error: Exception) -> str:
return f'<code>{escape(name)}</code>: не удалось:\n{escape(str(error))}'
def refresh_failed(platform: Platform, error: Exception) -> str:
return f'<code>{escape(platform.name)}</code>: не удалось проверить сервер:\n{escape(str(error))}'
def logs_caption(name: str, service: str) -> str:
return f'Логи {name}' + (f' / {service}' if service else '')
def logs_failed(name: str, error: Exception) -> str:
return f'<code>{escape(name)}</code>: не удалось получить логи:\n{escape(str(error))}'
+100
View File
@@ -0,0 +1,100 @@
# Generated by Django 6.1.1 on 2026-09-29 08:24
import App.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('App', '0009_point_task_supervisor'),
('Telegram', '0003_bot_webhook'),
]
operations = [
migrations.CreateModel(
name='DockerApp',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'приложение',
'verbose_name_plural': 'приложения',
'ordering': ['name'],
},
),
migrations.AlterField(
model_name='point',
name='owner',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='Telegram.user'),
),
migrations.AlterField(
model_name='server',
name='point',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='servers', to='App.point'),
),
migrations.AlterField(
model_name='server',
name='role',
field=models.CharField(choices=[('panel', 'панель'), ('mask', 'маскировка'), ('docker', 'docker')], max_length=8),
),
migrations.CreateModel(
name='AppVersion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('files', App.fields.EncryptedTextField(help_text='base64(zlib(json {путь: base64}))')),
('kind', models.CharField(choices=[('full', 'перезалито все'), ('update', 'апдейт'), ('rollback', 'откат')], max_length=8)),
('state', models.CharField(choices=[('draft', 'черновик'), ('applied', 'применена'), ('failed', 'не применилась')], default='draft', max_length=8)),
('error', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='App.dockerapp')),
],
options={
'verbose_name': 'версия приложения',
'verbose_name_plural': 'версии приложений',
'ordering': ['-pk'],
},
),
migrations.CreateModel(
name='Platform',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64, unique=True)),
('status', models.CharField(choices=[('installing', 'устанавливается'), ('updating', 'настраивается'), ('ready', 'активен'), ('error', 'ошибка'), ('releasing', 'возвращается')], default='installing', max_length=16)),
('error', models.TextField(blank=True)),
('task_token', models.CharField(blank=True, editable=False, max_length=32)),
('task_started_at', models.DateTimeField(blank=True, editable=False, null=True)),
('task_heartbeat', models.DateTimeField(blank=True, editable=False, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('status_snapshot', models.JSONField(blank=True, default=dict, editable=False, help_text='Последний DockerHost.status(): состояние приложений')),
('status_at', models.DateTimeField(blank=True, editable=False, null=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='Telegram.user')),
('task_chat', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='Telegram.chat')),
],
options={
'verbose_name': 'docker-сервер',
'verbose_name_plural': 'docker-серверы',
},
),
migrations.AddField(
model_name='dockerapp',
name='platform',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='apps', to='App.platform'),
),
migrations.AddField(
model_name='server',
name='platform',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='servers', to='App.platform'),
),
migrations.AddConstraint(
model_name='server',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('platform__isnull', True), ('point__isnull', False)), models.Q(('platform__isnull', False), ('point__isnull', True)), _connector='OR'), name='app_server_one_parent'),
),
migrations.AddConstraint(
model_name='dockerapp',
constraint=models.UniqueConstraint(fields=('platform', 'name'), name='app_dockerapp_name_unique'),
),
]
+319 -102
View File
@@ -1,12 +1,15 @@
import base64
import json
import re
import secrets
import zlib
from dataclasses import dataclass
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.db import models, transaction
from django.utils import timezone
from serverus import CF_WARP, Host, generate_password, generate_port, generate_username
from serverus import APP_NAME_RE, CF_WARP, Host, generate_password, generate_port, generate_username
from .fields import EncryptedTextField
@@ -68,29 +71,131 @@ class Credentials:
# find_* возвращают None, и это отражено в типе. Вызывающая сторона видит по имени и типу, нужна ли проверка на None.
class PointQuerySet(models.QuerySet):
def owned_by(self, owner) -> 'PointQuerySet':
"""Точки владельца от старых к новым."""
class ManagedQuerySet(models.QuerySet):
def owned_by(self, owner) -> 'ManagedQuerySet':
"""Записи владельца от старых к новым."""
return self.filter(owner=owner).order_by('created_at')
def find_by_pk(self, pk) -> 'Point | None':
def find_by_pk(self, pk):
return self.filter(pk=pk).first()
def find_by_name(self, name: str) -> 'Point | None':
def find_by_name(self, name: str):
return self.filter(name=name).first()
def find_last(self) -> 'Point | None':
"""Последняя созданная точка."""
def find_last(self):
"""Последняя созданная запись."""
return self.order_by('created_at').last()
def is_name_taken(self, name: str) -> bool:
return self.filter(name=name).exists()
def busy(self) -> 'PointQuerySet':
return self.filter(status__in=Point.BUSY_STATUSES)
def busy(self) -> 'ManagedQuerySet':
return self.filter(status__in=self.model.BUSY_STATUSES)
class PointManager(models.Manager.from_queryset(PointQuerySet)):
class ManagedManager(models.Manager.from_queryset(ManagedQuerySet)):
"""Долгая задача записи: token связывает запись, задачу и ее супервизора (App.tasks)."""
def start_task(self, obj: 'Managed', status: str, token: str, chat=None):
"""Запись занята задачей token. chat - куда сообщить, если задача прервется."""
obj.status = status
obj.error = ''
obj.task_token = token
obj.task_chat = chat
obj.task_started_at = None
obj.task_heartbeat = timezone.now()
obj.save(update_fields=['status', 'error', 'task_token', 'task_chat', 'task_started_at', 'task_heartbeat'])
def claim_task(self, pk, token: str) -> bool:
"""Задача token начала работу. False - запись уже не ее или это повтор после падения воркера."""
now = timezone.now()
return bool(self.filter(pk=pk, task_token=token, task_started_at__isnull=True)
.update(task_started_at=now, task_heartbeat=now))
def beat(self, pk, token: str):
"""Задача token жива."""
self.filter(pk=pk, task_token=token).update(task_heartbeat=timezone.now())
def finish_task(self, pk, token: str):
"""Задача token закончилась, статус она выставила сама."""
self.filter(pk=pk, task_token=token).update(**Managed.NO_TASK)
def abort_task(self, pk, token: str) -> bool:
"""Задача token прервалась: статус по INTERRUPTED. False - задача уже снята или успела выставить итог."""
with transaction.atomic():
obj = self.select_for_update().filter(pk=pk, task_token=token).first()
if obj is None:
return False
if obj.status not in self.model.INTERRUPTED:
# сценарий закончился, упало то, что после него (например уведомление): итог уже в статусе
self.filter(pk=pk).update(**Managed.NO_TASK)
return False
status, error = self.model.INTERRUPTED[obj.status]
self.filter(pk=pk).update(status=status, error=error, **Managed.NO_TASK)
return True
def unique_name(self, prefix: str) -> str:
while True:
name = f'{prefix}-{secrets.token_hex(3)}'
if not self.is_name_taken(name):
return name
class Managed(models.Model):
"""Запись, которой управляют долгие задачи (App.tasks): точка доступа или docker-сервер.
Пока запись занята задачей (BUSY_STATUSES), с ней ничего не сделать. Прерванная задача оставляет статус
по INTERRUPTED.
"""
class Status(models.TextChoices):
INSTALLING = 'installing', 'устанавливается'
UPDATING = 'updating', 'настраивается'
READY = 'ready', 'активен'
ERROR = 'error', 'ошибка'
RELEASING = 'releasing', 'возвращается'
BUSY_STATUSES = (Status.INSTALLING, Status.UPDATING, Status.RELEASING)
# статус и ошибка, если задача прервалась: упал воркер, вышел лимит времени
INTERRUPTED: dict[str, tuple[str, str]] = {}
NO_TASK = {'task_token': '', 'task_chat': None, 'task_started_at': None, 'task_heartbeat': None}
owner = models.ForeignKey('Telegram.User', on_delete=models.PROTECT, related_name='%(class)ss')
name = models.CharField(max_length=64, unique=True)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.INSTALLING)
error = models.TextField(blank=True)
# долгая задача и ее супервизор, см. App.tasks
task_token = models.CharField(max_length=32, blank=True, editable=False)
task_chat = models.ForeignKey(
'Telegram.Chat', on_delete=models.SET_NULL, null=True, blank=True, editable=False, related_name='+',
)
task_started_at = models.DateTimeField(null=True, blank=True, editable=False)
task_heartbeat = models.DateTimeField(null=True, blank=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True
def __str__(self):
return self.name
@property
def is_busy(self) -> bool:
return self.status in self.BUSY_STATUSES
def set_status(self, status: str, error: str = ''):
self.status = status
self.error = error
self.save(update_fields=['status', 'error'])
class PointQuerySet(ManagedQuerySet):
pass
class PointManager(ManagedManager.from_queryset(PointQuerySet)):
def create_point(
self, owner, panel: Credentials, mask: Credentials | None = None, *, domain: str = '', outbound_proxy: str = '',
) -> 'Point':
@@ -102,7 +207,7 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
with transaction.atomic():
point = self.create(
owner=owner,
name=self._unique_name(),
name=self.unique_name('point'),
domain=domain,
outbound_proxy=outbound_proxy,
panel_username=generate_username(),
@@ -117,54 +222,8 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
Server.objects.create_server(point, Server.Role.MASK, mask)
return point
# долгая задача точки: token связывает точку, задачу и ее супервизора (App.tasks)
def start_task(self, point: 'Point', status: str, token: str, chat=None):
"""Точка занята задачей token. chat - куда сообщить, если задача прервется."""
point.status = status
point.error = ''
point.task_token = token
point.task_chat = chat
point.task_started_at = None
point.task_heartbeat = timezone.now()
point.save(update_fields=['status', 'error', 'task_token', 'task_chat', 'task_started_at', 'task_heartbeat'])
def claim_task(self, pk, token: str) -> bool:
"""Задача token начала работу. False - точка уже не ее или это повтор после падения воркера."""
now = timezone.now()
return bool(self.filter(pk=pk, task_token=token, task_started_at__isnull=True)
.update(task_started_at=now, task_heartbeat=now))
def beat(self, pk, token: str):
"""Задача token жива."""
self.filter(pk=pk, task_token=token).update(task_heartbeat=timezone.now())
def finish_task(self, pk, token: str):
"""Задача token закончилась, статус она выставила сама."""
self.filter(pk=pk, task_token=token).update(**Point.NO_TASK)
def abort_task(self, pk, token: str) -> bool:
"""Задача token прервалась: статус по INTERRUPTED. False - задача уже снята или успела выставить итог."""
with transaction.atomic():
point = self.select_for_update().filter(pk=pk, task_token=token).first()
if point is None:
return False
if point.status not in Point.INTERRUPTED:
# сценарий закончился, упало то, что после него (например уведомление): итог уже в статусе
self.filter(pk=pk).update(**Point.NO_TASK)
return False
status, error = Point.INTERRUPTED[point.status]
self.filter(pk=pk).update(status=status, error=error, **Point.NO_TASK)
return True
def _unique_name(self) -> str:
while True:
name = f'point-{secrets.token_hex(3)}'
if not self.is_name_taken(name):
return name
class Point(models.Model):
class Point(Managed):
"""Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним.
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
@@ -174,27 +233,12 @@ class Point(models.Model):
CF_WARP = CF_WARP
class Status(models.TextChoices):
INSTALLING = 'installing', 'устанавливается'
UPDATING = 'updating', 'настраивается'
READY = 'ready', 'активен'
ERROR = 'error', 'ошибка'
RELEASING = 'releasing', 'возвращается'
BUSY_STATUSES = (Status.INSTALLING, Status.UPDATING, Status.RELEASING)
# статус и ошибка, если задача прервалась: упал воркер, вышел лимит времени
INTERRUPTED = {
Status.INSTALLING: (Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
Managed.Status.INSTALLING: (Managed.Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
# домен и прокси serverus применяет в самом конце, прежние настройки почти наверняка на месте
Status.UPDATING: (Status.READY, 'смена настройки прервалась, настройки прежние'),
Status.RELEASING: (Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
Managed.Status.UPDATING: (Managed.Status.READY, 'смена настройки прервалась, настройки прежние'),
Managed.Status.RELEASING: (Managed.Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
}
NO_TASK = {'task_token': '', 'task_chat': None, 'task_started_at': None, 'task_heartbeat': None}
owner = models.ForeignKey('Telegram.User', on_delete=models.PROTECT, related_name='points')
name = models.CharField(max_length=64, unique=True)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.INSTALLING)
error = models.TextField(blank=True)
domain = models.CharField(max_length=253, blank=True)
outbound_proxy = EncryptedTextField(blank=True, help_text=f'protocol://user:password@host:port или {CF_WARP}')
@@ -208,34 +252,17 @@ class Point(models.Model):
sub_port = models.PositiveIntegerField(help_text='Порт подписки панели')
sub_path = models.CharField(max_length=64, help_text='Путь подписки без слешей')
# долгая задача (установка, смена настройки, возврат) и ее супервизор, см. App.tasks
task_token = models.CharField(max_length=32, blank=True, editable=False)
task_chat = models.ForeignKey(
'Telegram.Chat', on_delete=models.SET_NULL, null=True, blank=True, editable=False, related_name='+',
)
task_started_at = models.DateTimeField(null=True, blank=True, editable=False)
task_heartbeat = models.DateTimeField(null=True, blank=True, editable=False)
# ежедневная проверка, см. App.tasks.check_point
check_token = models.CharField(max_length=32, blank=True, editable=False)
checked_at = models.DateTimeField(null=True, blank=True, editable=False)
health_error = models.TextField(blank=True, editable=False, help_text='Что не так по последней проверке')
created_at = models.DateTimeField(auto_now_add=True)
objects = PointManager()
class Meta:
verbose_name = 'точка доступа'
verbose_name_plural = 'точки доступа'
def __str__(self):
return self.name
@property
def is_busy(self) -> bool:
return self.status in self.BUSY_STATUSES
@property
def panel_host(self) -> str:
"""Адрес панели для https и подписки: домен, без него - ip панели. По ssh ходим только по ip."""
@@ -247,11 +274,6 @@ class Point(models.Model):
def subscription_url(self, sub_id: str) -> str:
return f'https://{self.panel_host}:{self.sub_port}/{self.sub_path}/{sub_id}'
def set_status(self, status: str, error: str = ''):
self.status = status
self.error = error
self.save(update_fields=['status', 'error'])
def server(self, role: str) -> 'Server':
"""Сервер с ролью role. Бросает Server.DoesNotExist, если его нет."""
return self.servers.get(role=role)
@@ -294,19 +316,35 @@ class ServerManager(models.Manager.from_queryset(ServerQuerySet)):
default_root_password=credentials.password,
)
def create_docker_server(self, platform: 'Platform', credentials: Credentials) -> 'Server':
"""Сервер docker-платформы. Вызывать через Server.objects, обычно в составе Platform.objects.create_platform."""
return self.create(
platform=platform,
role=Server.Role.DOCKER,
address=credentials.address,
root_password=credentials.password,
default_root_password=credentials.password,
)
class Server(models.Model):
"""Сервер под управлением системы. Поля ssh отражают текущее состояние сервера.
Создание: Server.objects.create_server, обычно в составе Point.objects.create_point. Поиск: методы ServerQuerySet,
Принадлежит точке доступа (роли panel, mask) или docker-платформе (роль docker), ровно чему-то одному.
Создание: Server.objects.create_server, обычно в составе Point.objects.create_point, и
Server.objects.create_docker_server в составе Platform.objects.create_platform. Поиск: методы ServerQuerySet,
в том числе в пределах точки: point.servers.find_by_address(address).
"""
class Role(models.TextChoices):
PANEL = 'panel', 'панель'
MASK = 'mask', 'маскировка'
DOCKER = 'docker', 'docker'
point = models.ForeignKey(Point, on_delete=models.CASCADE, related_name='servers')
point = models.ForeignKey(Point, on_delete=models.CASCADE, null=True, blank=True, related_name='servers')
platform = models.ForeignKey(
'Platform', on_delete=models.CASCADE, null=True, blank=True, related_name='servers',
)
role = models.CharField(max_length=8, choices=Role.choices)
address = models.GenericIPAddressField(unique=True)
ssh_port = models.PositiveIntegerField(default=22)
@@ -325,7 +363,14 @@ class Server(models.Model):
class Meta:
verbose_name = 'сервер'
verbose_name_plural = 'серверы'
constraints = [models.UniqueConstraint(fields=['point', 'role'], name='app_server_role_unique')]
constraints = [
models.UniqueConstraint(fields=['point', 'role'], name='app_server_role_unique'),
models.CheckConstraint(
condition=models.Q(point__isnull=False, platform__isnull=True)
| models.Q(point__isnull=True, platform__isnull=False),
name='app_server_one_parent',
),
]
def __str__(self):
return f'{self.address} ({self.get_role_display()})'
@@ -378,3 +423,175 @@ class Key(models.Model):
@property
def subscription_url(self) -> str:
return self.point.subscription_url(self.sub_id)
class PlatformQuerySet(ManagedQuerySet):
pass
class PlatformManager(ManagedManager.from_queryset(PlatformQuerySet)):
def create_platform(self, owner, credentials: Credentials) -> 'Platform':
"""Создает docker-платформу вместе с ее сервером. Вызывать через Platform.objects. Имя генерируется."""
with transaction.atomic():
platform = self.create(owner=owner, name=self.unique_name('docker'))
Server.objects.create_docker_server(platform, credentials)
return platform
class Platform(Managed):
"""Docker-сервер: приложения в ~/apps под управлением бота (serverus.DockerHost).
Операции с сервером идут по одной: пока платформа занята задачей, с ней ничего не сделать.
Создание: Platform.objects.create_platform. Поиск: методы PlatformQuerySet. Удаление: platform.delete()
удаляет и сервер, и приложения.
"""
INTERRUPTED = {
Managed.Status.INSTALLING: (Managed.Status.ERROR, 'установка прервалась, нажмите "Переустановить"'),
Managed.Status.UPDATING: (Managed.Status.READY, 'операция прервалась, проверьте состояние кнопкой "Проверить"'),
Managed.Status.RELEASING: (Managed.Status.ERROR, 'возврат прервался, нажмите "Вернуть" еще раз'),
}
status_snapshot = models.JSONField(
default=dict, blank=True, editable=False, help_text='Последний DockerHost.status(): состояние приложений',
)
status_at = models.DateTimeField(null=True, blank=True, editable=False)
objects = PlatformManager()
class Meta:
verbose_name = 'docker-сервер'
verbose_name_plural = 'docker-серверы'
@property
def server(self) -> Server:
"""Сервер платформы. Есть всегда: create_platform создает его вместе с платформой."""
return self.servers.get(role=Server.Role.DOCKER)
def app_status(self, name: str) -> dict:
"""Состояние приложения из последнего снимка: {'services': {...}, 'error': ''}, пусто - в снимке его нет."""
return self.status_snapshot.get('apps', {}).get(name, {})
def save_snapshot(self, snapshot: dict):
self.status_snapshot = snapshot
self.status_at = timezone.now()
self.save(update_fields=['status_snapshot', 'status_at'])
class DockerAppQuerySet(models.QuerySet):
def find_by_name(self, name: str) -> 'DockerApp | None':
return self.filter(name=name).first()
def is_name_taken(self, name: str) -> bool:
return self.filter(name=name).exists()
class DockerApp(models.Model):
"""Приложение на docker-платформе: папка ~/apps/<name>/, compose-проект name.
Файлы приложения хранятся версиями (AppVersion): текущая - последняя примененная. Сервер всегда получает полный
набор файлов текущей версии. Создание и удаление: App.platforms. Поиск: platform.apps.find_by_name(name).
"""
NAME_RE = APP_NAME_RE
platform = models.ForeignKey(Platform, on_delete=models.CASCADE, related_name='apps')
name = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
objects = DockerAppQuerySet.as_manager()
class Meta:
verbose_name = 'приложение'
verbose_name_plural = 'приложения'
ordering = ['name']
constraints = [models.UniqueConstraint(fields=['platform', 'name'], name='app_dockerapp_name_unique')]
def __str__(self):
return f'{self.name} ({self.platform})'
def current_version(self) -> 'AppVersion | None':
return self.versions.applied().first()
def previous_version(self) -> 'AppVersion | None':
"""Версия перед текущей: на нее откатывается приложение."""
return self.versions.applied()[1:2].first()
class AppVersionQuerySet(models.QuerySet):
def applied(self) -> 'AppVersionQuerySet':
"""Примененные версии от новых к старым."""
return self.filter(state=AppVersion.State.APPLIED).order_by('-pk')
def find_by_pk(self, pk) -> 'AppVersion | None':
return self.filter(pk=pk).first()
class AppVersionManager(models.Manager.from_queryset(AppVersionQuerySet)):
def create_draft(self, app: DockerApp, files: dict[str, bytes], kind: str) -> 'AppVersion':
"""Черновик версии: файлы, которые пользователь прислал и еще не применил. Прежние черновики удаляются."""
with transaction.atomic():
app.versions.filter(state=AppVersion.State.DRAFT).delete()
return self.create(app=app, files=AppVersion.encode(files), kind=kind)
class AppVersion(models.Model):
"""Полный набор файлов приложения. Файлы хранятся зашифрованными: в них .env с секретами.
Черновик (draft) становится примененной версией (applied) или неудачной (failed). Хранятся KEEP последних
примененных версий. Создание: AppVersion.objects.create_draft, дальше App.platforms.
"""
KEEP = 10
class Kind(models.TextChoices):
FULL = 'full', 'перезалито все'
UPDATE = 'update', 'апдейт'
ROLLBACK = 'rollback', 'откат'
class State(models.TextChoices):
DRAFT = 'draft', 'черновик'
APPLIED = 'applied', 'применена'
FAILED = 'failed', 'не применилась'
app = models.ForeignKey(DockerApp, on_delete=models.CASCADE, related_name='versions')
files = EncryptedTextField(help_text='base64(zlib(json {путь: base64}))')
kind = models.CharField(max_length=8, choices=Kind.choices)
state = models.CharField(max_length=8, choices=State.choices, default=State.DRAFT)
error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
objects = AppVersionManager()
class Meta:
verbose_name = 'версия приложения'
verbose_name_plural = 'версии приложений'
ordering = ['-pk']
def __str__(self):
return f'{self.app} #{self.pk} ({self.get_state_display()})'
@staticmethod
def encode(files: dict[str, bytes]) -> str:
data = {path: base64.b64encode(content).decode() for path, content in sorted(files.items())}
return base64.b64encode(zlib.compress(json.dumps(data).encode())).decode()
def get_files(self) -> dict[str, bytes]:
data = json.loads(zlib.decompress(base64.b64decode(self.files)))
return {path: base64.b64decode(content) for path, content in data.items()}
def mark_applied(self):
"""Версия применена: старые примененные версии сверх KEEP и неудачные попытки удаляются."""
with transaction.atomic():
self.state = self.State.APPLIED
self.error = ''
self.save(update_fields=['state', 'error'])
versions = self.app.versions
versions.filter(state=self.State.FAILED).delete()
stale = versions.applied().values_list('pk', flat=True)[self.KEEP:]
versions.filter(pk__in=list(stale)).delete()
def mark_failed(self, error: str):
self.state = self.State.FAILED
self.error = error
self.save(update_fields=['state', 'error'])
+165
View File
@@ -0,0 +1,165 @@
"""Сценарии docker-платформы поверх serverus.DockerHost: установка, приложения и их версии, возврат сервера.
Файлы приложения живут в App версиями (AppVersion): апдейт сливается с текущей версией здесь, а сервер всегда
получает полный набор файлов. Операции, которые меняют сервер, выполняются задачами (App.tasks) по одной.
"""
import logging
from dataclasses import dataclass
import serverus
from . import uploads
from .models import AppVersion, DockerApp, Platform
from .services import (
ProvisionError, bootstrap_server, check_result, installing, local_ca, release_server, reset_for_reinstall, updating,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Upload:
"""Итог разбора присланных файлов. version - черновик для применения, None - применять нечего или нельзя."""
version: AppVersion | None
diff: uploads.Diff
errors: list[str]
warnings: list[str]
def docker(platform: Platform) -> serverus.DockerHost:
return serverus.DockerHost(platform.server.host())
def install_platform(platform: Platform):
"""bootstrap сервера, Docker и ingress, затем приложения с текущими версиями (после переустановки ОС)."""
with installing(platform):
server = platform.server
bootstrap_server(server)
check_result(server, docker(platform).install(local_certs=local_ca() is not None))
server.uname = check_result(server, serverus.Server(server.host()).facts()).data.get('uname', '')
server.save(update_fields=['uname'])
for app in platform.apps.all():
version = app.current_version()
if version:
check_result(server, docker(platform).deploy(app.name, version.get_files()))
_refresh_after(platform)
def reinstall(platform: Platform, password: str | None = None):
"""Повторная установка после переустановки ОС у провайдера. password - новый пароль root от провайдера,
без него - присланный при добавлении сервера. Приложения разворачиваются заново с текущими версиями."""
reset_for_reinstall(platform.server, password)
install_platform(platform)
def release_platform(platform: Platform) -> tuple[str, str]:
"""Возвращает сервер владельцу: (новый пароль root, ошибка). Приложения на сервере продолжают работать.
Платформа удаляется из базы вместе с версиями приложений, только если сервер возвращен.
"""
server = platform.server
error = release_server(server)
if error:
platform.set_status(Platform.Status.ERROR, f'{server.address}: {error}')
return '', error
password = server.root_password
platform.delete()
return password, ''
def create_app(platform: Platform, name: str) -> DockerApp:
"""Новое приложение без файлов. ValueError - имя не подходит, занято или зарезервировано."""
if not DockerApp.NAME_RE.match(name):
raise ValueError('имя приложения: латиница в нижнем регистре, цифры, - и _, до 64 символов')
if name == serverus.INGRESS or platform.apps.is_name_taken(name):
raise ValueError(f'приложение {name} уже есть')
return DockerApp.objects.create(platform=platform, name=name)
def discard_new_app(app: DockerApp):
"""Отмена создания: приложение, которое ни разу не применялось, удаляется."""
if app.current_version() is None:
app.delete()
def prepare_upload(app: DockerApp, filename: str, content: bytes, kind: str) -> Upload:
"""Разбирает присланный файл в черновик версии. kind - AppVersion.Kind.UPDATE (поверх текущих файлов)
или FULL (архив целиком, отсутствующее удаляется). uploads.UploadError - файл не разобрать."""
new = uploads.read(filename, content)
if kind == AppVersion.Kind.FULL and not uploads.is_archive(filename):
raise uploads.UploadError('перезалить все можно только архивом: zip, tar, tar.gz')
current = app.current_version()
old = current.get_files() if current else {}
files = uploads.merge(old, new) if kind == AppVersion.Kind.UPDATE else new
return _draft(app, old, files, kind)
def prepare_rollback(app: DockerApp) -> Upload:
"""Черновик отката на предыдущую версию. ValueError - откатываться некуда."""
previous = app.previous_version()
if previous is None:
raise ValueError('предыдущей версии нет')
return _draft(app, app.current_version().get_files(), previous.get_files(), AppVersion.Kind.ROLLBACK)
def _draft(app: DockerApp, old: dict[str, bytes], files: dict[str, bytes], kind: str) -> Upload:
errors, warnings = uploads.check(files)
diff = uploads.diff(old, files)
version = None
if not errors and (diff or app.current_version() is None):
version = AppVersion.objects.create_draft(app, files, kind)
return Upload(version, diff, errors, warnings)
def apply(version: AppVersion) -> dict:
"""Разворачивает черновик на сервере. Возвращает data DockerHost.deploy: changed, removed, recreated, services.
Ошибка - версия отмечается неудачной, на сервере остается прежняя (compose-файл проверяется до изменений).
"""
app = version.app
platform = app.platform
with updating(platform):
try:
result = check_result(platform.server, docker(platform).deploy(app.name, version.get_files()))
except ProvisionError as error:
version.mark_failed(str(error))
raise
version.mark_applied()
_refresh_after(platform)
return result.data
def control(app: DockerApp, action: str, service: str = ''):
"""start, stop, restart или pull приложения или одного сервиса."""
platform = app.platform
with updating(platform):
check_result(platform.server, docker(platform).control(app.name, action, service))
_refresh_after(platform)
def remove_app(app: DockerApp, volumes: bool = False):
"""Удаляет приложение с сервера (volumes=True - вместе с данными) и из базы."""
platform = app.platform
with updating(platform):
check_result(platform.server, docker(platform).remove(app.name, volumes))
app.delete()
_refresh_after(platform)
def logs(app: DockerApp, service: str = '', lines: int = 500) -> str:
return check_result(app.platform.server, docker(app.platform).logs(app.name, service, lines)).data.get('logs', '')
def refresh(platform: Platform) -> dict:
"""Снимок состояния приложений с сервера в platform.status_snapshot."""
snapshot = check_result(platform.server, docker(platform).status()).data
platform.save_snapshot(snapshot)
return snapshot
def _refresh_after(platform: Platform):
"""Снимок после операции: операция уже прошла, недоступный сервер ее не отменяет."""
try:
refresh(platform)
except ProvisionError:
logger.warning('Platform %s: status refresh failed', platform, exc_info=True)
+61 -46
View File
@@ -7,7 +7,7 @@ from contextlib import contextmanager
import serverus
from django.conf import settings
from .models import KEY_NAME_RE, Key, Point, Server
from .models import KEY_NAME_RE, Key, Managed, Point, Server
class ProvisionError(Exception):
@@ -16,7 +16,7 @@ class ProvisionError(Exception):
def install_point(point: Point):
"""Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели."""
with _installing(point):
with installing(point):
for server in point.servers.all():
provision(server)
restore_keys(point)
@@ -29,12 +29,9 @@ def reinstall(point: Point, address: str | None = None, password: str | None = N
password - новый пароль root от провайдера, без него используется пароль, присланный при добавлении сервера.
"""
servers = [point.servers.get(address=address)] if address else point.servers.all()
with _installing(point):
with installing(point):
for server in servers:
server.known_hosts = ''
server.ssh_port = 22
server.root_password = password or server.default_root_password
server.save()
reset_for_reinstall(server, password)
provision(server)
# панель доступна по домену только через маскировку, поэтому после всех серверов
restore_keys(point)
@@ -47,22 +44,11 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
"""
passwords, errors = {}, {}
for server in point.servers.all():
if not server.private_key:
# возвращен прошлой попыткой: ключа на сервере больше нет, пароль уже новый
passwords[server.address] = server.root_password
continue
password = serverus.generate_password()
result = serverus.Server(server.host()).release(password)
if result.ok:
# release убирает ключ и возвращает ssh на 22 порт
server.root_password = password
server.ssh_port = 22
server.private_key = ''
server.public_key = ''
server.save(update_fields=['root_password', 'ssh_port', 'private_key', 'public_key'])
passwords[server.address] = password
error = release_server(server)
if error:
errors[server.address] = error
else:
errors[server.address] = result.error
passwords[server.address] = server.root_password
if errors:
point.set_status(Point.Status.ERROR, '\n'.join(f'{address}: {error}' for address, error in errors.items()))
else:
@@ -77,8 +63,8 @@ def set_domain(point: Point, domain: str):
"""
if not domain and point.mask:
raise ValueError('маскировка работает только с доменом')
with _updating(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca()))
with updating(point):
check_result(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca()))
point.domain = domain
point.save(update_fields=['domain'])
@@ -87,8 +73,8 @@ def set_outbound_proxy(point: Point, proxy: str):
"""Меняет исходящий прокси готовой точки: protocol://user:password@host:port, Point.CF_WARP или '' - напрямую."""
if proxy and proxy != Point.CF_WARP:
serverus.parse_proxy(proxy)
with _updating(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy))
with updating(point):
check_result(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy))
point.outbound_proxy = proxy
point.save(update_fields=['outbound_proxy'])
@@ -182,16 +168,45 @@ def xui_api(point: Point) -> serverus.XuiApi:
def provision(server: Server):
"""Базовая настройка сервера и специализация по его роли."""
_bootstrap(server)
bootstrap_server(server)
if server.role == Server.Role.PANEL:
_install_panel(server)
else:
_redirect_to_panel(server)
server.uname = _check(server, serverus.Server(server.host()).facts()).data.get('uname', '')
server.uname = check_result(server, serverus.Server(server.host()).facts()).data.get('uname', '')
server.save(update_fields=['uname'])
def _bootstrap(server: Server):
def reset_for_reinstall(server: Server, password: str | None = None):
"""Сервер переустановлен у провайдера: отпечаток новый, ssh на 22 порту, пароль от провайдера.
password - новый пароль root, без него - присланный при добавлении сервера.
"""
server.known_hosts = ''
server.ssh_port = 22
server.root_password = password or server.default_root_password
server.save()
def release_server(server: Server) -> str:
"""Возвращает сервер владельцу с новым паролем root (server.root_password). Возвращает ошибку или ''."""
if not server.private_key:
# возвращен прошлой попыткой: ключа на сервере больше нет, пароль уже новый
return ''
password = serverus.generate_password()
result = serverus.Server(server.host()).release(password)
if not result.ok:
return result.error
# release убирает ключ и возвращает ssh на 22 порт
server.root_password = password
server.ssh_port = 22
server.private_key = ''
server.public_key = ''
server.save(update_fields=['root_password', 'ssh_port', 'private_key', 'public_key'])
return ''
def bootstrap_server(server: Server):
if not server.private_key:
keys = serverus.generate_keypair(f'mypoint-{server.address}')
server.private_key, server.public_key = keys.private_key, keys.public_key
@@ -199,7 +214,7 @@ def _bootstrap(server: Server):
new_password = serverus.generate_password()
ssh_port = serverus.generate_ssh_port()
# при ошибке остается старый пароль, но ключ уже может быть прописан: повторный запуск войдет по ключу
_check(server, serverus.Server(server.host()).bootstrap(server.root_password, server.public_key, new_password, ssh_port))
check_result(server, serverus.Server(server.host()).bootstrap(server.root_password, server.public_key, new_password, ssh_port))
server.root_password = new_password
server.ssh_port = ssh_port
server.save(update_fields=['root_password', 'ssh_port'])
@@ -208,7 +223,7 @@ def _bootstrap(server: Server):
def _install_panel(server: Server):
point = server.point
panel = serverus.XuiPanel(server.host())
result = _check(server, panel.install(
result = check_result(server, panel.install(
username=point.panel_username,
password=point.panel_password,
panel_port=point.panel_port,
@@ -220,21 +235,21 @@ def _install_panel(server: Server):
))
point.panel_api_token = result.data.get('api_token', '')
point.save(update_fields=['panel_api_token'])
result = _check(server, panel.create_inbound())
result = check_result(server, panel.create_inbound())
point.panel_inbound_id = result.data.get('inbound_id')
point.save(update_fields=['panel_inbound_id'])
if point.outbound_proxy:
_check(server, panel.set_outbound(point.outbound_proxy))
check_result(server, panel.set_outbound(point.outbound_proxy))
def _redirect_to_panel(server: Server):
point = server.point
# 80 - выпуск и продление сертификата (HTTP-01), 443 - подключения клиентов
ports = (80, 443, point.panel_port, point.sub_port)
_check(server, serverus.RedirectProxy(server.host()).redirect(point.panel.address, ports))
check_result(server, serverus.RedirectProxy(server.host()).redirect(point.panel.address, ports))
def _check(server: Server, result: serverus.Result) -> serverus.Result:
def check_result(server: Server, result: serverus.Result) -> serverus.Result:
if result.known_hosts and result.known_hosts != server.known_hosts:
server.known_hosts = result.known_hosts
server.save(update_fields=['known_hosts'])
@@ -244,28 +259,28 @@ def _check(server: Server, result: serverus.Result) -> serverus.Result:
@contextmanager
def _installing(point: Point):
def installing(obj: Managed):
"""Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки."""
point.set_status(Point.Status.INSTALLING)
obj.set_status(Managed.Status.INSTALLING)
try:
yield
except Exception as error:
point.set_status(Point.Status.ERROR, str(error))
obj.set_status(Managed.Status.ERROR, str(error))
raise
point.set_status(Point.Status.READY)
obj.set_status(Managed.Status.READY)
@contextmanager
def _updating(point: Point):
"""Статус UPDATING на время смены настройки готовой точки, затем READY.
def updating(obj: Managed):
"""Статус UPDATING на время изменения готовой записи, затем READY.
При ошибке точка тоже остается READY с текстом ошибки: serverus меняет сертификат и прокси только после
успешного выпуска и проверки, так что панель работает с прежними настройками.
При ошибке запись тоже остается READY с текстом ошибки: serverus меняет сертификат и прокси точки только после
успешного выпуска и проверки, приложение docker-платформы - только после проверки compose-файла.
"""
point.set_status(Point.Status.UPDATING)
obj.set_status(Managed.Status.UPDATING)
try:
yield
except Exception as error:
point.set_status(Point.Status.READY, str(error))
obj.set_status(Managed.Status.READY, str(error))
raise
point.set_status(Point.Status.READY)
obj.set_status(Managed.Status.READY)
+189 -48
View File
@@ -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),
)
# ежедневная проверка: своя цепочка у каждой точки, живет, пока жива точка
+2 -2
View File
@@ -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
+509
View File
@@ -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
+165
View File
@@ -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
+2
View File
@@ -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', '')
+4
View File
@@ -59,6 +59,8 @@ class Shop(Interface):
Порядок разбора сообщения: команда, кнопка текущего состояния (`@text(..., state=...)`), кнопка, состояние диалога, `fallback`.
Файлы: `ctx.document` - присланный документ (`file_id`, `name`, `size`, `mime_type`) или `None`, `ctx.text` у документа - подпись. `ctx.download_document(max_size)` возвращает содержимое, `api.FileTooLarge` - файл больше `max_size`, `api.ApiError` - не скачался. Документ ловит обработчик состояния (`@state`). `ctx.reply_document(filename, content, caption)` и `messages.send_document(chat, ...)` отправляют файл документом.
`ctx.delete_message()` удаляет входящее сообщение из чата, например с паролем. Модуль с интерфейсом нужно импортировать при старте, например в `AppConfig.ready()`.
## Запуск
@@ -69,6 +71,8 @@ class Shop(Interface):
3. Отдельный процесс не нужен: обновления принимает сайт. Смена интерфейса применяется без перезапуска.
`TELEGRAM_API_URL` направляет бота на свой Bot API сервер (сервис `telegram` в `docker-compose.yaml`).
`TELEGRAM_FILES_URL` - раздача файлов своего Bot API сервера (сервис `telegram-files`). В `--local` режиме сервер не отдает файлы по HTTP, а пишет их в свою папку (`api.WORK_DIR`) с правами 0600 от uid 101. Рядом с ним работает nginx, воркеры которого тоже от uid 101: он отдает только `documents/` и слушает только локальный адрес.
`TELEGRAM_WEBHOOK_URL` - адрес сайта, как его видит Bot API сервер, к нему дописывается путь webhook. Сервер в docker запущен с `--local` (`TELEGRAM_LOCAL`), поэтому webhook может быть на http и на любом порту. Хост из этого адреса нужно добавить в `ALLOWED_HOSTS`.
Состояние webhook (адрес, очередь, последняя ошибка доставки) видно на странице бота в админке. Ошибка обработчика пишется в `last_error`, а телеграму отвечаем 200, чтобы он не повторял то же обновление.
+33 -1
View File
@@ -9,17 +9,24 @@ def configure_api_server():
if uses_own_server():
url = settings.TELEGRAM_API_URL.rstrip('/')
apihelper.API_URL = url + '/bot{0}/{1}'
apihelper.FILE_URL = url + '/file/bot{0}/{1}'
# в --local режиме сервер файлы не отдает, их раздает TELEGRAM_FILES_URL по пути внутри WORK_DIR
apihelper.FILE_URL = getattr(settings, 'TELEGRAM_FILES_URL', '').rstrip('/') + '{1}'
CLOUD_API_URL = 'https://api.telegram.org'
ALLOWED_UPDATES = ['message', 'my_chat_member']
# папка данных своего Bot API сервера (TELEGRAM_WORK_DIR образа): в --local режиме getFile отдает путь внутри нее
WORK_DIR = '/var/lib/telegram-bot-api'
class ApiError(Exception):
pass
class FileTooLarge(ApiError):
pass
def uses_own_server() -> bool:
return bool(getattr(settings, 'TELEGRAM_API_URL', ''))
@@ -34,3 +41,28 @@ def log_out_from_cloud(token: str):
def make_api(token: str) -> telebot.TeleBot:
return telebot.TeleBot(token, threaded=False)
def download_file(api: telebot.TeleBot, file_id: str, max_size: int) -> bytes:
"""Содержимое файла из сообщения. FileTooLarge - больше max_size байт, ApiError - не удалось скачать.
Свой Bot API сервер в --local режиме файлы по HTTP не отдает: getFile возвращает путь на его диске,
а раздает их TELEGRAM_FILES_URL (nginx рядом с сервером, см. configure_api_server).
"""
try:
file = api.get_file(file_id)
if file.file_size and file.file_size > max_size:
raise FileTooLarge(f'file is larger than {max_size} bytes')
path = file.file_path
if uses_own_server():
if not path.startswith(WORK_DIR + '/'):
raise ApiError('unexpected file path from Bot API server')
path = path[len(WORK_DIR):]
content = api.download_file(path)
except ApiError:
raise
except Exception as error:
raise ApiError(f'download: {error}') from error
if len(content) > max_size:
raise FileTooLarge(f'file is larger than {max_size} bytes')
return content
+35 -1
View File
@@ -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,
)
+10 -1
View File
@@ -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,
+17
View File
@@ -1,3 +1,5 @@
import io
from telebot import types
from .models import Chat, Message
@@ -22,3 +24,18 @@ def send(chat: Chat, text: str, keyboard: Keyboard | None = None, parse_mode: st
api = api or chat.bot.api()
sent = api.send_message(chat.chat_id, text, reply_markup=build_keyboard(keyboard), parse_mode=parse_mode)
return Message.objects.create(chat=chat, direction=Message.Direction.OUT, message_id=sent.message_id, text=text)
def send_document(
chat: Chat, filename: str, content: bytes, caption: str = '', keyboard: Keyboard | None = None,
parse_mode: str | None = None, api=None,
) -> Message:
"""Отправляет файл документом и сохраняет в историю подпись или имя файла."""
api = api or chat.bot.api()
sent = api.send_document(
chat.chat_id, types.InputFile(io.BytesIO(content), file_name=filename), caption=caption or None,
reply_markup=build_keyboard(keyboard), parse_mode=parse_mode,
)
return Message.objects.create(
chat=chat, direction=Message.Direction.OUT, message_id=sent.message_id, text=caption or filename,
)
+45 -1
View File
@@ -8,11 +8,32 @@ CHAT_ID = 42
class FakeApi:
"""Подменяет TeleBot: запоминает отправленные и удаленные сообщения."""
"""Подменяет TeleBot: запоминает отправленные и удаленные сообщения, отдает присланные документы."""
def __init__(self):
self.sent = []
self.deleted = []
self.documents = []
self.files = {}
"""file_id -> содержимое присланного документа."""
def get_file(self, file_id):
from .api import WORK_DIR, uses_own_server
# свой сервер отдает путь на своем диске, облако - относительный
path = f'{WORK_DIR}/1:token/documents/{file_id}' if uses_own_server() else f'documents/{file_id}'
return SimpleNamespace(file_id=file_id, file_path=path, file_size=len(self.files[file_id]))
def download_file(self, file_path):
return self.files[file_path.rsplit('/', 1)[-1]]
def send_document(self, chat_id, document, caption=None, reply_markup=None, parse_mode=None):
content = document.file.read()
self.documents.append(SimpleNamespace(
chat_id=chat_id, file_name=document.file_name, content=content, caption=caption, reply_markup=reply_markup,
))
self.sent.append(SimpleNamespace(chat_id=chat_id, text=caption, reply_markup=reply_markup, parse_mode=parse_mode))
return SimpleNamespace(message_id=len(self.sent))
def send_message(self, chat_id, text, reply_markup=None, parse_mode=None):
self.sent.append(SimpleNamespace(chat_id=chat_id, text=text, reply_markup=reply_markup, parse_mode=parse_mode))
@@ -51,6 +72,19 @@ def message_update_json(update_id: int, text: str, chat_type: str = 'private', u
}
def document_update_json(
update_id: int, file_id: str, file_name: str, size: int, caption: str = '', user_id: int = USER_ID,
) -> dict:
"""Обновление с документом в том виде, в каком его присылает Bot API."""
update = message_update_json(update_id, '', user_id=user_id)
message = update['message']
del message['text']
message['document'] = {'file_id': file_id, 'file_unique_id': file_id, 'file_name': file_name, 'file_size': size}
if caption:
message['caption'] = caption
return update
class Conversation:
"""Диалог пользователя с ботом: say() отправляет сообщение и возвращает ответ бота."""
@@ -68,3 +102,13 @@ class Conversation:
sent_before = len(self.api.sent)
self._handle_update(self.api, self.bot, message_update(self._update_id, text, user_id=self.user_id))
return self.api.last_text if len(self.api.sent) > sent_before else None
def send_document(self, file_name: str, content: bytes, caption: str = '') -> str | None:
"""Присылает боту документ и возвращает ответ бота."""
self._update_id += 1
file_id = f'file{self._update_id}'
self.api.files[file_id] = content
sent_before = len(self.api.sent)
update = document_update_json(self._update_id, file_id, file_name, len(content), caption, self.user_id)
self._handle_update(self.api, self.bot, types.Update.de_json(update))
return self.api.last_text if len(self.api.sent) > sent_before else None
+68 -1
View File
@@ -5,11 +5,12 @@ from unittest import mock
from django.test import TestCase, override_settings
from telebot import types
from . import api as bot_api
from . import interfaces
from .dispatcher import handle_update
from .interfaces import Interface, command, register, state, text
from .models import Bot, Chat, Message, User
from .testing import FakeApi, message_update, message_update_json
from .testing import Conversation, FakeApi, message_update, message_update_json
@register
@@ -42,6 +43,22 @@ class EchoInterface(Interface):
ctx.state = ''
ctx.reply(f'hi {ctx.text}')
@text('upload')
def ask_file(self, ctx):
ctx.state = 'await_file'
ctx.reply('file?')
@state('await_file')
def file(self, ctx):
if ctx.document is None:
return ctx.reply('not a file')
try:
content = ctx.download_document(max_size=10)
except bot_api.FileTooLarge:
return ctx.reply('too large')
ctx.state = ''
ctx.reply_document(f'echo-{ctx.document.name}', content, caption=f'got {ctx.text}')
def fallback(self, ctx):
ctx.reply(f'echo {ctx.text}')
@@ -128,6 +145,56 @@ class DispatcherTests(TestCase):
self.assertEqual(Chat.objects.count(), 1)
class DocumentTests(TestCase):
def setUp(self):
self.bot = Bot.objects.create(token='1:test', interface=interfaces.key(EchoInterface))
self.conversation = Conversation(self.bot)
self.conversation.say('upload')
def check_echo(self):
self.assertEqual(self.conversation.send_document('a.zip', b'zip', caption='caption'), 'got caption')
document = self.conversation.api.documents[-1]
self.assertEqual((document.file_name, document.content), ('echo-a.zip', b'zip'))
self.assertEqual(
list(Message.objects.order_by('pk').values_list('direction', 'text'))[-2:],
[('in', 'caption'), ('out', 'got caption')],
)
@override_settings(TELEGRAM_API_URL='')
def test_cloud_document(self):
self.check_echo()
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081')
def test_own_server_document(self):
self.check_echo()
def test_too_large(self):
self.assertEqual(self.conversation.send_document('big.zip', b'x' * 11), 'too large')
self.assertEqual(Message.objects.filter(direction='in').order_by('pk').last().text, 'big.zip')
def test_text_in_file_state(self):
self.assertEqual(self.conversation.say('hello'), 'not a file')
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081')
def test_own_server_rejects_foreign_path(self):
api = mock.Mock()
api.get_file.return_value = SimpleNamespace(file_path='/etc/passwd', file_size=1)
with self.assertRaises(bot_api.ApiError):
bot_api.download_file(api, 'id', 10)
api.download_file.assert_not_called()
@override_settings(TELEGRAM_API_URL='http://127.0.0.1:8081', TELEGRAM_FILES_URL='http://127.0.0.1:8083/')
def test_own_server_file_url(self):
from telebot import apihelper
with mock.patch.object(apihelper, 'API_URL', None), mock.patch.object(apihelper, 'FILE_URL', None):
bot_api.configure_api_server()
self.assertEqual(
apihelper.FILE_URL.format('1:t', '/1:t/documents/file_0.zip'),
'http://127.0.0.1:8083/1:t/documents/file_0.zip',
)
@override_settings(TELEGRAM_WEBHOOK_URL='http://localhost:8000/')
class PublishTests(TestCase):
def setUp(self):
+40 -1
View File
@@ -54,7 +54,7 @@ server.release(generate_password())
Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны.
- `bootstrap` - проверяет Debian 12 или 13, прописывает ключ, меняет пароль root, обновляет систему (если что-то обновилось - перезагружает в конце), включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ.
- `release` - ставит новый пароль root, сбрасывает nftables, убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
- `release` - ставит новый пароль root, возвращает конфиг nftables Debian по умолчанию (все разрешено, nftables остается включенным, Docker перезапускается, чтобы вернуть свои правила), убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
- `facts` - `uname -a` и версия ОС.
Специализации - наследники `Server`:
@@ -65,6 +65,39 @@ server.release(generate_password())
- `XuiPanel(host).create_inbound(remark='main', target='')` - подключение vless + xhttp + reality на 443, uTLS firefox, сброс трафика ежемесячно. Цель reality подбирает сканер панели (TLS 1.3, h2, X25519, валидный сертификат, минимальная задержка) из `target` или из своих кандидатов. Ключи x25519 генерирует панель, shortId и путь xhttp - serverus. Существующее подключение с тем же `remark` не трогает. Возвращает `data`: `inbound_id`, `target`, `server_name`, `public_key`, `short_ids`, `path`.
- `RedirectProxy(host).redirect(target_ip, ports)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip> <ports>`, который через nftables перенаправляет tcp-порты `ports` на те же порты target_ip с masquerade и MSS clamping. Точка передает 80, 443, порт панели и порт подписки; ssh остается на самом сервере, скрипт откажется перенаправлять его порт. Правила - `/etc/nftables.d/serverus-redirect.nft`, подключаются include из `/etc/nftables.conf`. Заодно включает bbr, fq и увеличенные буферы и таблицу conntrack.
- `DockerHost(host).install(local_certs=False)` - после `bootstrap`: фаервол (ssh, 80, 443/tcp+udp, транзитный трафик пропускается - его фильтрует Docker), Docker из репозитория Docker, логи контейнеров с ротацией (`log-driver: local`), автоматические обновления безопасности (`unattended-upgrades`), папка приложений `APPS_DIR` (`/root/apps`), сеть `web` и встроенное приложение `INGRESS` (`caddy`, образ `CADDY_IMAGE` закреплен, обновлять вручную). `local_certs` - сертификаты собственного CA Caddy вместо Let's Encrypt, для стенда.
## Docker-приложения
Приложение - папка `/root/apps/<name>/`, compose-проект с именем `name`:
```
docker-compose.yaml COMPOSE_FILE, обязателен
.env ENV_FILE, секреты, на сервере 0600
caddy.conf CADDY_FILE, необязательно: блок Caddy для веб-морды
data/<сервис>/ DATA_DIR, настройки сервиса, имя папки = имя сервиса в compose
```
Сервисы ничего не пишут в папку приложения, состояние - только в named volumes. Поэтому папка целиком повторяет присланные файлы, лишние файлы удаляются. Порты публикует только `caddy`; остальные сервисы общаются через docker-сеть, веб-морды подключаются к внешней сети `web`, а их домены описываются в `caddy.conf`:
```caddyfile
app.example.com {
reverse_proxy site-web-1:8080
}
```
Caddy выпускает сертификат для каждого домена из блоков сам, домен должен указывать на сервер.
- `deploy(name, files)` - `files` - полный набор файлов `{путь: bytes}`. Compose-файл и `caddy.conf` (`caddy validate` на копии конфигурации Caddy с новым блоком: ошибки и дубли доменов) проверяются до изменений: ошибка - приложение не тронуто. Файлы обновляются по месту (тот же inode, смонтированный файл остается тем же файлом), затем `docker compose up -d --remove-orphans`: compose сам пересоздает сервисы, у которых изменилась конфигурация, в том числе из-за `.env`. Сервисы, у которых изменилось что-то в `data/<сервис>/`, пересоздаются отдельно. Изменился `caddy.conf` - Caddy перезагружается; не принял блок - прежний возвращается, `deploy` падает с ошибкой Caddy. Возвращает `data`: `changed`, `removed` (пути), `recreated`, `services`.
- `status()` - все приложения: сервисы из compose-файла, сверенные с `docker ps -a`. Сервис без контейнера - `missing`. `data`: `{'apps': {app: {'services': {service: {'state', 'health', 'status'}}, 'error': ''}}}`.
- `control(name, action, service='')` - `start` (`up -d`), `stop`, `restart`, `pull` (новые образы и `up -d`) приложения или одного сервиса.
- `logs(name, service='', lines=500)` - `data['logs']`, до 1 Мб.
- `remove(name, volumes=False)` - контейнеры, папка и блок Caddy; `volumes=True` - вместе с данными.
Контейнерам нужен `restart: unless-stopped`: при изменении конфига nftables перезапускается и стирает правила Docker, Docker перезапускается следом, чтобы их вернуть. Без изменений конфига nftables не перезапускается.
Скрипты на сервере (`playbooks/files/app_sync.py`, `app_status.py`) копируются в `/root/apps/.serverus/` на каждом запуске.
Порты и логины: `generate_port(exclude=...)` - порт сервиса из 10000-20000 (ssh - 20000-60000), `generate_username()` - 6 символов.
Плейбуки ходят в API панели с самого сервера на `127.0.0.1` со своим токеном `serverus`, который перевыпускается на каждом запуске. Токен `api_token`, отданный вызывающей стороне, при этом не меняется.
@@ -106,6 +139,9 @@ make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на па
make serverus_cert ip=<ip> [domain=<domain>] [ca=yes] # сертификат на домен или на ip
make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую
make serverus_release ip=<ip> # вернуть сервер
make serverus_docker ip=<ip> password=<root_password> [local_certs=yes] # bootstrap + Docker + ingress
make serverus_app ip=<ip> action=deploy name=<app> dir=<папка> # залить приложение из локальной папки
make serverus_app ip=<ip> action=status|logs|start|stop|restart|pull|remove [name=<app>] [service=<service>] [volumes=yes]
```
То же без Makefile: `python -m serverus xui <ip> <password> --out <dir>`, `python -m serverus key add <name> --out <dir>` и `python -m serverus release --out <dir>`.
@@ -134,3 +170,6 @@ make serverus_release ip=<ip> # вернуть се
- [x] `release` на локальном стенде
- [ ] проверить на сервере с публичным адресом: сертификат Let's Encrypt на домен и на ip (продление короткого сертификата по cron), `release`, Debian 12
- [x] выпуск и отзыв ключей в боте поверх `XuiApi`
- [x] `DockerHost`: плейбуки приложений проверены на локальном Docker (deploy, выборочное пересоздание, отказ на битом compose, откат блока Caddy, статус, логи, удаление)
- [x] `DockerHost` на стенде (Debian 13): установка, https через `local_certs`, выборочное пересоздание, отказ битого `caddy.conf` и дубля домена до изменений, удаление сервиса заливкой, `release` с работающими приложениями, повторный bootstrap сервера с Docker
- [ ] `DockerHost` на сервере с публичным адресом: сертификаты Let's Encrypt через Caddy
+12
View File
@@ -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',
+87 -1
View File
@@ -5,6 +5,9 @@
python -m serverus cert [<domain>] --out <dir> [--ca <dir>]
python -m serverus outbound [<proxy>] --out <dir>
python -m serverus release --out <dir> [--password <new_root_password>]
python -m serverus docker <ip> <root_password> --out <dir> [--local-certs]
python -m serverus app deploy <name> <app_dir> --out <dir>
python -m serverus app status|logs|start|stop|restart|pull|remove [<name>] --out <dir> [--service <service>] [--volumes]
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
bootstrap меняет пароль и порт ssh, без этого файла доступ к серверу можно потерять.
@@ -15,7 +18,7 @@ import sys
from pathlib import Path
from . import (
Host, LocalCA, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
DockerHost, Host, LocalCA, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
generate_ssh_port, generate_username,
)
@@ -53,7 +56,25 @@ def main(argv=None) -> int:
release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
release.add_argument('--password', default='', help='новый пароль root, без него случайный')
docker = commands.add_parser('docker', help='bootstrap свежего сервера и установка Docker с ingress Caddy')
docker.add_argument('address')
docker.add_argument('password', help='текущий пароль root')
docker.add_argument('--out', type=Path, required=True, help='папка для доступов к серверу')
docker.add_argument('--local-certs', action='store_true', help='сертификаты своего CA Caddy: для стенда без публичного адреса')
app = commands.add_parser('app', help='приложение на сервере от команды docker')
app.add_argument('action', choices=['deploy', 'status', 'logs', 'start', 'stop', 'restart', 'pull', 'remove'])
app.add_argument('name', nargs='?', default='', help='имя приложения, для status не нужно')
app.add_argument('app_dir', nargs='?', type=Path, help='для deploy: локальная папка приложения')
app.add_argument('--out', type=Path, required=True, help='папка с доступами от команды docker')
app.add_argument('--service', default='', help='один сервис приложения')
app.add_argument('--volumes', action='store_true', help='remove: удалить и данные')
args = parser.parse_args(argv)
if args.command == 'docker':
return install_docker(args.address, args.password, args.out, args.local_certs)
if args.command == 'app':
return manage_app(args.action, args.name, args.app_dir, args.out, args.service, args.volumes)
if args.command == 'xui':
return install_xui(args.address, args.password, args.out, args.domain, not args.no_ssl, args.ca)
if args.command == 'key':
@@ -133,6 +154,71 @@ def install_panel(access: dict, out: Path, ca_dir: Path | None) -> int:
return 0
def install_docker(address: str, password: str, out: Path, local_certs: bool) -> int:
access_file = out / ACCESS_FILE
access = json.loads(access_file.read_text()) if access_file.exists() else {}
if access.get('bootstrapped'):
# повторный запуск после ошибки установки: сервер уже на ключе и новом порту
print('bootstrap: skipped, already done')
else:
keys = generate_keypair()
access = {
'address': address,
'ssh_port': generate_ssh_port(),
'root_password': generate_password(),
'private_key': keys.private_key,
'known_hosts': '',
}
save(out, access)
result = Server(Host(address, private_key=keys.private_key)).bootstrap(
password, keys.public_key, access['root_password'], access['ssh_port'],
)
access['known_hosts'] = result.known_hosts
access['bootstrapped'] = result.ok
save(out, access)
report('bootstrap', result)
if not result.ok:
return 1
result = DockerHost(load_host(access)).install(local_certs=local_certs)
report('docker', result)
if not result.ok:
return 1
print(f"ssh: ssh -i {out / KEY_FILE} -p {access['ssh_port']} root@{access['address']}")
return 0
def manage_app(action: str, name: str, app_dir: Path | None, out: Path, service: str, volumes: bool) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
docker = DockerHost(load_host(access))
try:
if action == 'deploy':
if app_dir is None or not app_dir.is_dir():
print('deploy: укажите локальную папку приложения', file=sys.stderr)
return 1
files = {
path.relative_to(app_dir).as_posix(): path.read_bytes()
for path in sorted(app_dir.rglob('*')) if path.is_file()
}
result = docker.deploy(name, files)
elif action == 'status':
result = docker.status()
elif action == 'logs':
result = docker.logs(name, service)
elif action == 'remove':
result = docker.remove(name, volumes)
else:
result = docker.control(name, action, service)
except ValueError as error:
print(error, file=sys.stderr)
return 1
report(action, result)
if action == 'logs':
print(result.data.get('logs', ''))
elif result.data:
print(json.dumps(result.data, indent=2, ensure_ascii=False))
return 0 if result.ok else 1
def manage_key(action: str, name: str, out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
api = XuiApi(panel_url(access), access['api_token'], ca_file=access.get('ca_file') or None)
+127
View File
@@ -0,0 +1,127 @@
import io
import re
import tarfile
import tempfile
from collections.abc import Mapping
from pathlib import Path, PurePosixPath
from .runner import Result
from .server import Server
CADDY_IMAGE = 'caddy:2.10.2-alpine'
"""Образ Caddy на docker-сервере. Обновлять вручную после проверки."""
INGRESS = 'caddy'
"""Встроенное приложение: единственное, которое публикует порты (80, 443). Заливать и удалять его нельзя."""
APPS_DIR = '/root/apps'
COMPOSE_FILE = 'docker-compose.yaml'
CADDY_FILE = 'caddy.conf'
"""Блок Caddy приложения с веб-мордой: лежит в корне приложения, на сервере подключается к ingress."""
DATA_DIR = 'data'
"""Настройки сервисов: data/<сервис>/. Изменения в них пересоздают только этот сервис."""
ENV_FILE = '.env'
APP_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
SERVICE_NAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$')
ACTIONS = ('start', 'stop', 'restart', 'pull')
LOG_LINES_MAX = 5000
class DockerHost(Server):
"""Сервер для docker-приложений.
Приложение - папка APPS_DIR/<name>/ (compose-проект с именем name): COMPOSE_FILE, ENV_FILE, CADDY_FILE (необязательно)
и DATA_DIR/<сервис>/ с настройками сервисов. Сервисы ничего не пишут в папку приложения, состояние - в named volumes,
поэтому папка целиком повторяет присланные файлы.
"""
def install(self, local_certs: bool = False) -> Result:
"""Ставит Docker и ingress на сервер после bootstrap.
Открывает 80 и 443 (tcp, udp для http/3), логи контейнеров ротируются, обновления безопасности ставятся сами.
Создает APPS_DIR, сеть web и приложение INGRESS: Caddy, который подключает caddy.conf приложений и сам выпускает
для их доменов сертификаты Let's Encrypt. local_certs - сертификаты от собственного CA Caddy, для стенда без
публичного адреса.
"""
return self._run('docker', caddy_image=CADDY_IMAGE, local_certs=local_certs)
def deploy(self, name: str, files: Mapping[str, bytes]) -> Result:
"""Приводит приложение name к files ({путь: содержимое}, полный набор файлов) и запускает его.
Сначала проверяет compose-файл: ошибка - на сервере ничего не изменилось. Затем обновляет файлы по месту,
лишние удаляет, делает docker compose up -d --remove-orphans (compose сам пересоздает сервисы с изменившейся
конфигурацией) и пересоздает сервисы, у которых изменилось что-то в DATA_DIR/<сервис>/. Изменился CADDY_FILE -
перезагружает Caddy; не принял - прежний блок возвращается, deploy завершается ошибкой.
Возвращает data: {'changed': [...], 'removed': [...], 'recreated': [...], 'services': [...]}.
"""
_check_app(name)
if name == INGRESS:
raise ValueError(f'{INGRESS} is managed by install')
paths = [_check_path(path) for path in files]
if COMPOSE_FILE not in paths:
raise ValueError(f'{COMPOSE_FILE} is required')
with tempfile.TemporaryDirectory(prefix='serverus-app-') as directory:
archive = Path(directory) / f'{name}.tar.gz'
archive.write_bytes(_pack(dict(zip(paths, files.values()))))
return self._run('app_deploy', app=name, archive=str(archive))
def status(self) -> Result:
"""Состояние всех приложений: сервисы из compose-файла, сверенные с docker ps -a.
Возвращает data: {'apps': {app: {'services': {service: {'state', 'health', 'status'}}, 'error': ''}}}.
state - running, exited, restarting... или missing, если контейнера сервиса нет.
error - текст, если compose-файл приложения не читается.
"""
return self._run('app_status')
def control(self, name: str, action: str, service: str = '') -> Result:
"""start (up -d), stop, restart или pull (новые образы и up -d) приложения или одного его сервиса."""
_check_app(name)
if action not in ACTIONS:
raise ValueError(f'action must be one of {ACTIONS}')
return self._run('app_control', app=name, action=action, service=_check_service(service))
def logs(self, name: str, service: str = '', lines: int = 500) -> Result:
"""Последние lines строк логов приложения или одного сервиса. Возвращает data: {'logs': текст}."""
_check_app(name)
lines = max(1, min(int(lines), LOG_LINES_MAX))
return self._run('app_logs', app=name, service=_check_service(service), lines=lines)
def remove(self, name: str, volumes: bool = False) -> Result:
"""Останавливает и удаляет приложение: контейнеры, папку и блок Caddy. volumes=True - вместе с данными."""
_check_app(name)
if name == INGRESS:
raise ValueError(f'{INGRESS} is managed by install')
return self._run('app_remove', app=name, volumes=volumes)
def _run(self, playbook: str, **extravars) -> Result:
return self.run(playbook, apps_dir=APPS_DIR, **extravars)
def _check_app(name: str):
if not APP_NAME_RE.match(name):
raise ValueError('app name: a-z, 0-9, - and _, up to 64 characters')
def _check_service(name: str) -> str:
if name and not SERVICE_NAME_RE.match(name):
raise ValueError('service name: a-z, A-Z, 0-9, ., - and _, up to 64 characters')
return name
def _check_path(path: str) -> str:
"""Относительный путь внутри приложения без . и .."""
pure = PurePosixPath(path)
if not path or pure.is_absolute() or any(part in ('', '.', '..') for part in path.split('/')):
raise ValueError(f'bad path: {path!r}')
return str(pure)
def _pack(files: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode='w:gz') as tar:
for path, content in sorted(files.items()):
info = tarfile.TarInfo(path)
info.size = len(content)
info.mode = 0o600 if PurePosixPath(path).name == ENV_FILE else 0o644
tar.addfile(info, io.BytesIO(content))
return buffer.getvalue()
+29
View File
@@ -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] }}"
+177
View File
@@ -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 }}"
+21
View File
@@ -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:] }}"
+27
View File
@@ -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
+16
View File
@@ -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 }}"
+135
View File
@@ -0,0 +1,135 @@
# extravars: apps_dir, caddy_image, local_certs
#
# Docker из репозитория Docker, логи контейнеров с ротацией, автоматические обновления безопасности, папка приложений,
# сеть web и ingress: приложение caddy, единственное, которое публикует порты. Caddy подключает блоки приложений
# из data/caddy/sites/ и сам выпускает сертификаты для их доменов. local_certs - сертификаты своего CA Caddy.
# Фаервол настраивается первым: nftables при старте стирает все правила, Docker ставит свои поверх.
- name: Install Docker platform
hosts: target
gather_facts: true
vars:
caddy_dir: "{{ apps_dir }}/caddy"
tasks:
- ansible.builtin.import_tasks: tasks/firewall.yml
vars:
firewall_tcp_ports: [ "{{ ansible_port }}", 80, 443 ]
firewall_udp_ports: [ 443 ]
# трафик контейнеров идет через forward: его фильтрует Docker
firewall_forward: true
- name: Install prerequisites
ansible.builtin.apt:
name: [ ca-certificates, curl, python3, unattended-upgrades ]
state: present
update_cache: true
- name: Enable automatic security updates
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
- name: Create keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
- name: Download Docker key
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Add Docker repository
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/docker.sources
mode: "0644"
content: |
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: {{ ansible_facts.distribution_release }}
Components: stable
Architectures: {{ 'arm64' if ansible_facts.architecture == 'aarch64' else 'amd64' }}
Signed-By: /etc/apt/keyrings/docker.asc
- name: Create Docker config directory
ansible.builtin.file:
path: /etc/docker
state: directory
mode: "0755"
- name: Configure Docker daemon
ansible.builtin.copy:
dest: /etc/docker/daemon.json
mode: "0644"
content: |
{
"log-driver": "local",
"log-opts": {"max-size": "10m", "max-file": "3"}
}
register: docker_daemon
- name: Install Docker
ansible.builtin.apt:
name: [ docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin ]
state: present
update_cache: true
# после частых перезапусков (фаервол, release) systemd может держать Docker остановленным по лимиту запусков
- name: Reset Docker start limit
ansible.builtin.command: systemctl reset-failed docker.service
changed_when: false
failed_when: false
- name: Start Docker
ansible.builtin.systemd_service:
name: docker
enabled: true
state: "{{ 'restarted' if docker_daemon.changed else 'started' }}"
- name: Create apps directory
ansible.builtin.file:
path: "{{ apps_dir }}"
state: directory
mode: "0700"
- name: Create web network
ansible.builtin.shell: docker network inspect web >/dev/null 2>&1 || { docker network create web && echo created; }
register: web_network
changed_when: "'created' in web_network.stdout"
- name: Create ingress directories
ansible.builtin.file:
path: "{{ caddy_dir }}/data/caddy/sites"
state: directory
mode: "0700"
- name: Write ingress compose file
ansible.builtin.template:
src: templates/caddy-compose.yaml.j2
dest: "{{ caddy_dir }}/docker-compose.yaml"
mode: "0600"
# Caddyfile смонтирован папкой: файл, замененный целиком, контейнер увидел бы только после пересоздания
- name: Write Caddyfile
ansible.builtin.template:
src: templates/Caddyfile.j2
dest: "{{ caddy_dir }}/data/caddy/Caddyfile"
mode: "0644"
register: caddyfile
- name: Start ingress
ansible.builtin.command: docker compose up -d --remove-orphans
args:
chdir: "{{ caddy_dir }}"
register: caddy_up
changed_when: "'Started' in caddy_up.stderr or 'Recreated' in caddy_up.stderr"
- name: Reload Caddyfile
ansible.builtin.command: docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
args:
chdir: "{{ caddy_dir }}"
when: caddyfile.changed and not caddy_up.changed
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Состояние docker-приложений: app_status.py <apps_dir> [<app>].
Сервисы берутся из compose-файла и сверяются с docker compose ps -a: описан, но контейнера нет - state missing.
Контейнеры, которых нет в compose-файле (остались от прошлой версии), тоже попадают в список.
Печатает JSON: {"apps": {app: {"services": {service: {"state", "health", "status"}}, "error": ""}}}.
"""
import json
import os
import subprocess
import sys
COMPOSE_FILE = 'docker-compose.yaml'
def compose(app_dir, *args):
result = subprocess.run(
['docker', 'compose', *args], cwd=app_dir, capture_output=True, text=True, timeout=120,
)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout).strip()[-500:])
return result.stdout
def containers(output):
"""ps --format json: старый compose печатает массив, новый - объект на строку."""
output = output.strip()
if not output:
return []
if output.startswith('['):
return json.loads(output)
return [json.loads(line) for line in output.splitlines() if line.strip()]
def app_status(app_dir):
services = {
name: {'state': 'missing', 'health': '', 'status': ''}
for name in compose(app_dir, 'config', '--services').split()
}
for container in containers(compose(app_dir, 'ps', '-a', '--format', 'json')):
state = {
'state': container.get('State', ''),
'health': container.get('Health', ''),
'status': container.get('Status', ''),
}
name = container.get('Service', '')
# несколько реплик: показываем проблемную
if services.get(name, {}).get('state') in (None, 'missing', 'running'):
services[name] = state
return services
def main(apps_dir, only=''):
apps = {}
names = [only] if only else sorted(os.listdir(apps_dir)) if os.path.isdir(apps_dir) else []
for name in names:
app_dir = os.path.join(apps_dir, name)
if name.startswith('.') or not os.path.isfile(os.path.join(app_dir, COMPOSE_FILE)):
continue
try:
apps[name] = {'services': app_status(app_dir), 'error': ''}
except (RuntimeError, ValueError, subprocess.TimeoutExpired) as error:
apps[name] = {'services': {}, 'error': str(error)}
print(json.dumps({'apps': apps}))
if __name__ == '__main__':
main(*sys.argv[1:])
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Приводит папку приложения к присланной копии: app_sync.py <stage> <app_dir>.
Файлы переписываются по месту (тот же inode): смонтированный в контейнер файл остается тем же файлом.
Лишние файлы удаляются, пустые папки за ними тоже.
Печатает JSON: {"changed": [...], "removed": [...], "created": true, если папки приложения не было}.
"""
import json
import os
import shutil
import stat
import sys
def files(root):
result = set()
for directory, _, names in os.walk(root):
for name in names:
result.add(os.path.relpath(os.path.join(directory, name), root))
return result
def same(src, dst):
if not os.path.isfile(dst) or os.path.islink(dst):
return False
if stat.S_IMODE(os.stat(src).st_mode) != stat.S_IMODE(os.stat(dst).st_mode):
return False
with open(src, 'rb') as a, open(dst, 'rb') as b:
return a.read() == b.read()
def clear(path):
"""Убирает то, что мешает положить файл: папку или ссылку на его месте."""
if os.path.islink(path) or (os.path.exists(path) and not os.path.isdir(path)):
os.remove(path)
elif os.path.isdir(path):
shutil.rmtree(path)
def ensure_dir(app_dir, relative):
"""Создает папки до файла; файл или ссылка на месте папки удаляется."""
current = app_dir
for part in relative.split(os.sep)[:-1]:
current = os.path.join(current, part)
if os.path.islink(current) or (os.path.exists(current) and not os.path.isdir(current)):
os.remove(current)
os.makedirs(current, exist_ok=True)
def main(stage, app_dir):
created = not os.path.isdir(app_dir)
os.makedirs(app_dir, mode=0o700, exist_ok=True)
new, old = files(stage), files(app_dir)
changed, removed = [], []
for relative in sorted(old - new):
os.remove(os.path.join(app_dir, relative))
removed.append(relative)
for relative in sorted(new):
src, dst = os.path.join(stage, relative), os.path.join(app_dir, relative)
if same(src, dst):
continue
ensure_dir(app_dir, relative)
if os.path.isdir(dst) or os.path.islink(dst):
clear(dst)
with open(src, 'rb') as source, open(dst, 'wb') as target:
shutil.copyfileobj(source, target)
os.chmod(dst, stat.S_IMODE(os.stat(src).st_mode))
changed.append(relative)
for directory, _, _ in os.walk(app_dir, topdown=False):
if directory != app_dir and not os.listdir(directory):
os.rmdir(directory)
print(json.dumps({'changed': changed, 'removed': removed, 'created': created}))
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])
+24 -3
View File
@@ -5,19 +5,40 @@
tasks:
- ansible.builtin.import_tasks: tasks/root_password.yml
# конфиг Debian по умолчанию: все разрешено. nftables остается включенным
- name: Restore default nftables config
ansible.builtin.copy:
dest: /etc/nftables.conf
mode: "0755"
content: |
#!/usr/sbin/nft -f
flush ruleset
- name: Flush and disable nftables
table inet filter {
chain input {
type filter hook input priority filter;
}
chain forward {
type filter hook forward priority filter;
}
chain output {
type filter hook output priority filter;
}
}
- name: Apply default nftables rules
ansible.builtin.systemd_service:
name: nftables
enabled: false
state: stopped
state: restarted
# flush ruleset стер и правила Docker, приложения на сервере остаются работать
- name: Restore Docker rules
ansible.builtin.shell: systemctl reset-failed docker.service 2>/dev/null; systemctl try-restart docker
register: docker_restart
changed_when: false
# rc 5 - Docker не установлен
failed_when: docker_restart.rc not in [0, 5]
- name: Remove traffic redirect
ansible.builtin.file:
@@ -0,0 +1,13 @@
# Скрипты приложений на сервере: копируются на каждом запуске, чтобы соответствовать версии serverus
- name: Create serverus scripts directory
ansible.builtin.file:
path: "{{ apps_dir }}/.serverus"
state: directory
mode: "0700"
- name: Install app scripts
ansible.builtin.copy:
src: "files/{{ item }}"
dest: "{{ apps_dir }}/.serverus/{{ item }}"
mode: "0755"
loop: [ app_sync.py, app_status.py ]
@@ -0,0 +1,4 @@
- name: Reload Caddy
ansible.builtin.command: docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
args:
chdir: "{{ apps_dir }}/caddy"
+15 -1
View File
@@ -1,4 +1,8 @@
# vars: firewall_tcp_ports, firewall_udp_ports (optional), firewall_forward (optional, пропускать транзитный трафик)
#
# Конфиг начинается с flush ruleset: при загрузке nftables стартует раньше Docker, и Docker добавляет свои правила
# поверх. Перезапуск nftables на работающем сервере стирает правила Docker, поэтому nftables перезапускается только
# при изменении конфига, а Docker следом восстанавливает свои правила (контейнеры с restart поднимутся сами).
- name: Install nftables
ansible.builtin.apt:
name: nftables
@@ -10,9 +14,19 @@
dest: /etc/nftables.conf
mode: "0755"
validate: /usr/sbin/nft -c -f %s
register: firewall_config
- name: Apply nftables
ansible.builtin.systemd_service:
name: nftables
enabled: true
state: restarted
state: "{{ 'restarted' if firewall_config.changed else 'started' }}"
# bootstrap меняет конфиг дважды подряд: reset-failed сбрасывает счетчик запусков, иначе systemd откажет по лимиту
- name: Restore Docker rules
ansible.builtin.shell: systemctl reset-failed docker.service 2>/dev/null; systemctl try-restart docker
when: firewall_config.changed
register: docker_restart
changed_when: false
# rc 5 - Docker не установлен
failed_when: docker_restart.rc not in [0, 5]
@@ -0,0 +1,9 @@
# {{ ansible_managed }}
{% if local_certs | bool %}
{
# стенд без публичного адреса: сертификаты от собственного CA Caddy вместо Let's Encrypt
local_certs
}
{% endif %}
import sites/*.caddy
@@ -0,0 +1,23 @@
# {{ ansible_managed }}
# ingress: единственное приложение, которое публикует порты. Блоки приложений - data/caddy/sites/<app>.caddy
services:
caddy:
image: {{ caddy_image }}
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./data/caddy:/etc/caddy:ro
- data:/data
- config:/config
networks: [ web ]
networks:
web:
external: true
volumes:
data:
config:
+171 -1
View File
@@ -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