chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
# Deploying Omnigent on Databricks Apps
|
||||
|
||||
This directory deploys the Omnigent server to
|
||||
[Databricks Apps](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/)
|
||||
via [Databricks Asset Bundles](https://docs.databricks.com/aws/en/dev-tools/bundles/):
|
||||
|
||||
- **Lakebase** (managed PostgreSQL) — the database for every store.
|
||||
- **UC Volumes** — the artifact store for agent bundles and executor
|
||||
storage snapshots.
|
||||
|
||||
> **Most Databricks users want the managed offering instead.**
|
||||
> [Omnigent on Databricks](https://docs.databricks.com/aws/en/omnigent/)
|
||||
> (Beta) runs the server for you, wired to workspace identity,
|
||||
> Foundation Models, AI Gateway, and MLflow Tracing out of the box.
|
||||
> Enable the **Omnigent** preview in your workspace settings and follow
|
||||
> the quickstart there. Use this directory only when you need to
|
||||
> self-manage the deployment: the managed service is not in your region
|
||||
> yet, or you need control it does not expose today (custom YAML
|
||||
> policies, bring-your-own provider API keys, custom egress controls).
|
||||
|
||||
The orchestrator at `deploy.py` builds the wheels, generates an app
|
||||
`pyproject.toml` + `uv.lock`, and then runs
|
||||
`databricks bundle deploy` + `bundle run` against the bundle config
|
||||
in `databricks.yml`. App config (Lakebase, UC volume) lives
|
||||
declaratively in `databricks.yml` — adding or removing a resource is
|
||||
a YAML edit, not a Python SDK call.
|
||||
|
||||
Runs unchanged from a laptop. Re-runnable; every step is idempotent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Databricks workspace with Databricks Apps, Lakebase, and UC
|
||||
Volumes enabled.
|
||||
2. The [Databricks CLI](https://docs.databricks.com/aws/en/dev-tools/cli/install.md)
|
||||
installed and authenticated. Either a CLI profile
|
||||
(`DATABRICKS_CONFIG_PROFILE=<profile>`) or env-based auth
|
||||
(`DATABRICKS_HOST` + `DATABRICKS_CLIENT_ID` + `DATABRICKS_CLIENT_SECRET`).
|
||||
3. The repo's local venv with the `databricks` extra:
|
||||
`uv sync --extra databricks` (use `uv`, not global pip).
|
||||
4. Permissions to create or use:
|
||||
- a Lakebase project (one per app — do not share with other apps);
|
||||
- a UC volume whose parent catalog/schema can grant access to the
|
||||
app service principal;
|
||||
- (optional) Databricks secrets for LLM API keys.
|
||||
|
||||
Set your workspace URL in `databricks.yml` under
|
||||
`targets.prod.workspace.host` (it ships as a `https://example.databricks.com`
|
||||
placeholder; DAB reads `workspace.host` before resolving variables, so it
|
||||
must be a literal).
|
||||
|
||||
## One-time bootstrap
|
||||
|
||||
### 1. Lakebase project (one per app — never share)
|
||||
|
||||
Reusing a shared autoscaling project causes the migrate-on-boot hook
|
||||
to fail with "permission denied for table agents" because the tables
|
||||
are owned by whoever ran migrations first. Always start fresh:
|
||||
|
||||
```python
|
||||
from databricks.sdk import WorkspaceClient
|
||||
from databricks.sdk.service.postgres import Project
|
||||
|
||||
wc = WorkspaceClient(profile="<your-profile>")
|
||||
wc.postgres.create_project(project=Project(), project_id="omnigent")
|
||||
|
||||
branch = "projects/omnigent/branches/production"
|
||||
endpoint = f"{branch}/endpoints/primary"
|
||||
|
||||
import time
|
||||
for _ in range(120):
|
||||
ep = wc.postgres.get_endpoint(name=endpoint)
|
||||
if ep.status and ep.status.current_state == "ACTIVE":
|
||||
break
|
||||
time.sleep(5)
|
||||
else:
|
||||
raise TimeoutError(endpoint)
|
||||
|
||||
database = next(iter(wc.postgres.list_databases(parent=branch)))
|
||||
print("database resource path:", database.name)
|
||||
```
|
||||
|
||||
### 2. UC Volumes
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS main.omnigent;
|
||||
CREATE VOLUME IF NOT EXISTS main.omnigent.artifacts;
|
||||
```
|
||||
|
||||
The `artifacts` volume is referenced declaratively in `databricks.yml`
|
||||
(the app resource) via `--var volume_name=…`.
|
||||
|
||||
### 3. First deploy — creates the app and its service principal
|
||||
|
||||
Run the [Deploy](#deploy) command once. The first
|
||||
`databricks bundle deploy` creates the app and provisions its service
|
||||
principal (SP). This first pass will **not** pass its `/health` check
|
||||
yet: the SP has no Lakebase schema grants, so the migrate-on-boot hook
|
||||
fails with `permission denied for schema public`. That's expected —
|
||||
the next step grants those privileges.
|
||||
|
||||
### 4. Grant the app SP Lakebase privileges
|
||||
|
||||
Now that the app (and its SP) exist, grant the SP the public schema
|
||||
privileges Alembic needs, then re-run the deploy:
|
||||
|
||||
```bash
|
||||
python deploy/databricks/grant_sp_perms.py \
|
||||
--app-name omnigent \
|
||||
--lakebase-endpoint projects/omnigent/branches/production/endpoints/primary \
|
||||
--database databricks_postgres \
|
||||
--profile <your-profile>
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Lakebase uses two spellings for the same database. The **resource
|
||||
> path** uses a hyphenated slug — `…/databases/databricks-postgres`
|
||||
> (what `deploy.py --lakebase-database` and `databricks.yml` want) —
|
||||
> while the underlying **PostgreSQL database name** uses an underscore,
|
||||
> `databricks_postgres` (what `grant_sp_perms.py --database` and the
|
||||
> app's `PGDATABASE` use). Pass each form where shown.
|
||||
|
||||
After this one-time grant, re-running the deploy boots the app cleanly
|
||||
and `/health` returns 200. Subsequent redeploys are a single
|
||||
`deploy.py` invocation.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
uv run python deploy/databricks/deploy.py \
|
||||
--app-name omnigent \
|
||||
--profile <your-profile> \
|
||||
--lakebase-branch projects/omnigent/branches/production \
|
||||
--lakebase-database projects/omnigent/branches/production/databases/databricks-postgres \
|
||||
--volume-name main.omnigent.artifacts
|
||||
```
|
||||
|
||||
The script builds wheels, classifies them by size, copies wheels into
|
||||
`src/`, regenerates `src/pyproject.toml` and `src/uv.lock`, runs
|
||||
`databricks bundle deploy --target prod`, runs
|
||||
`databricks bundle run omnigent --target prod`, and polls `/health`
|
||||
with backoff until 200.
|
||||
|
||||
All Omnigent wheels must fit under the Databricks Apps source
|
||||
snapshot limit (10 MB). If a wheel exceeds it, rebuild with
|
||||
`--skip-web-ui` or reduce the wheel size; uv lockfiles cannot point at
|
||||
UC Volume wheel paths because `uv lock` validates path sources locally.
|
||||
|
||||
Re-running is safe — every step is idempotent.
|
||||
|
||||
> [!TIP]
|
||||
> To lock against a private PyPI mirror or proxy instead of public
|
||||
> PyPI, set `UV_INDEX_URL` before running `deploy.py`.
|
||||
|
||||
## Smoke check
|
||||
|
||||
`deploy.py` polls `/health` automatically. To check other endpoints:
|
||||
|
||||
```bash
|
||||
TOKEN="$(databricks auth token <your-profile> --output json \
|
||||
| python -c 'import json, sys; print(json.load(sys.stdin)["access_token"])')"
|
||||
|
||||
curl --http1.1 -fsS \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
https://<app>.databricksapps.com/health
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
### Authentication
|
||||
|
||||
The app runs as a Databricks service principal. Credentials are
|
||||
managed automatically:
|
||||
|
||||
- **Lakebase** — OAuth tokens generated via
|
||||
`WorkspaceClient.postgres.generate_database_credential()`, injected
|
||||
into every new SQLAlchemy connection via a class-level
|
||||
`do_connect` event hook in `src/app.py`.
|
||||
- **UC Volumes** — Workspace credentials used by the Databricks SDK
|
||||
(ambient in Apps).
|
||||
- **TUI / API access** — Browser-based OAuth using the
|
||||
`databricks-cli` OIDC client with PKCE.
|
||||
|
||||
The Databricks Apps proxy injects `X-Forwarded-Email` on every
|
||||
request, so the app pins `OMNIGENT_AUTH_PROVIDER=header` (see
|
||||
`src/app.py`).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Header auth trusts the `X-Forwarded-Email` header verbatim. This is
|
||||
> safe **only** because the Databricks Apps platform terminates auth at
|
||||
> its proxy, strips any client-supplied copy of the header, and the app
|
||||
> port is never reachable except through that proxy. Don't expose the
|
||||
> app process directly (e.g. a port forward or alternate ingress that
|
||||
> bypasses the proxy): a caller who can set the header themselves could
|
||||
> then impersonate any user. If you front the app with anything other
|
||||
> than the standard Apps proxy, ensure it sanitizes the header too.
|
||||
|
||||
### Token lifecycle
|
||||
|
||||
Lakebase OAuth tokens expire after 60 minutes. The SQLAlchemy
|
||||
connection pool recycles connections every 5 minutes by default
|
||||
(configurable via `AP_POOL_RECYCLE_SECONDS`), ensuring fresh tokens
|
||||
on new connections.
|
||||
|
||||
### Storage
|
||||
|
||||
| Component | Backend | Purpose |
|
||||
|---|---|---|
|
||||
| Agent specs, tasks, conversations | Lakebase (PostgreSQL) | Durable metadata |
|
||||
| Agent bundles, executor snapshots | UC Volumes | Binary blob storage |
|
||||
| DBOS workflow state | Lakebase (same DB) | Workflow recovery |
|
||||
| Executor working dirs | Local ephemeral disk | Cache (restored from UC Volumes) |
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Environment variables read by `src/app.py`:
|
||||
|
||||
| Variable | Source | Description |
|
||||
|---|---|---|
|
||||
| `PGHOST` | Databricks runtime | Lakebase hostname |
|
||||
| `PGPORT` | Databricks runtime | Lakebase port (default 5432) |
|
||||
| `PGDATABASE` | Databricks runtime | Lakebase database name |
|
||||
| `PGUSER` | Databricks runtime | Lakebase user (service principal) |
|
||||
| `PGSSLMODE` | Databricks runtime | SSL mode (default `require`) |
|
||||
| `AP_LAKEBASE_ENDPOINT` | app resource `valueFrom: postgres` | Lakebase endpoint for token generation |
|
||||
| `AP_ARTIFACT_VOLUME_PATH` | app resource `valueFrom: artifact_volume` | UC Volume path for artifacts |
|
||||
| `DATABRICKS_APP_PORT` | Databricks runtime | App port (default 8000) |
|
||||
| `AP_POOL_RECYCLE_SECONDS` | Optional | Connection pool recycle interval (default 300) |
|
||||
|
||||
## Multi-app safety — one bundle, many apps
|
||||
|
||||
The same bundle directory can deploy many apps (one per `--app-name`).
|
||||
Terraform can only delete or replace what is tracked in the state it
|
||||
loads, so the blast radius of a deploy is exactly that state file.
|
||||
|
||||
- **Remote state is per app.** `targets.<t>.workspace.root_path` ends
|
||||
in `${var.app_name}`, so `--app-name X` reads and writes state only
|
||||
under `.bundle/omnigent/X`. A deploy of X cannot mutate app Y.
|
||||
- **The app resource's `name` is `${var.app_name}`.** If the loaded
|
||||
state tracks app X but you pass `app_name=Y`, terraform sees a name
|
||||
change and plans a **destroy of X + create of Y**. Never bind the
|
||||
bundle resource to one app and then deploy with a different
|
||||
`--app-name`.
|
||||
- The **local** cache at `deploy/databricks/.databricks/bundle/<target>/`
|
||||
is per-*target*. Before deploying a *different* app on a target
|
||||
you've used before, drop it: `rm -rf deploy/databricks/.databricks/bundle/<target>`
|
||||
(it's only a cache; the per-app remote state is the source of truth).
|
||||
|
||||
If a `bundle deploy` plan ever shows a delete or replace of a
|
||||
`databricks_app`, abort and re-check the bind and `--app-name` —
|
||||
routine redeploys only ever update in place.
|
||||
|
||||
## Common deploy modes
|
||||
|
||||
```bash
|
||||
# Iterate without rebuilding wheels (reuses dist/; useful when you only
|
||||
# changed app.py / app.yaml). Skips the clean-tree check.
|
||||
uv run python deploy/databricks/deploy.py --skip-build --allow-dirty ...
|
||||
|
||||
# API-only deploy (drops the SPA from the main wheel).
|
||||
uv run python deploy/databricks/deploy.py --skip-web-ui ...
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Deploy refuses: "working tree has uncommitted changes" / "HEAD is not at origin/main" | Clean-tree assertion | Commit/stash, `git checkout main && git pull`, or pass `--allow-dirty` |
|
||||
| `bundle deploy` fails: "Resource already managed by Terraform" | App already bound to another bundle directory | Run from that directory, or unbind: `databricks bundle deployment unbind omnigent` |
|
||||
| `bundle deploy` fails: "An app with the same name already exists" | App exists but isn't bound to this bundle (or a stale per-target local cache from a *different* app made `deploy.py` skip the bind) | `rm -rf deploy/databricks/.databricks/bundle/<target>`, then bind: `databricks bundle deployment bind omnigent <app-name> --target <target> --auto-approve --var ...` |
|
||||
| App fails "Error installing packages"; `/logz` shows "Ignoring existing lockfile due to … exclude newer …" then a PyPI fetch timeout | The Apps runtime pins a global uv `exclude-newer` cutoff; a lock generated without the matching option is re-resolved in-container, where PyPI is unreachable | Read the cutoff from `/logz` ("change of exclude newer timestamp from X to Y") and redeploy with `UV_EXCLUDE_NEWER=<cutoff>` in the environment |
|
||||
| `permission denied for table agents` | Lakebase tables owned by wrong user | Connect as the owner and `DROP TABLE … CASCADE`; redeploy |
|
||||
| `schema "dbos" already exists` | Same for the DBOS schema | `DROP SCHEMA dbos CASCADE` and redeploy |
|
||||
| `permission denied for schema public` | App SP missing schema grants | Run `grant_sp_perms.py` (one-time) |
|
||||
| `Field 'spec.role' cannot be empty` | Lakebase requires explicit role for extra databases | Use the project's default database; don't create extras |
|
||||
| Deploy refuses because a wheel is over 10 MB | uv app payload requires local wheel path sources | Rebuild with `--skip-web-ui` or reduce wheel size |
|
||||
| App starts cleanly but the first agent request 403s on the artifact volume | App SP has `WRITE_VOLUME` on the leaf but no `USE_CATALOG` / `USE_SCHEMA` on the parents | `deploy.py` grants both automatically — for a fresh catalog, redeploy or grant manually via `databricks grants update` |
|
||||
|
||||
## Files in this directory
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `deploy.py` | Orchestrator. Single entry point. |
|
||||
| `databricks.yml` | DAB bundle config. Declares the app + its resources. |
|
||||
| `build.sh` | Cleans static, builds the web UI, builds three wheels. |
|
||||
| `grant_sp_perms.py` | One-time Lakebase `public` schema grant for the app SP. |
|
||||
| `src/app.py` | The app process. SQLAlchemy `do_connect` token hook + Alembic-on-boot + uvicorn. |
|
||||
| `src/app.yaml` | App startup config — command + env-var wiring. |
|
||||
| `src/pyproject.toml` / `src/uv.lock` | Regenerated per deploy; not committed (they pin the per-deploy wheel version). |
|
||||
|
||||
## See also
|
||||
|
||||
- [`databricks.yml`](./databricks.yml) — DAB bundle config.
|
||||
- [Databricks Apps docs](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/).
|
||||
- [Databricks Asset Bundles docs](https://docs.databricks.com/aws/en/dev-tools/bundles/).
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the wheels and optional web UI assets needed for a Databricks Apps
|
||||
# deployment of Omnigent.
|
||||
#
|
||||
# Inputs:
|
||||
# SKIP_WEB_UI=1 Skip the web SPA build for API-only deployments.
|
||||
#
|
||||
# Outputs:
|
||||
# dist/omnigent-<version>-py3-none-any.whl
|
||||
# dist/omnigent_client-<version>-py3-none-any.whl
|
||||
# dist/omnigent_ui_sdk-<version>-py3-none-any.whl
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# This file lives at deploy/databricks/ — two levels deep — so the repo root is
|
||||
# two parents up.
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Each Vite build emits uniquely-hashed JS chunk filenames. Without a
|
||||
# sweep, orphaned chunks from prior builds accumulate in the static
|
||||
# dir, end up in the main wheel, and push it over the 10 MB Workspace
|
||||
# upload cap. Always start from a clean slate.
|
||||
echo "==> Cleaning stale static assets and build outputs"
|
||||
rm -rf omnigent/server/static/web-ui dist build omnigent.egg-info
|
||||
|
||||
if [[ "${SKIP_WEB_UI:-}" != "1" ]]; then
|
||||
echo "==> Building web SPA into omnigent/server/static/web-ui/"
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd "${REPO_ROOT}"
|
||||
else
|
||||
echo "==> SKIP_WEB_UI=1: skipping web build"
|
||||
fi
|
||||
|
||||
echo "==> Building omnigent-client wheel"
|
||||
uv build --wheel --out-dir dist/ sdks/python-client/
|
||||
|
||||
echo "==> Building omnigent-ui-sdk wheel"
|
||||
uv build --wheel --out-dir dist/ sdks/ui/
|
||||
|
||||
echo "==> Building omnigent wheel"
|
||||
uv build --wheel --out-dir dist/ .
|
||||
|
||||
echo ""
|
||||
echo "Built wheels:"
|
||||
ls -1 dist/
|
||||
@@ -0,0 +1,84 @@
|
||||
# Databricks Asset Bundle config for the Omnigent Databricks App.
|
||||
#
|
||||
# Every knob the app exposes lives here. deploy.py wraps `databricks
|
||||
# bundle deploy` + `bundle run`; per-app values are passed as `--var`
|
||||
# pairs (see deploy.py / README.md).
|
||||
|
||||
bundle:
|
||||
name: omnigent
|
||||
|
||||
variables:
|
||||
app_name:
|
||||
description: "Databricks App name (e.g. omnigent)."
|
||||
lakebase_branch:
|
||||
description: "projects/<app>/branches/production"
|
||||
lakebase_database:
|
||||
description: "projects/<app>/branches/production/databases/databricks-postgres"
|
||||
volume_name:
|
||||
description: "<catalog>.<schema>.<volume> for artifact storage"
|
||||
otel_table_schema:
|
||||
description: >
|
||||
UC schema (catalog.schema) holding the OTel destination tables.
|
||||
The platform writes to <schema>.otel_logs, otel_metrics, otel_spans.
|
||||
default: main.omnigent_logs
|
||||
|
||||
resources:
|
||||
apps:
|
||||
# Resource key must match _BUNDLE_RESOURCE_KEY in deploy.py. The app's
|
||||
# display name comes from ${var.app_name}.
|
||||
omnigent:
|
||||
name: ${var.app_name}
|
||||
description: "Omnigent server backed by Lakebase + UC Volumes."
|
||||
source_code_path: ./src
|
||||
# compute_size is intentionally NOT declared here. Terraform's
|
||||
# databricks_app provider uses an apps update API that rejects
|
||||
# compute_size changes ("not supported in this update API"). We
|
||||
# set it out-of-band via apps.create_update in deploy.py before
|
||||
# the bundle deploy runs.
|
||||
config:
|
||||
command: ["opentelemetry-instrument", "python", "app.py"]
|
||||
env:
|
||||
- name: AP_LAKEBASE_ENDPOINT
|
||||
value_from: postgres
|
||||
- name: AP_ARTIFACT_VOLUME_PATH
|
||||
value_from: artifact_volume
|
||||
- name: OTEL_TRACES_SAMPLER
|
||||
value: 'always_on'
|
||||
resources:
|
||||
- name: postgres
|
||||
postgres:
|
||||
branch: ${var.lakebase_branch}
|
||||
database: ${var.lakebase_database}
|
||||
permission: CAN_CONNECT_AND_CREATE
|
||||
- name: artifact_volume
|
||||
uc_securable:
|
||||
securable_full_name: ${var.volume_name}
|
||||
securable_type: VOLUME
|
||||
permission: WRITE_VOLUME
|
||||
|
||||
targets:
|
||||
# One target per workspace. workspace.host must be literal (DAB reads it
|
||||
# before resolving vars). deploy.py selects via --target. To deploy to a
|
||||
# different workspace, add another target block here and pass
|
||||
# `deploy.py --target <name>`.
|
||||
prod:
|
||||
mode: production
|
||||
workspace:
|
||||
host: https://example.databricks.com # ← your workspace URL
|
||||
# Isolate state per app so one bundle can deploy multiple apps.
|
||||
root_path: /Workspace/Users/${workspace.current_user.userName}/.bundle/${bundle.name}/${var.app_name}
|
||||
resources:
|
||||
apps:
|
||||
omnigent:
|
||||
# Optional: Databricks Apps platform OTel export. The platform
|
||||
# auto-injects OTEL_EXPORTER_OTLP_ENDPOINT into the app container
|
||||
# so any OTLP emitter (including telemetry.init() in src/app.py)
|
||||
# routes to the configured UC tables. The export API rejects tables
|
||||
# in default storage ("Cannot export App telemetry to tables in
|
||||
# default storage"), so this requires a workspace with a custom
|
||||
# storage location — drop this block if yours has none.
|
||||
telemetry_export_destinations:
|
||||
- unity_catalog:
|
||||
logs_table: ${var.otel_table_schema}.otel_logs
|
||||
metrics_table: ${var.otel_table_schema}.otel_metrics
|
||||
traces_table: ${var.otel_table_schema}.otel_spans
|
||||
@@ -0,0 +1,933 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deploy Omnigent to a Databricks App via Databricks Asset Bundles.
|
||||
|
||||
End-to-end orchestrator that wraps `databricks bundle deploy` +
|
||||
`databricks bundle run`. The build pieces (version stamp, wheel
|
||||
build, uv lock generation, stale-wheel sweep) stay in Python; the
|
||||
deploy itself is a DAB so the app's resource definition (Lakebase,
|
||||
UC volume) lives declaratively in ``databricks.yml``.
|
||||
|
||||
Runs unchanged from a laptop or from CI. Re-runnable;
|
||||
every step is idempotent.
|
||||
|
||||
Usage example:
|
||||
python deploy/databricks/deploy.py \\
|
||||
--app-name omnigent --profile <your-profile> \\
|
||||
--lakebase-branch projects/omnigent/branches/production \\
|
||||
--lakebase-database \\
|
||||
projects/omnigent/branches/production/databases/databricks-postgres \\
|
||||
--volume-name main.omnigent.artifacts
|
||||
|
||||
See ``README.md`` in the same directory for the full guide,
|
||||
including first-time infrastructure setup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from databricks.sdk import WorkspaceClient
|
||||
|
||||
_WORKSPACE_WHEEL_LIMIT_BYTES = 10 * 1024 * 1024
|
||||
_APP_REQUIRES_PYTHON = ">=3.12,<3.13"
|
||||
# Public PyPI by default. Set UV_INDEX_URL to lock against a private mirror or
|
||||
# proxy instead (see run_uv_lock).
|
||||
_UV_DEFAULT_INDEX_URL = "https://pypi.org/simple"
|
||||
|
||||
# Leaving these in the env when we hand off to the CLI/SDK can
|
||||
# silently route us to the wrong workspace, or upload code under the
|
||||
# wrong account. The deploy must use --profile / DATABRICKS_HOST +
|
||||
# DATABRICKS_CLIENT_ID explicitly.
|
||||
_ENV_VARS_TO_CLEAR = (
|
||||
"DATABRICKS_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CODEX",
|
||||
"CLAUDE_CODE",
|
||||
)
|
||||
|
||||
# Must match the `resources.apps.<key>` and `bundle.name` in databricks.yml.
|
||||
_BUNDLE_RESOURCE_KEY = "omnigent"
|
||||
|
||||
_WHEEL_PREFIXES = ("omnigent-", "omnigent_client-", "omnigent_ui_sdk-")
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[deploy] {msg}", flush=True)
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
# deploy/databricks/deploy.py → repo root is two parents up.
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _deploy_dir() -> Path:
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _src_dir() -> Path:
|
||||
return _deploy_dir() / "src"
|
||||
|
||||
|
||||
def _pyproject_paths() -> list[Path]:
|
||||
root = _repo_root()
|
||||
return [
|
||||
root / "pyproject.toml",
|
||||
root / "sdks" / "python-client" / "pyproject.toml",
|
||||
root / "sdks" / "ui" / "pyproject.toml",
|
||||
]
|
||||
|
||||
|
||||
def _read_base_version() -> str:
|
||||
"""Read the base version from the top-level pyproject.toml.
|
||||
|
||||
The three pyprojects share the same version; we only need to read
|
||||
one. The base value is the on-disk version; deploys append a
|
||||
`.postN` suffix so pip's wheel cache treats every deploy as a
|
||||
distinct release.
|
||||
"""
|
||||
text = (_repo_root() / "pyproject.toml").read_text()
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||
if not match:
|
||||
raise RuntimeError("could not find version in pyproject.toml")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _compute_deploy_version(base: str, explicit: str | None) -> str:
|
||||
if explicit:
|
||||
# Caller knows what they want — let it through after a sanity check.
|
||||
if not re.match(r"^\d+(\.\d+)*(\.dev\d+|\.post\d+|[+\-][\w.]+)?$", explicit):
|
||||
raise SystemExit(f"--version {explicit!r} is not a recognizable PEP 440 version")
|
||||
return explicit
|
||||
# Strip any existing `.postN` / `.devN` suffix so we don't stack
|
||||
# them if a prior deploy left pyproject.toml dirty (or someone
|
||||
# committed the bumped value). Without this, `0.1.0.post<old>`
|
||||
# would become `0.1.0.post<old>.post<new>` which isn't valid
|
||||
# PEP 440 and fails the wheel build.
|
||||
base = re.sub(r"(\.post\d+|\.dev\d+)+$", "", base)
|
||||
# Post-release, not dev: pip treats `.dev` as a pre-release and
|
||||
# ignores it when resolving `>=` constraints, so a deploy that
|
||||
# bumps via `.dev` clashes with `omnigent-ui-sdk` declaring
|
||||
# `omnigent-client>=0.1.0`. `.post` is a final release and
|
||||
# sorts strictly above the base.
|
||||
return f"{base}.post{int(time.time())}"
|
||||
|
||||
|
||||
def set_version_in_pyproject(path: Path, new_version: str) -> str:
|
||||
"""Rewrite the version line and lockstep sibling pins in a pyproject.
|
||||
|
||||
The release process pins the sibling SDK packages in lockstep
|
||||
(e.g. ``"omnigent-client==0.1.0rc2"``). Stamping only the
|
||||
``version = "..."`` line would leave those exact pins pointing at
|
||||
the unstamped base version, which no built wheel carries — so the
|
||||
app-level ``uv lock`` becomes unsatisfiable. Rewrite both.
|
||||
|
||||
:param path: Pyproject file to rewrite, e.g.
|
||||
``<repo>/sdks/ui/pyproject.toml``.
|
||||
:param new_version: Stamped deploy version, e.g. ``"0.1.0rc2.post123"``.
|
||||
:returns: The original file text, for restore after the build.
|
||||
"""
|
||||
original = path.read_text()
|
||||
updated, count = re.subn(
|
||||
r'(?m)^version\s*=\s*"[^"]+"',
|
||||
f'version = "{new_version}"',
|
||||
original,
|
||||
count=1,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError(f"could not rewrite version in {path}")
|
||||
# The lockstep graph is circular: omnigent pins both SDKs, the
|
||||
# client pins omnigent back, and the ui-sdk pins the client. All
|
||||
# three names must be stamped or the resolver still dead-ends.
|
||||
updated = re.sub(
|
||||
r'"(omnigent(?:-client|-ui-sdk)?)==[^"]+"',
|
||||
rf'"\1=={new_version}"',
|
||||
updated,
|
||||
)
|
||||
path.write_text(updated)
|
||||
return original
|
||||
|
||||
|
||||
def _stamp_versions(new_version: str) -> dict[Path, str]:
|
||||
"""Stamp `new_version` into all three pyprojects. Returns originals for restore."""
|
||||
backups: dict[Path, str] = {}
|
||||
for path in _pyproject_paths():
|
||||
backups[path] = set_version_in_pyproject(path, new_version)
|
||||
return backups
|
||||
|
||||
|
||||
def _restore_versions(backups: dict[Path, str]) -> None:
|
||||
for path, original in backups.items():
|
||||
path.write_text(original)
|
||||
|
||||
|
||||
def _clean_build_artifacts() -> None:
|
||||
"""Remove stale build outputs.
|
||||
|
||||
Old Vite bundles in ``omnigent/server/static/web-ui/``
|
||||
accumulate uniquely-hashed JS chunk filenames between builds.
|
||||
Without this sweep, orphan files get bundled into the main wheel
|
||||
and push it over the 10 MB Workspace upload limit.
|
||||
"""
|
||||
root = _repo_root()
|
||||
targets = [
|
||||
root / "omnigent" / "server" / "static" / "web-ui",
|
||||
root / "dist",
|
||||
root / "build",
|
||||
root / "omnigent.egg-info",
|
||||
]
|
||||
for target in targets:
|
||||
if target.exists():
|
||||
_log(f"cleaning {target.relative_to(root)}")
|
||||
shutil.rmtree(target)
|
||||
|
||||
|
||||
def _build_wheels(skip_web_ui: bool) -> list[Path]:
|
||||
"""Invoke build.sh and return the resulting wheel paths."""
|
||||
root = _repo_root()
|
||||
build_sh = _deploy_dir() / "build.sh"
|
||||
env = os.environ.copy()
|
||||
if skip_web_ui:
|
||||
env["SKIP_WEB_UI"] = "1"
|
||||
_log(f"$ {build_sh}" + (" (SKIP_WEB_UI=1)" if skip_web_ui else ""))
|
||||
subprocess.run([str(build_sh)], cwd=root, env=env, check=True)
|
||||
wheels = sorted((root / "dist").glob("*.whl"))
|
||||
if not wheels:
|
||||
raise RuntimeError("build.sh produced no wheels")
|
||||
return wheels
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ClassifiedWheels:
|
||||
"""Result of sorting built wheels by size for upload routing.
|
||||
|
||||
:param main: The top-level ``omnigent`` wheel — always uploaded
|
||||
with the ``[databricks]`` extra.
|
||||
:param small: Wheels ≤ 10 MB. Uploaded into the bundle's
|
||||
``source_code_path`` and referenced by relative path.
|
||||
:param oversize: Wheels > 10 MB. These cannot be used by the
|
||||
uv-based app payload because ``uv lock`` validates local path
|
||||
sources before the bundle is synced.
|
||||
"""
|
||||
|
||||
main: Path
|
||||
small: list[Path]
|
||||
oversize: list[Path]
|
||||
|
||||
|
||||
def _classify_wheels(wheels: Iterable[Path]) -> _ClassifiedWheels:
|
||||
main_wheel = next(w for w in wheels if w.name.startswith("omnigent-"))
|
||||
small: list[Path] = []
|
||||
oversize: list[Path] = []
|
||||
for wheel in wheels:
|
||||
if wheel.stat().st_size <= _WORKSPACE_WHEEL_LIMIT_BYTES:
|
||||
small.append(wheel)
|
||||
else:
|
||||
oversize.append(wheel)
|
||||
return _ClassifiedWheels(main=main_wheel, small=small, oversize=oversize)
|
||||
|
||||
|
||||
def _wheel_version(wheel: Path, prefix: str) -> str:
|
||||
"""Extract a deploy version from an Omnigent wheel filename.
|
||||
|
||||
:param wheel: Built wheel path, e.g.
|
||||
``dist/omnigent-0.1.0.post123-py3-none-any.whl``.
|
||||
:param prefix: Expected wheel filename prefix, e.g. ``"omnigent-"``.
|
||||
:returns: Version embedded in the wheel filename, e.g.
|
||||
``"0.1.0.post123"``.
|
||||
:raises RuntimeError: If the wheel filename does not match the
|
||||
deploy script's expected wheel naming convention.
|
||||
"""
|
||||
pattern = rf"^{re.escape(prefix)}(?P<version>.+)-py3-none-any\.whl$"
|
||||
match = re.match(pattern, wheel.name)
|
||||
if not match:
|
||||
raise RuntimeError(f"unexpected wheel name for {prefix}: {wheel.name}")
|
||||
return match.group("version")
|
||||
|
||||
|
||||
def _derive_deploy_version_from_wheels(wheels: list[Path]) -> str:
|
||||
"""Derive the deploy version from reused ``dist/`` wheels.
|
||||
|
||||
:param wheels: Wheel files from ``dist/``.
|
||||
:returns: Shared wheel version, e.g. ``"0.1.0.post123"``.
|
||||
:raises RuntimeError: If any required wheel is missing or the
|
||||
wheel versions do not match.
|
||||
"""
|
||||
expected_prefixes = ("omnigent-", "omnigent_client-", "omnigent_ui_sdk-")
|
||||
versions = []
|
||||
for prefix in expected_prefixes:
|
||||
matching = [wheel for wheel in wheels if wheel.name.startswith(prefix)]
|
||||
if len(matching) != 1:
|
||||
raise RuntimeError(f"expected exactly one {prefix} wheel, found {len(matching)}")
|
||||
versions.append(_wheel_version(matching[0], prefix))
|
||||
if len(set(versions)) != 1:
|
||||
raise RuntimeError(f"wheel versions do not match: {versions}")
|
||||
return versions[0]
|
||||
|
||||
|
||||
def _sweep_local_src_wheels(keep: set[str]) -> None:
|
||||
"""Delete Omnigent wheels from src/ whose filename is not in `keep`.
|
||||
|
||||
Old deploys accumulate wheels here. Databricks Apps installs the
|
||||
source directory as a project, so stale wheels can keep local path
|
||||
sources or lockfile entries alive if we leave them around. Trim to
|
||||
exactly the wheels we just built.
|
||||
"""
|
||||
src = _src_dir()
|
||||
if not src.exists():
|
||||
return
|
||||
for entry in src.iterdir():
|
||||
if not entry.is_file() or entry.suffix != ".whl":
|
||||
continue
|
||||
if not any(entry.name.startswith(p) for p in _WHEEL_PREFIXES):
|
||||
continue
|
||||
if entry.name in keep:
|
||||
continue
|
||||
_log(f"removing stale local wheel {entry.relative_to(_repo_root())}")
|
||||
entry.unlink()
|
||||
|
||||
|
||||
def _toml_string(value: str) -> str:
|
||||
"""Return ``value`` encoded as a TOML basic string.
|
||||
|
||||
:param value: String to encode for generated TOML, e.g.
|
||||
``"./omnigent-0.1.0-py3-none-any.whl"``.
|
||||
:returns: TOML string literal with quotes and backslashes escaped.
|
||||
"""
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def _wheel_source_path(wheel: Path) -> str:
|
||||
"""Return the path Databricks Apps should use for a wheel source.
|
||||
|
||||
:param wheel: Built wheel path, e.g.
|
||||
``dist/omnigent-0.1.0-py3-none-any.whl``.
|
||||
:returns: Relative source path for bundled wheels.
|
||||
:raises SystemExit: If ``wheel`` is oversize.
|
||||
"""
|
||||
if wheel.stat().st_size <= _WORKSPACE_WHEEL_LIMIT_BYTES:
|
||||
return f"./{wheel.name}"
|
||||
raise SystemExit(
|
||||
f"wheel {wheel.name} is over 10 MB; uv-based Databricks Apps "
|
||||
"deploys require all wheels in the app source snapshot"
|
||||
)
|
||||
|
||||
|
||||
def _uv_source_lines(
|
||||
main_wheel: Path,
|
||||
small_wheels: list[Path],
|
||||
oversize_wheels: list[Path],
|
||||
) -> list[str]:
|
||||
"""Build ``[tool.uv.sources]`` lines for the three deploy wheels.
|
||||
|
||||
:param main_wheel: Top-level ``omnigent`` wheel path, e.g.
|
||||
``dist/omnigent-0.1.0-py3-none-any.whl``.
|
||||
:param small_wheels: Wheels copied into the app source directory.
|
||||
:param oversize_wheels: Wheels too large for the app source directory.
|
||||
:returns: TOML lines mapping package names to wheel paths.
|
||||
"""
|
||||
wheels = {wheel.name: wheel for wheel in [*small_wheels, *oversize_wheels]}
|
||||
if main_wheel.name not in wheels:
|
||||
raise RuntimeError(f"main wheel {main_wheel.name} was not classified for deployment")
|
||||
source_lines = []
|
||||
for package_name, wheel_prefix in (
|
||||
("omnigent", "omnigent-"),
|
||||
("omnigent-client", "omnigent_client-"),
|
||||
("omnigent-ui-sdk", "omnigent_ui_sdk-"),
|
||||
):
|
||||
wheel = next(wheel for name, wheel in wheels.items() if name.startswith(wheel_prefix))
|
||||
source = _wheel_source_path(wheel)
|
||||
source_lines.append(f"{package_name} = {{ path = {_toml_string(source)} }}")
|
||||
return source_lines
|
||||
|
||||
|
||||
def build_uv_pyproject(
|
||||
main_wheel: Path,
|
||||
small_wheels: list[Path],
|
||||
oversize_wheels: list[Path],
|
||||
deploy_version: str,
|
||||
) -> str:
|
||||
"""Compose the app-level ``pyproject.toml`` for Databricks Apps.
|
||||
|
||||
:param main_wheel: Top-level ``omnigent`` wheel path, e.g.
|
||||
``dist/omnigent-0.1.0-py3-none-any.whl``.
|
||||
:param small_wheels: Wheels copied into the app source directory.
|
||||
:param oversize_wheels: Wheels too large for the app source directory.
|
||||
:param deploy_version: Version stamped into the wheels, e.g.
|
||||
``"0.1.0.post123"``.
|
||||
:returns: Complete TOML text for ``src/pyproject.toml``.
|
||||
"""
|
||||
source_lines = _uv_source_lines(main_wheel, small_wheels, oversize_wheels)
|
||||
dependencies = [
|
||||
f'"omnigent[databricks]=={deploy_version}"',
|
||||
f'"omnigent-client=={deploy_version}"',
|
||||
f'"omnigent-ui-sdk=={deploy_version}"',
|
||||
]
|
||||
return (
|
||||
"[project]\n"
|
||||
'name = "omnigent-databricks-app"\n'
|
||||
'version = "0.0.0"\n'
|
||||
f"requires-python = {_toml_string(_APP_REQUIRES_PYTHON)}\n"
|
||||
"dependencies = [\n"
|
||||
+ "".join(f" {dependency},\n" for dependency in dependencies)
|
||||
+ "]\n\n"
|
||||
"[tool.uv.sources]\n" + "\n".join(source_lines) + "\n"
|
||||
)
|
||||
|
||||
|
||||
def run_uv_lock(src: Path) -> None:
|
||||
"""Generate ``uv.lock`` for the Databricks Apps source directory.
|
||||
|
||||
:param src: App source directory containing ``pyproject.toml``,
|
||||
e.g. ``deploy/databricks/src``.
|
||||
"""
|
||||
# Honor a caller-supplied UV_INDEX_URL (e.g. a private mirror or proxy);
|
||||
# otherwise default to public PyPI. UV_INDEX / UV_DEFAULT_INDEX are dropped
|
||||
# so a stray value in the shell can't shadow the index we lock against.
|
||||
index_url = os.environ.get("UV_INDEX_URL") or _UV_DEFAULT_INDEX_URL
|
||||
env = os.environ.copy()
|
||||
env.pop("UV_INDEX", None)
|
||||
env.pop("UV_DEFAULT_INDEX", None)
|
||||
env["UV_INDEX_URL"] = index_url
|
||||
_log(f"uv lock --python 3.12 --index-url {index_url}")
|
||||
subprocess.run(
|
||||
["uv", "lock", "--python", "3.12", "--index-url", index_url],
|
||||
cwd=src,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def write_uv_dependency_files(
|
||||
src: Path,
|
||||
main_wheel: Path,
|
||||
small_wheels: list[Path],
|
||||
oversize_wheels: list[Path],
|
||||
deploy_version: str,
|
||||
) -> None:
|
||||
"""Write the uv dependency files Databricks Apps should install.
|
||||
|
||||
:param src: App source directory, e.g. ``deploy/databricks/src``.
|
||||
:param main_wheel: Top-level ``omnigent`` wheel path, e.g.
|
||||
``dist/omnigent-0.1.0-py3-none-any.whl``.
|
||||
:param small_wheels: Wheels copied into ``src``.
|
||||
:param oversize_wheels: Wheels too large for ``src``.
|
||||
:param deploy_version: Version stamped into the wheels, e.g.
|
||||
``"0.1.0.post123"``.
|
||||
"""
|
||||
requirements = src / "requirements.txt"
|
||||
if requirements.exists():
|
||||
_log(f"removing {requirements}; Databricks Apps must use uv")
|
||||
requirements.unlink()
|
||||
|
||||
pyproject = build_uv_pyproject(
|
||||
main_wheel,
|
||||
small_wheels,
|
||||
oversize_wheels,
|
||||
deploy_version,
|
||||
)
|
||||
(src / "pyproject.toml").write_text(pyproject)
|
||||
_log("src/pyproject.toml:\n" + pyproject)
|
||||
run_uv_lock(src)
|
||||
|
||||
|
||||
def _smoke_check(wc: WorkspaceClient, app_url: str) -> None:
|
||||
"""Poll /health on the running app and fail if it never returns 200.
|
||||
|
||||
``databricks bundle run`` returns as soon as the app start is
|
||||
signalled, but uvicorn takes a few extra seconds to bind. Retry
|
||||
up to a minute to ride out the warm-up; surface the most recent
|
||||
error if it never goes green.
|
||||
"""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
token = wc.config.authenticate()["Authorization"].removeprefix("Bearer ").strip()
|
||||
url = f"{app_url.rstrip('/')}/health"
|
||||
|
||||
last_err: str = ""
|
||||
for attempt in range(12):
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
body = resp.read().decode()
|
||||
if resp.status == 200:
|
||||
_log(f"/health ok: {body!r}")
|
||||
return
|
||||
last_err = f"HTTP {resp.status}: {body!r}"
|
||||
except urllib.error.HTTPError as exc:
|
||||
last_err = f"HTTP {exc.code}: {exc.reason}"
|
||||
except urllib.error.URLError as exc:
|
||||
last_err = f"URLError: {exc.reason}"
|
||||
if attempt < 11:
|
||||
time.sleep(5)
|
||||
raise RuntimeError(f"/health did not return 200 within 60s; last: {last_err}")
|
||||
|
||||
|
||||
def _assert_clean_tree(skip: bool) -> None:
|
||||
"""Refuse to deploy if the working tree has uncommitted changes or
|
||||
HEAD is not at origin/main.
|
||||
|
||||
Pass ``--allow-dirty`` to override. Reason: deploys are intended
|
||||
to come from a known commit on main, so the deployed app code is
|
||||
reproducible from git history. A dirty tree means whatever we
|
||||
deploy is not reachable from any commit, which is the cause of
|
||||
nearly every "wait, what's actually live?" debugging session.
|
||||
"""
|
||||
root = _repo_root()
|
||||
if skip:
|
||||
_log("--allow-dirty: skipping clean-tree assertion")
|
||||
return
|
||||
status = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
if status.strip():
|
||||
raise SystemExit(
|
||||
"working tree has uncommitted changes:\n"
|
||||
+ status
|
||||
+ "\ncommit or stash, or pass --allow-dirty to override."
|
||||
)
|
||||
subprocess.run(["git", "fetch", "origin", "main", "--quiet"], cwd=root, check=True)
|
||||
head = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
main = subprocess.run(
|
||||
["git", "rev-parse", "origin/main"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
if head != main:
|
||||
raise SystemExit(
|
||||
f"HEAD ({head}) is not at origin/main ({main}); "
|
||||
f"rebase or pass --allow-dirty to override."
|
||||
)
|
||||
_log(f"clean tree at origin/main {head[:12]}")
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--app-name",
|
||||
required=True,
|
||||
help="Databricks App name, e.g. 'omnigent'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lakebase-branch",
|
||||
required=True,
|
||||
help=("Full Lakebase branch resource path, e.g. 'projects/omnigent/branches/production'."),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lakebase-database",
|
||||
required=True,
|
||||
help=(
|
||||
"Full Lakebase database resource path, e.g. "
|
||||
"'projects/omnigent/branches/production/databases/databricks-postgres'."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--volume-name",
|
||||
required=True,
|
||||
help=(
|
||||
"UC Volume full name (catalog.schema.volume) for artifact storage, "
|
||||
"e.g. 'main.omnigent.artifacts'."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compute-size",
|
||||
default="LARGE",
|
||||
choices=["SMALL", "MEDIUM", "LARGE"],
|
||||
help="App compute size. Pinned via databricks.yml so it doesn't drift.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--otel-table-schema",
|
||||
default="main.omnigent_logs",
|
||||
help=(
|
||||
"UC schema (catalog.schema) holding the OTel destination tables. "
|
||||
"The Databricks Apps platform writes logs/metrics/spans to "
|
||||
"<schema>.otel_{logs,metrics,spans}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
default="prod",
|
||||
help=(
|
||||
"DAB target from databricks.yml selecting the destination "
|
||||
"workspace, e.g. 'prod'. The authenticating identity "
|
||||
"(--profile or env) must belong to the target's workspace."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
default=None,
|
||||
help=(
|
||||
"Databricks CLI profile to authenticate with. Omit when running "
|
||||
"with env-based auth (DATABRICKS_HOST + DATABRICKS_CLIENT_ID + "
|
||||
"DATABRICKS_CLIENT_SECRET)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
default=None,
|
||||
help=(
|
||||
"Explicit PEP 440 version to stamp into pyprojects for this "
|
||||
"deploy. Default: <base-version>.post<unix-ts>."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Reuse existing dist/ wheels — skip the npm + uv build.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-web-ui",
|
||||
action="store_true",
|
||||
help="Build without the SPA (API-only deploy).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--app-url",
|
||||
default=None,
|
||||
help=(
|
||||
"Override the smoke-check URL. By default the script reads "
|
||||
"the URL from the App resource returned by the SDK."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-smoke-check",
|
||||
action="store_true",
|
||||
help="Skip the post-deploy /health check.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-version-bump",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Don't restore pyproject.toml files after build. Useful "
|
||||
"when you want the bumped version to land in a commit "
|
||||
"(e.g. CI auto-tagged release builds)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-dirty",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Skip the assertion that the working tree is clean and at "
|
||||
"origin/main. Deploys are normally required to be from a "
|
||||
"known commit on main."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _clear_env_vars() -> None:
|
||||
for name in _ENV_VARS_TO_CLEAR:
|
||||
if name in os.environ:
|
||||
_log(f"unsetting {name} to avoid leaking into the SDK")
|
||||
del os.environ[name]
|
||||
|
||||
|
||||
def _ensure_bound(args: argparse.Namespace) -> None:
|
||||
"""Bind the bundle to the named app if it exists but is unbound.
|
||||
|
||||
If the app already exists in the workspace and the bundle has no
|
||||
Terraform state for it (always true on a fresh checkout),
|
||||
``bundle deploy`` fails with "An app with the same name already
|
||||
exists". The fix is ``bundle deployment bind``, which adopts the
|
||||
existing app into the bundle's state.
|
||||
|
||||
There's no clean way to ask "is the bundle already bound to this
|
||||
app" — ``bundle summary`` reflects what's *declared* in the YAML,
|
||||
not what's tracked by Terraform. So we just always try to bind
|
||||
when the app exists and treat "already managed by Terraform" as
|
||||
success.
|
||||
"""
|
||||
# Late-import so --help works without the SDK.
|
||||
from databricks.sdk import WorkspaceClient
|
||||
from databricks.sdk.errors.platform import NotFound
|
||||
|
||||
wc = WorkspaceClient(profile=args.profile) if args.profile else WorkspaceClient()
|
||||
try:
|
||||
wc.apps.get(name=args.app_name)
|
||||
except NotFound:
|
||||
_log(f"app {args.app_name!r} not found; bundle deploy will create it")
|
||||
return
|
||||
|
||||
_log(f"binding bundle resource {_BUNDLE_RESOURCE_KEY!r} → app {args.app_name!r}")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"databricks",
|
||||
"bundle",
|
||||
"deployment",
|
||||
"bind",
|
||||
_BUNDLE_RESOURCE_KEY,
|
||||
args.app_name,
|
||||
"--target",
|
||||
args.target,
|
||||
"--auto-approve",
|
||||
*_profile_arg(args),
|
||||
*_bundle_vars(args),
|
||||
],
|
||||
cwd=_deploy_dir(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
combined = "".join(stream for stream in (result.stdout, result.stderr) if stream)
|
||||
if "already managed" in combined.lower() or "already bound" in combined.lower():
|
||||
_log("bundle already bound to this app; continuing")
|
||||
return
|
||||
sys.stderr.write(combined)
|
||||
raise SystemExit(f"bundle deployment bind failed (exit {result.returncode})")
|
||||
|
||||
|
||||
def _ensure_compute_size(
|
||||
wc: WorkspaceClient,
|
||||
app_name: str,
|
||||
desired: str,
|
||||
) -> None:
|
||||
"""Resize the app to `desired` if it's not already there.
|
||||
|
||||
The bundle's Terraform databricks_app resource can't update
|
||||
compute_size on an existing app (the old apps update API rejects
|
||||
it). The newer ``apps.create_update`` endpoint can. Run that here
|
||||
so the subsequent bundle deploy sees no diff and doesn't error.
|
||||
"""
|
||||
from databricks.sdk.errors.platform import NotFound
|
||||
from databricks.sdk.service.apps import App, ComputeSize
|
||||
|
||||
try:
|
||||
current = wc.apps.get(name=app_name)
|
||||
except NotFound:
|
||||
_log(f"app {app_name!r} not found; bundle deploy will create at {desired}")
|
||||
return
|
||||
|
||||
current_value = current.compute_size.value if current.compute_size else None
|
||||
if current_value == desired:
|
||||
_log(f"compute_size already {desired}; skipping resize")
|
||||
return
|
||||
|
||||
_log(f"resizing app {app_name!r}: {current_value} → {desired}")
|
||||
wc.apps.create_update_and_wait(
|
||||
app_name=app_name,
|
||||
update_mask="compute_size",
|
||||
app=App(name=app_name, compute_size=ComputeSize(desired)),
|
||||
)
|
||||
|
||||
|
||||
def _bundle_vars(args: argparse.Namespace) -> list[str]:
|
||||
"""CLI args to pass to `databricks bundle` as --var pairs."""
|
||||
return [
|
||||
"--var",
|
||||
f"app_name={args.app_name}",
|
||||
"--var",
|
||||
f"lakebase_branch={args.lakebase_branch}",
|
||||
"--var",
|
||||
f"lakebase_database={args.lakebase_database}",
|
||||
"--var",
|
||||
f"volume_name={args.volume_name}",
|
||||
"--var",
|
||||
f"otel_table_schema={args.otel_table_schema}",
|
||||
]
|
||||
|
||||
|
||||
def _profile_arg(args: argparse.Namespace) -> list[str]:
|
||||
return ["--profile", args.profile] if args.profile else []
|
||||
|
||||
|
||||
def _ensure_app_sp_uc_traversal(
|
||||
args: argparse.Namespace,
|
||||
app_sp: str | None,
|
||||
) -> None:
|
||||
"""Grant USE_CATALOG + USE_SCHEMA to the app SP on the volume's parents.
|
||||
|
||||
Apps' ``uc_securable`` only grants the leaf (WRITE_VOLUME); the
|
||||
SP can boot but 403s on first volume read if the parent catalog
|
||||
doesn't grant USE to ``account users``. Idempotent.
|
||||
"""
|
||||
if not app_sp:
|
||||
_log("app SP not resolved yet; skipping UC traversal grants")
|
||||
return
|
||||
|
||||
parts = args.volume_name.split(".")
|
||||
if len(parts) != 3:
|
||||
raise SystemExit(f"--volume-name {args.volume_name!r} must be catalog.schema.volume")
|
||||
catalog, schema_only, _ = parts
|
||||
schema_fqn = f"{catalog}.{schema_only}"
|
||||
|
||||
import json as _json
|
||||
|
||||
for kind, fqn, priv in (
|
||||
("catalog", catalog, "USE_CATALOG"),
|
||||
("schema", schema_fqn, "USE_SCHEMA"),
|
||||
):
|
||||
_log(f"granting {priv} on {kind} {fqn} → app SP {app_sp}")
|
||||
payload = _json.dumps({"changes": [{"principal": app_sp, "add": [priv]}]})
|
||||
subprocess.run(
|
||||
[
|
||||
"databricks",
|
||||
"grants",
|
||||
"update",
|
||||
kind,
|
||||
fqn,
|
||||
*_profile_arg(args),
|
||||
"--json",
|
||||
payload,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
_clear_env_vars()
|
||||
_assert_clean_tree(skip=args.allow_dirty)
|
||||
|
||||
base_version = _read_base_version()
|
||||
deploy_version = _compute_deploy_version(base_version, args.version)
|
||||
_log(f"deploy version: {deploy_version} (base: {base_version})")
|
||||
|
||||
backups: dict[Path, str] = {}
|
||||
try:
|
||||
if not args.skip_build:
|
||||
_clean_build_artifacts()
|
||||
backups = _stamp_versions(deploy_version)
|
||||
wheels = _build_wheels(skip_web_ui=args.skip_web_ui)
|
||||
else:
|
||||
dist = _repo_root() / "dist"
|
||||
wheels = sorted(dist.glob("*.whl"))
|
||||
if not wheels:
|
||||
raise SystemExit("--skip-build was set but dist/ has no wheels to redeploy")
|
||||
wheel_version = _derive_deploy_version_from_wheels(wheels)
|
||||
if args.version and args.version != wheel_version:
|
||||
raise SystemExit(
|
||||
f"--version {args.version!r} does not match reused wheel "
|
||||
f"version {wheel_version!r}"
|
||||
)
|
||||
deploy_version = wheel_version
|
||||
_log(f"deploy version from reused wheels: {deploy_version}")
|
||||
_log(f"reusing wheels: {[w.name for w in wheels]}")
|
||||
finally:
|
||||
if backups and not args.keep_version_bump:
|
||||
_restore_versions(backups)
|
||||
|
||||
classified = _classify_wheels(wheels)
|
||||
for wheel in wheels:
|
||||
size_mb = wheel.stat().st_size / 1024 / 1024
|
||||
_log(f" {wheel.name} {size_mb:.2f} MB")
|
||||
if classified.oversize:
|
||||
raise SystemExit(
|
||||
"uv-based Databricks Apps deploys require all Omnigent wheels "
|
||||
"to fit in the app source snapshot. Rebuild with --skip-web-ui "
|
||||
"or reduce wheel size; UC Volume wheel paths are not used "
|
||||
"because uv lock validates path sources locally."
|
||||
)
|
||||
|
||||
# Late-import the SDK so `--help` works without it installed.
|
||||
from databricks.sdk import WorkspaceClient
|
||||
|
||||
wc = WorkspaceClient(profile=args.profile) if args.profile else WorkspaceClient()
|
||||
|
||||
# 1) Prep the bundle's source_code_path (src/) — sweep stale
|
||||
# wheels locally, then copy the new small wheels in.
|
||||
src = _src_dir()
|
||||
src.mkdir(parents=True, exist_ok=True)
|
||||
_sweep_local_src_wheels(keep={w.name for w in classified.small})
|
||||
for wheel in classified.small:
|
||||
dest = src / wheel.name
|
||||
_log(f"copy {wheel.name} → {dest.relative_to(_repo_root())}")
|
||||
shutil.copy2(wheel, dest)
|
||||
|
||||
# 2) Generate pyproject.toml + uv.lock. Remove requirements.txt
|
||||
# first because Databricks Apps gives it precedence over uv.
|
||||
write_uv_dependency_files(
|
||||
src,
|
||||
classified.main,
|
||||
classified.small,
|
||||
classified.oversize,
|
||||
deploy_version,
|
||||
)
|
||||
|
||||
# 4) Bind the bundle to the existing app (if any).
|
||||
_ensure_bound(args)
|
||||
|
||||
# 4a) Reconcile compute_size out-of-band. Terraform's databricks_app
|
||||
# update path doesn't support compute_size changes ("not supported
|
||||
# in this update API"). The new SDK apps.create_update endpoint
|
||||
# does — call it ourselves so the subsequent bundle deploy sees no
|
||||
# diff. Skipped when already matching.
|
||||
_ensure_compute_size(wc, args.app_name, args.compute_size)
|
||||
|
||||
# 5) databricks bundle deploy --target <target> (syncs src/ to the
|
||||
# bundle workspace folder and creates/updates the app resource).
|
||||
_log(f"databricks bundle deploy --target {args.target}")
|
||||
subprocess.run(
|
||||
[
|
||||
"databricks",
|
||||
"bundle",
|
||||
"deploy",
|
||||
"--target",
|
||||
args.target,
|
||||
*_profile_arg(args),
|
||||
*_bundle_vars(args),
|
||||
],
|
||||
cwd=_deploy_dir(),
|
||||
check=True,
|
||||
)
|
||||
|
||||
# 5) databricks bundle run <key> --target <target> (starts/restarts
|
||||
# the app with the just-deployed source).
|
||||
_log(f"databricks bundle run {_BUNDLE_RESOURCE_KEY} --target {args.target}")
|
||||
subprocess.run(
|
||||
[
|
||||
"databricks",
|
||||
"bundle",
|
||||
"run",
|
||||
_BUNDLE_RESOURCE_KEY,
|
||||
"--target",
|
||||
args.target,
|
||||
*_profile_arg(args),
|
||||
*_bundle_vars(args),
|
||||
],
|
||||
cwd=_deploy_dir(),
|
||||
check=True,
|
||||
)
|
||||
|
||||
# 6) Resolve URL + smoke-check.
|
||||
app = wc.apps.get(name=args.app_name)
|
||||
app_url = args.app_url or app.url
|
||||
|
||||
# 7) Grant the app SP USE_CATALOG + USE_SCHEMA on the volume's
|
||||
# parents (Apps' uc_securable resource only grants the leaf).
|
||||
_ensure_app_sp_uc_traversal(args, app.service_principal_client_id)
|
||||
|
||||
if not args.no_smoke_check:
|
||||
if not app_url:
|
||||
raise SystemExit("no app URL available for smoke check")
|
||||
_smoke_check(wc, app_url)
|
||||
_log(f"done. app: {app_url}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Grant a Databricks App service principal Lakebase schema privileges.
|
||||
|
||||
Run this after ``wc.apps.create`` creates the app service principal and
|
||||
before ``wc.apps.deploy`` starts the app. The app needs these grants so
|
||||
Alembic can create and migrate Omnigent tables on first boot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Protocol, cast
|
||||
|
||||
import psycopg
|
||||
from databricks.sdk import WorkspaceClient
|
||||
|
||||
|
||||
class _GrantArgs(Protocol):
|
||||
"""
|
||||
Parsed CLI arguments for the grant helper.
|
||||
|
||||
:param app_name: Databricks App name, e.g. ``"omnigent"``.
|
||||
:param lakebase_endpoint: Full Lakebase endpoint resource path, e.g.
|
||||
``"projects/omnigent/branches/production/endpoints/primary"``.
|
||||
:param database: PostgreSQL database name, e.g.
|
||||
``"databricks_postgres"``.
|
||||
:param profile: Optional Databricks CLI profile name, e.g.
|
||||
``"<your-profile>"``.
|
||||
"""
|
||||
|
||||
app_name: str
|
||||
lakebase_endpoint: str
|
||||
database: str
|
||||
profile: str | None
|
||||
|
||||
|
||||
def _parse_args() -> _GrantArgs:
|
||||
"""
|
||||
Parse command-line arguments for the grant helper.
|
||||
|
||||
:returns: Parsed CLI arguments.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--app-name",
|
||||
required=True,
|
||||
help="Databricks App name, e.g. 'omnigent'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lakebase-endpoint",
|
||||
required=True,
|
||||
help=(
|
||||
"Full Lakebase endpoint resource path, e.g. "
|
||||
"'projects/omnigent/branches/production/endpoints/primary'."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--database",
|
||||
required=True,
|
||||
help="PostgreSQL database name, e.g. 'databricks_postgres'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
default=None,
|
||||
help="Optional Databricks CLI profile name, e.g. '<your-profile>'.",
|
||||
)
|
||||
return cast(_GrantArgs, parser.parse_args())
|
||||
|
||||
|
||||
def _grant_sql(sp_uuid: str) -> str:
|
||||
"""
|
||||
Build the GRANT statement block for an app service principal.
|
||||
|
||||
:param sp_uuid: App service principal client ID, e.g.
|
||||
``"00000000-0000-0000-0000-000000000000"``. Lakebase creates a
|
||||
PostgreSQL role with this identifier when the app is created.
|
||||
:returns: SQL statements granting schema, table, and sequence
|
||||
privileges in the PostgreSQL ``public`` schema.
|
||||
"""
|
||||
escaped = sp_uuid.replace('"', '""')
|
||||
quoted = f'"{escaped}"'
|
||||
return f"""
|
||||
GRANT ALL ON SCHEMA public TO {quoted};
|
||||
GRANT ALL ON ALL TABLES IN SCHEMA public TO {quoted};
|
||||
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO {quoted};
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO {quoted};
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO {quoted};
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_endpoint_host(wc: WorkspaceClient, endpoint_name: str) -> str | None:
|
||||
"""
|
||||
Resolve the Lakebase endpoint hostname.
|
||||
|
||||
:param wc: Databricks workspace client.
|
||||
:param endpoint_name: Full Lakebase endpoint resource path, e.g.
|
||||
``"projects/omnigent/branches/production/endpoints/primary"``.
|
||||
:returns: Endpoint hostname, or ``None`` when the endpoint is not ready.
|
||||
"""
|
||||
endpoint = wc.postgres.get_endpoint(name=endpoint_name)
|
||||
if endpoint.status is None or endpoint.status.hosts is None:
|
||||
return None
|
||||
return endpoint.status.hosts.host
|
||||
|
||||
|
||||
def _build_conn_params(
|
||||
wc: WorkspaceClient, host: str, database: str, endpoint_name: str
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Build psycopg connection params using a short-lived Lakebase OAuth token.
|
||||
|
||||
Returned as keyword params (not a hand-built conninfo string) so the
|
||||
token — which we don't control the contents of — is never string-
|
||||
interpolated into a DSN where whitespace or ``key=value`` metacharacters
|
||||
could be mis-parsed.
|
||||
|
||||
:param wc: Databricks workspace client.
|
||||
:param host: Lakebase endpoint hostname, e.g.
|
||||
``"example.database.cloud.databricks.com"``.
|
||||
:param database: PostgreSQL database name, e.g.
|
||||
``"databricks_postgres"``.
|
||||
:param endpoint_name: Full Lakebase endpoint resource path, e.g.
|
||||
``"projects/omnigent/branches/production/endpoints/primary"``.
|
||||
:returns: psycopg connection keyword params for the current user.
|
||||
"""
|
||||
cred = wc.postgres.generate_database_credential(endpoint=endpoint_name)
|
||||
pg_user = wc.current_user.me().user_name
|
||||
return {
|
||||
"host": host,
|
||||
"port": "5432",
|
||||
"dbname": database,
|
||||
"user": pg_user,
|
||||
"password": cred.token,
|
||||
"sslmode": "require",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
Resolve the app service principal and apply Lakebase grants.
|
||||
|
||||
:returns: Process exit code, ``0`` on success and ``1`` when the app
|
||||
or Lakebase endpoint is not ready.
|
||||
"""
|
||||
args = _parse_args()
|
||||
|
||||
wc = WorkspaceClient(profile=args.profile) if args.profile else WorkspaceClient()
|
||||
|
||||
app = wc.apps.get(name=args.app_name)
|
||||
sp_uuid = app.service_principal_client_id
|
||||
if not sp_uuid:
|
||||
print(
|
||||
f"ERROR: app '{args.app_name}' has no service_principal_client_id "
|
||||
"yet; wait for wc.apps.create() to finish.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
host = _resolve_endpoint_host(wc, args.lakebase_endpoint)
|
||||
if not host:
|
||||
print(
|
||||
f"ERROR: endpoint '{args.lakebase_endpoint}' has no hostname "
|
||||
"in status; wait for the endpoint to become ACTIVE.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"==> Granting public schema privileges to app SP {sp_uuid}")
|
||||
params = _build_conn_params(wc, host, args.database, args.lakebase_endpoint)
|
||||
with psycopg.connect(autocommit=True, **params) as conn, conn.cursor() as cur:
|
||||
cur.execute(_grant_sql(sp_uuid))
|
||||
|
||||
print("Done. The app can create and migrate Omnigent tables on first boot.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Databricks Apps entry point for omnigent.
|
||||
|
||||
Starts omnigent with Lakebase (managed PostgreSQL) as the
|
||||
database and UC Volumes as the artifact store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
logging.basicConfig(level=logging.INFO, stream=sys.stderr, force=True)
|
||||
logger = logging.getLogger("omnigent-app")
|
||||
|
||||
# ── Lakebase token cache ──────────────────────────────────
|
||||
#
|
||||
# Lakebase tokens are valid for ~60 minutes. The previous design
|
||||
# minted a fresh token on every new physical Postgres connection
|
||||
# inside SQLAlchemy's ``do_connect`` event hook — a synchronous
|
||||
# Databricks SDK HTTPS round-trip costing 100–300 ms per call.
|
||||
# Under the 200-runner load test that meant ~20 mints/minute as
|
||||
# the pool churned overflow connections, with each mint blocking
|
||||
# whatever thread (sometimes the asyncio event-loop thread) was
|
||||
# establishing the connection.
|
||||
#
|
||||
# This cache mints once per endpoint and reuses the token across
|
||||
# all subsequent ``do_connect`` calls until the TTL expires. 50
|
||||
# minutes leaves a 10-minute safety margin before Lakebase rejects
|
||||
# the token. Concurrent first-time mints are NOT serialized — we
|
||||
# release the lock around the SDK call so a thundering herd of
|
||||
# initial connections all mints once each (worst case) rather than
|
||||
# waiting on a single in-flight mint. The cache is then populated
|
||||
# atomically with whichever mint finishes first; the late losers
|
||||
# just overwrite with their own (equally-valid) token.
|
||||
_TOKEN_TTL_SECONDS = 50 * 60
|
||||
_token_cache: dict[str, tuple[str, float]] = {}
|
||||
_token_cache_lock = threading.Lock()
|
||||
|
||||
try:
|
||||
import sqlalchemy
|
||||
from databricks.sdk import WorkspaceClient
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────
|
||||
|
||||
# Required env vars — injected by Databricks Apps runtime from
|
||||
# the resources declared in databricks.yml / app.yaml.
|
||||
LAKEBASE_ENDPOINT = os.environ["AP_LAKEBASE_ENDPOINT"]
|
||||
VOLUME_PATH = os.environ["AP_ARTIFACT_VOLUME_PATH"]
|
||||
PGHOST = os.environ["PGHOST"]
|
||||
PGDATABASE = os.environ["PGDATABASE"]
|
||||
PGUSER = os.environ["PGUSER"]
|
||||
|
||||
# Optional with documented defaults.
|
||||
# Databricks Apps expects the app to listen on DATABRICKS_APP_PORT
|
||||
# (8000 by convention) — deliberately decoupled from the CLI's
|
||||
# local-server default (6767 in host/local_server.py).
|
||||
PORT = int(os.environ.get("DATABRICKS_APP_PORT", "8000"))
|
||||
PGPORT = os.environ.get("PGPORT", "5432")
|
||||
PGSSLMODE = os.environ.get("PGSSLMODE", "require")
|
||||
# Recycle DB connections before Lakebase 60-min token expiry.
|
||||
# 300s (5 min) is conservative — well under the 60-min token TTL.
|
||||
POOL_RECYCLE_SECONDS = int(os.environ.get("AP_POOL_RECYCLE_SECONDS", "300"))
|
||||
logger.info(
|
||||
"Config: PGHOST=%s PGDATABASE=%s PGUSER=%s VOLUME=%s PORT=%d",
|
||||
PGHOST,
|
||||
PGDATABASE,
|
||||
PGUSER,
|
||||
VOLUME_PATH,
|
||||
PORT,
|
||||
)
|
||||
|
||||
# ── Lakebase token injection ──────────────────────────────
|
||||
|
||||
_workspace_client = WorkspaceClient()
|
||||
|
||||
def _get_cached_token(endpoint: str) -> str:
|
||||
"""Return a cached Lakebase token for ``endpoint``, minting if needed.
|
||||
|
||||
Fast path: cached token whose expiry is in the future is returned
|
||||
without contacting the workspace. Slow path: mint a new token via
|
||||
the Databricks SDK (synchronous HTTPS). The mint runs OUTSIDE the
|
||||
cache lock so multiple concurrent first-time mints don't serialize
|
||||
behind one another — the last winner writes the cache, which is
|
||||
safe since every minted token is independently valid for ~60 min.
|
||||
|
||||
:param endpoint: Lakebase endpoint resource name, e.g.
|
||||
``"projects/foo/branches/production/endpoints/primary"``.
|
||||
:returns: A Lakebase database credential token.
|
||||
:raises RuntimeError: If the SDK returns a credential with no token.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with _token_cache_lock:
|
||||
cached = _token_cache.get(endpoint)
|
||||
if cached is not None and cached[1] > now:
|
||||
return cached[0]
|
||||
credential = _workspace_client.postgres.generate_database_credential(
|
||||
endpoint=endpoint,
|
||||
)
|
||||
if credential.token is None:
|
||||
raise RuntimeError("Lakebase credential response did not include a token")
|
||||
with _token_cache_lock:
|
||||
_token_cache[endpoint] = (credential.token, now + _TOKEN_TTL_SECONDS)
|
||||
return credential.token
|
||||
|
||||
# SQLAlchemy fixes the signature of do_connect; the dialect /
|
||||
# conn_rec / cargs args aren't used here, but they have to be
|
||||
# named so the hook accepts them. Underscore prefix tells the
|
||||
# linter we know they're unused.
|
||||
@sqlalchemy.event.listens_for(sqlalchemy.engine.Engine, "do_connect")
|
||||
def _inject_lakebase_credentials(_dialect, _conn_rec, _cargs, cparams):
|
||||
if cparams.get("host") != PGHOST:
|
||||
return
|
||||
cparams["password"] = _get_cached_token(LAKEBASE_ENDPOINT)
|
||||
cparams["sslmode"] = PGSSLMODE
|
||||
|
||||
# ── Start omnigent ─────────────────────────────────────
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
|
||||
from omnigent.runtime import init as init_runtime
|
||||
from omnigent.runtime import telemetry
|
||||
from omnigent.runtime.agent_cache import AgentCache
|
||||
from omnigent.runtime.caps import RuntimeCaps
|
||||
from omnigent.server.app import create_app
|
||||
from omnigent.server.auth import create_auth_provider
|
||||
|
||||
# OTel: the Databricks Apps platform auto-injects
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT when `telemetry_export_destinations`
|
||||
# is set on the app — telemetry.init() picks that up and routes
|
||||
# OTLP to the platform collector, which writes to the configured
|
||||
# UC tables. No-op if neither OTEL nor MLflow env vars are set.
|
||||
telemetry.init()
|
||||
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
||||
from omnigent.stores.artifact_store.databricks_volumes import (
|
||||
DatabricksVolumesArtifactStore,
|
||||
)
|
||||
from omnigent.stores.comment_store.sqlalchemy_store import (
|
||||
SqlAlchemyCommentStore,
|
||||
)
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
||||
SqlAlchemyConversationStore,
|
||||
)
|
||||
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
||||
from omnigent.stores.host_store import HostStore
|
||||
from omnigent.stores.permission_store.sqlalchemy_store import (
|
||||
SqlAlchemyPermissionStore,
|
||||
)
|
||||
from omnigent.stores.policy_store.sqlalchemy_store import SqlAlchemyPolicyStore
|
||||
|
||||
DB_URI = f"postgresql+psycopg://{PGUSER}@{PGHOST}:{PGPORT}/{PGDATABASE}"
|
||||
ARTIFACT_URI = f"dbfs:{VOLUME_PATH}"
|
||||
CACHE_DIR = Path(tempfile.mkdtemp(prefix="ap_cache_"))
|
||||
|
||||
logger.info("DB_URI: %s", DB_URI[:80])
|
||||
logger.info("ARTIFACT_URI: %s", ARTIFACT_URI)
|
||||
|
||||
# The app SP owns the tables — run any pending Alembic upgrades
|
||||
# before the stores boot, since the verify-schema check refuses
|
||||
# to start a stale DB. Idempotent: a no-op when the DB is at head.
|
||||
from omnigent.db.utils import _run_migrations as _run_alembic_upgrade
|
||||
|
||||
_migration_engine = sqlalchemy.create_engine(DB_URI)
|
||||
try:
|
||||
_run_alembic_upgrade(_migration_engine, DB_URI)
|
||||
finally:
|
||||
_migration_engine.dispose()
|
||||
|
||||
agent_store = SqlAlchemyAgentStore(DB_URI)
|
||||
file_store = SqlAlchemyFileStore(DB_URI)
|
||||
conversation_store = SqlAlchemyConversationStore(DB_URI)
|
||||
artifact_store = DatabricksVolumesArtifactStore(ARTIFACT_URI)
|
||||
file_comment_store = SqlAlchemyCommentStore(DB_URI)
|
||||
permission_store = SqlAlchemyPermissionStore(DB_URI)
|
||||
policy_store = SqlAlchemyPolicyStore(DB_URI)
|
||||
host_store = HostStore(DB_URI)
|
||||
|
||||
agent_cache = AgentCache(artifact_store=artifact_store, cache_dir=CACHE_DIR)
|
||||
|
||||
init_runtime(
|
||||
agent_cache=agent_cache,
|
||||
caps=RuntimeCaps(),
|
||||
agent_store=agent_store,
|
||||
file_store=file_store,
|
||||
conversation_store=conversation_store,
|
||||
artifact_store=artifact_store,
|
||||
comment_store=file_comment_store,
|
||||
policy_store=policy_store,
|
||||
)
|
||||
|
||||
# The Databricks Apps proxy injects ``X-Forwarded-Email`` on
|
||||
# every request, so we run in header mode. Header is the
|
||||
# framework default, but pin it explicitly so the hosted product
|
||||
# keeps its existing behavior regardless of any ambient
|
||||
# OMNIGENT_AUTH_ENABLED in the deploy env (an explicit
|
||||
# provider always wins over the enable switch).
|
||||
os.environ.setdefault("OMNIGENT_AUTH_PROVIDER", "header")
|
||||
auth_provider = create_auth_provider()
|
||||
app = create_app(
|
||||
agent_store=agent_store,
|
||||
file_store=file_store,
|
||||
conversation_store=conversation_store,
|
||||
artifact_store=artifact_store,
|
||||
agent_cache=agent_cache,
|
||||
comment_store=file_comment_store,
|
||||
permission_store=permission_store,
|
||||
policy_store=policy_store,
|
||||
host_store=host_store,
|
||||
auth_provider=auth_provider,
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting omnigent on 0.0.0.0:%d", PORT)
|
||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||
|
||||
except Exception: # noqa: BLE001 — startup catch-all; we want every failure logged
|
||||
logger.error("FATAL: omnigent failed to start:\n%s", traceback.format_exc())
|
||||
# Keep the process alive briefly so logs can be captured
|
||||
import time
|
||||
|
||||
time.sleep(30)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,8 @@
|
||||
command: ["opentelemetry-instrument", "python", "app.py"]
|
||||
env:
|
||||
- name: AP_LAKEBASE_ENDPOINT
|
||||
valueFrom: postgres
|
||||
- name: AP_ARTIFACT_VOLUME_PATH
|
||||
valueFrom: artifact_volume
|
||||
- name: OTEL_TRACES_SAMPLER
|
||||
value: 'always_on'
|
||||
Reference in New Issue
Block a user