Documentation · keepup-admin 0.5.0

Building an application on keepup

From installing it to several replicas against one database. Everything named here is the package's public interface: names from its modules' __all__.

Getting started

You need Python 3.11 or newer. SQLite is enough for development; PostgreSQL is for production.

python3 -m venv .venv && . .venv/bin/activate
# the full panel: the HTTP layer and sign-in, the sections, the database and one driver
pip install "keepup-admin[panel,postgres]" uvicorn

Capabilities arrive as extras: one extra, one distribution. The base is the constructor only — the kernel, the plugin catalogue, the service registry, the lifecycle and the transport seam. It declares no library of anybody else's, not even FastAPI; every capability declares its own.

DistributionWhat it gives
keepup-adminThe kernel: descriptors, the catalogue, the service registry, the lifecycle, settings, roles, locks, the bus, the cache.
keepup-dbDatabaseManagerV2, tables, retention, SQLAlchemy.
keepup-authSign-in, panel sessions, roles, OIDC, somebody else's identity system.
keepup-usersThe tables of accounts, roles and permissions.
keepup-uiThe panel's themes.
keepup-auditThe audit of incoming requests, events, the administrator's trail.
keepup-metricsThe collector, /metrics and snapshot retention.
keepup-integration-logOutgoing calls to external systems.
keepup-postgresThe PostgreSQL driver and the LISTEN/NOTIFY bus.
keepup-sqliteThe SQLite driver and the in-process bus.
keepup-tasksThe scheduler and the locks and scheduler services.
keepup-modulesThe section catalogue, the grants to roles, the decision about plugins.
keepup-clusterThe registry of replicas and the commands addressed to them.
keepup-log-shippingShipping the log to a collector.
keepup-httpcreate_app, the panel shell, routes, FastAPI and Starlette.

The panel extra installs everything the panel needs but the driver; the driver is chosen separately — postgres in production, sqlite for development. Two drivers of one required service stop the start on purpose.

An application is a file that hands over settings and gets an ASGI application back. The tables plugins declare (get_declared_tables()) are created by the kernel at start; the first accounts are created by keepup-auth:

main.py
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": []}
What the panel extra brings. keepup-http serves the panel shell from shell_mount — it lives inside the package, and there is nothing to copy into your tree. keepup-auth registers sign-in, and the root / answers without a client page of the application's own. static_mounts=() is needed only when the application has no static/ directory: otherwise create_app fails on mounting a directory that does not exist.

Two values are set by whoever deploys, not by the code:

export SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')"
export ADMIN_INITIAL_PASSWORD="choose-your-own"
uvicorn main:app --port 8000
Without SECRET_KEY the application does not start. The session signing key must be at least 32 characters long, and known placeholders are refused. If ADMIN_INITIAL_PASSWORD is not set, the password of the admin account is generated at random and printed into the start-up log once.

What answers after that:

  • /selfcare — the admin panel: sign in as admin, eight sections; the shell and the sections are installed by keepup-http.
  • /api/docs and /api/redoc — the API description.
  • /api/health — the health of the process.

The default database is SQLite at data/keepup_db, relative to the working directory.

Checks

The tree for development is installed from the repository: pip install -e ".[test]" -e packages/keepup-*. One command runs all of it: .github/scripts/check.sh — the lint, the suite with coverage, the release and the graph, the security audit and the build of every wheel; --fast skips the wheels and the audit. Tests in the tree are named *_tests.py and run by path: python -m pytest tests/your_tests.py -v.

Project layout

The framework reads data relative to the process's working directory, and plugin code from the directory the application names. So start the process from your application's root:

yours/
├── main.py                   # KeepupSettings -> app
├── plugins/
│   └── hello.py              # class HelloPlugin(BasePlugin)
├── config/
│   ├── modules.json          # plugins, panel sections, roles
│   ├── auth.yaml             # provider: local (optional)
│   └── postgres.properties   # where the database is (optional)
└── static/                   # your own front end, if any

Derive the plugins directory from the file's location rather than from the working directory — then plugins are found wherever the process was started:

import os

PLUGINS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plugins")
# The settings name the directory further on: plugins_dir=PLUGINS_DIR.

