diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5abd0f4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +target +.cargo +*.log +*_out.html diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..44790c9 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,62 @@ +name: Docker build and smoke test + +on: + push: + branches: + - main + - 'fix/docker-*' + paths: + - Dockerfile + - .dockerignore + - docker-compose.yml + - Makefile + - Cargo.toml + - Cargo.lock + - src/** + - scripts/smoke-docker.py + - .github/workflows/docker.yml + pull_request: + paths: + - Dockerfile + - .dockerignore + - docker-compose.yml + - Makefile + - Cargo.toml + - Cargo.lock + - src/** + - scripts/smoke-docker.py + - .github/workflows/docker.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docker-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PORT: '11380' + steps: + - uses: actions/checkout@v4 + + - name: Validate Compose configuration + run: docker compose config --quiet + + - name: Build and start the service + run: make docker-compose-up + + - name: Check HTTP startup and MCP protocol + run: python3 scripts/smoke-docker.py + + - name: Show container logs + if: always() + run: docker compose logs --no-color + + - name: Stop the service + if: always() + run: docker compose down diff --git a/Dockerfile b/Dockerfile index d01ada5..ca25595 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM rust:bookworm as builder +FROM rust:bookworm AS builder WORKDIR /usr/src/app @@ -13,8 +13,6 @@ RUN apt-get update && apt-get install -y cmake clang RUN mkdir -p .cargo && \ echo '[source.crates-io]\n\ replace-with = "rsproxy-sparse"\n\ -[source.rsproxy]\n\ -registry = "sparse+https://rsproxy.cn/index/"\n\ [source.rsproxy-sparse]\n\ registry = "sparse+https://rsproxy.cn/index/"\n\ [registries.rsproxy]\n\ @@ -25,7 +23,7 @@ git-fetch-with-cli = true' > .cargo/config.toml COPY . . # Build the application -RUN cargo build --release +RUN cargo build --release --locked # Runtime stage FROM debian:bookworm-slim diff --git a/docker-compose.yml b/docker-compose.yml index 6a7f988..5244124 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: sosearch: build: diff --git a/scripts/smoke-docker.py b/scripts/smoke-docker.py new file mode 100644 index 0000000..4156f08 --- /dev/null +++ b/scripts/smoke-docker.py @@ -0,0 +1,50 @@ +"""Validate the built container without depending on public search engines.""" + +import json +import os +import subprocess +import time +import urllib.error +import urllib.request + + +port = os.environ.get("PORT", "11380") +url = f"http://127.0.0.1:{port}/search" +deadline = time.monotonic() + 30 +while True: + try: + with urllib.request.urlopen(url, timeout=2): + raise AssertionError("HTTP must reject a missing query") + except urllib.error.HTTPError as error: + assert error.code == 400, f"Unexpected HTTP status: {error.code}" + break + except (urllib.error.URLError, TimeoutError): + if time.monotonic() >= deadline: + raise + time.sleep(1) +print("HTTP: service started and rejects missing query with 400") + +requests = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + {"jsonrpc": "2.0", "id": 3, "method": "ping"}, +] +process = subprocess.run( + ["docker", "compose", "run", "--rm", "--no-deps", "-T", "sosearch", "./SoSearch", "--mcp"], + input="".join(json.dumps(request) + "\n" for request in requests), + capture_output=True, + text=True, + timeout=30, + check=True, +) +responses = [json.loads(line) for line in process.stdout.splitlines()] +assert len(responses) == 3, process.stdout +assert [response["id"] for response in responses] == [1, 2, 3] +assert all(response["jsonrpc"] == "2.0" for response in responses) +assert responses[0]["result"]["serverInfo"]["name"] == "sosearch" +assert responses[0]["result"]["capabilities"] == {"tools": {}} +tool = responses[1]["result"]["tools"][0] +assert tool["name"] == "web_search" +assert tool["inputSchema"]["required"] == ["query"] +assert responses[2]["result"] == {} +print("MCP: initialize, tool discovery and ping passed")