Lesson 12 — deploying it¶
What you will build¶
A running application, and the checks that decide whether it stays that way. Everything in this path so far has run inside a test client; this lesson runs the same object under a server, in the order a deployment runs it, and shows what happens when the order is wrong.
What you need¶
The database extra (lesson 3) and a server. uvicorn is not part of Xyberos — a framework that
refuses to depend on an HTTP library should not force a server on you either:
The deploy step¶
Three commands, in this order, and the order is the lesson:
python -m xyberos migrate --url "sqlite:///var/app.db" --migrations examples/user_management/migrations
uvicorn --app-dir examples --factory user_management.app:app_factory
curl http://127.0.0.1:8000/health
- Migrate first. A deployment that starts before migrating answers its first query with a
500. - Then serve.
--factorycallsuser_management.app:app_factory, which composes the application and reads the identity secret from the environment when the server starts rather than when the module is imported. - Then check.
/healthtouches the database, so a process that cannot reach it never joins the load balancer.
examples/learn_traditional/traditional12_deploy.py runs those checks against a temporary database,
including the failure:
"""Lesson 12 — what a deployment has to get right, checked rather than asserted.
Run it with:
python -m learn_traditional.traditional12_deploy
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from starlette.applications import Starlette
from user_management.app import build_asgi_app, create_app
from user_management.identity import SECRET_ENV, PrincipalVerifier
from user_management.migrate import migrate
from xyberos.database import SqliteSettings
from xyberos.http.testing import create_test_client
SECRET = "a-secret-the-deployment-keeps"
ADMIN_HEADERS = PrincipalVerifier(SECRET).headers("admin@idp", "admin")
def report(label: str, value: object) -> None:
"""Print one fact a deployment depends on."""
print(f"{label}: {value}")
def routes_of(application: Starlette) -> list[str]:
"""Return the paths the ASGI application answers on, sorted, as a deployment would list them."""
return sorted({route.path for route in application.routes if hasattr(route, "path")})
def main() -> None:
with tempfile.TemporaryDirectory() as directory:
settings = SqliteSettings(path=Path(directory) / "users.db")
app = create_app(settings=settings, secret=SECRET)
app.validate()
report("the application validates", "yes")
asgi = build_asgi_app(app)
report("it answers on", routes_of(asgi))
with create_test_client(asgi) as client:
report("before migrating, /health answers", client.get("/health").status_code)
report(
"before migrating, a query fails",
client.get("/users", headers=ADMIN_HEADERS).status_code,
)
migrate(settings)
report("the deploy step migrated", Path(settings.path).name)
started = build_asgi_app(create_app(settings=settings, secret=SECRET))
with create_test_client(started) as client:
report("/users without an assertion", client.get("/users").status_code)
report(
"/users as an administrator",
client.get("/users", headers=ADMIN_HEADERS).status_code,
)
report("an unknown path", client.get("/nope").status_code)
health = client.get("/health")
report("every answer carries a request id", bool(health.headers.get("x-request-id")))
report("no secret is echoed", SECRET not in health.text)
unconfigured = build_asgi_app(create_app(settings=settings))
with create_test_client(unconfigured) as client:
report(
"a deployment with no secret refuses every request",
client.get("/users", headers=ADMIN_HEADERS).status_code,
)
report(f"the secret is read from {SECRET_ENV}", "yes")
if __name__ == "__main__":
main()
The 500 line prints a traceback on stderr, and that division is deliberate: the log is where the
internal message goes, and the client gets a generic error body. That is the error policy from the
fundamentals path doing its job, and it is worth seeing once under a real server.
What it prints¶
the application validates: yes
it answers on: ['/health', '/me', '/pages/users', '/pages/users/{user_id}/toggle', '/users', '/users/{user_id}']
before migrating, /health answers: 200
before migrating, a query fails: 500
the deploy step migrated: users.db
/users without an assertion: 401
/users as an administrator: 200
an unknown path: 404
every answer carries a request id: True
no secret is echoed: True
a deployment with no secret refuses every request: 401
the secret is read from XYBEROS_IDENTITY_SECRET: yes
| Line | Why a deployment cares |
|---|---|
the application validates: yes |
app.validate() is the same graph check xyberos check runs; a missing provider should be a failed start, not a failed request |
it answers on: [...] |
the route table, as the running object sees it — the list a deployment's smoke test can assert |
before migrating, /health answers: 200 |
health proves the database answers; it says nothing about the schema |
before migrating, a query fails: 500 |
the deploy step is not optional, and this is what forgetting it looks like |
/users without an assertion: 401 |
the identity check is in the running application, not only in the tests |
/users as an administrator: 200 |
and the assertion works the way lesson 8 signed it |
every answer carries a request id: True |
one correlation id per request, in the response and in the logs |
no secret is echoed: True |
the identity secret is configuration, and configuration does not travel back to a client |
a deployment with no secret refuses every request: 401 |
fail closed: a missing secret is a refusal, not a bypass |
The checklist, and what is not on it¶
| Job | What to do | What Xyberos does |
|---|---|---|
| Schema | run xyberos migrate as a deploy step |
nothing at startup — by design (ADR 0014's sibling rule in principles) |
| Configuration | environment variables, one per deployment | reads them; does not fetch, rotate, or audit secrets |
| Identity | the proxy authenticates and strips the assertion headers | nothing: header hardening is the adapter's middleware and your proxy |
| TLS, CORS, size and rate limits | at the ASGI server and the proxy | nothing, on purpose (security guide) |
| Logs | structured lines with the request's correlation id | the id is bound per request; the sink is yours |
| Readiness | something that reflects your dependencies | /health is the shape, not the policy |
| Backups | the database file, and its WAL while the process runs | nothing |
The last two rows are where most deployments of this application fail in practice, in opposite directions: a readiness probe that only checks the process, and a backup that copies a file while WAL holds the newest writes.
Common mistakes¶
- SQLite with several writers. WAL lets readers proceed, but writes serialize. Run one worker, or the
application's database belongs on a provider built for concurrency — which is what lesson 3's
DatabaseProviderseam is for. - Migrating on startup. Every process tries, on every boot, and a slow migration becomes a container that never becomes healthy. It is a deploy step because it is a decision.
- A secret on the command line.
--urlvalues are redacted by themigratecommand, but anything in an argument list is visible topsand lands in shell history. The identity secret is read from the environment and never printed. - Deploying behind a proxy that forwards
X-Authenticated-Subject. The assertion becomes forgeable and every401in this path becomes decoration. Test it by sending the header yourself and expecting a refusal. - Treating
/healthas readiness. It answers200for a database whose schema is a revision behind, which is exactly the state a deploy step exists to prevent. The exercise is the fix. - Exposing the database file. It sits next to the application in this example; in a deployment it lives behind filesystem permissions, not behind a URL.
Exercise¶
Add GET /ready: a readiness check that compares the columns the code reads with the columns the
database has, and answers 503 until they agree. It should say 200 against a database that has been
migrated, and name what is missing against one that has not.
Show the answer
"""Lesson 12, exercise — a readiness check that notices a schema behind the code.
The answer to the lesson's exercise. `/health` proves the process can reach its database; it says
nothing about whether the tables the code reads are there. This one compares the columns the service
needs with the columns the database has, and answers `503` until they agree.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Annotated
from sqlalchemy import Connection, text
from starlette.responses import JSONResponse, Response
from user_management.app import build_asgi_app, create_app
from user_management.identity import PrincipalVerifier
from user_management.migrate import migrate
from xyberos import App, Inject
from xyberos.database import DatabaseProvider, SqliteSettings
from xyberos.http.testing import create_test_client
SECRET = "a-secret-the-deployment-keeps"
ADMIN_HEADERS = PrincipalVerifier(SECRET).headers("admin@idp", "admin")
#: What the code reads. Kept next to the check rather than derived from `tables.py`, so a column
#: that was added to the model and never migrated is a failure rather than a silent match.
EXPECTED_COLUMNS = ("id", "email", "display_name", "enabled", "external_subject")
def columns_of(connection: Connection) -> list[str]:
"""Read the users table's column names through a synchronous connection."""
described = connection.execute(text("PRAGMA table_info(users)")).all()
return [str(row[1]) for row in described]
class SchemaReader:
"""Answers what the database's schema is missing, without writing anything."""
def __init__(self, database: Annotated[DatabaseProvider, Inject()]) -> None:
self._database = database
async def missing_columns(self) -> list[str]:
"""Return the columns the code reads that the database does not have."""
async with self._database.transaction() as connection:
described = await connection.run_sync(columns_of)
return sorted(set(EXPECTED_COLUMNS) - set(described))
def create_readiness_app(*, settings: SqliteSettings) -> App:
"""The lesson's application, with the exercise's readiness route added to it."""
app = create_app(settings=settings, secret=SECRET)
app.provide(SchemaReader)
@app.get("/ready")
async def ready(schema: Annotated[SchemaReader, Inject()]) -> Response:
missing = await schema.missing_columns()
if missing:
return JSONResponse({"status": "not ready", "missing": missing}, status_code=503)
return JSONResponse({"status": "ready"})
return app
def main() -> None:
"""Run readiness against a database the code has outgrown, then against a current one."""
with tempfile.TemporaryDirectory() as directory:
settings = SqliteSettings(path=Path(directory) / "users.db")
before = build_asgi_app(create_readiness_app(settings=settings))
with create_test_client(before) as client:
print(
"liveness before migrating:",
client.get("/health").status_code,
"(the database answers)",
)
not_ready = client.get("/ready")
print("readiness before migrating:", not_ready.status_code, not_ready.json())
migrate(settings)
after = build_asgi_app(create_readiness_app(settings=settings))
with create_test_client(after) as client:
print(
"readiness after migrating:",
client.get("/ready").status_code,
client.get("/ready").json(),
)
print(
"and the routes still work:",
client.get("/users", headers=ADMIN_HEADERS).status_code,
)
if __name__ == "__main__":
main()
Where this path ends¶
You have an application with a schema it migrated, routes it authorized, an interface it renders, an assertion it verifies, and a deploy step it documents. The next paths add two things to it: AI Apps puts a model behind a facade with a schema and a tool allowlist, and Hybrid Apps is where the model recommends and the application decides — the same service, the same rules, and a human in the loop at the one place that matters.