Two keepup applications in one directory are not untidy but wrong: at start the panel's section catalogue is topped up from config/modules.json, and the second application would receive the first one's sections.

KeepupSettings

The one place to configure. Anything the framework cannot know arrives here; the defaults close rather than open.

FieldDefaultWhat it sets
title"KeepUP"The application's name and the API description title.
project_name"keepup"The project name in logs.
plugins_dirNoneThe directory the kernel loads plugins from.
plugins_config_path"config/modules.json"Where plugins, sections and roles are declared.
plugin_managerNoneYour own PluginManager, if the application built one itself.
profileNoneA profile — a patch over the catalogue: "panel" or "metrics-only".
database_dialectNoneWhich driver to run: "postgresql" or "sqlite".
cors_origins()Origins that may read answers. Empty — none.
metrics_publicFalseWhether /metrics is served without credentials.
security_headersTrueSecurity headers on every answer.
content_security_policyThe framework's policyThe panel's content policy; None sends none.
csp_report_onlyFalseSend the policy as a report, refusing nothing.
max_upload_bytesNoneThe limit for a route that reads its own body; None — the route decides.
max_json_bytes2 MiBThe body the framework parses; None — no limit.
performancean empty objectThe connection pool, the audit buffer and the metrics interval in one object.
docs_url, redoc_url"/api/docs", "/api/redoc"Where the API description is; None removes it.
openapi_url"/openapi.json"Where the OpenAPI schema is served; None removes it.
openapi_publicFalseWhether the schema answers anybody, not administrators only.
static_dir, static_mounts"static", seven mountsYour own static files; StaticMount(path, directory, name).
shell_mount"/keepup-static"Where the panel shell is served from keepup-http.
client_page"main.html"The page / answers with.
built_in_themes()The application's themes: (name, page_file, brand, logo).
gated_pages{}A page served only while the named plugin runs.
audit_redactionNoneWhat the audit keeps of a request body; by default field names only.
password_ruleNone(password, username) -> problem text or None.
oidc, oidc_account_policyNoneSign-in through an external provider, and whom to admit.
identity_providerNoneSomebody else's identity system, when configured in code.
notification_channelNoneThe channel of the bus between replicas; without it there is no bus.
remote_log_url, remote_log_tokenNoneA log collector and its credential.
public_configNoneWhat the screen before sign-in shows.
record_login, pending_documentsNoneWhat happens after sign-in; which documents a user has not accepted yet.
declared_event_typesNoneEvent types for the panel's filter.
on_startup, on_shutdownNoneCoroutines (app) before the plugins and after them.

profile is a patch over the plugin catalogue (plugins/builtin.json declares "panel" and "metrics-only"), not a second configuration format: one codebase serves a full panel, a worker and a process that answers only /metrics. The metrics-only profile replaced the retired disable_http_server setting. The app_tables and extra_setup settings and the keepup.schema module with its init_db() were removed in 0.5.0.

Plugins and routes

A feature of the application is a plugin: a subclass of BasePlugin in a file of its own. The framework loads it as declared in config/modules.json.

The class name follows from the id

The file is plugins/<id>.py, the class is f"{id.capitalize()}Plugin": hello → HelloPlugin, integration_logs → Integration_logsPlugin. A wrong name does not stop the start: the plugin simply does not load, and the log keeps a warning.

Hooks

MethodWhen and why
__init__(self, config=None)The framework passes one argument — config from the declaration. Call super().__init__(id, name, config).
async initialize() -> boolYour tables, background work, checks. False or an exception — the plugin is not up, and the panel shows why.
get_api_routes() -> listHTTP routes as dictionaries.
get_websocket_routes() -> listWebSocket routes; empty by default.
get_handlers() -> dictNamed callables for other plugins: plugin_manager.get_plugin(id).get_handlers(). Never published over HTTP.
async post_construct()After the server is fully up — for self-checks that need a live HTTP server.
async cleanup()On shutdown.

A route is a dictionary

There are no decorators: the framework builds the FastAPI wrapper for every route itself, so sign-in, parameter handling and the audit are the same for all of them.

