Начало работы
Нужен Python 3.11 или новее. Для разработки хватит SQLite, в работе — PostgreSQL.
python3 -m venv .venv && . .venv/bin/activate
# полная панель: HTTP-слой и вход, разделы, база и один драйвер
pip install "keepup-admin[panel,postgres]" uvicorn
Возможности ставятся extras: один экстра — один дистрибутив. База — только конструктор: ядро, каталог плагинов, реестр служб, жизненный цикл и шов транспорта. Она не объявляет ни одной чужой библиотеки, даже FastAPI; каждая возможность объявляет свои.
| Дистрибутив | Что даёт |
|---|---|
keepup-admin | Ядро: дескрипторы, каталог, реестр служб, жизненный цикл, настройки, роли, блокировки, шина, кэш. |
keepup-db | DatabaseManagerV2, tables, retention, SQLAlchemy. |
keepup-auth | Вход, сессии панели, роли, OIDC, чужой провайдер личности. |
keepup-users | Таблицы учётных записей, ролей и прав. |
keepup-ui | Темы панели. |
keepup-audit | Аудит входящих запросов, события, след администратора. |
keepup-metrics | Сборщик, /metrics и хранение снимков. |
keepup-integration-log | Исходящие вызовы во внешние системы. |
keepup-postgres | Драйвер PostgreSQL и шина LISTEN/NOTIFY. |
keepup-sqlite | Драйвер SQLite и шина «в своём процессе». |
keepup-tasks | Планировщик и службы locks и scheduler. |
keepup-modules | Каталог разделов, выдачи ролей, решение о плагинах. |
keepup-cluster | Реестр реплик и команды им. |
keepup-log-shipping | Доставка журнала сборщику. |
keepup-http | create_app, оболочка панели, маршруты, FastAPI и Starlette. |
Экстра panel ставит всё, что нужно панели, кроме драйвера; драйвер выбирают отдельно — postgres в работе, sqlite для разработки. Два драйвера одной обязательной службы останавливают старт намеренно.
Приложение — это файл, который передаёт настройки и получает ASGI-приложение. Таблицы, объявленные плагинами (get_declared_tables()), ядро создаёт само при старте; первые учётные записи создаёт keepup-auth:
import os
from keepup import KeepupSettings, create_app
PLUGINS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plugins")
app = create_app(KeepupSettings(
title="Your product",
project_name="yours",
plugins_dir=PLUGINS_DIR,
static_mounts=(),
))
config/modules.json
{"plugins": [], "roles": []}
panel. keepup-http отдаёт оболочку панели из shell_mount — она лежит внутри пакета, копировать её к себе не нужно. keepup-auth регистрирует вход, и корень / отвечает без клиентской страницы приложения. static_mounts=() нужен только тогда, когда у приложения нет своего каталога static/: иначе create_app падает на монтировании несуществующего каталога.Два значения задаёт тот, кто разворачивает, а не код:
export SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')"
export ADMIN_INITIAL_PASSWORD="придумайте-свой"
uvicorn main:app --port 8000
SECRET_KEY приложение не стартует. Ключ подписи сессий обязан быть не короче 32 символов, и заведомые заглушки отвергаются. Если ADMIN_INITIAL_PASSWORD не задан, пароль учётной записи admin генерируется случайно и печатается в журнал запуска один раз.
Что после этого отвечает:
/selfcare— панель администрирования: вход подadmin, восемь разделов; оболочку и разделы ставитkeepup-http./api/docsи/api/redoc— описание API./api/health— здоровье процесса.
База по умолчанию — SQLite в data/keepup_db относительно рабочего каталога.
Проверки
Дерево для разработки ставится из репозитория: pip install -e ".[test]" -e packages/keepup-*. Одна команда прогоняет всё: .github/scripts/check.sh — линт, набор с покрытием, выпуск и граф, аудит безопасности и сборку всех колёс; --fast пропускает колёса и аудит. Тесты в дереве называются *_tests.py и запускаются по пути: python -m pytest tests/your_tests.py -v.
Раскладка проекта
Каркас читает данные относительно рабочего каталога процесса, а код плагинов — из каталога, который назовёт приложение. Поэтому запускайте процесс из корня своего приложения:
yours/
├── main.py # KeepupSettings -> app
├── plugins/
│ └── hello.py # class HelloPlugin(BasePlugin)
├── config/
│ ├── modules.json # плагины, разделы панели, роли
│ ├── auth.yaml # provider: local (необязателен)
│ └── postgres.properties # где база (необязателен)
└── static/ # свой фронтенд, если он есть
Каталог плагинов вычисляйте от расположения файла, а не от рабочего каталога — тогда плагины находятся, откуда бы ни запустили процесс:
import os
PLUGINS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plugins")
# Дальше каталог называет настройка: plugins_dir=PLUGINS_DIR.
Два приложения на keepup в одном каталоге — ошибка, а не беспорядок: при старте каталог разделов панели дополняется из config/modules.json, и второе приложение получило бы разделы первого.
KeepupSettings
Единственная точка настройки. Всё, чего каркас не может знать сам, приходит сюда; значения по умолчанию закрывают, а не открывают.
| Поле | По умолчанию | Что задаёт |
|---|---|---|
title | "KeepUP" | Имя приложения и заголовок описания API. |
project_name | "keepup" | Имя проекта в журналах. |
plugins_dir | None | Каталог, из которого ядро грузит плагины. |
plugins_config_path | "config/modules.json" | Объявление плагинов, разделов и ролей. |
plugin_manager | None | Свой экземпляр PluginManager, если приложение собрало его само. |
profile | None | Профиль — заплатка поверх каталога: "panel" или "metrics-only". |
database_dialect | None | Какой драйвер запускать: "postgresql" или "sqlite". |
cors_origins | () | Источники, которым можно читать ответы. Пусто — никому. |
metrics_public | False | Отдавать ли /metrics без учётных данных. |
security_headers | True | Заголовки безопасности на каждом ответе. |
content_security_policy | Политика каркаса | Политика содержимого для панели; None не отправляет её. |
csp_report_only | False | Отправлять политику как отчёт, ничего не запрещая. |
max_upload_bytes | None | Предел для маршрута, который читает тело сам; None — решает маршрут. |
max_json_bytes | 2 МиБ | Предел тела, которое разбирает каркас; None — без предела. |
performance | пустой объект | Пул соединений, буфер аудита и интервал метрик одним объектом. |
docs_url, redoc_url | "/api/docs", "/api/redoc" | Где описание API; None убирает. |
openapi_url | "/openapi.json" | Где отдаётся схема OpenAPI; None убирает её. |
openapi_public | False | Отвечать ли схему кому угодно, а не только администратору. |
static_dir, static_mounts | "static", семь точек | Своя статика; StaticMount(path, directory, name). |
shell_mount | "/keepup-static" | Где отдаётся оболочка панели из keepup-http. |
client_page | "main.html" | Страница, которой отвечает /. |
built_in_themes | () | Темы приложения: (name, page_file, brand, logo). |
gated_pages | {} | Страница отдаётся, только пока работает названный плагин. |
audit_redaction | None | Что аудит пишет из тела запроса; по умолчанию — только имена полей. |
password_rule | None | (password, username) -> текст проблемы или None. |
oidc, oidc_account_policy | None | Вход через внешний провайдер и кого впускать. |
identity_provider | None | Чужая система личности, когда её настраивают кодом. |
notification_channel | None | Канал шины между репликами; без него шины нет. |
remote_log_url, remote_log_token | None | Сборщик логов и учётные данные к нему. |
public_config | None | Что показывать на экране до входа. |
record_login, pending_documents | None | Что делать после входа; какие документы пользователь ещё не принял. |
declared_event_types | None | Типы событий для фильтра в панели. |
on_startup, on_shutdown | None | Корутины (app) до плагинов и после них. |
profile — заплатка поверх каталога плагинов (plugins/builtin.json объявляет «panel» и «metrics-only»), а не второй формат настройки: один код служит полной панелью, воркером и процессом, который отвечает только /metrics. Профиль metrics-only заменил удалённую настройку disable_http_server. Настройки app_tables и extra_setup и модуль keepup.schema с его init_db() удалены в 0.5.0.
Плагины и маршруты
Возможность приложения — плагин: класс-наследник BasePlugin в своём файле. Каркас грузит его по объявлению в config/modules.json.
Имя класса выводится из id
Файл — plugins/<id>.py, класс — f"{id.capitalize()}Plugin": hello → HelloPlugin, integration_logs → Integration_logsPlugin. Ошибка в имени не роняет старт: плагин просто не загружается, а в журнале остаётся предупреждение.
Хуки
| Метод | Когда и зачем |
|---|---|
__init__(self, config=None) | Каркас передаёт один аргумент — config из объявления. Вызовите super().__init__(id, name, config). |
async initialize() -> bool | Свои таблицы, фоновые задачи, проверки. False или исключение — плагин не поднят, причина видна в панели. |
get_api_routes() -> list | Маршруты HTTP словарями. |
get_websocket_routes() -> list | Маршруты WebSocket; по умолчанию пусто. |
get_handlers() -> dict | Именованные функции для других плагинов: plugin_manager.get_plugin(id).get_handlers(). Наружу по HTTP не публикуются. |
async post_construct() | После полного старта сервера — для самопроверок, которым нужен живой HTTP. |
async cleanup() | При остановке. |
Маршрут — это словарь
Декораторов нет: каркас сам строит обёртку FastAPI для каждого маршрута, поэтому вход, разбор параметров и аудит у всех маршрутов одинаковы.
| Ключ | По умолчанию | Смысл |
|---|---|---|
path | — | Путь, с параметрами в фигурных скобках. |
methods | — | ["GET"], ["POST"] и т. д. |
handler | — | Корутина-обработчик. |
require_auth | True | Нужен ли вход; обработчик получает current_user. |
permission | нет | Право, которое обязан иметь вызывающий; проверяется до обработчика. |
audit | True | Писать ли запрос в аудит входящих; False — не писать. |
max_body_bytes | предел приложения | Наибольшее тело, которое принимает маршрут. |
response_media_type | JSON | Что отвечает маршрут, когда это не JSON. |
params | нет | Маска параметров: по имени type (str, int, float, bool), required, in, choices, min, max, max_length, pattern. |
is_upload | False | Обработчик получает сам запрос, а не разобранный JSON. |
raw_request | False | Обработчик получает Request и проверяет вход сам. |
include_in_schema | True | Показывать ли маршрут в описании API. |
Обработчик принимает current_user: dict и обычные именованные аргументы: параметры пути и строки запроса приходят по имени, тело записи — в request. Незнакомый параметр — ответ 400, а не 500: если у маршрута есть маска, решает она и приводит типы; если нет — сигнатура обработчика (обработчик с **kwargs принимает всё).
def get_api_routes(self):
return [{
"path": "/api/shops/{shop_id}/offers",
"methods": ["GET"],
"handler": self.list_offers,
"params": {
"shop_id": {"type": "int"},
"status": {"type": "str", "choices": ["active", "archived"]},
},
}]
async def list_offers(self, current_user: dict, shop_id: int, status: str = "active"):
...
Объявление и включение
{
"plugins": [
{"id": "hello", "name": "Hello", "enabled": true, "priority": 100,
"config": {"greeting": "hi"}}
],
"roles": [
{"name": "ADMIN", "modules": [], "plugins": ["hello"]}
]
}
Будет ли объявленный плагин работать, решается по порядку, от сильного к слабому: PLUGINS_DISABLE → PLUGINS_ENABLE → переключатель в панели → флаг enabled в файле. Меньший priority поднимается раньше. Список plugins у роли — это видимость в /api/plugins, а не включение.
Ядро
Плагин загружается конструктором — ядром. Плагин объявляет дескриптор: id, вид (required, optional, transport), что ему нужно (requires, wants) и что он даёт (provides, contributions). Из дескрипторов собирается каталог: собственный каталог каркаса (plugins/builtin.json), файл приложения (config/modules.json) и профиль (settings.profile) — каждый следующий слой правит предыдущий. Плагины публикуют службы в реестр (provides), а берут чужие через requires; жизненный цикл запускает их в две фазы — сперва те, чьи требования уже выполнены, затем остальные. Обязательный плагин и транспорт включаются самим фактом установки, необязательный — файлом, PLUGINS_ENABLE, профилем или администратором.
from keepup.kernel import create_runtime
runtime = create_runtime(settings) # Runtime, ещё не запущенный
Имена служб, которые плагин может требовать и публиковать, перечислены в doc/service-catalogue.md; контракт конструктора — в doc/plugin_constructor.md. Если приложение собрало менеджер плагинов само, настройка plugin_manager передаёт его ядру.
Панель
Панель живёт на /selfcare. Оболочка — страницы тем, скрипт и стили — едет с keepup-http и отдаётся из shell_mount; база фронтенд не несёт, копировать оболочку к себе не нужно.
Восемь разделов каркаса
Их и оболочку ставит keepup-http. При старте они заносятся в каталог и по умолчанию выданы роли ADMIN:
- Пользователи — учётные записи, роли, сессии.
- Разделы — каталог разделов панели и выдача их ролям.
- Кластер — реплики, их плагины и состояние; остановка, запуск, перезапуск.
- Метрики — процессор и память по репликам.
- Темы — оформление панели.
- События — журнал событий приложения.
- Фоновые задачи — задачи планировщика и распределённые блокировки.
- Журнал интеграций — исходящие вызовы во внешние системы.
Свой раздел
Раздел — это скрипт и, при желании, стиль, объявленные в modules[] того же modules.json, и строка в modules роли. Оболочка загружает скрипт и вызывает window[initFunction]().
{
"modules": [{
"id": "orders",
"name": "Orders",
"js": "/static/modules/js/orders.js",
"css": "/static/modules/css/orders.css",
"initFunction": "initOrdersModule",
"routePath": "/selfcare/modules/orders",
"sectionId": "orders-section",
"config": {}
}],
"roles": [{"name": "CLIENT", "modules": ["orders"], "plugins": []}]
}
Каталог живёт в базе; файл — затравка. При старте добавляются только разделы и выдачи, которых в базе ещё нет, — то, что администратор выключил в панели, так и остаётся выключенным.
Темы
Своих тем каркас не навязывает: темы — дистрибутив keepup-ui (keepup_ui.themes), приложение перечисляет их в built_in_themes кортежами (name, page_file, brand, logo). На пустой базе активной становится первая; на существующей остаётся выбор администратора.
База данных
Где база
Если задана переменная DB_TYPE, всё берётся из окружения: DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DB_PATH, DB_POOL_*. Если нет — из config/postgres.properties (ключи db.*); пароль и тогда можно передать через DB_PASSWORD. Без того и другого — SQLite.
export DB_TYPE=postgres DB_HOST=db.internal DB_NAME=yours DB_USER=yours
export DB_PASSWORD=…
Запросы
DatabaseManagerV2 — движок SQLAlchemy с пулом. Параметры только именованные.
from keepup_db import DatabaseManagerV2
rows = DatabaseManagerV2.execute(
"SELECT id, name FROM orders WHERE owner_id = :owner", {"owner": user_id})
with DatabaseManagerV2.get_session() as session: # commit или rollback сам
...
Таблица объявляется один раз
Миграций нет. Таблица — объявление SQLAlchemy Core для обоих диалектов; ensure_tables() при каждом старте создаёт недостающее: CREATE TABLE IF NOT EXISTS, недостающие столбцы, индексы. Новый столбец — это просто новый Column.
from sqlalchemy import Column, Integer, Text
from keepup_db import tables
orders = tables.table(
"orders",
Column("id", Integer, primary_key=True, autoincrement=True),
Column("owner_id", Integer, nullable=False),
Column("title", Text, nullable=False),
tables.foreign_key("owner_id", "users", ("id",), ondelete="CASCADE"),
)
tables.ensure_tables(orders)
Различия диалектов живут в объявлении (with_variant, ddl_if, tables.per_dialect(postgres=…, sqlite=…)). Столбец на чужой таблице — например, на users — добавляется через tables.ensure_columns("users", Column(…)), а не вторым объявлением.
Несколько реплик против одной базы
Приложение на keepup рассчитано на несколько процессов за балансировщиком. Всё согласование идёт через общую базу — реплики не знают адресов друг друга.
Распределённая блокировка
Фоновая работа, которая должна идти в одной реплике, берётся под блокировку в таблице distributed_locks. Держатель продлевает её, пока работает, поэтому работа может длиться сколько угодно; перехватывается только блокировка того, кто замолчал.
from keepup.locks import distributed_lock, with_distributed_lock
@with_distributed_lock("nightly_report", timeout=300)
async def nightly_report():
... # идёт в одной реплике; в остальных запуск пропускается
async def purge():
try:
async with distributed_lock("purge_journal", timeout=1):
...
except Exception:
pass # чистит другая реплика — этого достаточно
Декоратор при занятой блокировке пропускает запуск и возвращает None; контекстный менеджер бросает исключение.
Шина сообщений
Задайте notification_channel — и каркас поднимет шину на PostgreSQL LISTEN/NOTIFY до инициализации плагинов. deliver() отправляет конверт другим репликам и раздаёт его подписчикам своей; publish() — только другим.
from keepup import notification_bus
bus = notification_bus.get_notification_bus()
if bus is not None:
bus.subscribe(self.on_envelope) # в initialize() плагина
await notification_bus.deliver({"kind": "order_changed", "order_id": 42})
InProcessTransport): publish() возвращает True, однако сообщение не покидает процесс.Реестр кластера
Каждая реплика публикует себя в cluster_members и забирает адресованные ей команды из cluster_commands. Раздел «Кластер» панели показывает реплики и их плагины и умеет три команды: stop — процесс жив, но отвечает 503 на API и приостанавливает планировщик; start — обратно; restart — штатная остановка и перезапуск той же командной строки.
Фоновые задачи
Планировщик, который ставит keepup-tasks (keepup_tasks.scheduler), — один на процесс, его задачи видны в разделе «Фоновые задачи» и запускаются оттуда вручную. От запуска одной и той же задачи во всех репликах защищает не планировщик, а блокировка вокруг самой задачи.
Пользователи и вход
Учётные записи живут в базе приложения (provider: local в config/auth.yaml; без файла — то же самое). Роли две — ADMIN и CLIENT (keepup.roles), но пользователь держит набор ролей: он лежит в таблице user_roles, и разделы с плагинами всех ролей набора склеиваются. Спрашивать надо keepup_auth.user_roles.has_role(user, ROLE_ADMIN) (в панели — window.AppRoles.has(user, role)), а не сравнивать user["role"]. Колонки users.role больше нет: в ответах входа (/api/auth/me и список пользователей) поле role осталось посчитанным из набора — для старых панелей. Пустой набор запрещён на запись, а доступ закрывают блокировкой учётной записи, а не снятием ролей. Вход выдаёт токен, подписанный SECRET_KEY, а панель держит сессию в cookie.
- Регистрация закрыта, пока
SELF_REGISTRATION_ENABLEDне равнаtrue. - Правило пароля —
password_rule(password, username)в настройках: вернуть текст проблемы илиNone. - Свои маршруты вне плагинов получают пользователя зависимостями
get_current_userиget_current_adminизkeepup_auth.dependencies.
Вход через внешний провайдер
OpenID Connect включается одним полем. Без него маршруты /api/auth/oidc/login и /api/auth/oidc/callback даже не регистрируются.
import os
from keepup import KeepupSettings
from keepup.settings import OidcSettings
settings = KeepupSettings(
title="Your product",
oidc=OidcSettings(
issuer="https://id.example.com/realms/yours",
client_id="yours",
client_secret=os.environ["OIDC_CLIENT_SECRET"],
redirect_uri="https://yours.example.com/api/auth/oidc/callback",
roles_claim="groups",
),
)
Без oidc_account_policy человеку, которого приложение ещё не знает, вход закрыт. Политика — функция от утверждений провайдера; готовые лежат в keepup_auth.oidc_policy: refuse_unknown(), create_account(), create_if_email_domain([...]) — последняя сверяет адрес, только если провайдер его подтвердил.
keepup_auth.oidc_policy по-прежнему не объявляет __all__. Готовые политики работают, но их имена не входят в обещание совместимости.Наблюдаемость
Четыре независимых канала, все подключены в жизненном цикле приложения.
- Аудит входящих запросов — каждый маршрут плагина пишется в
incoming_requestsчерез буфер. Из тела запроса по умолчанию остаются только имена полей; иначе решаетaudit_redaction(готовые —keepup_audit.audit.names_onlyиkeep_as_is). - Журнал событий —
await emit_event(event_type, event_text, event_data)изkeepup_audit.events; раздел «События» фильтрует поdeclared_event_types. - Метрики —
/metricsдля Prometheus (без учётных данных — только еслиmetrics_public=True) и снимки процессора и памяти по репликам для панели. - Логи — локально с ротацией, а при заданном
remote_log_urlещё и пакетами в ваш сборщик.
from keepup_audit.events import emit_event
await emit_event("order_accepted", "Order #42 accepted", {"order_id": 42})
Безопасность по умолчанию
Где значению по умолчанию пришлось бы угадывать, каркас закрывает. Приложение само говорит, что ему нужно открыть.
| Что | По умолчанию | Как открыть |
|---|---|---|
| Ключ подписи | Нет; без него старт отказывает | SECRET_KEY, от 32 символов |
| Первый администратор | Случайный пароль в журнале, один раз | ADMIN_INITIAL_PASSWORD |
| CORS | Ни одного источника | cors_origins |
/metrics | Только с учётными данными | metrics_public=True |
| Тело запроса в аудите | Только имена полей | audit_redaction |
| Незнакомый параметр | 400 | **kwargs или маска params |
| Регистрация | Закрыта | SELF_REGISTRATION_ENABLED=true |
| Вход через провайдер | Неизвестным отказ | oidc_account_policy |
| Заголовки безопасности | Включены | security_headers=False |
| Размер тела запроса | 2 МиБ — умолчание каркаса | своё число: max_upload_bytes, а у маршрута — max_body_bytes |
Функции, которые плагины дают друг другу через get_handlers(), по HTTP не публикуются. Проверка токенов провайдера отвергает симметричные алгоритмы и none и на любой провал отвечает одинаково, чтобы по ответу нельзя было понять, какая проверка не прошла.
Публичный интерфейс и версии
Модуль, из которого приложению можно импортировать, объявляет __all__. Всё остальное — внутреннее и может измениться без предупреждения, включая любое имя с подчёркиванием. Тест в репозитории каркаса держит это правило против настоящих приложений: имя, взятое из каркаса и не объявленное публичным, роняет прогон.
Номер версии — три числа. Мажорное меняет то, на что уже опираются: API отвечает иначе, путь проходится иначе. Минорное добавляет возможности или убирает устаревшее. Третье — срочная правка дефекта и ничего больше.
.github/scripts/check.sh) держат эту границу для каждого дистрибутива.Переезд модулей
Почти все имена keepup.<что-то> уехали в свои дистрибутивы. Слоя совместимости нет: старый импорт даёт ModuleNotFoundError.
| Было | Стало |
|---|---|
keepup.db | keepup_db |
keepup.tables | keepup_db.tables |
keepup.positional_sql | keepup_db.positional_sql |
keepup.retention | keepup_db.retention |
keepup.audit | keepup_audit.audit |
keepup.events | keepup_audit.events |
keepup.admin_trail | keepup_audit.trail |
keepup.scheduler | keepup_tasks.scheduler |
keepup.metrics | keepup_metrics |
keepup.metrics_api | keepup_metrics.api |
keepup.metrics_retention | keepup_metrics.retention |
keepup.cluster | keepup_cluster.cluster |
keepup.log_shipping | keepup_log_shipping.log_shipping |
keepup.modules | keepup_modules.catalogue |
keepup.integrations | keepup_integration_log.integrations |
keepup.plugins.admin | keepup_modules.decisions |
keepup.plugins.routes | keepup_http.routes |
keepup.auth и keepup.auth.* | keepup_auth и keepup_auth.* |
keepup.themes | keepup_ui.themes |
keepup.api_docs | keepup_http.api_docs |
keepup.events_api | keepup_http.events_api |
keepup.body_limit | keepup_http.body_limit |
keepup.factory | keepup_http.factory |
keepup.web | keepup_http.web |
Каталога-пакета keepup/ тоже нет: корень репозитория и есть пакет. В базе остались keepup.roles, keepup.locks, keepup.notification_bus, keepup.kernel, keepup.plugins.base, keepup.settings, keepup.security, keepup.cache, keepup.instance, keepup.api_versions и keepup.logging_setup.
Что изменилось от версии к версии — в CHANGELOG.