[Feature]: Merge flip-omop-db into the monorepo as the trust/omop-db build source - #837
Conversation
Verbatim import of the omop-db image build source from the private londonaicentre/flip-omop-db repository (main @ 5cf75d4) into trust/omop-db/: OMOP CDM 5.4 DDL/PK/index/constraint SQL, readonly-user role SQL, image Dockerfile, build compose, populate scripts and uv project files. Excluded from the snapshot: mock-data CSVs (moving to the public Hugging Face dataset), docs/omop.svg, .vscode/, .env.development, the standalone Makefile (folded into the existing trust/omop-db/Makefile in a follow-up commit), and the git history (contains personal identifiers and private-repository references; the archived private repo remains the historical record). Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
- Package layout: scripts become the omop-db-tools uv project (src/omop_db_tools/) with main() entry points, absolute imports, and Google-style docstrings; pyproject gains the FLIP ruff select list, mypy, the 72h uv exclude-newer cooldown, hatchling packaging and a regenerated lockfile; unused asyncpg dependency dropped. - Security hardening (flagged by review of the snapshot commit): drop the POSTGRES_* build args from the Dockerfile (initdb only needs runtime env — nothing credential-shaped is baked into image config); validate SQL identifiers before interpolation in the insert/delete helpers; replace the pickled DICOM relationship frame with a CSV read (no pickle deserialisation; the published bundle ships CSVs only). - New canonical-dataset module (dataset.py): build/fetch the single mock dataset (source_trust provenance column) and deterministically split it across N trusts — legacy mode reproduces the original two-trust membership (consistent with the published mock PACS data), modulo mode supports any trust count. 26 unit tests. - Makefile: fold the private repo's build/populate/push pipeline into the existing harness Makefile (fetch-vocab-core extracts the licensed vocab bundle from the already-public image instead of org S3; fetch-vocab-dicom pulls the freely-redistributable DICOM bundle from Hugging Face) and add the standard lint/mypy/local_test contract. - Build compose: volumes renamed trust1/trust2 -> Trust_1/Trust_2 to converge with the runtime harness paths; pgadmin behind an opt-in profile; image tag parameterised via OMOP_DB_TAG. - Apache 2.0 headers on all imported files; OHDSI CommonDataModel and DICOM2OMOP entries added to THIRD_PARTY_NOTICES.md; internal S3 bucket references scrubbed; README/CONTRIBUTING rewritten (stale docs removed); CLAUDE.md/AGENTS.md pair added; uv-lock pre-commit entry and root UV_PROJECTS registration. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
- Trust composes: omop-db image tag now ${OMOP_DB_TAG:-latest} (the kit
files already declare OMOP_DB_TAG; it was previously unused) and the
"built from" comments point at trust/omop-db instead of the private
repo. No CI build workflow yet: the baked vocabulary is licensed
material that cannot be fetched in CI, so image publishing stays a
documented manual step (make build / make push).
- data-access-api: the SQL-defence doc comments now cite the in-repo
trust/omop-db/files/create_readonly_users.sql path.
- Docs: component-omop-database.rst gains schema (MI-CDM extension),
access-control and mocked-instance sections; the cohort-query guide's
stale R-CDM radiology_occurrence references become the MI-CDM
image_occurrence table actually shipped by the schema.
- Root/trust CLAUDE.md structure tables note the merged build source
(AGENTS.md mirrors regenerated).
The canonical mock dataset (single dataset, source_trust provenance,
12 CSVs) and the pickle-free DICOM vocab bundle are published to the
public HF dataset aicentreflip/trust-data under omop-csv/20260729/ and
omop-vocab/ — make fetch-dataset / fetch-vocab-dicom pull them
anonymously (verified).
Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
… bundles from build context) Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…on next push Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
|
✅ Acceptance criteria have been automatically imported from the linked issue(s) and added to the PR description. |
Correctness / silent-failure fixes: - apply-constraints now runs psql with ON_ERROR_STOP=1 (a failed FK block previously left psql exiting 0, shipping pgdata without integrity) and fans out per trust like populate. - fetch-vocab-core / fetch-vocab-dicom extract/download into a temp path and rename on success — a failed docker cp is no longer masked by the succeeding docker rm, and a partial copy/download can no longer satisfy the "already present" check on the next run. - populate derives its fan-out from NUM_TRUSTS (was hardcoded to two, silently dropping slice 3 of a documented three-way example) and each populate-trust-<N> fails fast when OMOP_DB_PORT_TRUST_<N> is unset — an empty port previously made pg_isready fall back to 5432 and report readiness of an unrelated server. - wait-for-postgres.sh: rejects a missing port argument, checks pg_isready exists, and bounds the wait (120 attempts). - up-build depends on build (no silent fallback to the published GHCR image); fetch-vocab-core extracts from VOCAB_SOURCE_TAG (default latest) instead of the tag being built; push asks for confirmation before repointing the default :latest. - split_for_trust validates source_trust values are exactly 1..N (a gapped set previously dropped rows silently) and rejects NaN person_id rows (previously lost from every modulo partition). - build_canonical requires trust dirs to exist and raises when a table is present in some sources but missing from others. - import_tables validates all required CSVs up front (before the destructive clean), and refuses headers-only required tables. - fetch_canonical names the version in 404 errors and rejects HTML responses (the /blob/-vs-/resolve/ Hugging Face trap). - safe_insert reports inserted-vs-skipped counts and its docstring now states the real contract (CONCEPT_RELATIONSHIP has no unique constraint, so idempotency does not extend to re-runs); date parsing uses errors="raise" so a malformed date names the offending value. - ensure_vocab_dir verifies the four expected CSVs after extraction or when accepting a pre-existing directory. - config: build the DB URL with sqlalchemy URL.create so credentials containing @/:/ are escaped. Repo integration: - track .env.build.example (trust/.gitignore's .env.* was silently swallowing it — every documented entry point referenced an untracked file) via a negation rule. - new "Trust - OMOP DB CI" workflow (lint+mypy+unit tests, path filtered) and omop-db added to the root unit_test target — the suite previously ran nowhere automated. - data-access-api OpenAPI examples, tests and trust docs now use omop.image_occurrence: the imported DDL ships no radiology_occurrence table (MI-CDM superseded R-CDM); data-access-api suite re-run green. - pgadmin gains no-new-privileges and a working documented invocation (--env-file .env.build); Apache header on .dockerignore. Docs truthed against the code: two-trust build stack stated explicitly (N>2 needs a compose service + port), pgdata tarball layout specified (archive root = db_data contents), .data_version consumer chain corrected (only the AWS Makefile reads the file), stale default- passwords note replaced with the real rotation path, history-narrating comments trimmed. 46 unit tests (was 26), all green. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…overridable) Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
|
✅ Acceptance criteria have been automatically imported from the linked issue(s) and added to the PR description. |
…dles fetch-vocab-core now downloads s3://$(VOCAB_S3_BUCKET)/vocab/ (default flipdev-aicentre — the same bucket, key layout and technique the private repo used) with temp+rename at both the download and unzip stages; the image-extraction path moves to an explicit credential-free fallback, fetch-vocab-core-from-image. README documents what the bundles actually are: the core bundle is an OHDSI Athena export (snapshot v5.0 30-AUG-24, 59 vocabularies, ~3.6 GB; roster table with versions and licensing character — SNOMED CT Int/US/UK, LOINC 2.77, NHS Read v2, dm+d, ICD family, RxNorm/NDC), and the DICOM bundle is a byte-for-byte copy of four files from DICOM2OMOP's files/OMOP CDM Staging/ at upstream commit 1ef3354 (verified by content diff; the relationship frame converted from pickle to CSV for the published copy). Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…ole lacks kms:Decrypt) Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
|
✅ Acceptance criteria have been automatically imported from the linked issue(s) and added to the PR description. |
…vocab Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
garciadias
left a comment
There was a problem hiding this comment.
Thorough migration - all seven acceptance-criteria items from #834 verified satisfied in substance, 37+ new unit tests, and a real reduction in what gets published (image 4.3GB→450MB, pgdata tarballs 2.2GB→11MB). Left one comment on a concrete gap in the new k8s vocab-load Job's local/kind-cluster credential path, which the PR body already flags as not yet run to completion with real credentials - this confirms that caveat with a specific fix.
Two minor items, not blocking on their own:
- deploy/providers/AWS/Makefile:488 passes -e omop_postgres_password=... as a plaintext ansible-playbook CLI argument - the only secret-shaped -e in this file; every other credential is handled via env/unset rather than argv, which is visible to other local users via ps aux for the process lifetime. Worth passing it via environment: in the task or a cleaned-up temp extra-vars file instead.
- trust/omop-db/files/OMOPCDM_postgresql_5.4_ddl.sql, _indices.sql, _primary_keys.sql, _constraints.sql all open with a copyright header in a different format from the rest of the tree (missing "(c)", missing "& King's College London", dated 2024 not 2026) - cosmetic, but worth aligning with the template the rest of this PR uses.
…tainer The fetch-bundle initContainer gated AWS_PROFILE/AWS_DEFAULT_REGION on omopDb.initJob.hostAwsMount.enabled but carried no matching host-aws volume/volumeMount, so on a local/kind cluster the profile pointed at no credentials file and the S3 fetch would fail. Mirror the volume pair the sibling omop-db-init-job.yaml uses. Dormant on EKS (flag defaults off; IRSA). Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Thorough, well-tested vendoring PR. I rebuilt the image from scratch, ran the full init chain (schema → PKs → indices → readonly-role creation), confirmed the read-only role can SELECT but not INSERT (defence-in-depth against the cohort-query docstring claims), ran the new vocab loader twice against a synthetic bundle to confirm its idempotency guards actually work, ran the full unit suite (47 passing, ruff/mypy clean), and rendered the Helm chart both with and without vocab-load enabled. Everything the PR claims held up under independent verification, and all CI checks are green.
One real gap worth a follow-up (doesn't need to block this merge): the dev/trust kit-file examples (trust/.env.example, trust/.env.GSTT.development.example, trust/.env.KCH.development.example) still default OMOP_DB_TAG=latest. Before this PR that was fine (omop-db had no CI publishing, so :latest just meant "whatever was last manually pushed"), but this PR adds the same :stag-on-develop/:prod-on-main CI tagging every other repo-built service uses — and dev environments elsewhere in the project track :stag. Left unchanged, freshly-scaffolded dev/trust stacks will now silently pull the prod-pinned image instead of tracking develop.
Left three minor inline comments (stale copyright header on the imported CDM SQL files, the vocab-load Helm default failing without org S3 credentials, and an Ansible cleanup task that isn't wrapped to run on failure). None of these block. Approving.
Four findings from the approving review, plus the same defect where it
recurred elsewhere.
Attribution: the four OMOPCDM_postgresql_5.4_*.sql files carried a sole
"Copyright 2024 Guy's and St Thomas'" header while THIRD_PARTY_NOTICES.md
(added by this PR) states they derive from OHDSI CommonDataModel — the
header contradicted our own notices file. Replaced with the dual-attribution
form already used for vendored SQL at trust/xnat/postgres/XNAT.sql: upstream
OHDSI copyright + SPDX, then FLIP modifications. Also drops the U+2019 smart
quotes these files alone carried. Headers only; no SQL changed.
Image tag: omop-db was the only trust image defaulting to :latest, which
this PR's new CI publishing redefines as the *production* image — so a dev
kit ran omop-db from prod while its four siblings ran :stag. The composes
now resolve ${OMOP_DB_TAG:-${DOCKER_TAG}}, keeping the per-service override
for the case XNAT_TAG exists for (omop-db changes rarely, so its branch tag
is the one most likely to be missing). No trust/Makefile export is needed —
the kit reaches compose via --env-file and DOCKER_TAG is already exported.
Existing kits keep their literal OMOP_DB_TAG=latest line and are unaffected;
only newly scaffolded kits pick up the new default. Swept the same :latest
default out of the Helm chart (every other service there is :stag, and the
tag is now load-bearing — the vocab-load Job runs the image's own loader)
and the Ansible play, which now takes the env's DOCKER_TAG from the Makefile.
Helm vocab-load: enabled:true against a hardcoded flipdev-aicentre meant a
default install failed its post-install hook — and a failed hook fails the
whole release — for anyone without org S3. The bundle is licensed
(SNOMED CT / LOINC / Read v2 / dm+d) so an initJob-style public fallback is
not legally available. Gated the Job on s3Bucket, mirroring initJob's own
empty-bucket idiom, and defaulted the bucket empty: no bucket, no Job, clean
install. This also unhardcodes the dev bucket, which stag/prod EKS could
never read. Because "no vocabulary" is otherwise silent — cohort queries
joining omop.concept return nothing while every probe stays green — the skip
is announced in a new NOTES.txt and documented in the chart README, and
sync_k8s_kit.py now emits the bucket from the kit's AICENTRE_BUCKET_NAME so
the org path stays zero-touch.
Trust EC2 cleanup: the vocab-load tasks were a flat list, so any failure
between the unzip and the teardown left the unpacked bundle at rest on the
host — against the play's own stated intent — and orphaned the throwaway
container. Wrapped in block/always. This also clears a retry trap: the
creates: guards key on the unpacked directory, so a re-run after a partial
failure previously skipped both the download and the unzip and reused a
possibly-truncated bundle. docker rm -f gets failed_when: false since it
exits non-zero when the container never started.
Verified: helm lint; template gate matrix (no bucket, bucket set, disabled,
CI values); NOTES warning fires only when vocabulary is genuinely absent;
compose resolves omop-db:stag beside its siblings and still honours a pinned
OMOP_DB_TAG; ansible-playbook --syntax-check; prettier (the hard CI gate on
deploy/providers/AWS/*.yml); k8s sync tests 13 passed; omop-db ruff + mypy +
47 unit tests.
Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
|
Thanks for the thorough pass — replies are in-thread on the three inline comments. This is for the fourth item from your review body, which has no thread to reply in: the You're right, and it's sharper than "fine before": this PR is what makes it wrong. Rather than swapping the literal to Nothing in the wild changes. I swept the same
One thing worth flagging that fell out of checking this. The published |
…erage Multi-agent review of 7492916 found nine defects, seven of them claims that commit itself introduced. Verified each empirically before fixing. Two comments asserted things that are false: - `docker rm -f` does NOT exit non-zero on a missing container — it has exited 0 since Docker 23 (measured on 29.4.3; plain `docker rm` is the one that exits 1). So the blanket `failed_when: false` did nothing for the case it was added for and suppressed only genuine failures: a dead daemon or an un-removable container would leave a Postgres process holding the OMOP data directory and the loopback port while the play reported success, and the trust's real omop-db later mounts that same PGDATA. Narrowed to tolerate only "No such container". - The vocab-load gate does not "mirror" omop-db-init-job. That sibling gates on `enabled` alone and handles an empty bucket at runtime by falling back to an anonymous Hugging Face download; it always renders. Ours deliberately does the opposite, which is the whole point — the licensed bundle has no mirror. The claim also contradicted values.yaml and the chart README, which had it right. Also corrected: values.yaml claimed both jobs "read the same bucket ... one place to configure it, not two" (they are separate keys, both defaulting empty, and sync-kit writes only vocabLoad's); the advice to set `vocabLoad.enabled: false` for an external database is inert, since both Jobs already gate on `omopDb.enabled`; the README called re-running `helm upgrade` a "cheap no-op" when `vocab-work` is an emptyDir, so every upgrade re-downloads the multi-GB bundle; the operator instructions used bare `make sync-kit` / `make deploy-trust-k8s`, but no root `sync-kit` target exists and the root `deploy-trust-k8s` forwards to the chart's plain `deploy`, dropping `KIT=` and deploying with no bucket — the exact silent failure that section warns about; and trust/omop-db/README.md still said "the trust stacks resolve :latest by default", the twin of the Makefile claim already fixed in 7492916. Two coverage gaps, both created by the gating change: - Defaulting `s3Bucket: ""` turned the vocab-load Job off in *every* CI render path, leaving its ~110-line body unrendered. Confirmed by injecting a `.Values` typo: the chart still linted and templated clean, i.e. it would merge green and then fail the whole Helm release of the first operator to configure a bucket. Added two `helm template` steps — one asserting the Job renders with a bucket (this one catches the injected typo), one pinning the default-off semantics. - The new sync_k8s_kit assertions were loose substrings, so a block emitted one level too deep (nested under trustApi, which is additionalProperties:true and would be silently accepted by Helm) satisfied all three. Anchored on newlines; the negative test now keys on `vocabLoad`/`s3Bucket` rather than `omopDb:`, so it will not break when an unrelated omopDb key joins the override. Left alone deliberately: `AICENTRE_BUCKET_NAME=flipdev-aicentre` in the kit scaffold is wrong for a stag/prod kit, but it is pre-existing and already consumed by fl-client's kitFromS3 — changing it belongs in its own PR. Softened the values.yaml and README wording to say the kit must carry your own environment's bucket rather than implying sync-kit always gets it right. Verified: ansible --syntax-check; prettier; helm lint; gate matrix (0 default / 3 with bucket / 0 disabled / 0 CI values); both new CI steps pass on the real chart and the first fails on a deliberately broken one; 13 k8s tests. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…p-db Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk> # Conflicts: # .github/workflows/test_helm_chart.yml # AGENTS.md # CLAUDE.md # trust/AGENTS.md # trust/CLAUDE.md # trust/data-access-api/tests/routers/test_cohort.py
…oader Eleven defects found by a full-diff review of this PR. Each was reproduced locally before being accepted, and four were downgraded from their initial severity after adversarial re-checking. Deployment: - `make ansible-init` (and therefore `full-deploy`) failed outright. The new vocab-load play asserts `omop_postgres_password`, which only `seed-trust-data` passes; ansible-init runs before register-trusts, has no KIT in scope, and so cannot supply it. Skip the play there via the `omop-vocab` tag it already carries — the vocabulary is still seeded later by seed-trust-data. - The `always:` cleanup removed the container before deleting the bundle. A failing task in `always:` aborts the remaining ones, so a `docker rm` against an unreachable daemon left ~3.6GB of licensed vocabulary at rest on the host. Deletion now runs first. - Both `creates:` guards keyed on the unpacked directory, so an interrupted unzip made the next run skip both the download and the unzip and reuse a truncated bundle. Each stage now stages into a temp path and renames on success, matching `fetch-vocab-core` in trust/omop-db/Makefile. CI: - secret-scanning.yml's uv-lock cooldown gate never checked trust/omop-db, even though UV_PROJECTS and the pre-commit hook both list it — the supply-chain backstop was silently absent for the new project. - Neither vocab-load render gate could fail. `! grep -q` is exempt from `set -e` and only failed the step by virtue of being the last line, and the absence assertion also passed on an empty render. Both are now explicit `if` blocks with ::error:: messages, plus a positive control. - test_trust_omop_db.yml lacked `environment: flip`, so CODECOV_TOKEN resolved empty and the coverage upload failed silently. omop-db: - load_core_vocab.sh tested a command substitution inside an `if` condition, which `set -e` does not cover. A failed psql probe yielded "", compared unequal to "t", and fell through to the COPY — silently duplicating rows in the four tables that carry no primary key. - A degenerate NUM_TRUSTS (0, empty, non-numeric) made populate, apply-constraints and export-pgdata fan out to zero targets and exit 0. - constraints.sql guard 18 reused guard 17's sentinel, so PAYER_PLAN_PERIOD's foreign keys were never applied on any run. The DO/IF NOT EXISTS wrapper is a local addition, not upstream OHDSI, so the typo is ours to fix. Docs: - The README said the vocabulary bundle was needed "not for running the trust stacks", contradicting the same file's mandatory `make load-omop-vocab` step and leading readers into the silent empty-cohort failure it warns about. - Gave the load-omop-vocab commands an explicit -C, and corrected a workflow comment that still described the vocabulary as "baked". Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
|
Ready for another look. CI is green (33 pass, 1 skip) and every review thread is resolved. Your approval was dismissed by two pushes since, so for the delta:
Nothing else has changed since you approved. |
The omop-vocab-load hook fetched and unzipped the multi-GB licensed bundle into a fresh emptyDir on every render, then handed it to a loader that skips every table it already holds. The cheap half was conditional and the expensive half was not — and because this is a post-upgrade hook whose failure fails the whole release, that download sat on the critical path of every helm upgrade. Add a probe-vocab initContainer that asks the database first, via a new --check mode on load_core_vocab.sh, and skip the fetch when nothing is missing. A routine upgrade now costs one query per vocabulary table. The loader still runs when the probe finds everything present: the bundle is now read only for the tables actually being loaded, so a no-op run needs no bundle on disk and still re-applies the FK constraints — which is how a previous run that loaded every table and then died before applying them heals. Also: - drop the bundle-present check in fetch-bundle; the emptyDir is recreated with the pod (restartPolicy: Never), so it could never match - declare resources on all three containers. The fetch writes the bundle to node-local storage, and without an ephemeral-storage request the kubelet ranks this pod first for eviction under disk pressure — and an evicted download restarts from zero. Sizing is values-driven (workDirSize, fetchResources, loadResources) - stop discarding both package managers' stderr when installing unzip; a trust behind an egress allowlist needs to see why it could not reach a mirror - share the OMOP connection env between probe and loader via a named template, so the two cannot drift onto different databases - CI gate asserting the probe renders and is ordered before the fetch Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…install The vocab-load Job installed unzip with microdnf/yum on every fetch, so loading a vocabulary required egress to the Amazon Linux package mirror on top of S3 — a trust behind an allowlist has the bucket open and the mirror not. unzip is the only tool that was missing, and it does not belong in the download step: the omop-db image already carries the loader and constraints, so put it there and let the loader unpack. fetch-bundle is now a bare `aws s3 cp` and S3 is the only host the Job needs to reach. This adds no new deployment constraint: the Job already requires an image new enough to carry /flip/omop/load_core_vocab.sh (FLIP#842), and every existing GHCR tag predates that, so unzip lands in the same first CI publish. The storage peak moves with the unpack — the fetch now holds only the zip while the loader briefly holds zip and unpacked contents together — so the ephemeral-storage requests move with it. CI gate rejects a run-time package install returning to this Job. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
… vocab Job Review of the two preceding commits found the new Helm gate did not constrain what it claimed, the loader had lost a wait it needed, and several comments stated mechanisms that are not how Kubernetes behaves. Tests. load_core_vocab.sh had no automated coverage before this branch and the Kubernetes hook now decides whether to download a multi-GB bundle purely from its exit codes. tests/test_load_core_vocab.sh drives the real script with psql stubbed on PATH — no Postgres, no bundle, no credentials — so it is a unit test by the repo's rule and runs from `make local_test`. Modelled on the existing harness at deploy/providers/AWS/scripts/tests/test_add_fl_kits.sh. 15 cases, mutation-tested: breaking --check, merging the two PENDING loops, or letting an unreadable guard fall through each fail a named case. Every variable the script reads is set explicitly, because trust/omop-db/Makefile exports the kit env file's keys into its recipes and an "unset" case would otherwise test nothing. Gate. It passed five mutants. A Job that probes and fetches but never loads; a fetch-bundle with its work-dir mount deleted (so its skip test reads a path that cannot exist and every upgrade silently re-downloads); a resources block rendering null; the fetch guard INVERTED, which the bare `-f /vocab-work/...` pattern matched happily as a substring of `! -f /vocab-work/...`; and `--check || true` followed by an unconditional touch, which writes the marker whatever the probe says and only surfaces on the next fresh install as a failed release. The marker assertions now carry their whole `if` line, and the gate also checks the loader, a mount count of 3, and no null resources. The image half of the no-package-install contract moves to test_trust_omop_db.yml and becomes a real check: build the image, assert unzip, the loader and the constraints are in it. It was asserted as a Dockerfile grep in the Helm workflow, whose pull_request trigger is path-filtered to deploy/providers/kubernetes/** — so it could not fire for the Dockerfile change it was guarding. Its new home runs on every PR and gates the GHCR publish. Loader wait. Restored. It was dropped on the reasoning that the loader starts seconds after the probe, which only holds on the cached path: when there is work to do, a multi-GB download sits between them, and the chart deploys with `helm upgrade --install` and no --wait, so the release's own rollout may still be in flight. Failing there discards the Pod and the bundle it just fetched. Comments. Four claims were wrong, all in ways that invite a bad future edit: - emptyDir sizeLimit is enforced by the kubelet on its own, so the ephemeral-storage limits were not what bounded the work dir; the requests earn their place through scheduling and eviction ranking instead. - The pod's ephemeral-storage ceiling is NOT the sum of all containers' limits. Init containers are taken as a max against the regular containers' total — the same rule the requests were already documented with. Read as a sum, three 4Gi limits look like 12Gi of headroom and are really 4Gi. - A container's own ephemeral-storage accounting covers its writable layer and logs, never an emptyDir, so the zip is charged to the pod and not to the fetch. - The chart README attached the fall-through-to-fetch behaviour to the probe's timeout, which is actually a hard failure; only a reachable-but-unqueryable database falls through. Also corrected the cost of a no-op upgrade: two round-trips per table plus a constraint-catalogue pass, not one query per table. Smaller: an absent bundle directory reports itself as such instead of blaming an incomplete bundle, since a caller that probed first skipped the fetch on purpose; the probe logs why it fell through, so "vocabulary genuinely absent" and "could not reach the database" are no longer identical Completed pods; the values schema rejects an emptied workDirSize or resources block rather than silently dropping the bound. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Measured from s3://flipdev-aicentre/vocab/: the 20240916 bundle is ~600 MB zipped against the ~3.6 GB unpacked figure already documented, so a loader that unpacks beside the zip peaks near 4.2 GB. That is the number sizing the Kubernetes vocab-load Job's work dir, and it was previously an unverifiable guess in a comment. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
hostAwsMount mounts a hostPath into the fetch container, and an empty path renders as `path: ""` — a volume the kubelet refuses, so the pod never admits and the failure surfaces as an unschedulable hook rather than as the configuration mistake it is. Requires the value instead, with a message saying it is a path on the NODE: on single-node k3s that is the workstation's $HOME/.aws, but on kind the node is a container and the path only exists if the cluster was created with a matching extraMount. The chart-wide cleanup of that value — dropping the hardcoded personal default and removing the fl-client's redundant copy of the mount — is a separate change against develop, since it touches templates this PR does not own. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Closes #834
Implements #842 and #843 (folded in by request — each keeps one residual org-admin action, tracked there).
What
Merges the private
flip-omop-dbrepo into the monorepo astrust/omop-db/— and takes the opportunity to make every published OMOP artifact licence-clean: the image, the pgdata tarballs and the HF dataset now carry no licensed vocabulary content at all.Snapshot import, not a history merge: one verbatim commit at flip-omop-db
main(5cf75d4); the private history (personal identifiers, private-repo references, no DCO) stays archived in the private repo.The vocab-free architecture (#842 + #843)
The core OMOP vocabulary (an OHDSI Athena export: SNOMED CT Int/US/UK, LOINC 2.77, NHS Read v2, dm+d, … — 59-vocabulary roster table in the README) is licensed material, and it was 99.8% of the published payload: the old image carried a 3.6 GB baked bundle and each 2.2 GB pgdata tarball was ~8 GB of vocab tables around ~15 MB of synthetic cohort. Now:
Dockerfile): schema init chain + read-only roles + seed-time helpers only. No data inputs, no credentials → CI-published via the new test-gateddocker_build_omop_db.yml(sameworkflow_runpattern as the other services), and ~4.3 GB → ~450 MB..data_version20260729(built viamake populate CORE_VOCAB=0+make export-pgdata).files/load_core_vocab.sh): streams the licensed bundle into a running database over TCP (COPY FROM STDIN— no mounts, no server-side files, no image layers) and applies the FK constraints; core-aware idempotency guards tolerate the DICOM vocab already present in the tarballs. Wired into all three environments: devmake load-omop-vocab, EC2 via a new Ansible play insideseed-trust-data(throwaway container on loopback, kit credentials passed by the AWS Makefile), Kubernetes via the chart'somop-vocab-loadpost-install/upgrade hook Job (omopDb.vocabLoadvalues + schema).make fetch-vocab-corefroms3://flipdev-aicentre/vocab/(VOCAB_S3_BUCKETparameterised, temp+rename robustness); everyone else self-serves an equivalent export from OHDSI Athena under their own licences (NHS TRUD pointer for UK editions). The image-extraction fallback is removed.files/OMOP CDM Staging/at upstream commit1ef3354(verified by content diff; Apache 2.0; pinned in THIRD_PARTY_NOTICES), fetched anonymously from HF with the upstream pickle converted to CSV — no pickle deserialisation anywhere.Also in this PR
.data_versionpath untouched (AWS Makefile hardcodes it).omop_db_tools.dataset): the synthetic per-trust CSVs (verified:FAK…accession IDs, fake NHS numbers) live as ONE dataset on HF (omop-csv/20260729/,source_trustprovenance).legacymode reproduces the original two-trust membership exactly (frame-equal, keeps accession IDs consistent with the mock Orthanc volumes);modulopartitionsperson_id % N. Two-trust build stack; N>2 fails fast until a compose service + port exist.sqlalchemy.URL.createescaping; build compose carries the runtime service's hardening (no-new-privileges,cap_drop: ALL+ five caps — freshinitdbverified under it); OCI source labels re-link the GHCR package to this repo;data_analyst_readerrotation paths documented (password lives in pgdata, not the image).ON_ERROR_STOPon constraints, temp+rename fetches,NUM_TRUSTS-derived fan-out with port fail-fast, bounded first-boot readiness wait, gapped-source_trustand NaN-person_idguards, per-source asymmetry rejection inbuild_canonical, validate-before-destructive-clean,.env.build.exampleun-swallowed fromtrust/.gitignore,radiology_occurrence→image_occurrenceacross data-access-api OpenAPI examples and trust docs. Unit tests 26 → 46.Trust - OMOP DB CI(lint+mypy+unit) anddocker_build_omop_db.yml; omop-db in rootunit_test; uv-lock hook +UV_PROJECTS.component-omop-database.rstexpanded; README documents both vocab bundles (roster, provenance, acquisition) and the full build/populate/export/seed workflows; CLAUDE/AGENTS pairs throughout.Verification (all live on this branch)
populate CORE_VOCAB=0+export-pgdata→ 11.4 MB tarballs.load_core_vocab.sh+ constraints → 4,187 persons, 6,108,439 concepts (SNOMED 1,084,286 + DICOM 8,811 coexisting), 173 FKs, the user-guide's exampleJOIN omop.conceptquery returns 2,479 rows,data_analyst_readerwrite denied, loader re-run is a clean no-op.omopDb.enabled=false), and the kind E2E does a real cluster install — the StatefulSet + tarball init path are cluster-verified, and the vocab-load hook's machinery was exercised for real (it ran and failed on S3 auth in credential-less CI, as designed; it is disabled in the CI values). Not yet run to completion with real S3 credentials in a cluster — the loader script itself is identical to the host-side-proven one; verify the credentialed Job on the first real k8s deploy. Ansible play: live-verified end-to-end on a stag trust EC2 (targeted terraform create → provisioning → 11 MB tarball restore → instance-role S3 fetch → loader + constraints; on-box verification identical to the local numbers: 4,187 persons / 6.1 M concepts / 173 FKs / roles enforced). Found and fixed en route: per-env vocab bucket wiring, and bundles must be uploaded--sse AES256(trust role lackskms:Decrypton bucket KMS keys — documented in the README).pre-commitclean on the branch diff; omop-db tools 46 tests + data-access-api 151 tests green.Review round 3 (@7492916c) — the approving review's four items
All four were flagged non-blocking on the approval; each is fixed, and the same defect is swept where it recurred elsewhere.
OMOPCDM_postgresql_5.4_*.sqlfiles carried a soleCopyright 2024 Guy's and St Thomas'header whileTHIRD_PARTY_NOTICES.md(added by this PR) states they derive from OHDSI CommonDataModel — so the header contradicted our own notices file, which makes this an attribution fix rather than a stale-year one. Rewritten in the dual-attribution form already used for vendored SQL attrust/xnat/postgres/XNAT.sql: upstream OHDSI copyright + SPDX, then FLIP modifications. Also drops the U+2019 smart quotes these four alone carried. Headers only — no SQL changed.OMOP_DB_TAG=latest. Correct — and this PR is what made it wrong, since the new CI publishing redefines:latestas the production image, so a dev kit ran omop-db from prod while its four siblings ran:stag. The composes now resolve${OMOP_DB_TAG:-${DOCKER_TAG}}, keeping the per-service override for exactly the caseXNAT_TAGexists for (omop-db changes rarely, so its branch tag is the one most likely to be absent). Notrust/Makefileexport needed — the kit reaches compose via--env-fileandDOCKER_TAGis already exported for dev. Swept the same:latestdefault out of the Helm chart (omopDb.image.tag→stag, matching all eight siblings; the tag is now load-bearing because the vocab-load Job runs the image's own loader) and the Ansible play, which now receives the env'sDOCKER_TAGfrom the Makefile.vocabLoaddefault. Gated the Job ons3Bucketand defaulted it empty, mirroringinitJob's own empty-bucket idiom — no bucket ⇒ no Job ⇒ clean install. A public fallback is not legally available here (SNOMED CT / LOINC / Read v2 / dm+d), which is what makes theinitJobpattern the right shape rather than a mirror. This also unhardcodesflipdev-aicentre, which a stag/prod EKS install could never read. Since "no vocabulary" is otherwise silent — cohort queries joiningomop.conceptreturn nothing while every probe stays green — the skip is announced in a newNOTES.txt, documented in the chart README (+ a troubleshooting entry), andsync_k8s_kit.pynow emits the bucket from the kit'sAICENTRE_BUCKET_NAMEso the org path stays zero-touch.block/always. This also clears a retry trap the flat list had: thecreates:guards key on the unpacked directory, so a re-run after a partial failure skipped both the download and the unzip and reused a possibly-truncated bundle.docker rm -fgetsfailed_when: false, since in analways:it runs even when the container never started and would otherwise fail the play it exists to clean up after.Behaviour in the wild is unchanged by item 2:
OMOP_DB_TAGis not inHUB_SHARED_KEYS, sosync-trust-kitnever strips it — existing kits keep their literalOMOP_DB_TAG=latestline and resolve exactly as before. Only kits newly scaffolded bymake new-trustpick up the new default.Verified on the branch:
helm lint; the template gate matrix (no bucket / bucket set /enabled=false/ CI values); the NOTES warning fires only when the vocabulary is genuinely absent; compose resolvesomop-db:stagbeside its four siblings and still honours a pinnedOMOP_DB_TAG;ansible-playbook --syntax-check+--list-tasks; prettier (the one hard CI gate ondeploy/providers/AWS/*.yml); k8s sync tests 13 passed; omop-db ruff + mypy + 47 unit tests. The Ansible path is not re-run live — the stag trust EC2 used for the original verification was destroyed after that round; the changed play is otherwise identical to the live-verified one.Post-merge admin actions (kept open on #842/#843)
:latestis replaced on the first main publish; historical sha-pinned versions need manual deletion). Note the currently published tags arelatest, trust1, trust2, stag— all hand-pushed, all predating the vocab-free rework, so all still carry the licensed bundle.trust<N>_pgdata_20260129.tar(2.2 GB, vocab-bearing) files from the public HF dataset once deployments are on20260729.londonaicentre/flip-omop-dbwith a pointer README; org-admin confirm the GHCR package re-links to FLIP after the first labelled push.:stag:gh workflow run docker_build_omop_db.yml --ref developonce after merge. CI has never published this image, and theworkflow_rungate does not fire retroactively on the merge that introduces it (workflow_dispatchbypasses the gate). Until then:stagis the old hand-pushed image, which lacksload_core_vocab.sh.:proddoes not exist yet and is created on the first main promotion.Note for reviewers: existing dev environments will download the new 11 MB volumes on their next
update-omop-data(version bump) and must then runmake load-omop-vocabonce per trust — called out in the README.develop merged in (@8323dba7) + review round 4 (@f64446ea)
Merge.
origin/develop(147 commits) merged in; 6 conflicts. Only one had substance:trust/data-access-api/tests/routers/test_cohort.py— #839 deleted the_parse_and_emithelper thisbranch's side still called, so the resolution takes develop's
validate_queryshape carrying thisbranch's
radiology_occurrence→image_occurrencerename (that test held the last such reference inthe tree;
image_occurrencewas already canonical at the merge-base, in the trust-api integrationfixtures). The other five were unions:
test_helm_chart.yml(this branch's two vocab-load gates +develop's FLIP-PT-091 orthanc gate) and both CLAUDE/AGENTS pairs. No test was lost — verified by
enumerating every
def test_*across both parents and the merge.Review round 4. A full-diff review of this PR found 11 defects; each was reproduced locally
before being fixed, and four were downgraded from their initial severity after re-checking.
Deployment
make ansible-init— and sofull-deploy— failed outright. The new vocab-load play assertsomop_postgres_password, which onlyseed-trust-datapasses.ansible-initruns beforeregister-trustsin every chain, has noKITin scope (hence itstrust_numfallback to 1) andstructurally cannot supply it, so passing the vars there is not an option — the play is now
excluded via the
omop-vocabtag it already carries. Verified:--list-tasks --skip-tags omop-vocabselects zero tasks from that play,--tags datastill selects all nine.always:aborts theremaining ones (verified), so with the container removal ahead of it a
docker rmagainst a deaddaemon skipped the ~3.6 GB bundle deletion. Deletion now runs first.
creates:guards reused a truncated bundle — both keyed on the unpacked directory, so aninterrupted unzip made the next run skip download and unzip. Both stages now stage into a temp
path and rename on success, matching
fetch-vocab-core.CI
4. The uv-lock cooldown gate never checked
trust/omop-db—secret-scanning.ymlhardcodes asecond copy of the project list that this PR didn't update, so the supply-chain backstop CLAUDE.md
names was silently absent for the new project.
5. Neither vocab-load render gate could fail.
! grep -qis exempt fromset -eand worked onlyby being the last line (appending any line disarms it — verified), and the absence assertion also
passed on an empty render. Both are now explicit
if+::error::, with a positive control.6.
test_trust_omop_db.ymllackedenvironment: flip, soCODECOV_TOKENresolved empty and theupload failed silently. (Unverified from the CLI — repo secret scoping isn't readable; harmless if
the token turns out to be repo-level.)
omop-db
7.
load_core_vocab.shswallowed a failed guard. A command substitution inside anifconditionis exempt from
set -e, so a failedpsqlprobe yielded"", compared unequal to"t"and fellthrough to the
COPY— silently duplicating rows in the four tables with no primary key. Now aplain assignment plus a
casethat treats anything butt/fas fatal.8. A degenerate
NUM_TRUSTS(0,00, empty, non-numeric) madepopulate/apply-constraints/export-pgdatafan out to zero targets and exit 0 —populatewould fetch the bundles and loadnothing. Guarded.
9.
constraints.sqlguard 18 reused guard 17's sentinel, soPAYER_PLAN_PERIOD's 10 FKs werenever applied on any run. Impact is ~nil (the table is unused), but the
DO/IF NOT EXISTSwrapper is a local addition — upstream OHDSI ships a flat list of plain
ALTER TABLEstatements — so the typo is ours.
Docs — 10/11: the README claimed the vocabulary bundle was needed "not for running the trust
stacks", contradicting its own mandatory
make load-omop-vocabstep; gave those commands an explicit-C; corrected a workflow comment still calling the vocabulary "baked".Verified on the branch: omop-db ruff + mypy + 47 tests;
bash -n; theNUM_TRUSTSguard across9 edge cases;
ansible-playbook --syntax-check+ both tag selections;helm lint+ everyhelm-templategate, including proof the rewritten gates now fail when the Job is wrongly presentand when the render is empty; prettier on the gated AWS scope; full
pre-commiton the diff.Filed separately (all develop-side, none introduced here): #879 (cohort schema-allowlist
bypassable by dropping the qualifier — the one security finding, demonstrated), #880, #881, #882, #883.
Acceptance Criteria
Imported from issue #834
trust/omop-db/, snapshot-imported with FLIP conventions applied (Apache headers, ruff/mypy config, uv 72h cooldown, Makefile target contract, CLAUDE.md/AGENTS.md pair, uv-lock pre-commit entry, UV_PROJECTS)${OMOP_DB_TAG}instead of hardcoded:latest; "built from" comments point at the in-repo pathcomponent-omop-database.rstexpanded, staleradiology_occurrencereferences reconciled toimage_occurrence, data-access-api cross-references point in-repo