Migrate pal-e-docs from SQLite to Postgres (CNPG) #76
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set.
Reference
ldraney/pal-e-api#76
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Plan
plan-2026-02-26-tf-modularize-postgres-- Phase 3, Parts B/C/D (traceability only)Repo
forgejo_admin/pal-e-docsUser Story
As a platform operator
I want pal-e-docs running on the shared CNPG Postgres cluster
So that migrations are safe (no SQLite auto-commit DDL crashes) and we unlock future search capabilities
Context
pal-e-docs currently uses SQLite with Litestream for backup/replication. A CNPG Postgres cluster is already running:
pal-e-postgresinpostgresnamespace (healthy, Postgres 17.4)paledocs, user:paledocspal-e-postgres-rw.postgres.svc.cluster.local:5432paledocs-db-urlkeyDATABASE_URLin thepal-e-docsnamespace (created by Terraform — pal-e-platform issue #22, applied before this work deploys)ArgoCD auto-syncs from
pal-e-docs/k8s/on main with prune + self-heal enabled. When PVC and ConfigMap are removed from kustomization.yaml, ArgoCD will delete them. This is intentional.This is a two-PR delivery (see Deployment Sequence below).
SQLite-isms in Existing Migrations
The following patterns in
alembic/versions/are SQLite-specific and will fail on Postgres:sa.text('(CURRENT_TIMESTAMP)')— files:54714bc57fbe,a1b2c3d4e5f6,b2c3d4e5f6a7. Postgres wantsCURRENT_TIMESTAMP(no parens) ornow().server_default=sa.text('1')for Boolean — file:b2c3d4e5f6a7(users tableis_approved). Postgres wantstrue.f6a7b8c9d0e1(sprint schema expansion) — upgrade ispass(no-op on SQLite). On Postgres withnative_enum=False, CHECK constraints for expanded enum values may need handling.Recommended approach: Since we're running against an empty Postgres database, the cleanest path is:
if context.get_x_argument...or usesa.func.now()instead ofsa.text)sa.text('(CURRENT_TIMESTAMP)')withsa.func.now(), replacesa.text('1')withsa.text('true')guarded by dialect check or just usesa.true_().File Targets
PR 1 — Code changes (Postgres support, backward compatible):
src/pal_e_docs/config.py-- adddatabase_url: str | None = Nonesettingsrc/pal_e_docs/database.py-- usedatabase_urlwhen set, fall back to SQLite. Conditionally attach SQLite pragma listener.alembic/env.py-- usesettings.database_urlwhen availablealembic/versions/54714bc57fbe_initial_schema.py-- fix(CURRENT_TIMESTAMP)→sa.func.now()alembic/versions/a1b2c3d4e5f6_add_repos_table.py-- same fixalembic/versions/b2c3d4e5f6a7_add_users_table.py-- fix timestamp + boolean defaultalembic/versions/c3d4e5f6a7b8_add_project_is_public_and_page_note_fk.py-- fix boolean default if presentalembic/versions/f6a7b8c9d0e1_sprint_schema_expansion.py-- add Postgres CHECK constraint handlingpyproject.toml-- addpsycopg2-binarydependency (orpsycopg[binary])Dockerfile-- if psycopg2-binary needs system deps, add themFiles NOT to touch:
k8s/*-- that's PR 2tests/-- existing tests should still pass against SQLitePR 2 — Manifest changes (switch to Postgres, remove Litestream):
k8s/deployment.yaml-- replacePALDOCS_DATABASE_PATHenv withPALDOCS_DATABASE_URLfrom secret, remove Litestream init + sidecar containers, remove data volume + litestream-config volumek8s/kustomization.yaml-- removepvc.yamlandlitestream-configmap.yamlk8s/pvc.yaml-- DELETE this filek8s/litestream-configmap.yaml-- DELETE this fileData migration script (can live in repo root or scripts/):
scripts/migrate_sqlite_to_postgres.py-- dump SQLite → insert into Postgres, reset PK sequencesAcceptance Criteria
PALDOCS_DATABASE_URL=postgresql://...env varPALDOCS_DATABASE_PATH=/data/foo.db(backward compat for local dev)alembic upgrade headruns cleanly against an empty Postgres databaseTest Expectations
alembic upgrade headagainst a local Postgrespytest tests/(should pass unchanged for PR 1)Deployment Sequence (CRITICAL)
Steps 2-5 are manual — coordinated by Betty Sue during a short maintenance window. The dev agent delivers PR 1, PR 2, and the migration script. Lucas handles the operational steps.
Constraints
database.pyengine creation must be at module level (current pattern) — don't change to lazy initconfig.pyusing pydantic-settings withenv_prefix = "PALDOCS_"PALDOCS_DATABASE_URLenv var name must match the config field name (database_url) with the prefixpsycopg2-binaryis fine for now (avoids libpq build dep). Can switch topsycopg[binary](psycopg3) if preferred.alembic upgrade headagainst production — that's Lucas's job during the maintenance windowChecklist
Related
pal-e-platformproject -- issue #22 (Terraform secret) must be applied before PR 2 mergesplan-2026-02-25-platform-observability-- Litestream removal affects backup strategyPR #78 Review
BLOCKERS
None.
NITS
None. The diff is clean and minimal -- 84 lines removed, 5 added. Exactly the right scope for an infrastructure swap.
KEY VERIFICATIONS (per review request)
paledocs-db-urlwith keyDATABASE_URL-- Confirmed. Matches Terraform resource atmain.tf:1108-1117(kubernetes_secret_v1.paledocs_db_url). Name:paledocs-db-url, key:DATABASE_URL.PALDOCS_DATABASE_URL-- Confirmed. Matches PR #77'sconfig.pyfielddatabase_url(pydantic-settings mapsPALDOCS_prefix + field name to env varPALDOCS_DATABASE_URL).litestream-restoreinit container (image, args, envFrom, volumeMounts, resources) is entirely gone.litestream-replicatesidecar container is entirely gone.volumessection (PVCpal-e-docs-data+ configmaplitestream-config) and the main container'svolumeMountsfor/dataare all removed.pvc.yamldeleted -- Confirmed. File deleted in diff.litestream-configmap.yamldeleted -- Confirmed. File deleted in diff.kustomization.yamlupdated -- Confirmed. References to bothpvc.yamlandlitestream-configmap.yamlremoved.volumes,volumeMounts, orinitContainerssections.SOP COMPLIANCE
76-postgres-manifest-switchreferences issue #76)plan-2026-02-26-tf-modularize-postgres)VERDICT: APPROVED
The manifest changes are correct, complete, and well-scoped. All three layers (Terraform secret, k8s deployment, Python config) agree on naming. The deploy ordering documented in the PR body and test plan is appropriate -- this must be merged after PR #77, Terraform apply, alembic migration, and data migration.
PR #77 Review
BLOCKERS
None found. The code is correct and well-scoped.
NITS
Unused import in migration script (
scripts/migrate_sqlite_to_postgres.pyline 15):from sqlalchemy.orm import Session # noqa: F401is imported but never used. Thenoqasuppresses the lint warning, but the import should just be removed entirely.psycopg2-binaryvspsycopg2: Usingpsycopg2-binaryis fine for containerized deployments, but worth noting that some teams prefer the source-builtpsycopg2for production. Not a blocker -- just a conscious tradeoff to be aware of.alembic/env.pymodel imports: The diff shows Sprint and SprintItem models are not imported in the updatedalembic/env.py. The current main branch also lacks them (it only imports Note, NoteLink, NoteRevision, NoteTag, Project, Repo, Tag, User). This is a pre-existing gap, not introduced by this PR, but worth noting since those models need to be imported for autogenerate to detect them. Since the sprint migration files were hand-written, this has not caused problems yet.OBSERVATIONS (Positive)
sa.text('(CURRENT_TIMESTAMP)')orsa.text('1')has been updated. The later migrations (d4e5f6a7b8c9throughh8c9d0e1f2g3) already used portable constructs -- no gaps.database.pyrewrite is clean: The_build_engine()function correctly branches onsettings.database_url, and the SQLite pragma listener is conditionally registered only when the dialect is sqlite. No pragma leakage to Postgres.config.pychange is minimal and correct:database_url: str | None = Nonemaps toPALDOCS_DATABASE_URLvia thePALDOCS_env prefix. When unset, falls back to SQLite. Zero behavior change for existing deployments.alembic/env.pycorrectly prefers Postgres URL: Same logic as database.py --settings.database_urlwhen set, SQLite path otherwise.note_tagsandnote_links(composite PKs, no auto-increment sequences). Thesetval()call withfalseas the third argument means the nextnextval()returns the set value, which isMAX(id) + 1-- correct.pg_url.split('@')[-1]strips user:pass before printing. Good security practice.SOP COMPLIANCE
76-postgres-code-changesreferences issue #76plan-2026-02-26-tf-modularize-postgresVERDICT: APPROVED