From d92f817e73757a3e98771676e27c7c098adac7f0 Mon Sep 17 00:00:00 2001 From: bsodfather Date: Sun, 30 Aug 2026 22:22:13 +0300 Subject: [PATCH] fix(ci): harden community image smoke --- .gitea/workflows/ci.yml | 33 ++++++++++-- .gitea/workflows/release.yml | 2 +- apps/admin-api/Dockerfile | 12 ++--- apps/mcp-server/Dockerfile | 12 ++--- deploy/community/docker-compose.images.yml | 4 +- deploy/community/docker-compose.yml | 4 +- docker-compose.yml | 4 +- scripts/authenticated-product-smoke.py | 51 +++++++++++++++++-- scripts/docker-http-healthcheck.sh | 24 +++++++++ .../unit/test_authenticated_product_smoke.py | 46 +++++++++++++++++ 10 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 scripts/docker-http-healthcheck.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5cabe9a..148d77c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -286,17 +286,44 @@ jobs: CRANK_SESSION_SECRET=ci-session-secret CRANK_PASSWORD_PEPPER=ci-password-pepper CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test - CRANK_BOOTSTRAP_ADMIN_PASSWORD=ci-admin-password CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=CI Owner CRANK_BASE_URL=http://127.0.0.1 + CRANK_ENVIRONMENT=ci + CRANK_OUTBOUND_ALLOWED_HOSTS=admin-api CRANK_PUBLISH_BIND=127.0.0.1 CRANK_ADMIN_PUBLISH_PORT=0 CRANK_MCP_PUBLISH_PORT=0 CRANK_UI_PUBLISH_PORT=0 CRANK_DEMO_SEED=true EOF - docker compose -f deploy/community/docker-compose.images.yml \ - --env-file .tmp/community-smoke.env --profile local-db up -d --wait + compose=(docker compose -f deploy/community/docker-compose.images.yml \ + --env-file .tmp/community-smoke.env --profile local-db) + "${compose[@]}" up -d --wait postgres + "${compose[@]}" run --rm migrate + bootstrap_json="$("${compose[@]}" run --rm --no-deps \ + --entrypoint crank-migrate migrate admin-auth bootstrap-create \ + --email owner@crank.test --display-name 'CI Owner')" + bootstrap_token="$(python3 -c \ + 'import json,sys; print(json.loads(sys.stdin.read())["bootstrap_token"])' \ + <<<"$bootstrap_json")" + install -d -m 700 .tmp/community-bootstrap + printf '%s' "$bootstrap_token" > .tmp/community-bootstrap/token + printf '%s' 'ci-admin-password' > .tmp/community-bootstrap/password + printf '%s' 'ci-password-pepper' > .tmp/community-bootstrap/password-pepper + chmod 600 .tmp/community-bootstrap/token \ + .tmp/community-bootstrap/password \ + .tmp/community-bootstrap/password-pepper + "${compose[@]}" run --rm --no-deps \ + -v "$PWD/.tmp/community-bootstrap:/run/bootstrap:ro" \ + --entrypoint crank-migrate migrate admin-auth bootstrap-complete \ + --token-file /run/bootstrap/token \ + --password-file /run/bootstrap/password \ + --password-pepper-file /run/bootstrap/password-pepper + rm -f .tmp/community-bootstrap/token \ + .tmp/community-bootstrap/password \ + .tmp/community-bootstrap/password-pepper + rmdir .tmp/community-bootstrap + "${compose[@]}" up -d --wait - name: Run authenticated Community image smoke env: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b4d4199..e24257b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -217,8 +217,8 @@ jobs: CRANK_SESSION_SECRET=release-smoke-session CRANK_PASSWORD_PEPPER=release-smoke-pepper CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test - CRANK_BOOTSTRAP_ADMIN_PASSWORD=release-smoke-password CRANK_BASE_URL=http://127.0.0.1 + CRANK_ENVIRONMENT=release-smoke CRANK_PUBLISH_BIND=127.0.0.1 CRANK_ADMIN_PUBLISH_PORT=0 CRANK_MCP_PUBLISH_PORT=0 diff --git a/apps/admin-api/Dockerfile b/apps/admin-api/Dockerfile index 8ea8159..99593db 100644 --- a/apps/admin-api/Dockerfile +++ b/apps/admin-api/Dockerfile @@ -18,21 +18,15 @@ FROM debian:bookworm-slim COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt -RUN test -s /etc/ssl/certs/ca-certificates.crt \ - && sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \ - && grep -q '^URIs: https://deb.debian.org/' /etc/apt/sources.list.d/debian.sources \ - && ! grep -Eq '^[[:space:]]*URIs:[[:space:]]+http://' /etc/apt/sources.list.d/debian.sources \ - && apt-get -o Acquire::Retries=3 update \ - && apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* - WORKDIR /app COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api COPY --from=builder /tmp/crank-migrate /usr/local/bin/crank-migrate COPY apps/admin-api/docker-entrypoint.sh /usr/local/bin/crank-admin-entrypoint +COPY scripts/docker-http-healthcheck.sh /usr/local/bin/crank-http-healthcheck -RUN chmod 0755 /usr/local/bin/crank-admin-entrypoint +RUN test -s /etc/ssl/certs/ca-certificates.crt \ + && chmod 0755 /usr/local/bin/crank-admin-entrypoint /usr/local/bin/crank-http-healthcheck ENV CRANK_ADMIN_BIND=0.0.0.0:3001 ENV CRANK_STORAGE_ROOT=/var/lib/crank/storage diff --git a/apps/mcp-server/Dockerfile b/apps/mcp-server/Dockerfile index 33e6850..3400979 100644 --- a/apps/mcp-server/Dockerfile +++ b/apps/mcp-server/Dockerfile @@ -55,17 +55,13 @@ FROM debian:bookworm-slim COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt -RUN test -s /etc/ssl/certs/ca-certificates.crt \ - && sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \ - && grep -q '^URIs: https://deb.debian.org/' /etc/apt/sources.list.d/debian.sources \ - && ! grep -Eq '^[[:space:]]*URIs:[[:space:]]+http://' /etc/apt/sources.list.d/debian.sources \ - && apt-get -o Acquire::Retries=3 update \ - && apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* - WORKDIR /app COPY --from=builder /tmp/mcp-server /usr/local/bin/mcp-server +COPY scripts/docker-http-healthcheck.sh /usr/local/bin/crank-http-healthcheck + +RUN test -s /etc/ssl/certs/ca-certificates.crt \ + && chmod 0755 /usr/local/bin/crank-http-healthcheck ENV CRANK_MCP_BIND=0.0.0.0:3002 diff --git a/deploy/community/docker-compose.images.yml b/deploy/community/docker-compose.images.yml index 9b2f536..9fb8cc1 100644 --- a/deploy/community/docker-compose.images.yml +++ b/deploy/community/docker-compose.images.yml @@ -138,7 +138,7 @@ services: ports: - "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_ADMIN_PUBLISH_PORT:-3001}:3001" healthcheck: - test: ["CMD", "curl", "--fail", "http://127.0.0.1:3001/ready"] + test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3001", "/ready"] interval: 15s timeout: 5s retries: 5 @@ -199,7 +199,7 @@ services: ports: - "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_MCP_PUBLISH_PORT:-3002}:3002" healthcheck: - test: ["CMD", "curl", "--fail", "http://127.0.0.1:3002/ready"] + test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3002", "/ready"] interval: 15s timeout: 5s retries: 5 diff --git a/deploy/community/docker-compose.yml b/deploy/community/docker-compose.yml index 3711408..7d5be1d 100644 --- a/deploy/community/docker-compose.yml +++ b/deploy/community/docker-compose.yml @@ -141,7 +141,7 @@ services: ports: - "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_ADMIN_PUBLISH_PORT:-3001}:3001" healthcheck: - test: ["CMD", "curl", "--fail", "http://127.0.0.1:3001/ready"] + test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3001", "/ready"] interval: 15s timeout: 5s retries: 5 @@ -202,7 +202,7 @@ services: ports: - "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_MCP_PUBLISH_PORT:-3002}:3002" healthcheck: - test: ["CMD", "curl", "--fail", "http://127.0.0.1:3002/ready"] + test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3002", "/ready"] interval: 15s timeout: 5s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index 1d0d2d9..828c26c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -127,7 +127,7 @@ services: ports: - "${CRANK_PUBLISH_BIND:-127.0.0.1}:3001:3001" healthcheck: - test: ["CMD", "curl", "--fail", "http://127.0.0.1:3001/ready"] + test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3001", "/ready"] interval: 15s timeout: 5s retries: 5 @@ -190,7 +190,7 @@ services: ports: - "${CRANK_PUBLISH_BIND:-127.0.0.1}:3002:3002" healthcheck: - test: ["CMD", "curl", "--fail", "http://127.0.0.1:3002/health"] + test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3002", "/health"] interval: 15s timeout: 5s retries: 5 diff --git a/scripts/authenticated-product-smoke.py b/scripts/authenticated-product-smoke.py index 98fbfa3..3c85b2a 100755 --- a/scripts/authenticated-product-smoke.py +++ b/scripts/authenticated-product-smoke.py @@ -134,6 +134,7 @@ class Client: def __init__(self, base_url: str, timeout_seconds: int) -> None: self.base_url = base_url.rstrip("/") self.timeout_seconds = timeout_seconds + self.csrf_token: str | None = None cookie_jar = http.cookiejar.CookieJar() self.opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(cookie_jar) @@ -156,6 +157,21 @@ class Client: request_headers = {"Accept": "application/json"} if headers: request_headers.update(headers) + if ( + self.csrf_token + and method not in ("GET", "HEAD", "OPTIONS") + and ( + path_or_url.startswith("/api/admin/") + or path_or_url.startswith("/api/auth/") + ) + and path_or_url + not in ( + "/api/auth/login", + "/api/auth/bootstrap/complete", + "/api/auth/session/csrf", + ) + ): + request_headers.setdefault("x-csrf-token", self.csrf_token) if payload is not None: data = json.dumps(payload).encode("utf-8") request_headers["Content-Type"] = "application/json" @@ -203,12 +219,21 @@ def admin_path(workspace_id: str, suffix: str) -> str: def login(client: Client, email: str, password: str) -> None: - client.request_json( + response = client.request_json( "POST", "/api/auth/login", {"email": email, "password": password}, expected=(200,), ) + session = require_object(response.body, "login") + csrf_token = session.get("csrf_token") + if ( + not isinstance(csrf_token, str) + or not 32 <= len(csrf_token) <= 256 + or not all(character.isalnum() or character in "-_." for character in csrf_token) + ): + raise safe_error("login", "invalid_csrf_token") + client.csrf_token = csrf_token def resolve_workspace( @@ -326,6 +351,17 @@ def create_agent(client: Client, workspace_id: str, agent_slug: str) -> tuple[st raise safe_error("agent_create", "invalid_response") from error +def agent_etag(client: Client, workspace_id: str, agent_id: str) -> str: + response = client.request_json( + "GET", + admin_path(workspace_id, f"/agents/{agent_id}"), + ) + etag = response.headers.get("ETag") if response.headers is not None else None + if not isinstance(etag, str) or len(etag) > 128 or not etag.startswith('"') or not etag.endswith('"'): + raise safe_error("agent_precondition", "invalid_response") + return etag + + def edit_and_archive_operation( client: Client, workspace_id: str, @@ -377,11 +413,13 @@ def bind_and_publish_agent( "enabled": True, } ], + headers={"If-Match": agent_etag(client, workspace_id, agent_id)}, ) published = client.request_json( "POST", admin_path(workspace_id, f"/agents/{agent_id}/publish"), {"version": agent_version}, + headers={"If-Match": agent_etag(client, workspace_id, agent_id)}, ).body try: published_version = int(published["published_version"]) @@ -436,8 +474,15 @@ def cleanup_smoke_assets( if agent_id: try: client.request_json( - "DELETE", - admin_path(workspace_id, f"/agents/{agent_id}"), + "POST", + admin_path(workspace_id, f"/agents/{agent_id}/unpublish"), + headers={"If-Match": agent_etag(client, workspace_id, agent_id)}, + expected=(200, 404), + ) + client.request_json( + "POST", + admin_path(workspace_id, f"/agents/{agent_id}/archive"), + headers={"If-Match": agent_etag(client, workspace_id, agent_id)}, expected=(200, 404), ) except SmokeError as error: diff --git a/scripts/docker-http-healthcheck.sh b/scripts/docker-http-healthcheck.sh new file mode 100644 index 0000000..87ce81e --- /dev/null +++ b/scripts/docker-http-healthcheck.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +port="${1:?port is required}" +path="${2:?path is required}" + +if [[ ! "$port" =~ ^[0-9]{1,5}$ ]] || (( port < 1 || port > 65535 )); then + exit 64 +fi + +if [[ "$path" != /* || "$path" == *$'\r'* || "$path" == *$'\n'* ]]; then + exit 64 +fi + +# Bash provides /dev/tcp without adding a network client package to the runtime +# image. Compose still applies its own five-second timeout to the whole probe. +exec 3<>"/dev/tcp/127.0.0.1/${port}" +printf 'GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n' "$path" >&3 + +IFS=$'\r' read -r -t 2 status <&3 +case "$status" in + 'HTTP/1.0 200 '*|'HTTP/1.1 200 '*) ;; + *) exit 1 ;; +esac diff --git a/tests/unit/test_authenticated_product_smoke.py b/tests/unit/test_authenticated_product_smoke.py index b59b4c8..85387dd 100644 --- a/tests/unit/test_authenticated_product_smoke.py +++ b/tests/unit/test_authenticated_product_smoke.py @@ -17,6 +17,48 @@ def load_smoke_module(): class AuthenticatedProductSmokeTests(unittest.TestCase): + def test_client_attaches_csrf_only_to_browser_api_mutations(self) -> None: + smoke = load_smoke_module() + + class Response: + status = 200 + headers = {} + + def read(self, _limit): + return b"{}" + + class Opener: + def __init__(self): + self.requests = [] + + def open(self, request, timeout): + self.requests.append((request, timeout)) + return Response() + + client = smoke.Client("http://crank.test", 5) + opener = Opener() + client.opener = opener + client.csrf_token = "a" * 32 + + client.request_json("POST", "/api/admin/workspaces/ws/operations", {}) + client.request_json("POST", "http://mcp.test/v1/ws/agent", {}) + + self.assertEqual(opener.requests[0][0].get_header("X-csrf-token"), "a" * 32) + self.assertIsNone(opener.requests[1][0].get_header("X-csrf-token")) + + def test_login_keeps_server_issued_csrf_token(self) -> None: + smoke = load_smoke_module() + + class FakeClient: + csrf_token = None + + def request_json(self, *args, **kwargs): + return smoke.JsonResponse(200, {}, {"csrf_token": "b" * 32}) + + client = FakeClient() + smoke.login(client, "owner@crank.test", "safe-password") + self.assertEqual(client.csrf_token, "b" * 32) + def test_operation_payload_uses_internal_upstream(self) -> None: smoke = load_smoke_module() @@ -136,6 +178,8 @@ class AuthenticatedProductSmokeTests(unittest.TestCase): return smoke.JsonResponse(200, {}, {"operation_id": "op_safe", "version": 7}) if path.endswith("/operations/op_safe"): return smoke.JsonResponse(200, {"ETag": '"safe-etag"'}, {"id": "op_safe"}) + if path.endswith("/agents/agent_safe"): + return smoke.JsonResponse(200, {"ETag": '"safe-agent-etag"'}, {"id": "agent_safe"}) if path.endswith("/publish") and "/operations/" in path: return smoke.JsonResponse(200, {}, {"published_version": 7}) if path.endswith("/agents"): @@ -158,6 +202,8 @@ class AuthenticatedProductSmokeTests(unittest.TestCase): self.assertEqual((agent_id, agent_version, published_agent_version), ("agent_safe", 3, 3)) binding = next(payload for _, path, payload, _ in client.requests if path.endswith("/bindings"))[0] self.assertEqual(binding["operation_version"], 7) + binding_request = next(request for request in client.requests if request[1].endswith("/bindings")) + self.assertEqual(binding_request[3]["headers"], {"If-Match": '"safe-agent-etag"'}) publish = next(request for request in client.requests if request[1].endswith("/operations/op_safe/publish")) self.assertEqual(publish[3]["headers"], {"If-Match": '"safe-etag"'})