From 44344a75cbcacdecddaf39019a66fe9f06f603e2 Mon Sep 17 00:00:00 2001 From: Zachary Smith Date: Wed, 21 Oct 2026 15:45:13 -0400 Subject: [PATCH 1/5] Add Dockerfile and Datum Cloud deploy pipeline - Add a Dockerfile (python:3.12-slim, runs migrations then uvicorn on container start) -- verified end-to-end against a real Postgres container - Add .github/workflows/deploy.yml: build+push to ghcr.io on push to main, then datumctl compute deploy to Datum Cloud Compute - Add DEPLOY.md documenting the one-time setup and what's still unverified (Compute access for the project is pending; Postgres and document storage are external services since Datum Compute has no persistent disk) Stacked on security-hardening (#11) -- this Dockerfile depends on the sqlalchemy[asyncio]/greenlet fix in that branch to actually boot. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 11 +++++ .github/workflows/deploy.yml | 91 ++++++++++++++++++++++++++++++++++++ DEPLOY.md | 87 ++++++++++++++++++++++++++++++++++ Dockerfile | 19 ++++++++ 4 files changed, 208 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy.yml create mode 100644 DEPLOY.md create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd733e7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.venv* +__pycache__/ +*.pyc +.git/ +.github/ +tests/ +.env +.env.example +data/ +*.md +docker-compose.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..97e7ab8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,91 @@ +name: Deploy + +# Builds the api image and deploys it to Datum Cloud Compute on every push to +# main (i.e. after a PR merges). See DEPLOY.md for the one-time setup this +# depends on (Datum project, service account, Secrets, DATABASE_URL). +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: deploy-api + cancel-in-progress: false + +env: + IMAGE: ghcr.io/altsmanager/api + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.image.outputs.image }} + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set image tag + id: image + run: echo "image=${IMAGE}:${{ github.sha }}" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.image.outputs.image }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + env: + DATUM_PROJECT: altsmanager + steps: + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + + # datumctl is a published Go module (go.datum.net/datumctl); there's no + # separate install script as of this writing. + - name: Install datumctl + run: go install go.datum.net/datumctl@latest + + # Service-account login per https://www.datum.net/docs/datumctl/auth/logging-in. + # The service account must be scoped to exactly this one project -- + # datum-cloud/datumctl#324 is an open bug where --credentials login fails + # non-interactively if the account can see more than one context. + - name: Authenticate to Datum Cloud + env: + DATUM_SERVICE_ACCOUNT_KEY: ${{ secrets.DATUM_SERVICE_ACCOUNT_KEY }} + run: | + umask 077 + printf '%s' "$DATUM_SERVICE_ACCOUNT_KEY" > "$RUNNER_TEMP/datum-key.json" + datumctl login --credentials "$RUNNER_TEMP/datum-key.json" + rm -f "$RUNNER_TEMP/datum-key.json" + + # NOTE: unverified against a real deployment -- Compute access for this + # project is pending as of when this workflow was written (see DEPLOY.md). + # `--image`/`--city`/`--min`/`--http-port` are documented; confirm this + # command preserves the workload's existing Secret references (DATABASE_URL, + # SECRET_KEY, etc. -- set up via the one-time manifest apply in DEPLOY.md) + # on an update rather than only on first create, and adjust if not. + - name: Deploy to Datum Compute + run: | + datumctl compute deploy altsmanager-api \ + --image="${{ needs.build-and-push.outputs.image }}" \ + --city=DFW \ + --min=1 \ + --http-port=8000 diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..9a17d78 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,87 @@ +# Deploying to Datum Cloud + +This repo deploys to [Datum Cloud](https://datum.net) Compute via +`.github/workflows/deploy.yml`, which runs on every push to `main`. This +document is the one-time setup that workflow depends on, plus the pieces that +still need to be verified once Compute access is live (see status below). + +## Status + +- [x] Datum Cloud project `altsmanager` created (org: Humans of the Internet / + `joe-company-inc-3a1tyj`) +- [ ] **Compute enabled for this project** — blocking everything below. + Requested from Datum; nothing here can be applied or verified until it's + granted. +- [ ] Postgres: a Supabase project for AltsManager (decided over self-hosting + on Datum Compute, since Datum Compute has no persistent disk — a container + restart would lose all data) +- [ ] Document storage: an S3-compatible bucket (e.g. Wasabi) — same reasoning + as Postgres: `STORAGE_BACKEND=local` cannot be used here, only `s3` +- [ ] Datum service account for CI, scoped to only the `altsmanager` project +- [ ] GitHub repo secrets/variables (list below) + +## Architecture + +``` +GitHub (push to main) + -> build image, push to ghcr.io/altsmanager/api + -> datumctl compute deploy altsmanager-api --http-port=8000 + +Datum Compute workload "altsmanager-api" + -> DATABASE_URL secret -> Supabase Postgres (external) + -> S3_* secrets -> Wasabi (or other S3-compatible) bucket (external) + -> public HTTPS hostname via --http-port (Datum-generated, no custom domain yet) +``` + +Postgres and document storage are **not** Datum Compute workloads -- both need +persistent state Datum Compute's container runtime doesn't offer, so they're +external managed services the api workload connects to. + +## One-time setup + +1. **Get Compute enabled** for the `altsmanager` project in the Humans of the + Internet org. (Nothing past this point can happen without it.) + +2. **Create the Supabase project**, run migrations against it once + (`alembic upgrade head` with `DATABASE_URL` pointed at it), and note its + connection string. + +3. **Create an S3-compatible bucket** (Wasabi or similar) for documents, and + note its credentials. + +4. **Create a Datum Secret** holding the app's runtime config -- `SECRET_KEY` + (generate with `python -c "import secrets; print(secrets.token_urlsafe(48))"`), + `DATABASE_URL`, `CORS_ORIGINS` (the web app's deployed URL), `STORAGE_BACKEND=s3`, + and the `S3_*` values. This is created once via a Workload manifest apply + (not via `datumctl compute deploy`, which has no documented way to attach + secret references) and the Workload's `envFrom` references it. + +5. **Create a Datum service account** scoped to only the `altsmanager` + project (see note in `deploy.yml` about datum-cloud/datumctl#324), download + its credentials JSON, and set it as the `DATUM_SERVICE_ACCOUNT_KEY` GitHub + Actions secret in this repo. + +6. **First deploy**: either push to `main`, or run the `Deploy` workflow + manually (`workflow_dispatch`). This creates the `altsmanager-api` Compute + workload and gives it a public HTTPS hostname -- note that hostname, it's + needed by the `web` repo's `API_PUBLIC_URL` variable. + +## Verify once Compute access is granted + +The `deploy.yml` workflow was written against the Datum compute guide's +documented CLI shape, but untested against a real deployment (no access yet): + +- Does `datumctl compute deploy --image=... --http-port=...`, run + again against an existing workload, preserve its `envFrom`/Secret + references, or does it need those passed again on every call? +- The exact non-interactive `datumctl login --credentials` behavior + (datum-cloud/datumctl#324 describes a failure mode when the service account + can see more than one project/context). + +## Required GitHub secrets/variables + +| Name | Type | Purpose | +|---|---|---| +| `DATUM_SERVICE_ACCOUNT_KEY` | secret | Service account credentials JSON for `datumctl login --credentials` | + +(`GITHUB_TOKEN` for GHCR push is automatic, no setup needed.) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6036f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY alembic.ini . +COPY alembic ./alembic +COPY app ./app +COPY scripts ./scripts + +RUN useradd --create-home --uid 1000 appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 +CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"] From bfbae6950a2d39f4bc5cff5ac63ff96bd58a33c7 Mon Sep 17 00:00:00 2001 From: Zachary Smith Date: Wed, 21 Oct 2026 20:56:57 -0400 Subject: [PATCH 2/5] Fix container networking for Datum Compute, restyle admin panel Two real deploy-blocking bugs found and fixed against a live Datum Cloud deployment: - uvicorn was bound to 0.0.0.0 (IPv4), but Datum Compute's network (Galactic VPC) is IPv6-only -- instances have no IPv4 address at all. Bind to :: instead, matching Datum's own example workloads. - uvicorn didn't trust the edge's proxy headers, so redirects (e.g. the admin panel's post-login redirect) were generated as http:// even though Datum's edge terminates TLS and only exposes https:// -- browsers correctly refused to follow the downgrade. Added --proxy-headers --forwarded-allow-ips='*' (safe here: the container has no other public ingress path). Also restyle the sqladmin panel (navy/gold theme, polished login page) via sqladmin's supported templates_dir override -- two small template files layering on its built-in Tabler theme, no fork needed. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 5 ++ Dockerfile | 2 +- app/admin.py | 2 + app/admin_templates/sqladmin/base.html | 53 +++++++++++++++++++++ app/admin_templates/sqladmin/login.html | 63 +++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 app/admin_templates/sqladmin/base.html create mode 100644 app/admin_templates/sqladmin/login.html diff --git a/.gitignore b/.gitignore index 5bb1ccc..e0a7d5f 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,8 @@ __marimo__/ # Locally-stored uploaded documents (STORAGE_BACKEND=local) /data/ + +# Live-state snapshot saved locally by `datumctl compute deploy` -- not clean +# source (resourceVersion/managedFields/status churn on every deploy); the +# deploy pipeline manages the workload declaratively via deploy.yml's flags. +/workload.yaml diff --git a/Dockerfile b/Dockerfile index e6036f9..0f49bae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,4 @@ RUN useradd --create-home --uid 1000 appuser && chown -R appuser:appuser /app USER appuser EXPOSE 8000 -CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"] +CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host :: --port 8000 --proxy-headers --forwarded-allow-ips='*'"] diff --git a/app/admin.py b/app/admin.py index 28bc3fa..cb881cc 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,3 +1,4 @@ +import os import uuid from fastapi import FastAPI @@ -139,6 +140,7 @@ def mount_admin(app: FastAPI) -> Admin: authentication_backend=AdminAuth(secret_key=settings.secret_key), base_url="/admin", title="AltsManager Admin", + templates_dir=os.path.join(os.path.dirname(__file__), "admin_templates"), ) for view in ( EntityAdmin, diff --git a/app/admin_templates/sqladmin/base.html b/app/admin_templates/sqladmin/base.html new file mode 100644 index 0000000..67b5426 --- /dev/null +++ b/app/admin_templates/sqladmin/base.html @@ -0,0 +1,53 @@ +{% extends "sqladmin_original/base.html" %} +{% block head_tail %} + +{% endblock %} diff --git a/app/admin_templates/sqladmin/login.html b/app/admin_templates/sqladmin/login.html new file mode 100644 index 0000000..a5c7bae --- /dev/null +++ b/app/admin_templates/sqladmin/login.html @@ -0,0 +1,63 @@ +{% extends "sqladmin/base.html" %} +{% block head_tail %} +{{ super() }} + +{% endblock %} +{% block body %} + +{% endblock %} From 8cf0a257e66fb6cb2a9c1c69cacea3d4ac4c91ab Mon Sep 17 00:00:00 2001 From: Zachary Smith Date: Wed, 21 Oct 2026 20:58:22 -0400 Subject: [PATCH 3/5] Update DEPLOY.md and deploy.yml with verified deployment details The api workload is now actually live (https://grain-noodle-m23qc.datumproxy.net), so replace the "unverified" placeholders with what a real deployment required: --runtime-class=general-purpose (immutable, platform default won't run a plain Docker image), deploying by digest rather than tag, and the Secret/ Supabase/Wasabi setup that's already done. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/deploy.yml | 29 ++++---- DEPLOY.md | 134 ++++++++++++++++++++--------------- 2 files changed, 92 insertions(+), 71 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 97e7ab8..093853f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,21 +33,25 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Set image tag - id: image - run: echo "image=${IMAGE}:${{ github.sha }}" >> "$GITHUB_OUTPUT" - - uses: docker/setup-buildx-action@v3 - name: Build and push + id: push uses: docker/build-push-action@v6 with: context: . push: true - tags: ${{ steps.image.outputs.image }} + tags: ${{ env.IMAGE }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max + # Deploy by digest, not tag -- a `datumctl compute deploy` re-run + # against the same tag didn't reliably pick up a newly-pushed image + # during manual testing (see DEPLOY.md "Gotchas" #5). + - name: Set deploy image reference + id: image + run: echo "image=${IMAGE}@${{ steps.push.outputs.digest }}" >> "$GITHUB_OUTPUT" + deploy: needs: build-and-push runs-on: ubuntu-latest @@ -76,16 +80,17 @@ jobs: datumctl login --credentials "$RUNNER_TEMP/datum-key.json" rm -f "$RUNNER_TEMP/datum-key.json" - # NOTE: unverified against a real deployment -- Compute access for this - # project is pending as of when this workflow was written (see DEPLOY.md). - # `--image`/`--city`/`--min`/`--http-port` are documented; confirm this - # command preserves the workload's existing Secret references (DATABASE_URL, - # SECRET_KEY, etc. -- set up via the one-time manifest apply in DEPLOY.md) - # on an update rather than only on first create, and adjust if not. + # --runtime-class is required and immutable: the platform default + # (unikernel) can't run a plain `docker build` image. See DEPLOY.md + # "Gotchas" for this and the other flags below -- all verified against + # a real deployment. - name: Deploy to Datum Compute run: | datumctl compute deploy altsmanager-api \ --image="${{ needs.build-and-push.outputs.image }}" \ + --runtime-class=general-purpose \ --city=DFW \ --min=1 \ - --http-port=8000 + --http-port=8000 \ + --env-from-secret=altsmanager-api-secrets \ + --env=ENVIRONMENT=production diff --git a/DEPLOY.md b/DEPLOY.md index 9a17d78..00b55e9 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,82 +1,98 @@ # Deploying to Datum Cloud -This repo deploys to [Datum Cloud](https://datum.net) Compute via -`.github/workflows/deploy.yml`, which runs on every push to `main`. This -document is the one-time setup that workflow depends on, plus the pieces that -still need to be verified once Compute access is live (see status below). +This repo deploys to [Datum Cloud](https://datum.net) Compute. `api` is live +at **https://grain-noodle-m23qc.datumproxy.net** (health: `/health`, docs: +`/docs`, admin: `/admin`). `.github/workflows/deploy.yml` automates the same +`datumctl compute deploy` flow on every push to `main`, once its one-time +setup (below) is done. ## Status -- [x] Datum Cloud project `altsmanager` created (org: Humans of the Internet / - `joe-company-inc-3a1tyj`) -- [ ] **Compute enabled for this project** — blocking everything below. - Requested from Datum; nothing here can be applied or verified until it's - granted. -- [ ] Postgres: a Supabase project for AltsManager (decided over self-hosting - on Datum Compute, since Datum Compute has no persistent disk — a container - restart would lose all data) -- [ ] Document storage: an S3-compatible bucket (e.g. Wasabi) — same reasoning - as Postgres: `STORAGE_BACKEND=local` cannot be used here, only `s3` +- [x] Datum Cloud project `altsmanager` (org: Humans of the Internet / + `joe-company-inc-3a1tyj`), Compute enabled +- [x] Postgres: Supabase (session pooler, port 5432 -- not the transaction + pooler on 6543, which doesn't play well with SQLAlchemy's plain pool / + alembic), schema migrated, decided over self-hosting since Datum Compute + has no persistent disk +- [x] Document storage: Wasabi bucket `altsmanager-documents` + (`https://s3.wasabisys.com`, `us-east-1`, versioned), same reasoning as + Postgres +- [x] `altsmanager-api-secrets` Secret created (`DATABASE_URL`, `SECRET_KEY`, + `STORAGE_BACKEND`, `S3_*`) via `datumctl apply -f` -- native `Secret` + objects aren't reachable through the datum-mcp `resource` tool, only + `datumctl`/the console +- [x] First manual deploy done and verified end-to-end (see "Gotchas" below + for what it took to get there) - [ ] Datum service account for CI, scoped to only the `altsmanager` project -- [ ] GitHub repo secrets/variables (list below) + (manual deploys so far used a user session, not a service account) +- [ ] GitHub repo secrets/variables (list below) -- `deploy.yml` itself is + still unexercised by actual CI ## Architecture ``` GitHub (push to main) - -> build image, push to ghcr.io/altsmanager/api - -> datumctl compute deploy altsmanager-api --http-port=8000 - -Datum Compute workload "altsmanager-api" - -> DATABASE_URL secret -> Supabase Postgres (external) - -> S3_* secrets -> Wasabi (or other S3-compatible) bucket (external) - -> public HTTPS hostname via --http-port (Datum-generated, no custom domain yet) + -> build image, push to ghcr.io/altsmanager/api (must be a PUBLIC package -- + Datum Compute has no documented imagePullSecrets flag on `compute deploy`) + -> datumctl compute deploy altsmanager-api --runtime-class=general-purpose --http-port=8000 + +Datum Compute workload "altsmanager-api" (general-purpose runtime class) + -> DATABASE_URL secret -> Supabase Postgres (external, session pooler) + -> S3_* secrets -> Wasabi bucket (external) + -> public HTTPS hostname via --http-port (Datum-generated *.datumproxy.net; + api.altsmanager.org not yet attached as a custom hostname) ``` Postgres and document storage are **not** Datum Compute workloads -- both need persistent state Datum Compute's container runtime doesn't offer, so they're external managed services the api workload connects to. -## One-time setup - -1. **Get Compute enabled** for the `altsmanager` project in the Humans of the - Internet org. (Nothing past this point can happen without it.) - -2. **Create the Supabase project**, run migrations against it once - (`alembic upgrade head` with `DATABASE_URL` pointed at it), and note its - connection string. - -3. **Create an S3-compatible bucket** (Wasabi or similar) for documents, and - note its credentials. - -4. **Create a Datum Secret** holding the app's runtime config -- `SECRET_KEY` - (generate with `python -c "import secrets; print(secrets.token_urlsafe(48))"`), - `DATABASE_URL`, `CORS_ORIGINS` (the web app's deployed URL), `STORAGE_BACKEND=s3`, - and the `S3_*` values. This is created once via a Workload manifest apply - (not via `datumctl compute deploy`, which has no documented way to attach - secret references) and the Workload's `envFrom` references it. - -5. **Create a Datum service account** scoped to only the `altsmanager` +## Gotchas found getting this running (read before touching the Dockerfile/deploy flags) + +1. **Runtime class must be `general-purpose`, not the platform-default + `unikernel`.** `unikernel` (Unikraft) only runs specially-built + position-independent binaries; a plain `docker build` image won't boot + under it (manifests as `ImageUnavailable`-looking failures, not an obvious + "wrong runtime" error). **Runtime class is immutable once a workload + exists** -- getting it wrong means delete + recreate. Always pass + `--runtime-class=general-purpose` explicitly. +2. **The container must bind to `::` (IPv6 wildcard), never `0.0.0.0`.** + Datum Compute's network is IPv6-only -- an instance has no IPv4 address at + all. Binding to `0.0.0.0` looks completely healthy in the app's own logs + (and even in `compute_workload_diagnose`) while the ALB gets instant + connection-refused on every request. The Dockerfile's `CMD` passes + `--host ::` to uvicorn for this reason. +3. **Trust the edge's proxy headers**, or redirects break. Datum's edge + terminates TLS and forwards plain HTTP to the container, but uvicorn + doesn't know that by default, so it generates `Location: http://...` + redirects -- which a browser correctly refuses to follow from an HTTPS + page. Fixed with `--proxy-headers --forwarded-allow-ips='*'` (safe here: + the container has no other public ingress path than Datum's edge). +4. **GHCR packages are private by default.** Datum Compute can't pull a + private image anonymously, and `datumctl compute deploy` has no + `imagePullSecrets`-equivalent flag. Make the package public: org -> + Packages -> the package -> Package settings -> Change visibility. +5. **Prefer deploying by image digest** (`ghcr.io/owner/image@sha256:...`) + over a mutable tag. A `datumctl compute deploy` re-run with the *same tag* + did not reliably pick up a newly-pushed image during testing here; pinning + the digest forced a correct pull every time. +6. **After a redeploy, give the edge real time to settle before concluding + something is broken.** Even with everything else correct, the ALB + reported `serving: true` / `confidence: unverified` while still returning + 503s with `UF` (upstream connection failure) response flags for over a + minute after a clean rollout -- `alb_traffic_summary` and + `compute_workload_diagnose` are the right tools to check (they'll show the + app/workload as genuinely healthy while the edge path is still catching + up), not a sign the deploy itself failed. + +## One-time setup (remaining) + +1. **Create a Datum service account** scoped to only the `altsmanager` project (see note in `deploy.yml` about datum-cloud/datumctl#324), download its credentials JSON, and set it as the `DATUM_SERVICE_ACCOUNT_KEY` GitHub Actions secret in this repo. - -6. **First deploy**: either push to `main`, or run the `Deploy` workflow - manually (`workflow_dispatch`). This creates the `altsmanager-api` Compute - workload and gives it a public HTTPS hostname -- note that hostname, it's - needed by the `web` repo's `API_PUBLIC_URL` variable. - -## Verify once Compute access is granted - -The `deploy.yml` workflow was written against the Datum compute guide's -documented CLI shape, but untested against a real deployment (no access yet): - -- Does `datumctl compute deploy --image=... --http-port=...`, run - again against an existing workload, preserve its `envFrom`/Secret - references, or does it need those passed again on every call? -- The exact non-interactive `datumctl login --credentials` behavior - (datum-cloud/datumctl#324 describes a failure mode when the service account - can see more than one project/context). +2. **Run the `Deploy` workflow** (push to `main`, or `workflow_dispatch`) and + confirm it reaches the same state the manual deploys already did. ## Required GitHub secrets/variables From 7acf67122a037dcb2a9a3212d9f2e4ce3acab25e Mon Sep 17 00:00:00 2001 From: Zachary Smith Date: Wed, 21 Oct 2026 23:33:15 -0400 Subject: [PATCH 4/5] Fix user creation in admin panel (NULL hashed_password) Excluding hashed_password from the User admin form (correct -- never let an admin type/view a raw hash) also made it impossible to create a user at all: the column is NOT NULL, and nothing on the form could ever populate it, so every create hit a NotNullViolation. Add a real (not model-backed) "password" field via scaffold_form, hashed server-side in on_model_change before insert. Required on create, optional on edit (leave blank to keep the current password). Also strips whitespace from the email field -- a trailing space on a real create attempt in production stored an unusable address. Verified end-to-end: create with no password -> clean 400 with a readable message (previously a raw SQL traceback); create with password -> user created with a correctly-hashed password and trimmed email, confirmed able to log in via POST /api/v1/auth/login. Co-Authored-By: Claude Sonnet 5 --- app/admin.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/app/admin.py b/app/admin.py index cb881cc..62dce61 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,13 +1,17 @@ import os import uuid +from typing import Any from fastapi import FastAPI from sqladmin import Admin, ModelView from sqladmin.authentication import AuthenticationBackend from starlette.requests import Request +from wtforms import PasswordField +from wtforms.form import Form as WTForm +from wtforms.validators import Optional as OptionalField from app.core.config import get_settings -from app.core.security import verify_password +from app.core.security import hash_password, verify_password from app.crud.user import get_user_by_email from app.db.session import SessionLocal, engine from app.models.counterparty import Counterparty @@ -56,11 +60,15 @@ async def authenticate(self, request: Request) -> bool: class EntityAdmin(ModelView, model=Entity): + name = "Entity" + name_plural = "Entities" column_list = [Entity.id, Entity.name, Entity.entity_type, Entity.jurisdiction] form_excluded_columns = [Entity.created_at, Entity.updated_at] class CounterpartyAdmin(ModelView, model=Counterparty): + name = "Counterparty" + name_plural = "Counterparties" column_list = [ Counterparty.id, Counterparty.name, @@ -83,6 +91,8 @@ class InvestmentAdmin(ModelView, model=Investment): class InvestmentCounterpartyAdmin(ModelView, model=InvestmentCounterparty): + name = "Investment Counterparty" + name_plural = "Investment Counterparties" column_list = [ InvestmentCounterparty.id, InvestmentCounterparty.investment_id, @@ -131,6 +141,27 @@ class UserAdmin(ModelView, model=User): form_excluded_columns = [User.hashed_password, User.created_at, User.updated_at] can_delete = False + async def scaffold_form(self, rules: list[str] | None = None) -> type[WTForm]: + form_class = await super().scaffold_form(rules) + form_class.password = PasswordField( + "Password", + validators=[OptionalField()], + description="Required when creating a user. Leave blank on edit to keep the current password.", + ) + return form_class + + async def on_model_change( + self, data: dict[str, Any], model: Any, is_created: bool, request: Request + ) -> None: + if isinstance(data.get("email"), str): + data["email"] = data["email"].strip() + + password = data.pop("password", None) + if password: + data["hashed_password"] = hash_password(password) + elif is_created: + raise ValueError("Password is required when creating a user.") + def mount_admin(app: FastAPI) -> Admin: settings = get_settings() From 54d4b5bb72d389ffa910e656977059edc28f3756 Mon Sep 17 00:00:00 2001 From: Zachary Smith Date: Thu, 22 Oct 2026 06:57:41 -0400 Subject: [PATCH 5/5] Track docker base image in dependabot Prompted by discovering web's Dockerfile was still on an EOL Node.js base image (see AltsManager/web@0dfe378) -- add the same coverage here so this repo's python:3.12-slim base doesn't go stale unnoticed either. Co-Authored-By: Claude Sonnet 5 --- .github/dependabot.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4776211..6793c26 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,8 @@ updates: directory: "/" schedule: interval: "weekly" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly"