Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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/
91 changes: 91 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Docker

on:
workflow_dispatch:
workflow_run:
workflows: ["Publish to PyPI"]
types: [completed]

env:
IMAGE: sidequery/sidemantic

jobs:
docker:
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
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
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 ${{ 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"
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 connection
run: PGPASSWORD=any psql -h localhost -p 5433 -U any -d sidemantic -c "SELECT 1 AS test"

- 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 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()
run: docker stop sidemantic-test || true

- name: Push to Docker Hub
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ steps.version.outputs.version }}
cache-from: type=gha
41 changes: 41 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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)
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/bin/sh
set -e

# SIDEMANTIC_MODE: "serve" (default), "mcp", or "both"
MODE="${SIDEMANTIC_MODE:-serve}"

# 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
SERVE_ARGS="$SERVE_ARGS --connection \"$SIDEMANTIC_CONNECTION\""
fi
if [ -n "$SIDEMANTIC_DB" ]; then
SERVE_ARGS="$SERVE_ARGS --db \"$SIDEMANTIC_DB\""
fi
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

# 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)
eval exec sidemantic serve $SERVE_ARGS "$@"
;;
mcp)
eval exec sidemantic mcp-serve $MCP_ARGS "$@"
;;
both)
eval sidemantic serve $SERVE_ARGS &
SERVE_PID=$!
trap "kill $SERVE_PID 2>/dev/null" EXIT
eval exec sidemantic mcp-serve $MCP_ARGS "$@"
;;
*)
echo "Unknown SIDEMANTIC_MODE: $MODE (use serve, mcp, or both)" >&2
exit 1
;;
esac
6 changes: 4 additions & 2 deletions sidemantic/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions sidemantic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
9 changes: 6 additions & 3 deletions sidemantic/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down