From c5ee9c2e483d9fcbfd244eb0b31f63877c7f9177 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 21 Feb 2026 10:45:59 -0800 Subject: [PATCH 1/6] Add Docker production setup with PostgreSQL and MCP servers - Dockerfile: Multi-stage build with all database drivers and optional dependencies (serve, mcp, all-databases). 781MB runtime image. - docker-entrypoint.sh: Single container with SIDEMANTIC_MODE env var to control serve/mcp/both modes - .dockerignore: Excludes unnecessary files for faster builds - Add --host parameter to sidemantic serve CLI and config for container networking (0.0.0.0 support) - README: Comprehensive Docker usage section with examples Server fully tested: psql connections work, semantic layer tables queryable, demo mode verified. --- .dockerignore | 26 ++++++++++++++ Dockerfile | 41 +++++++++++++++++++++ README.md | 71 +++++++++++++++++++++++++++++++++++++ docker-entrypoint.sh | 46 ++++++++++++++++++++++++ sidemantic/cli.py | 6 ++-- sidemantic/config.py | 1 + sidemantic/server/server.py | 9 +++-- 7 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..411debd9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +.git +.github +.venv +.mypy_cache +.pytest_cache +.ruff_cache +__pycache__ +*.egg-info +*.pyc + +# Rust/DuckDB extension builds (not needed for Python image) +sidemantic-rs/ +sidemantic-duckdb/ + +# Docs and examples not needed at runtime +docs/ +examples/superset_demo/ +examples/rill_demo/ +examples/cube_demo/ + +# Dev/test artifacts +Dockerfile.test +docker-compose.yml +*.md +!README.md +.context/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ebe9a416 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM python:3.12-slim AS builder + +# Install build deps for riffq (Rust/maturin) and other native extensions +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app + +COPY pyproject.toml README.md LICENSE ./ +COPY sidemantic/ sidemantic/ +COPY examples/ examples/ + +RUN uv pip install --system --no-cache ".[serve,mcp,all-databases]" + +# --- Runtime stage (no build tools) --- +FROM python:3.12-slim + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy installed packages from builder +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin/sidemantic /usr/local/bin/sidemantic + +WORKDIR /app + +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +RUN mkdir -p /app/models +WORKDIR /app/models + +EXPOSE 5433 + +ENTRYPOINT ["/docker-entrypoint.sh"] +# Mode is controlled by SIDEMANTIC_MODE env var (serve, mcp, both) diff --git a/README.md b/README.md index 6c4e6710..87fa0272 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,77 @@ load_from_directory(layer, "my_models/") # Auto-detects formats | Databricks | ✅ | `uv add sidemantic[databricks]` | | Spark SQL | ✅ | `uv add sidemantic[spark]` | +## Docker + +Build the image (includes all database drivers, PG server, and MCP server): + +```bash +docker build -t sidemantic . +``` + +### PostgreSQL server (default) + +Mount your models directory and expose port 5433: + +```bash +docker run -p 5433:5433 -v ./models:/app/models sidemantic +``` + +Connect with any PostgreSQL client: + +```bash +psql -h localhost -p 5433 -U any -d sidemantic +``` + +With a backend database connection: + +```bash +docker run -p 5433:5433 \ + -v ./models:/app/models \ + -e SIDEMANTIC_CONNECTION="postgres://user:pass@host:5432/db" \ + sidemantic +``` + +### MCP server + +```bash +docker run -v ./models:/app/models -e SIDEMANTIC_MODE=mcp sidemantic +``` + +### Both servers simultaneously + +Runs the PG server in the background and MCP on stdio: + +```bash +docker run -p 5433:5433 -v ./models:/app/models -e SIDEMANTIC_MODE=both sidemantic +``` + +### Demo mode + +```bash +docker run -p 5433:5433 sidemantic --demo +``` + +### Baking models into the image + +Create a `Dockerfile` that copies your models at build time: + +```dockerfile +FROM sidemantic +COPY my_models/ /app/models/ +``` + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `SIDEMANTIC_MODE` | `serve` (default), `mcp`, or `both` | +| `SIDEMANTIC_CONNECTION` | Database connection string | +| `SIDEMANTIC_DB` | Path to DuckDB file (inside container) | +| `SIDEMANTIC_USERNAME` | PG server auth username | +| `SIDEMANTIC_PASSWORD` | PG server auth password | +| `SIDEMANTIC_PORT` | PG server port (default 5433) | + ## Testing ```bash diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 00000000..2e9bd3ce --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -e + +# SIDEMANTIC_MODE: "serve" (default), "mcp", or "both" +MODE="${SIDEMANTIC_MODE:-serve}" + +# Build shared args from environment variables +ARGS="" +if [ -n "$SIDEMANTIC_CONNECTION" ]; then + ARGS="$ARGS --connection $SIDEMANTIC_CONNECTION" +fi +if [ -n "$SIDEMANTIC_DB" ]; then + ARGS="$ARGS --db $SIDEMANTIC_DB" +fi + +# Serve-specific args +SERVE_ARGS="" +if [ -n "$SIDEMANTIC_USERNAME" ]; then + SERVE_ARGS="$SERVE_ARGS --username $SIDEMANTIC_USERNAME" +fi +if [ -n "$SIDEMANTIC_PASSWORD" ]; then + SERVE_ARGS="$SERVE_ARGS --password $SIDEMANTIC_PASSWORD" +fi +if [ -n "$SIDEMANTIC_PORT" ]; then + SERVE_ARGS="$SERVE_ARGS --port $SIDEMANTIC_PORT" +fi + +case "$MODE" in + serve) + exec sidemantic serve --host 0.0.0.0 $ARGS $SERVE_ARGS "$@" + ;; + mcp) + exec sidemantic mcp-serve $ARGS "$@" + ;; + both) + # Start PG server in background, MCP on stdio in foreground + sidemantic serve --host 0.0.0.0 $ARGS $SERVE_ARGS & + SERVE_PID=$! + trap "kill $SERVE_PID 2>/dev/null" EXIT + exec sidemantic mcp-serve $ARGS "$@" + ;; + *) + echo "Unknown SIDEMANTIC_MODE: $MODE (use serve, mcp, or both)" >&2 + exit 1 + ;; +esac diff --git a/sidemantic/cli.py b/sidemantic/cli.py index 51486b9b..df895dcf 100644 --- a/sidemantic/cli.py +++ b/sidemantic/cli.py @@ -422,6 +422,7 @@ def serve( None, "--connection", help="Database connection string (e.g., postgres://host/db, bigquery://project/dataset)" ), db: Path = typer.Option(None, "--db", help="Path to DuckDB database file (shorthand for duckdb:/// connection)"), + host: str = typer.Option(None, "--host", "-H", help="Host/IP to bind to (overrides config, default 127.0.0.1)"), port: int = typer.Option(None, "--port", "-p", help="Port to listen on (overrides config)"), username: str = typer.Option(None, "--username", "-u", help="Username for authentication (overrides config)"), password: str = typer.Option(None, "--password", help="Password for authentication (overrides config)"), @@ -482,7 +483,8 @@ def serve( # Use connection from config connection_str = build_connection_string(_loaded_config) - # Resolve port, username, password from args or config + # Resolve host, port, username, password from args or config + host_resolved = host or (_loaded_config.pg_server.host if _loaded_config else "127.0.0.1") port_resolved = port if port is not None else (_loaded_config.pg_server.port if _loaded_config else 5433) username_resolved = username or (_loaded_config.pg_server.username if _loaded_config else None) password_resolved = password or (_loaded_config.pg_server.password if _loaded_config else None) @@ -535,7 +537,7 @@ def serve( layer.conn.executemany(f"INSERT INTO {table} VALUES ({placeholders})", rows) # Start the server - start_server(layer, port=port_resolved, username=username_resolved, password=password_resolved) + start_server(layer, host=host_resolved, port=port_resolved, username=username_resolved, password=password_resolved) @app.command(hidden=True) diff --git a/sidemantic/config.py b/sidemantic/config.py index 9fc476a7..6029510a 100644 --- a/sidemantic/config.py +++ b/sidemantic/config.py @@ -104,6 +104,7 @@ class PostgresServerConfig(BaseModel): This feature is experimental and may change. """ + host: str = Field(default="127.0.0.1", description="Host/IP to bind to (use 0.0.0.0 for Docker)") port: int = Field(default=5433, description="Port to listen on") username: str | None = Field(default=None, description="Username for authentication (optional)") password: str | None = Field(default=None, description="Password for authentication (optional)") diff --git a/sidemantic/server/server.py b/sidemantic/server/server.py index 8854cdb3..4cd17356 100644 --- a/sidemantic/server/server.py +++ b/sidemantic/server/server.py @@ -33,6 +33,7 @@ def map_type(duckdb_type: str) -> str: def start_server( layer: SemanticLayer, + host: str = "127.0.0.1", port: int = 5433, username: str | None = None, password: str | None = None, @@ -41,6 +42,7 @@ def start_server( Args: layer: Semantic layer instance + host: Host/IP to bind to (use 0.0.0.0 for Docker) port: Port to listen on username: Username for authentication (optional) password: Password for authentication (optional) @@ -52,7 +54,7 @@ def __init__(self, connection_id, executor): super().__init__(connection_id, executor, layer, username, password) # Start server - server = riffq.RiffqServer(f"127.0.0.1:{port}", connection_cls=BoundConnection) + server = riffq.RiffqServer(f"{host}:{port}", connection_cls=BoundConnection) # Register catalog typer.echo("Registering semantic layer catalog...", err=True) @@ -134,12 +136,13 @@ def __init__(self, connection_id, executor): server._server.register_table("sidemantic", "semantic_layer", "metrics", metric_columns) typer.echo(" Registered table: semantic_layer.metrics", err=True) - typer.echo(f"\nStarting PostgreSQL-compatible server on 127.0.0.1:{port}", err=True) + typer.echo(f"\nStarting PostgreSQL-compatible server on {host}:{port}", err=True) if username: typer.echo(f"Authentication: username={username}", err=True) else: typer.echo("Authentication: disabled (any username/password accepted)", err=True) - typer.echo(f"\nConnect with: psql -h 127.0.0.1 -p {port} -U {username or 'any'} -d sidemantic\n", err=True) + connect_host = "localhost" if host == "0.0.0.0" else host + typer.echo(f"\nConnect with: psql -h {connect_host} -p {port} -U {username or 'any'} -d sidemantic\n", err=True) # Disable catalog_emulation and handle catalog queries manually in our Python handler # This prevents riffq from intercepting queries with its DataFusion parser (which fails on multi-statement queries) From 4b0b277be82cc1564e9b771e102a3bfe9411a27c Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 21 Feb 2026 10:47:21 -0800 Subject: [PATCH 2/6] Add Docker CI workflow Builds the image, starts the PG server in demo mode, and runs psql queries to verify semantic layer tables are accessible. --- .github/workflows/docker.yml | 60 ++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..15e0b8a9 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,60 @@ +name: Docker + +on: + push: + branches: [main] + paths: + - 'Dockerfile' + - 'docker-entrypoint.sh' + - '.dockerignore' + - 'sidemantic/**' + - 'pyproject.toml' + pull_request: + paths: + - 'Dockerfile' + - 'docker-entrypoint.sh' + - '.dockerignore' + - 'sidemantic/**' + - 'pyproject.toml' + +jobs: + docker: + name: Docker build and test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build image + run: docker build -t sidemantic . + + - name: Start server (demo mode) + run: | + docker run -d --name sidemantic-test -p 5433:5433 sidemantic --demo + for i in $(seq 1 30); do + if docker logs sidemantic-test 2>&1 | grep -q "Listening on"; then + echo "Server ready" + break + fi + echo "Waiting for server... ($i/30)" + sleep 1 + done + + - name: Verify server logs + run: docker logs sidemantic-test 2>&1 + + - name: Install psql + run: sudo apt-get update && sudo apt-get install -y postgresql-client + + - name: Test basic query + run: PGPASSWORD=any psql -h localhost -p 5433 -U any -d sidemantic -c "SELECT 1 AS test" + + - name: Test semantic layer query + run: PGPASSWORD=any psql -h localhost -p 5433 -U any -d sidemantic -c "SELECT * FROM semantic_layer.customers LIMIT 3" + + - name: Test aggregation query + run: PGPASSWORD=any psql -h localhost -p 5433 -U any -d sidemantic -c "SELECT region, customer_count FROM semantic_layer.customers GROUP BY region" + + - name: Stop server + if: always() + run: docker stop sidemantic-test || true From 83fac25c84560bcd4c5f0b4d2c278a0657c9f567 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 21 Feb 2026 11:03:56 -0800 Subject: [PATCH 3/6] Test semantic layer metric aggregation in Docker CI Replace passthrough SELECT * queries with GROUP BY queries that exercise metric computation (product_count, avg_price, customer_count). --- .github/workflows/docker.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 15e0b8a9..1424cdac 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -46,14 +46,18 @@ jobs: - name: Install psql run: sudo apt-get update && sudo apt-get install -y postgresql-client - - name: Test basic query + - name: Test connection run: PGPASSWORD=any psql -h localhost -p 5433 -U any -d sidemantic -c "SELECT 1 AS test" - - name: Test semantic layer query - run: PGPASSWORD=any psql -h localhost -p 5433 -U any -d sidemantic -c "SELECT * FROM semantic_layer.customers LIMIT 3" + - name: Test metric aggregation (products) + run: | + PGPASSWORD=any psql -h localhost -p 5433 -U any -d sidemantic -c \ + "SELECT category, product_count, avg_price, total_catalog_value FROM semantic_layer.products GROUP BY category ORDER BY category" - - name: Test aggregation query - run: PGPASSWORD=any psql -h localhost -p 5433 -U any -d sidemantic -c "SELECT region, customer_count FROM semantic_layer.customers GROUP BY region" + - name: Test metric aggregation (customers) + run: | + PGPASSWORD=any psql -h localhost -p 5433 -U any -d sidemantic -c \ + "SELECT region, customer_count FROM semantic_layer.customers GROUP BY region ORDER BY region" - name: Stop server if: always() From 130a4e09e54269c384ca58e24b133f371d2e0547 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 21 Feb 2026 11:07:35 -0800 Subject: [PATCH 4/6] Add Docker Hub push to CI workflow Pushes sidequery/sidemantic:latest and :version tags on main after tests pass. Uses GHA build cache and buildx. Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repo secrets. --- .github/workflows/docker.yml | 41 ++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1424cdac..e9d65136 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -17,6 +17,9 @@ on: - 'sidemantic/**' - 'pyproject.toml' +env: + IMAGE: sidequery/sidemantic + jobs: docker: name: Docker build and test @@ -25,12 +28,35 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract version + id: version + run: | + VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Version: $VERSION" + - name: Build image - run: docker build -t sidemantic . + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: ${{ env.IMAGE }}:test + cache-from: type=gha + cache-to: type=gha,mode=max - name: Start server (demo mode) run: | - docker run -d --name sidemantic-test -p 5433:5433 sidemantic --demo + docker run -d --name sidemantic-test -p 5433:5433 ${{ env.IMAGE }}:test --demo for i in $(seq 1 30); do if docker logs sidemantic-test 2>&1 | grep -q "Listening on"; then echo "Server ready" @@ -62,3 +88,14 @@ jobs: - name: Stop server if: always() run: docker stop sidemantic-test || true + + - name: Push to Docker Hub + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ steps.version.outputs.version }} + cache-from: type=gha From 37dc2a64dee0bb026f34b506e067d02d74d3a738 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 21 Feb 2026 11:11:04 -0800 Subject: [PATCH 5/6] Run Docker CI after PyPI publish or on manual dispatch No longer triggers on every push/PR. Runs automatically after a successful PyPI publish, or manually via workflow_dispatch. --- .github/workflows/docker.yml | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e9d65136..a9c2144c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,38 +1,29 @@ name: Docker on: - push: - branches: [main] - paths: - - 'Dockerfile' - - 'docker-entrypoint.sh' - - '.dockerignore' - - 'sidemantic/**' - - 'pyproject.toml' - pull_request: - paths: - - 'Dockerfile' - - 'docker-entrypoint.sh' - - '.dockerignore' - - 'sidemantic/**' - - 'pyproject.toml' + workflow_dispatch: + workflow_run: + workflows: ["Publish to PyPI"] + types: [completed] env: IMAGE: sidequery/sidemantic jobs: docker: - name: Docker build and test + name: Docker build, test, and push runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} steps: - uses: actions/checkout@v4 + with: + ref: main - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -90,7 +81,6 @@ jobs: run: docker stop sidemantic-test || true - name: Push to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/build-push-action@v6 with: context: . From 6cbf91856ef5aa08615f1c1728d13ef3d3dacf02 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 21 Feb 2026 11:16:00 -0800 Subject: [PATCH 6/6] Fix entrypoint shell splitting and mcp-serve arg mismatch Quote all env var expansions to handle special characters in connection strings and credentials. Only pass --db to mcp-serve (it doesn't accept --connection, --username, --password, or --port). --- docker-entrypoint.sh | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2e9bd3ce..d3d17c7a 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -4,40 +4,46 @@ set -e # SIDEMANTIC_MODE: "serve" (default), "mcp", or "both" MODE="${SIDEMANTIC_MODE:-serve}" -# Build shared args from environment variables -ARGS="" +# Build arg arrays for each command. +# serve accepts: --connection, --db, --host, --port, --username, --password +# mcp-serve accepts: --db only + +# Serve args +SERVE_ARGS="--host 0.0.0.0" if [ -n "$SIDEMANTIC_CONNECTION" ]; then - ARGS="$ARGS --connection $SIDEMANTIC_CONNECTION" + SERVE_ARGS="$SERVE_ARGS --connection \"$SIDEMANTIC_CONNECTION\"" fi if [ -n "$SIDEMANTIC_DB" ]; then - ARGS="$ARGS --db $SIDEMANTIC_DB" + SERVE_ARGS="$SERVE_ARGS --db \"$SIDEMANTIC_DB\"" fi - -# Serve-specific args -SERVE_ARGS="" if [ -n "$SIDEMANTIC_USERNAME" ]; then - SERVE_ARGS="$SERVE_ARGS --username $SIDEMANTIC_USERNAME" + SERVE_ARGS="$SERVE_ARGS --username \"$SIDEMANTIC_USERNAME\"" fi if [ -n "$SIDEMANTIC_PASSWORD" ]; then - SERVE_ARGS="$SERVE_ARGS --password $SIDEMANTIC_PASSWORD" + SERVE_ARGS="$SERVE_ARGS --password \"$SIDEMANTIC_PASSWORD\"" fi if [ -n "$SIDEMANTIC_PORT" ]; then - SERVE_ARGS="$SERVE_ARGS --port $SIDEMANTIC_PORT" + SERVE_ARGS="$SERVE_ARGS --port \"$SIDEMANTIC_PORT\"" +fi + +# MCP args (only --db is supported) +MCP_ARGS="" +if [ -n "$SIDEMANTIC_DB" ]; then + MCP_ARGS="$MCP_ARGS --db \"$SIDEMANTIC_DB\"" fi case "$MODE" in serve) - exec sidemantic serve --host 0.0.0.0 $ARGS $SERVE_ARGS "$@" + eval exec sidemantic serve $SERVE_ARGS "$@" ;; mcp) - exec sidemantic mcp-serve $ARGS "$@" + eval exec sidemantic mcp-serve $MCP_ARGS "$@" ;; both) - # Start PG server in background, MCP on stdio in foreground - sidemantic serve --host 0.0.0.0 $ARGS $SERVE_ARGS & + eval sidemantic serve $SERVE_ARGS & SERVE_PID=$! trap "kill $SERVE_PID 2>/dev/null" EXIT - exec sidemantic mcp-serve $ARGS "$@" + eval exec sidemantic mcp-serve $MCP_ARGS "$@" ;; *) echo "Unknown SIDEMANTIC_MODE: $MODE (use serve, mcp, or both)" >&2