KeyDefaultMeaning
path—The path, with parameters in braces.
methods—["GET"], ["POST"] and so on.
handler—The handler coroutine.
require_authTrueWhether sign-in is required; the handler receives current_user.
permissionnoneThe right the caller must hold; checked before the handler.
auditTrueWhether the request is written to the audit; False — not written.
max_body_bytesthe application's limitThe largest body the route accepts.
response_media_typeJSONWhat the route answers when it is not JSON.
paramsnoneThe parameter mask: per name type (str, int, float, bool), required, in, choices, min, max, max_length, pattern.
is_uploadFalseThe handler receives the request itself, not parsed JSON.
raw_requestFalseThe handler receives the Request and authenticates on its own.
include_in_schemaTrueWhether the route appears in the API description.

A handler takes current_user: dict and plain keyword arguments: path and query parameters arrive by name, the body of a write arrives in request. An unknown parameter is answered with 400, not 500: when the route declares a mask, the mask decides and converts types; otherwise the handler's signature does (a handler with **kwargs takes everything).

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"):
    ...

Declaring and enabling

{
  "plugins": [
    {"id": "hello", "name": "Hello", "enabled": true, "priority": 100,
     "config": {"greeting": "hi"}}
  ],
  "roles": [
    {"name": "ADMIN", "modules": [], "plugins": ["hello"]}
  ]
}

Whether a declared plugin runs is decided in order, strongest first: PLUGINS_DISABLE → PLUGINS_ENABLE → the switch in the panel → the enabled flag in the file. A lower priority initialises earlier. A role's plugins list is visibility in /api/plugins, not enablement.

The kernel

A plugin is loaded by the constructor — the kernel. A plugin declares a descriptor: id, its kind (required, optional, transport), what it needs (requires, wants) and what it gives (provides, contributions). A catalogue is composed of those descriptors: the framework's own catalogue (plugins/builtin.json), the application's file (config/modules.json) and a profile (settings.profile) — each later layer patches the one before. Plugins publish services into the registry (provides) and take other plugins' services through requires; the lifecycle runs them in two phases — first those whose requirements are already met, then the rest. A required plugin and a transport are enabled by being installed; an optional one by the file, by PLUGINS_ENABLE, by a profile or by an administrator.

from keepup.kernel import create_runtime

runtime = create_runtime(settings)   # a Runtime, not yet started

The service names a plugin may require and publish are listed in doc/service-catalogue.md; the constructor's contract is in doc/plugin_constructor.md. If the application built a plugin manager itself, the plugin_manager setting hands it to the kernel.

The panel

The panel lives at /selfcare. Its shell — theme pages, script and styles — ships with keepup-http and is served from shell_mount; the base carries no front end, and there is nothing to copy into your tree.

Eight framework sections

keepup-http installs them and the shell. They are entered into the catalogue at start and granted to ADMIN by default:

  • Users — accounts, roles, sessions.
  • Sections — the panel's section catalogue and its grants to roles.
  • Cluster — replicas, their plugins and state; stop, start, restart.
  • Metrics — CPU and memory per replica.
  • Themes — the panel's look.
  • Events — the application's event log.
  • Background tasks — scheduler jobs and distributed locks.
  • Integration log — outgoing calls to external systems.

A section of your own

A section is a script and, optionally, a stylesheet declared in modules[] of the same modules.json, plus a line in a role's modules. The shell loads the script and calls 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": []}]
}

The catalogue lives in the database; the file is its seed. At start only sections and grants that have no row yet are added — what an administrator switched off in the panel stays switched off.

The theme owns the chrome, a section owns itself. A section's stylesheet declares no rules for the sidebar, the navigation or the main area: section styles load after the page, and the chrome would start depending on which sections a role is granted.

Themes

The framework imposes no theme of its own: the themes are the keepup-ui distribution (keepup_ui.themes), and the application lists its own in built_in_themes as (name, page_file, brand, logo) tuples. On an empty database the first becomes active; on an existing one the administrator's choice stays.

Database

Where the database is

When DB_TYPE is set, everything comes from the environment: DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DB_PATH, DB_POOL_*. When it is not — from config/postgres.properties (db.* keys), with the password still allowed to come from DB_PASSWORD. With neither — SQLite.

