diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..677f6e2e8 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,120 @@ +name: Build and push Docker image to GHCR + +# Builds linux/amd64 and linux/arm64 on NATIVE runners (not QEMU) so that +# C-extension packages (cffi, cryptography, tree-sitter) compile correctly on +# each arch. The two platform images are pushed as digests, then merged into +# a single multi-arch manifest. + +on: + push: + branches: ["v8"] + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + packages: write # required to push to ghcr.io + +jobs: + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + + # Push a platform-specific digest (no tag yet — the merge job tags it). + - name: Build and push digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} + + # Pass the digest to the merge job via an artifact. + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: /tmp/digests/* + retention-days: 1 + + merge: + name: Merge into multi-arch manifest + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + # latest on every v8 push + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/v8' }} + # semver tags on release (e.g. 0.9.28, 0.9) + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + # short-sha for traceability + type=sha,prefix=sha- + + - name: Create and push multi-arch manifest + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + + - name: Inspect final manifest + run: | + docker buildx imagetools inspect ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.version }} diff --git a/Dockerfile b/Dockerfile index a313833c5..8b80f2c5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,24 @@ -# graphify MCP server as a shared HTTP service (issue #1143). +# graphify MCP server as a shared HTTP service (issue #1, #1143). +# Works with both Docker and Podman. # -# Build: docker build -t graphify . -# Run: docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \ -# /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET" +# Recommended: use the Makefile or docker-compose.yaml instead of bare commands. +# make build # build the image +# make index # index the current directory (uses graphify CLI) +# make up # start the MCP HTTP server +# +# Manual build: +# docker build -t graphify . # Docker +# podman build -t graphify . # Podman +# +# Run MCP HTTP server (default): +# docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data:ro" \ +# -e GRAPHIFY_API_KEY="$SECRET" graphify \ +# /data/graph.json --transport http --host 0.0.0.0 +# +# Code-only index (override entrypoint to graphify CLI): +# docker run --rm --entrypoint graphify \ +# -v "$(pwd):/src:ro" -v "$(pwd)/graphify-out:/data/graphify-out" graphify \ +# extract /src --code-only --output /data # # Builds from source so the image includes the Streamable HTTP transport even # before it lands on PyPI. The graph.json is mounted at runtime (-v), never @@ -13,7 +29,12 @@ WORKDIR /app COPY . /app # The [mcp] extra pulls mcp + starlette + uvicorn, which the HTTP transport needs. -RUN pip install --no-cache-dir ".[mcp]" +# Pin mcp<2.0.0: mcp 2.0.0 removed mcp.types.AnyUrl which graphify.serve imports, +# and introduced pyjwt[crypto] -> cryptography (Rust) which crashes with SIGILL on +# some ARM64 container runtimes. mcp 1.x avoids both issues. +RUN pip install --no-cache-dir ".[mcp]" \ + && pip install --no-cache-dir "mcp<2.0.0" \ + && pip install --no-cache-dir "cryptography>=41,<42" # Run as a non-root user — the server is network-exposed. RUN useradd --create-home --uid 10001 graphify @@ -21,5 +42,5 @@ USER graphify EXPOSE 8080 -ENTRYPOINT ["python", "-m", "graphify.serve"] +ENTRYPOINT ["graphify-mcp"] CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"] diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..e4cd00ae6 --- /dev/null +++ b/Makefile @@ -0,0 +1,84 @@ +# Makefile — build and run graphify via Docker or Podman (issue #1) +# +# Auto-detects docker; falls back to podman. +# Override: make RUNTIME=podman build + +RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null) +COMPOSE := $(RUNTIME) compose + +# Directory to index (override with: make index SRC=/path/to/project) +SRC ?= $(CURDIR) + +# ANSI colors +BOLD := \033[1m +CYAN := \033[36m +GREEN := \033[32m +YELLOW := \033[33m +DIM := \033[2m +RESET := \033[0m + +.PHONY: help pull build up down index logs +.DEFAULT_GOAL := help + +## Show this help message +help: + @printf "\n$(BOLD)$(CYAN)graphify$(RESET) — containerized knowledge graph for your codebase\n" + @printf "$(DIM)Runtime: $(RUNTIME)$(RESET)\n\n" + @printf "$(BOLD)$(GREEN)Quick start$(RESET)\n" + @printf " $(CYAN)make pull$(RESET) then $(CYAN)make index$(RESET) then $(CYAN)GRAPHIFY_API_KEY=secret make up$(RESET)\n\n" + @printf "$(BOLD)$(GREEN)Targets$(RESET)\n" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "pull" "Pull pre-built image from GHCR (no local build needed)" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "build" "Build image locally from source" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "index" "Index SRC dir into ./graphify-out/graph.json (code-only)" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "up" "Start the MCP HTTP server (pull or build first)" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "down" "Stop the MCP HTTP server" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "logs" "Tail MCP server logs" + @printf "\n$(BOLD)$(GREEN)Variables$(RESET)\n" + @printf " $(CYAN)SRC$(RESET) Directory to index $(DIM)(default: \$$PWD)$(RESET)\n" + @printf " $(CYAN)RUNTIME$(RESET) docker or podman $(DIM)(default: auto-detect)$(RESET)\n" + @printf " $(CYAN)GRAPHIFY_API_KEY$(RESET) Bearer token for MCP server $(DIM)(required for \`make up\`)$(RESET)\n" + @printf " $(CYAN)GRAPHIFY_PORT$(RESET) Host port for MCP server $(DIM)(default: 8080)$(RESET)\n" + @printf " $(CYAN)GRAPHIFY_IMAGE$(RESET) Override GHCR image $(DIM)(default: ghcr.io/slmingol/graphify:latest)$(RESET)\n" + @printf " $(CYAN)ANTHROPIC_API_KEY$(RESET) LLM key for semantic index $(DIM)(optional)$(RESET)\n" + @printf "\n$(BOLD)$(GREEN)Examples$(RESET)\n" + @printf " $(DIM)# Index a different project$(RESET)\n" + @printf " make index SRC=/path/to/project\n\n" + @printf " $(DIM)# Semantic extraction with Claude$(RESET)\n" + @printf " make index ANTHROPIC_API_KEY=sk-...\n\n" + @printf " $(DIM)# Use podman explicitly$(RESET)\n" + @printf " make RUNTIME=podman up\n\n" + +## Pull the pre-built image from GHCR (fastest path — no local build needed) +pull: + $(COMPOSE) pull + +## Build the graphify image locally from source +build: + $(COMPOSE) build + +## Pull from GHCR if available, otherwise build locally, then start the MCP server +## Set GRAPHIFY_API_KEY in your shell before running. +up: + $(COMPOSE) pull mcp 2>/dev/null || $(COMPOSE) build mcp + $(COMPOSE) up mcp + +## Stop the MCP HTTP server +down: + $(COMPOSE) down + +## Index SRC into ./graphify-out/graph.json (code-only, no API key needed) +## For full semantic extraction pass your key: make index ANTHROPIC_API_KEY=sk-... +## Usage: make index OR make index SRC=/path/to/project +index: + mkdir -p ./graphify-out + $(COMPOSE) --profile cli pull graphify 2>/dev/null || $(COMPOSE) --profile cli build graphify + $(COMPOSE) --profile cli run --rm \ + -v "$(SRC):/src:ro" \ + $(if $(ANTHROPIC_API_KEY),-e ANTHROPIC_API_KEY=$(ANTHROPIC_API_KEY)) \ + $(if $(OPENAI_API_KEY),-e OPENAI_API_KEY=$(OPENAI_API_KEY)) \ + $(if $(GEMINI_API_KEY),-e GEMINI_API_KEY=$(GEMINI_API_KEY)) \ + graphify extract /src --code-only --output /data + +## Tail MCP server logs +logs: + $(COMPOSE) logs -f mcp diff --git a/README.md b/README.md index a4f188f46..aa563572f 100644 --- a/README.md +++ b/README.md @@ -472,19 +472,76 @@ The MCP server gives your assistant structured access: `query_graph`, `get_node` | `--stateless` | off | No per-session state (for load-balanced / CI deployments) | | `--session-timeout` | `3600` | Reap idle stateful sessions after N seconds (`0` disables) | -The default `127.0.0.1` bind is loopback-only. Set `--host 0.0.0.0` **and** `--api-key` together when exposing on a shared host. Run it in a container: - -```bash -docker build -t graphify . -docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \ - /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET" -``` +The default `127.0.0.1` bind is loopback-only. Set `--host 0.0.0.0` **and** `--api-key` together when exposing on a shared host. > **WSL / Linux note:** Ubuntu ships `python3`, not `python`. Use a venv to avoid conflicts: > ```bash > python3 -m venv .venv && .venv/bin/pip install "graphifyy[mcp]" > ``` +### Running in a container + +A pre-built multi-platform image (`linux/amd64` + `linux/arm64`) is published to GHCR on every release — no local Python install required. Works with Docker and Podman. + +**Quick start with `make` (recommended):** + +```bash +# Pull from GHCR — no build step needed +make pull + +# Index a codebase (mounts current directory read-only) +make index + +# Index a different directory +make index SRC=/path/to/project + +# Start the MCP HTTP server +GRAPHIFY_API_KEY=secret make up +``` + +**Or use `docker compose` / `podman compose` directly:** + +```bash +# Pull the pre-built image +docker compose pull # or: podman compose pull + +# Code-only index (no API key needed) +docker compose --profile cli run --rm graphify extract /src --code-only --output /data + +# Full semantic index (pass your LLM key) +docker compose --profile cli run --rm \ + -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ + graphify extract /src --backend claude --output /data + +# Start the MCP HTTP server +GRAPHIFY_API_KEY=secret docker compose up mcp +``` + +**Or bare `docker` / `podman` (no compose):** + +```bash +# Code-only index +docker run --rm \ + -v "$(pwd):/src:ro" \ + -v "$(pwd)/graphify-out:/data/graphify-out" \ + ghcr.io/graphify-labs/graphify:latest \ + extract /src --code-only --output /data + +# Serve +docker run -p 8080:8080 \ + -v "$(pwd)/graphify-out:/data:ro" \ + -e GRAPHIFY_API_KEY="$SECRET" \ + --entrypoint graphify-mcp \ + ghcr.io/graphify-labs/graphify:latest \ + /data/graph.json --transport http --host 0.0.0.0 +``` + +Point your AI assistant at `http://localhost:8080/mcp` with the same key. + +> **Podman note:** `make` and `docker-compose.yaml` auto-detect `podman` when `docker` is not in `PATH`. On SELinux systems (RHEL, Fedora, CoreOS) append `:Z` to volume paths if you see permission errors. + +> **Override the image:** set `GRAPHIFY_IMAGE=ghcr.io/your-org/graphify:custom` to use a custom registry or tag in compose. Set `RUNTIME=podman` to force Podman in the Makefile. + --- ## Environment variables diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..a8d29ba95 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,101 @@ +# docker-compose.yaml — run graphify without a local Python install (issue #1) +# Works with both `docker compose` and `podman compose`. +# Use the Makefile targets for convenience: `make pull`, `make build`, `make index`, `make up`. +# +# Image resolution order +# ---------------------- +# When both `image:` and `build:` are set, compose uses the local image if present +# (pulled from GHCR or previously built), otherwise pulls from GHCR. +# To force a local build: make build (docker compose build) +# To pull the latest release: make pull (docker compose pull) +# +# Quick start +# ----------- +# 1. Pull from GHCR (no build required): +# make pull # or: docker compose pull +# +# 2. Index a codebase: +# make index # indexes the current directory +# make index SRC=/path/to/project # indexes a different directory +# +# 3. Start the MCP HTTP server: +# GRAPHIFY_API_KEY=secret make up # or: docker compose up mcp +# +# 4. Point your AI assistant at http://localhost:8080/mcp with the same key. +# +# Environment variables +# --------------------- +# GRAPHIFY_API_KEY — Bearer token for the HTTP server (required for `mcp` service) +# GRAPHIFY_PORT — override the host-side port (default: 8080) +# GRAPHIFY_IMAGE — override the GHCR image (default: ghcr.io/slmingol/graphify:latest) +# +# SELinux note (RHEL / Fedora / CoreOS) +# -------------------------------------- +# If volume mounts fail with "permission denied", append :Z to each volume path: +# ./graphify-out:/data:Z,ro +# ./:/src:ro,Z + +x-image: &image ${GRAPHIFY_IMAGE:-ghcr.io/slmingol/graphify:latest} + +services: + # --------------------------------------------------------------------------- + # graphify CLI — index a codebase, run queries, explain nodes, etc. + # /src is your project (read-only); output lands in ./graphify-out/graph.json. + # + # Code-only index (no API key needed): + # docker compose --profile cli run --rm graphify extract /src --code-only --output /data + # + # Full semantic index (pass LLM key via -e): + # docker compose --profile cli run --rm -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ + # graphify extract /src --backend claude --output /data + # + # Query / explain (graph must exist first): + # docker compose --profile cli run --rm graphify query "who calls authenticate?" + # docker compose --profile cli run --rm graphify explain "APIRouter" + # docker compose --profile cli run --rm graphify path "FastAPI" "ModelField" + # + # user: "0" — run as root inside the container so the host-user-owned files + # on the bind-mount are readable under podman rootless user-namespace mapping. + # --------------------------------------------------------------------------- + graphify: + image: *image + build: . + entrypoint: ["graphify"] + user: "0" + volumes: + - ./graphify-out:/data/graphify-out + - ./:/src:ro + profiles: + - cli + + # --------------------------------------------------------------------------- + # MCP HTTP server — expose a pre-built graph.json to your AI assistant. + # Requires ./graphify-out/graph.json to exist (build it with the service above). + # --------------------------------------------------------------------------- + mcp: + image: *image + build: . + command: + - /data/graph.json + - --transport + - http + - --host + - "0.0.0.0" + - --port + - "8080" + ports: + - "${GRAPHIFY_PORT:-8080}:8080" + environment: + GRAPHIFY_API_KEY: ${GRAPHIFY_API_KEY:-} + volumes: + - ./graphify-out:/data:ro + restart: unless-stopped + healthcheck: + # TCP socket check — works regardless of HTTP response code or SSE framing. + test: + - "CMD-SHELL" + - "python -c \"import socket; socket.create_connection(('localhost', 8080), timeout=2).close()\"" + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s