From cec61b886ef0aca336155b59e478e932a10054fc Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Tue, 13 Oct 2026 21:21:36 -0500 Subject: [PATCH] Run the sync server image as a numeric UID/GID; explain unwritable /data The image now runs as USER 10001:10001 with a matching group, so Kubernetes can verify runAsNonRoot and fsGroup: 10001 grants it a mounted volume. When the database can't be opened because its directory isn't writable, the server says so and how to fix it, instead of SQLite's bare "unable to open database file". The README covers the pod securityContext. --- README.md | 17 +++++++++++++++++ crates/mtgc-server/Dockerfile | 9 ++++++--- crates/mtgc-server/src/main.rs | 26 +++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8046b62..4082381 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,23 @@ answers `ok` for probes. Keep `MTGC_OIDC_CLIENT_SECRET` in a Secret. Run the account commands with `kubectl exec -it deploy/ -- mtgc-server user add `. +A freshly provisioned volume is usually owned by root, which the server +(UID/GID 10001) can't write to — it fails at startup with "`/data` isn't +writable". Have Kubernetes hand the volume to its group in the pod spec: + +```yaml +spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 +``` + +A few volume types ignore `fsGroup` (`hostPath`, and some NFS and local-path +provisioners). For those, `chown 10001:10001` the backing directory once, or +add an init container that does it. + To build the image yourself and push it to your own registry: ```bash diff --git a/crates/mtgc-server/Dockerfile b/crates/mtgc-server/Dockerfile index d1f0c93..d313add 100644 --- a/crates/mtgc-server/Dockerfile +++ b/crates/mtgc-server/Dockerfile @@ -13,10 +13,13 @@ FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* \ - && useradd --system --uid 10001 --home /data mtgc \ - && mkdir /data && chown mtgc /data + && groupadd --system --gid 10001 mtgc \ + && useradd --system --uid 10001 --gid 10001 --home /data mtgc \ + && mkdir /data && chown 10001:10001 /data COPY --from=build /mtgc-server /usr/local/bin/mtgc-server -USER mtgc +# Numeric, so Kubernetes can verify `runAsNonRoot` (it can't resolve names), +# and with a fixed group so `fsGroup: 10001` grants a mounted volume to it. +USER 10001:10001 VOLUME /data ENV MTGC_DB=/data/mtgc-server.db \ MTGC_BIND=0.0.0.0:8080 diff --git a/crates/mtgc-server/src/main.rs b/crates/mtgc-server/src/main.rs index 9308f64..1092a1e 100644 --- a/crates/mtgc-server/src/main.rs +++ b/crates/mtgc-server/src/main.rs @@ -46,8 +46,7 @@ fn serve() -> anyhow::Result<()> { .init(); let config = Config::from_env()?; - let conn = db::open(&config.db_path) - .with_context(|| format!("opening {}", config.db_path.display()))?; + let conn = open_at(&config.db_path)?; let bind = config.bind; tracing::info!( public_url = %config.public_url, @@ -90,7 +89,28 @@ async fn shutdown_signal() { /// configuration, so it works before OIDC (say) is set up. fn open_db() -> anyhow::Result { let path = std::env::var("MTGC_DB").unwrap_or_else(|_| "mtgc-server.db".into()); - db::open(std::path::Path::new(&path)).with_context(|| format!("opening {path}")) + open_at(std::path::Path::new(&path)) +} + +/// Open the database, explaining the usual reason it can't be: a mounted +/// volume the server's user can't write to. SQLite's own message ("unable to +/// open database file") doesn't say so. +fn open_at(path: &std::path::Path) -> anyhow::Result { + db::open(path).map_err(|e| { + let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); + let probe = dir.join(".mtgc-write-test"); + let writable = std::fs::write(&probe, b"").and_then(|_| std::fs::remove_file(&probe)); + let err = anyhow::Error::new(e).context(format!("opening {}", path.display())); + match writable { + Ok(()) => err, + Err(w) => err.context(format!( + "{} isn't writable by this process ({w}). In the container image the server \ + runs as UID/GID 10001: on Kubernetes, set the pod's securityContext.fsGroup \ + to 10001; for a bind mount, `chown 10001:10001` the host directory", + dir.display() + )), + } + }) } fn user(args: &[String]) -> anyhow::Result<()> {