export DB_TYPE=postgres DB_HOST=db.internal DB_NAME=yours DB_USER=yours
export DB_PASSWORD=…

Queries

DatabaseManagerV2 is a SQLAlchemy engine with a pool. Parameters are named only.

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:   # commits or rolls back itself
    ...

A table is declared once

There are no migrations. A table is a SQLAlchemy Core declaration for both dialects; ensure_tables() creates whatever is missing at every start: CREATE TABLE IF NOT EXISTS, missing columns, indexes. A new column is just a new 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)

Dialect differences live in the declaration (with_variant, ddl_if, tables.per_dialect(postgres=…, sqlite=…)). A column on a table somebody else owns — users, say — is added with tables.ensure_columns("users", Column(…)), not with a second declaration.

Several replicas against one database

An application on keepup is meant to run as several processes behind a load balancer. All coordination goes through the shared database — replicas do not know each other's addresses.

A distributed lock

Background work that must run in one replica is taken under a lock in the distributed_locks table. The holder renews it while working, so the work may take as long as it needs; only the lock of a holder that went quiet is taken over.

from keepup.locks import distributed_lock, with_distributed_lock

@with_distributed_lock("nightly_report", timeout=300)
async def nightly_report():
    ...   # runs in one replica; the others skip this run

async def purge():
    try:
        async with distributed_lock("purge_journal", timeout=1):
            ...
    except Exception:
        pass  # another replica is purging -- that is as good

With the lock taken, the decorator skips the run and returns None; the context manager raises.

The message bus

Set notification_channel and the framework starts a bus on PostgreSQL LISTEN/NOTIFY before the plugins initialise. deliver() sends an envelope to the other replicas and hands it to this one's subscribers; publish() — to the others only.

from keepup import notification_bus

bus = notification_bus.get_notification_bus()
if bus is not None:
    bus.subscribe(self.on_envelope)          # in the plugin's initialize()

await notification_bus.deliver({"kind": "order_changed", "order_id": 42})
Delivery is best-effort. NOTIFY neither stores nor replays, so an envelope names an object rather than carrying its state (the limit is 7500 bytes), and the subscriber re-reads the object itself. On SQLite the bus exists, but in its own process (InProcessTransport): publish() returns True, yet the message does not leave the process.

The cluster registry

Every replica publishes itself in cluster_members and collects the commands addressed to it from cluster_commands. The panel's Cluster section shows replicas and their plugins and knows three commands: stop — the process stays alive but answers 503 to the API and pauses its scheduler; start — back; restart — a normal shutdown and a re-run of the same command line.

Background jobs

The scheduler keepup-tasks installs (keepup_tasks.scheduler) is one per process; its jobs are visible in the Background tasks section and can be run from there by hand. What keeps a job from running in every replica is not the scheduler but the lock around the job itself.

Users and sign-in

Accounts live in the application's database (provider: local in config/auth.yaml; without the file — the same). There are two roles, ADMIN and CLIENT (keepup.roles), but a user holds a set of roles: it lives in the user_roles table, and the sections and plugins of every role in the set are glued together. Ask with keepup_auth.user_roles.has_role(user, ROLE_ADMIN) (in the panel, window.AppRoles.has(user, role)) rather than comparing user["role"]. The users.role column is gone: in the answers of a sign-in (/api/auth/me and the user list) the role field remains, computed from the set — for older panels. An empty set is refused on write, and access is closed by blocking an account rather than by taking roles away. Signing in issues a token signed with SECRET_KEY, and the panel keeps its session in a cookie.

  • Registration is closed until SELF_REGISTRATION_ENABLED is true.
  • The password rule is password_rule(password, username) in the settings: return a problem text or None.
  • Your own routes outside plugins get the user through the get_current_user and get_current_admin dependencies from keepup_auth.dependencies.

Sign-in through an external provider

OpenID Connect is switched on with one field. Without it the /api/auth/oidc/login and /api/auth/oidc/callback routes are not even registered.

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",
    ),
)

Without oidc_account_policy, a person the application has never seen is refused. A policy is a function of the provider's claims; ready ones are in keepup_auth.oidc_policy: refuse_unknown(), create_account(), create_if_email_domain([...]) — the last compares the address only when the provider says it verified it.

