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/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..a9c2144c --- /dev/null +++ b/.github/workflows/docker.yml @@ -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 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..d3d17c7a --- /dev/null +++ b/docker-entrypoint.sh @@ -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 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)