diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a61fdd..6692a20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,3 +67,34 @@ jobs: - name: Build run: cd cli && cargo build --verbose + + docker-readonly: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build image (local) + run: docker build -f docker/Dockerfile -t biosynth:ci . + + - name: Run read-only container + run: | + mkdir -p out + docker run --rm --read-only \ + --tmpfs /tmp:rw,exec,mode=1777 \ + -e BVS_READ_ONLY_DB=1 \ + -v "${{ github.workspace }}/out:/out" \ + biosynth:ci \ + synthetic \ + --output /out/genotypes/{index}.txt \ + --count 1 \ + --seed 42 + + docker run --rm --read-only \ + --tmpfs /tmp:rw,exec,mode=1777 \ + -e BVS_READ_ONLY_DB=1 \ + -v "${{ github.workspace }}/out:/out" \ + biosynth:ci \ + genotype-to-vcf \ + --input /out/genotypes/0001.txt \ + --output /out/out.vcf diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 24d3a84..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: docker - -on: - push: - branches: - - main - tags: - - "v*" - workflow_dispatch: {} - -permissions: - contents: read - packages: write - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Read version - id: version - run: echo "version=$(grep '^version = ' cli/Cargo.toml | head -n1 | cut -d'\"' -f2)" >> "$GITHUB_OUTPUT" - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/openmined/biosynth - tags: | - type=ref,event=tag - type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=${{ steps.version.outputs.version }} - type=sha - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - file: docker/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b5a428..049b02d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -261,6 +261,55 @@ jobs: uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-docker: + name: Publish Docker image + runs-on: ubuntu-latest + needs: [build, publish-crate] + if: needs.build.result == 'success' + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - name: Read version + id: version + run: echo "version=$(grep '^version = ' cli/Cargo.toml | head -n1 | cut -d'\"' -f2)" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/openmined/biosynth + tags: | + type=raw,value=latest + type=raw,value=${{ steps.version.outputs.version }} + type=sha + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max with: tag_name: v${{ steps.version.outputs.version }} release_name: Release v${{ steps.version.outputs.version }} diff --git a/cli/src/commands/genotype_to_vcf.rs b/cli/src/commands/genotype_to_vcf.rs index 0fb0bb3..44d3748 100644 --- a/cli/src/commands/genotype_to_vcf.rs +++ b/cli/src/commands/genotype_to_vcf.rs @@ -42,7 +42,7 @@ pub fn run_genotype_to_vcf(args: GenotypeToVcfArgs) -> Result<()> { bail!("--missing-log can only be used with a single input file"); } - let sqlite_path = ensure_reference_db(Some(&args.sqlite), args.force_download)?; + let sqlite_path = resolve_sqlite_path(&args)?; let store = if read_only_db_requested() { StatsStore::connect_read_only(&sqlite_path)? } else { @@ -110,6 +110,16 @@ fn read_only_db_requested() -> bool { } } +fn resolve_sqlite_path(args: &GenotypeToVcfArgs) -> Result { + if read_only_db_requested() { + let baked_in = PathBuf::from("/app/data/genostats.sqlite"); + if baked_in.exists() { + return Ok(baked_in); + } + } + ensure_reference_db(Some(&args.sqlite), args.force_download) +} + fn convert_file( input: &Path, output_paths: &OutputPaths, diff --git a/cli/src/commands/synthetic.rs b/cli/src/commands/synthetic.rs index e563ee9..6b855ba 100644 --- a/cli/src/commands/synthetic.rs +++ b/cli/src/commands/synthetic.rs @@ -71,8 +71,12 @@ pub fn run_synthetic(args: SyntheticArgs) -> Result<()> { bail!("Day range must be between 1 and 31"); } - let sqlite_path = ensure_reference_db(Some(&args.sqlite), args.force_download)?; - let store = StatsStore::connect(&sqlite_path)?; + let sqlite_path = resolve_sqlite_path(&args)?; + let store = if read_only_db_requested() { + StatsStore::connect_read_only(&sqlite_path)? + } else { + StatsStore::connect(&sqlite_path)? + }; let references = store.all_references(args.limit)?; if references.is_empty() { bail!( @@ -123,6 +127,26 @@ pub fn run_synthetic(args: SyntheticArgs) -> Result<()> { Ok(()) } +fn read_only_db_requested() -> bool { + match std::env::var("BVS_READ_ONLY_DB") { + Ok(value) => matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + Err(_) => false, + } +} + +fn resolve_sqlite_path(args: &SyntheticArgs) -> Result { + if read_only_db_requested() { + let baked_in = PathBuf::from("/app/data/genostats.sqlite"); + if baked_in.exists() { + return Ok(baked_in); + } + } + ensure_reference_db(Some(&args.sqlite), args.force_download) +} + fn write_single_file( path: &PathBuf, references: &[ReferenceVariant], diff --git a/cli/src/stats.rs b/cli/src/stats.rs index 51a843f..37c4813 100644 --- a/cli/src/stats.rs +++ b/cli/src/stats.rs @@ -59,8 +59,7 @@ impl StatsStore { } pub fn connect_read_only(path: &Path) -> Result { - let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .with_context(|| format!("Open database at {:?} (read-only)", path))?; + let conn = open_read_only_connection(path)?; configure_connection_read_only(&conn)?; Ok(Self { sqlite_path: path.to_path_buf(), @@ -70,8 +69,7 @@ impl StatsStore { pub fn open_connection(&self) -> Result { let conn = if self.read_only { - Connection::open_with_flags(&self.sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .with_context(|| format!("Open database at {:?} (read-only)", self.sqlite_path))? + open_read_only_connection(&self.sqlite_path)? } else { Connection::open(&self.sqlite_path) .with_context(|| format!("Open database at {:?}", self.sqlite_path))? @@ -332,3 +330,18 @@ fn configure_connection_read_only(conn: &Connection) -> Result<()> { conn.pragma_update(None, "query_only", "ON")?; Ok(()) } + +fn open_read_only_connection(path: &Path) -> Result { + let uri = read_only_uri(path); + Connection::open_with_flags( + uri, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .with_context(|| format!("Open database at {:?} (read-only)", path)) +} + +fn read_only_uri(path: &Path) -> String { + let raw = path.to_string_lossy(); + let escaped = raw.replace(' ', "%20"); + format!("file:{escaped}?immutable=1") +} diff --git a/test-docker.sh b/test-docker.sh new file mode 100755 index 0000000..9c77d57 --- /dev/null +++ b/test-docker.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env sh +set -eu + +IMAGE_TAG="${IMAGE_TAG:-biosynth:ci}" +OUTPUT_FILE="${OUTPUT_FILE:-/out/out.vcf}" + +docker build -f docker/Dockerfile -t "${IMAGE_TAG}" . + +mkdir -p out + +docker run --rm --read-only \ + --tmpfs /tmp:rw,exec,mode=1777 \ + -e BVS_READ_ONLY_DB=1 \ + -v "$PWD/out:/out" \ + "${IMAGE_TAG}" \ + synthetic \ + --output /out/genotypes/{index}.txt \ + --count 1 \ + --seed 42 + +docker run --rm --read-only \ + --tmpfs /tmp:rw,exec,mode=1777 \ + -e BVS_READ_ONLY_DB=1 \ + -v "$PWD/out:/out" \ + "${IMAGE_TAG}" \ + genotype-to-vcf \ + --input /out/genotypes/0001.txt \ + --output "${OUTPUT_FILE}"