This is an internal interface: in 0.5.0 the keepup_auth.oidc_policy module still does not declare __all__. The ready policies work, but their names are not covered by the compatibility promise.

Observability

Four independent channels, all wired into the application's lifespan.

  • An audit of incoming requests — every plugin route is written to incoming_requests through a buffer. Of a request body, only field names are kept by default; otherwise audit_redaction decides (ready ones: keepup_audit.audit.names_only and keep_as_is).
  • An event log — await emit_event(event_type, event_text, event_data) from keepup_audit.events; the Events section filters by declared_event_types.
  • Metrics — /metrics for Prometheus (without credentials only with metrics_public=True) and CPU and memory snapshots per replica for the panel.
  • Logs — rotated locally, and with remote_log_url set, also shipped in batches to your collector.
from keepup_audit.events import emit_event

await emit_event("order_accepted", "Order #42 accepted", {"order_id": 42})

Secure by default

Where a default would have to guess, the framework closes. The application says what it needs open.

WhatDefaultHow to open
Signing keyNone; the start refuses without itSECRET_KEY, 32 characters or more
First administratorA random password in the log, onceADMIN_INITIAL_PASSWORD
CORSNo origin at allcors_origins
/metricsCredentials onlymetrics_public=True
Request body in the auditField names onlyaudit_redaction
Unknown parameter400**kwargs or a params mask
RegistrationClosedSELF_REGISTRATION_ENABLED=true
Sign-in through a providerUnknown accounts refusedoidc_account_policy
Security headersOnsecurity_headers=False
Request body size2 MiB — the framework's defaultyour own number: max_upload_bytes, or max_body_bytes per route

The callables plugins give each other through get_handlers() are not published over HTTP. Verification of a provider's tokens refuses symmetric algorithms and none, and answers every failure the same way, so an answer does not tell which check failed.

Public interface and versions

A module an application may import from declares __all__. Everything else is internal and may change without notice, including any name starting with an underscore. A test in the framework's repository holds this against real applications: a name taken from the framework and not declared public fails the run.

A version is three numbers. Major changes what people already rely on: an API that answers differently, a path walked differently. Minor adds capability or removes what is outgrown. The third is an urgent fix of a defect and nothing else.

Use 0.5.0 or newer. The base declares no library of anybody else's — not even FastAPI: every capability declares its own, and the repository's checks (.github/scripts/check.sh) hold that line for every distribution.

Where the modules moved

Almost every keepup.<something> name moved to a distribution of its own. There is no compatibility layer: an old import raises ModuleNotFoundError.

WasIs
keepup.dbkeepup_db
keepup.tableskeepup_db.tables
keepup.positional_sqlkeepup_db.positional_sql
keepup.retentionkeepup_db.retention
keepup.auditkeepup_audit.audit
keepup.eventskeepup_audit.events
keepup.admin_trailkeepup_audit.trail
keepup.schedulerkeepup_tasks.scheduler
keepup.metricskeepup_metrics
keepup.metrics_apikeepup_metrics.api
keepup.metrics_retentionkeepup_metrics.retention
keepup.clusterkeepup_cluster.cluster
keepup.log_shippingkeepup_log_shipping.log_shipping
keepup.moduleskeepup_modules.catalogue
keepup.integrationskeepup_integration_log.integrations
keepup.plugins.adminkeepup_modules.decisions
keepup.plugins.routeskeepup_http.routes
keepup.auth and keepup.auth.*keepup_auth and keepup_auth.*
keepup.themeskeepup_ui.themes
keepup.api_docskeepup_http.api_docs
keepup.events_apikeepup_http.events_api
keepup.body_limitkeepup_http.body_limit
keepup.factorykeepup_http.factory
keepup.webkeepup_http.web

There is no keepup/ package directory either: the repository root is the package. What stays in the base is keepup.roles, keepup.locks, keepup.notification_bus, keepup.kernel, keepup.plugins.base, keepup.settings, keepup.security, keepup.cache, keepup.instance, keepup.api_versions and keepup.logging_setup.

What changed from version to version is in the CHANGELOG.