Files
Daniel Hofbauerandpre-commit-ci[bot] 2566844063 feat(sidecar): editable SQL Server / Azure SQL data source (#2913)
* feat(sidecar): editable SQL Server / Azure SQL endpoints (#2802)

Add the /mssql router with status, session connect, catalog listing, read
and transactional write-back, backed by the shared helpers in db_common.
Microsoft Entra (password, service principal, interactive, managed
identity) and Windows integrated auth are supported; writes are 2D.

Desktop spawn installs the mssql extra and gates the desktop-only auth
methods; managed identity additionally requires an explicit opt-in because
it would otherwise lend the sidecar host's Azure identity to every caller.
CI runs a live SQL Server 2022 integration job. The Docker image documents
but does not install pyodbc or ODBC Driver 18.

* fix(sidecar): bind SQL Server values by column type and tidy write path

Rebind json_safe strings (hex for binary, ISO for temporal) to native
Python values before UPDATE/INSERT, since SQL Server rejects the implicit
nvarchar cast (errors 257 and 241). Route the write path through the
_connection helper, note the single-SRID probe assumption, serialize
pyproj transforms, and drop the duplicate Entra token fetch on connect.

* fix(sidecar): keep write error when rollback fails; scrub fresh tokens

A broken connection raises from rollback() too, which used to replace the
original 400 with an unhandled 500; roll back through a helper that logs
the secondary failure and lets the original error propagate. Also add the
token fetched inside _open_connection to the values scrubbed from driver
errors, since only connect-time secrets were covered before.

* fix(sidecar): retain live token for later scrubbers; accept Z timestamps

Store the access token fetched per connection on the session so the
table/read/write and rollback scrubbers cover it, not just connect-time
secrets. Normalize a trailing Z before fromisoformat so Date.toISOString
values bind on Python 3.10, and add the missing blank line between CI jobs.

* fix(sidecar): reproject geography columns with non-4326 SRIDs

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* fix(sidecar): bind time/datetimeoffset safely, scrub probe token, docs wording

* style: wrap live fixture SQL

* fix(sidecar): preserve seven fractional digits for time and datetime2

* fix(sidecar): rebind primary keys by type; assert stored sub-microsecond values live

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-10-04 17:58:02 +02:00

196 lines
11 KiB
Docker

# Run the build stage on the builder's native platform: the output is
# arch-independent static files, so emulating arm64 with QEMU here only
# slows down multi-arch builds without changing the result.
# ($BUILDPLATFORM is a Docker-provided automatic ARG, set by BuildKit.)
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS build
# Debian rather than Alpine because this stage now also runs the JupyterLite
# build, whose Python dependency tree (jupyterlab, notebook, jupyterlite-core)
# resolves to prebuilt manylinux wheels here instead of compiling against musl.
# Only apps/geolibre-desktop/dist is copied into the runtime image, so the larger
# builder costs nothing in the shipped image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-venv \
&& rm -rf /var/lib/apt/lists/*
# A venv keeps pip off the Debian-managed interpreter, which refuses installs
# under PEP 668.
ENV VIRTUAL_ENV=/opt/jupyterlite
RUN python3 -m venv "$VIRTUAL_ENV"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /app
# Copy every workspace member's package.json before npm ci so the install
# layer is cached. Adding a new package under apps/ or packages/ requires
# adding its package.json here, or npm ci fails with a missing workspace.
COPY package.json package-lock.json ./
COPY patches patches
COPY scripts/apply-dependency-patches.mjs scripts/apply-dependency-patches.mjs
COPY apps/geolibre-desktop/package.json apps/geolibre-desktop/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/collab-core/package.json packages/collab-core/package.json
COPY packages/map/package.json packages/map/package.json
COPY packages/plugins/package.json packages/plugins/package.json
COPY packages/processing/package.json packages/processing/package.json
COPY packages/ui/package.json packages/ui/package.json
RUN npm ci
# Its own layer, keyed only on the requirements file, so touching app source does
# not reinstall the Python tree.
COPY apps/geolibre-desktop/jupyterlite/requirements.txt apps/geolibre-desktop/jupyterlite/requirements.txt
RUN pip install --no-cache-dir -r apps/geolibre-desktop/jupyterlite/requirements.txt
COPY . .
ARG GEOLIBRE_APP_BASE=/
ARG VITE_GEE_OAUTH_CLIENT_ID=
ARG VITE_MAPILLARY_ACCESS_TOKEN=
# Set to 1 (or true) to disable the first-launch welcome wizard for the whole
# deployment; visitors land straight on the map.
ARG VITE_WELCOME_DISABLED=
# Comma-separated origins allowed to drive a framed app over the embed
# postMessage API. Usually set at RUN time instead (-e GEOLIBRE_EMBED_ORIGINS=…),
# which the entrypoint writes into the runtime config without a rebuild.
ARG VITE_GEOLIBRE_EMBED_ORIGINS=
# Self-hosted project sharing server (https://…, or "off" to remove Share and the
# Project Gallery). Unset uses the public hosted service. Like the embed origins,
# normally set at RUN time instead (-e GEOLIBRE_SHARE_URL=…) so a prebuilt image
# can be repointed without a rebuild.
ARG VITE_GEOLIBRE_SHARE_URL=
# Self-hosted collaboration relay (wss://…). Unset leaves collaboration dark.
# Also settable at RUN time (-e GEOLIBRE_COLLAB_URL=…).
ARG VITE_GEOLIBRE_COLLAB_URL=
# Name shown at the start of the toolbar and in the browser tab, replacing
# "GeoLibre". Also settable at RUN time (-e GEOLIBRE_APP_NAME=…).
ARG VITE_GEOLIBRE_APP_NAME=
# GeoLens catalog default. Also settable at RUN time
# (-e GEOLIBRE_GEOLENS_URL=...).
ARG VITE_GEOLENS_DEFAULT_URL=same-origin
# Set to 1 to strip every external CDN reference (unpkg.com, cdn.jsdelivr.net,
# …) from the build output, for deployments that may not load third-party
# hosts. Features that depend on CDN-hosted assets are disabled or degraded —
# see docs/self-hosting.md. Build-time only: the flag is baked into the bundle,
# so it cannot be flipped at RUN time the way the embed/share/collab URLs can.
ARG GEOLIBRE_NO_EXTERNAL_CDN=
# Comma-separated list of the deployment capabilities the interface may offer
# (project:edit, data:add, processing:run, export:data, plugins:install,
# settings:manage), or "none" to grant nothing — for a kiosk or classroom
# instance. Unset grants everything, so an existing build is unchanged. See
# docs/deployment-capabilities.md. Build-time default; at run time
# -e GEOLIBRE_CAPABILITIES=... overrides it on a prebuilt image through the
# deployment.json and runtime config the entrypoint writes (issue #2783).
ARG VITE_GEOLIBRE_CAPABILITIES=
ENV GEOLIBRE_APP_BASE=${GEOLIBRE_APP_BASE}
ENV VITE_GEE_OAUTH_CLIENT_ID=${VITE_GEE_OAUTH_CLIENT_ID}
ENV VITE_MAPILLARY_ACCESS_TOKEN=${VITE_MAPILLARY_ACCESS_TOKEN}
ENV VITE_WELCOME_DISABLED=${VITE_WELCOME_DISABLED}
ENV VITE_GEOLIBRE_EMBED_ORIGINS=${VITE_GEOLIBRE_EMBED_ORIGINS}
ENV VITE_GEOLIBRE_SHARE_URL=${VITE_GEOLIBRE_SHARE_URL}
ENV VITE_GEOLIBRE_COLLAB_URL=${VITE_GEOLIBRE_COLLAB_URL}
ENV VITE_GEOLIBRE_APP_NAME=${VITE_GEOLIBRE_APP_NAME}
ENV VITE_GEOLENS_DEFAULT_URL=${VITE_GEOLENS_DEFAULT_URL}
ENV GEOLIBRE_NO_EXTERNAL_CDN=${GEOLIBRE_NO_EXTERNAL_CDN}
ENV VITE_GEOLIBRE_CAPABILITIES=${VITE_GEOLIBRE_CAPABILITIES}
# The `prebuild` hook of apps/geolibre-desktop runs scripts/build-jupyterlite.mjs,
# which generates the site the Notebook panel embeds. That script is best-effort
# by default: without the `jupyter lite` CLI it warns and exits 0, and the build
# still succeeds. nginx then answers the panel's iframe URL with index.html
# through its SPA fallback, so the panel renders a second copy of GeoLibre
# instead of a notebook, which is how this image shipped for so long
# (GeoLibre#1851). Make the absence fatal here rather than silent.
ENV GEOLIBRE_JUPYTERLITE_REQUIRED=1
RUN npm run build
# The site is generated, not committed (public/jupyterlite/ is git-ignored), so
# assert it reached the output rather than trusting the step above.
RUN test -f apps/geolibre-desktop/dist/jupyterlite/lab/index.html \
|| (echo "ERROR: dist/jupyterlite is missing; the Notebook panel would render a second copy of the app." && exit 1)
# Runtime image bundles the static web app (served by nginx) and the optional
# Python conversion/Whitebox sidecar (uvicorn), reverse-proxied at /sidecar.
# A glibc base (not alpine/musl) is required for the prebuilt geo wheels
# (duckdb, rasterio/rio-cogeo, freestiler, whitebox-workflows).
FROM python:3.12-slim-bookworm AS runtime
# TARGETARCH is provided by BuildKit (amd64 / arm64).
ARG TARGETARCH
# libexpat1 is a runtime dependency of rasterio (pulled in by rio-cogeo) that
# the slim base image does not ship. openssl provides `openssl passwd` used by
# entrypoint.sh to hash the optional Basic Auth password.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx libexpat1 openssl \
&& rm -rf /var/lib/apt/lists/* \
&& rm -f /etc/nginx/sites-enabled/default
# Install the sidecar package plus the core conversion stack. duckdb and
# rio-cogeo (rasterio) publish linux/arm64 wheels, so Vector->GeoParquet,
# CSV->GeoParquet and Raster->COG work on both architectures.
COPY backend/geolibre_server /opt/geolibre_server
RUN pip install --no-cache-dir /opt/geolibre_server \
&& pip install --no-cache-dir "duckdb>=1.1.0" "rio-cogeo>=5.0.0"
# freestiler (PMTiles) and whitebox-workflows publish no linux/arm64 wheels, so
# they are installed on amd64 only. On arm64 those tools report unavailable
# while the other conversions keep working.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
pip install --no-cache-dir "freestiler>=0.1.0" "whitebox-workflows>=2.0.2"; \
else \
echo "Skipping freestiler + whitebox-workflows on $TARGETARCH (no wheels)"; \
fi
# Point the sidecar at this interpreter so it skips the managed-runtime
# bootstrap and uses the prebaked packages. Confine conversion reads/writes to
# /data by default: the sidecar is reachable same-origin through the nginx
# proxy, so without this an arbitrary same-origin caller could read or
# overwrite container paths. Mount input files at /data (read-write for
# outputs); override GEOLIBRE_CONVERSION_ROOTS to widen or disable.
ENV GEOLIBRE_CONVERSION_PYTHON=/usr/local/bin/python \
WBW_EXTERNAL_PYTHON=/usr/local/bin/python \
GEOLIBRE_CONVERSION_ROOTS=/data
RUN mkdir -p /data
# For the same reason, the sidecar's PostGIS endpoints refuse every destination
# until GEOLIBRE_POSTGIS_HOSTS names the allowed databases (comma-separated
# `host` or `host:port`) — otherwise a same-origin caller could aim them at
# hosts only this container can reach. Deliberately left unset: set it at
# `docker run` time to enable PostGIS, or `*` to accept any connection string.
# SQL Server uses the same destination allowlist via GEOLIBRE_MSSQL_HOSTS; it is
# intentionally unset in this image. This image also does not install pyodbc or
# Microsoft ODBC Driver 18: operators must add both in a derived image.
# WARNING: docker/nginx.conf's CSP allows http://localhost:* / http://127.0.0.1:*
# (and ws:// equivalents) in connect-src for local-dev data sources (PMTiles/COGs
# from a dev server on another port). This image is intended for local/single-user
# use; on a public host those allowances let the served JS probe each visitor's
# loopback. Drop them from the CSP before publishing publicly.
# Ship nginx.conf as an immutable template (not loaded directly). entrypoint.sh
# renders it to /etc/nginx/conf.d/default.conf on every boot, substituting the
# per-launch sidecar token, so a container restart never keeps a stale token.
COPY docker/nginx.conf /etc/nginx/nginx.conf.template
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY docker/deployment_policy.py /usr/local/lib/geolibre/deployment_policy.py
COPY docker/sidecar_policy.py /usr/local/lib/geolibre/sidecar_policy.py
RUN chmod +x /usr/local/bin/entrypoint.sh \
# Default auth snippet (disabled). entrypoint.sh rewrites it at start based
# on GEOLIBRE_AUTH_USER/GEOLIBRE_AUTH_PASSWORD; baking a valid default keeps
# `nginx -t` and non-entrypoint invocations working.
&& printf '# Basic Auth disabled (GEOLIBRE_AUTH_USER/GEOLIBRE_AUTH_PASSWORD not set).\n' > /etc/nginx/geolibre-auth.conf \
&& printf '# AI proxy disabled (GEOLIBRE_AI_URL not set).\n' > /etc/nginx/geolibre-ai-proxy.conf \
&& printf '# Sidecar guards generated at boot.\n' > /etc/nginx/geolibre-sidecar-guards.conf \
&& printf '# Sidecar off switch generated at boot.\n' > /etc/nginx/geolibre-sidecar-off.conf
COPY --from=build /app/apps/geolibre-desktop/dist /usr/share/nginx/html
EXPOSE 80
# /healthz is exempt from the optional Basic Auth, so the check keeps passing
# when GEOLIBRE_AUTH_USER/GEOLIBRE_AUTH_PASSWORD are set.
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1/healthz', timeout=4).status==200 else 1)" || exit 1
CMD ["/usr/local/bin/entrypoint.sh"]