diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..53b8d2a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +dist +build +.worktrees diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 38b39c9..4e072f5 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -25,6 +25,7 @@ on: permissions: contents: read + packages: write concurrency: group: pipeline-${{ github.ref }} @@ -64,33 +65,67 @@ jobs: name: Build needs: prep runs-on: ubuntu-latest + env: + SERVICE_REGISTRY: ghcr.io + SERVICE_IMAGE_REPO_OWNER: ${{ vars.IMAGE_REPO_OWNER || github.repository_owner }} + SERVICE_IMAGE_NAME: xworkmate-bridge outputs: artifact_name: ${{ steps.artifact_meta.outputs.artifact_name }} + service_image_ref: ${{ steps.service_ref.outputs.image_ref }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Log in to GHCR + if: ${{ github.event_name != 'pull_request' }} + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: - go-version-file: go.mod - cache: true + registry: ghcr.io + username: ${{ vars.GHCR_USERNAME || github.repository_owner }} + password: ${{ secrets.GHCR_TOKEN || github.token }} - - name: Build x86 artifact - run: bash ./scripts/github-actions/build-artifact.sh dist + - name: Resolve service image ref + id: service_ref + run: | + set -euo pipefail + image_repo="${SERVICE_REGISTRY}/${SERVICE_IMAGE_REPO_OWNER}/${SERVICE_IMAGE_NAME}" + image_tag="${GITHUB_SHA}" + image_ref="${image_repo}:${image_tag}" - - name: Smoke test built artifact - run: ./dist/xworkmate-bridge --help >/dev/null + echo "image_repo=${image_repo}" >> "$GITHUB_OUTPUT" + echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT" + echo "image_ref=${image_ref}" >> "$GITHUB_OUTPUT" + + - name: Build and optionally push service image + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.service_ref.outputs.image_ref }} + + - name: Write image ref artifact + run: | + set -euo pipefail + mkdir -p dist + printf '%s\n' "${{ steps.service_ref.outputs.image_ref }}" > dist/service-image-ref.txt - name: Record artifact metadata id: artifact_meta - run: echo "artifact_name=xworkmate-bridge-linux-amd64" >> "$GITHUB_OUTPUT" + run: echo "artifact_name=xworkmate-bridge-service-image-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" - - name: Upload artifact + - name: Upload image ref artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ steps.artifact_meta.outputs.artifact_name }} - path: dist/xworkmate-bridge + path: dist/service-image-ref.txt deploy: name: Deploy @@ -101,6 +136,8 @@ jobs: run_apply: ${{ steps.deploy_meta.outputs.run_apply }} env: INTERNAL_SERVICE_TOKEN: ${{ github.event_name == 'workflow_dispatch' && inputs.internal_service_token || secrets.INTERNAL_SERVICE_TOKEN }} + GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.repository_owner }} + GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN || github.token }} steps: - name: Checkout service repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -114,18 +151,11 @@ jobs: token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }} path: playbooks - - name: Download build artifact + - name: Download build image ref artifact uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 with: name: ${{ needs.build.outputs.artifact_name }} - path: xworkmate-bridge/dist - - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: xworkmate-bridge/go.mod - cache: true - cache-dependency-path: xworkmate-bridge/go.sum + path: xworkmate-bridge/dist/image-artifact - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 @@ -166,7 +196,9 @@ jobs: working-directory: xworkmate-bridge env: INTERNAL_SERVICE_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }} - XWORKMATE_BRIDGE_ARTIFACT_PATH: ${{ github.workspace }}/xworkmate-bridge/dist/xworkmate-bridge + GHCR_USERNAME: ${{ env.GHCR_USERNAME }} + GHCR_PASSWORD: ${{ env.GHCR_PASSWORD }} + XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH: ${{ github.workspace }}/xworkmate-bridge/dist/image-artifact/service-image-ref.txt run: bash ./scripts/github-actions/deploy.sh "${{ steps.deploy_meta.outputs.target_host }}" "${{ steps.deploy_meta.outputs.run_apply }}" ../playbooks publish_release: @@ -177,7 +209,7 @@ jobs: permissions: contents: write steps: - - name: Download build artifact + - name: Download image ref artifact uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 with: name: ${{ needs.build.outputs.artifact_name }} @@ -198,7 +230,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | gh release create "${{ steps.release_meta.outputs.tag }}" \ - dist/xworkmate-bridge#xworkmate-bridge-linux-amd64 \ + dist/service-image-ref.txt#xworkmate-bridge-service-image-ref.txt \ --repo "${{ github.repository }}" \ --target "${{ github.sha }}" \ --title "${{ steps.release_meta.outputs.title }}" \ @@ -206,7 +238,9 @@ jobs: validate: name: Validate - needs: deploy + needs: + - build + - deploy if: ${{ needs.deploy.result == 'success' && needs.deploy.outputs.run_apply == 'true' }} runs-on: ubuntu-latest env: @@ -221,4 +255,4 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Validate deployed endpoints - run: bash ./scripts/github-actions/validate-deploy.sh + run: bash ./scripts/github-actions/validate-deploy.sh "${{ needs.build.outputs.service_image_ref }}" "${BRIDGE_SERVER_URL}" "${OPENCLAW_URL}" "${CODEX_RPC_URL}" "${OPENCODE_RPC_URL}" "${GEMINI_RPC_URL}" "${INTERNAL_SERVICE_TOKEN}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b4b3ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# Stage 1 - build the bridge binary +FROM golang:1.25.1 AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/xworkmate-bridge . + +# Stage 2 - minimal runtime image +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/xworkmate-bridge /usr/local/bin/xworkmate-bridge + +EXPOSE 8787 + +ENTRYPOINT ["/usr/local/bin/xworkmate-bridge", "serve", "--listen", "0.0.0.0:8787"] diff --git a/internal/acp/runtime_version.go b/internal/acp/runtime_version.go new file mode 100644 index 0000000..355907e --- /dev/null +++ b/internal/acp/runtime_version.go @@ -0,0 +1,54 @@ +package acp + +import "strings" + +type imageVersionInfo struct { + ImageRef string `json:"image_ref"` + Tag string `json:"tag,omitempty"` + Commit string `json:"commit,omitempty"` + Version string `json:"version,omitempty"` +} + +func parseImageVersionInfo(imageRef string) imageVersionInfo { + ref := strings.TrimSpace(imageRef) + info := imageVersionInfo{ImageRef: ref} + if ref == "" { + return info + } + + if idx := strings.LastIndex(ref, "@"); idx >= 0 { + ref = ref[:idx] + } + + tag := ref + if idx := strings.LastIndex(tag, ":"); idx >= 0 && idx > strings.LastIndex(tag, "/") { + tag = tag[idx+1:] + } + tag = strings.TrimSpace(tag) + info.Tag = tag + + switch { + case isHexCommit(tag): + info.Commit = tag + info.Version = tag + default: + info.Version = tag + } + + return info +} + +func isHexCommit(value string) bool { + if len(value) != 40 { + return false + } + for _, r := range value { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'f': + default: + return false + } + } + return true +} diff --git a/internal/acp/server.go b/internal/acp/server.go index 3abd13d..086bfc3 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "sync" "time" @@ -74,23 +75,7 @@ func Serve(args []string) error { server := NewServer() httpServer := &http.Server{ Addr: strings.TrimSpace(*listen), - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/": - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = w.Write([]byte("xworkmate-bridge is running")) - case "/bridge/bootstrap/health": - server.HandleBridgeBootstrapHealth(w, r) - case "/bridge/bootstrap/consume": - server.HandleBridgeBootstrapConsume(w, r) - case "/acp/rpc": - server.HandleRPC(w, r) - case "/acp": - server.HandleWebSocket(w, r) - default: - http.NotFound(w, r) - } - }), + Handler: server.Handler(), ReadTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, IdleTimeout: 2 * time.Minute, @@ -115,6 +100,37 @@ func NewServer() *Server { } } +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("xworkmate-bridge is running")) + case "/api/ping": + info := parseImageVersionInfo(os.Getenv("IMAGE")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "image": info.ImageRef, + "tag": info.Tag, + "commit": info.Commit, + "version": info.Version, + }) + case "/bridge/bootstrap/health": + s.HandleBridgeBootstrapHealth(w, r) + case "/bridge/bootstrap/consume": + s.HandleBridgeBootstrapConsume(w, r) + case "/acp/rpc": + s.HandleRPC(w, r) + case "/acp": + s.HandleWebSocket(w, r) + default: + http.NotFound(w, r) + } + }) +} + func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { origin := strings.TrimSpace(r.Header.Get("Origin")) if !s.originAllowed(origin) { diff --git a/internal/acp/web_contract_test.go b/internal/acp/web_contract_test.go index 2b678ca..1d67843 100644 --- a/internal/acp/web_contract_test.go +++ b/internal/acp/web_contract_test.go @@ -8,6 +8,70 @@ import ( "testing" ) +func TestHTTPHandlerRootAndPingExposeRuntimeVersionInfo(t *testing.T) { + t.Setenv("IMAGE", "ghcr.io/x-evor/xworkmate-bridge:0123456789abcdef0123456789abcdef01234567") + + server := NewServer() + handler := server.Handler() + + rootRecorder := httptest.NewRecorder() + rootRequest := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/", nil) + handler.ServeHTTP(rootRecorder, rootRequest) + + if rootRecorder.Code != http.StatusOK { + t.Fatalf("expected root 200, got %d", rootRecorder.Code) + } + if !strings.Contains(rootRecorder.Body.String(), "xworkmate-bridge is running") { + t.Fatalf("expected root body to contain service banner, got %q", rootRecorder.Body.String()) + } + + pingRecorder := httptest.NewRecorder() + pingRequest := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/ping", nil) + handler.ServeHTTP(pingRecorder, pingRequest) + + if pingRecorder.Code != http.StatusOK { + t.Fatalf("expected ping 200, got %d", pingRecorder.Code) + } + + var payload map[string]any + if err := json.Unmarshal(pingRecorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode ping payload: %v", err) + } + + if got := payload["status"]; got != "ok" { + t.Fatalf("expected status ok, got %#v", got) + } + if got := payload["image"]; got != "ghcr.io/x-evor/xworkmate-bridge:0123456789abcdef0123456789abcdef01234567" { + t.Fatalf("expected full image ref, got %#v", got) + } + if got := payload["tag"]; got != "0123456789abcdef0123456789abcdef01234567" { + t.Fatalf("expected full image tag, got %#v", got) + } + if got := payload["commit"]; got != "0123456789abcdef0123456789abcdef01234567" { + t.Fatalf("expected full image commit, got %#v", got) + } + if got := payload["version"]; got != "0123456789abcdef0123456789abcdef01234567" { + t.Fatalf("expected full image version, got %#v", got) + } +} + +func TestParseImageVersionInfoHandlesTaggedImageRef(t *testing.T) { + info := parseImageVersionInfo("ghcr.io/x-evor/xworkmate-bridge:main-2026-04-12") + + if info.ImageRef != "ghcr.io/x-evor/xworkmate-bridge:main-2026-04-12" { + t.Fatalf("expected full image ref, got %q", info.ImageRef) + } + if info.Tag != "main-2026-04-12" { + t.Fatalf("expected tag main-2026-04-12, got %q", info.Tag) + } + if info.Commit != "" { + t.Fatalf("expected empty commit for non-hex tag, got %q", info.Commit) + } + if info.Version != "main-2026-04-12" { + t.Fatalf("expected version main-2026-04-12, got %q", info.Version) + } +} + func TestHandleWebSocketRejectsUnknownOrigin(t *testing.T) { t.Setenv("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus") diff --git a/scripts/github-actions/deploy.sh b/scripts/github-actions/deploy.sh index a6d9d83..b2d8c50 100644 --- a/scripts/github-actions/deploy.sh +++ b/scripts/github-actions/deploy.sh @@ -4,7 +4,25 @@ set -euo pipefail TARGET_HOST="${1:?target host is required}" RUN_APPLY="${2:?run_apply flag is required}" PLAYBOOK_DIR="${3:-playbooks}" -XWORKMATE_BRIDGE_ARTIFACT_PATH="${XWORKMATE_BRIDGE_ARTIFACT_PATH:?artifact path is required}" +XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH="${XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH:?image artifact path is required}" + +if [[ ! -f "${XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH}" ]]; then + echo "image artifact not found at ${XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH}" >&2 + exit 1 +fi + +SERVICE_COMPOSE_IMAGE="$(tr -d '\n' < "${XWORKMATE_BRIDGE_IMAGE_ARTIFACT_PATH}" | xargs)" +if [[ -z "${SERVICE_COMPOSE_IMAGE}" ]]; then + echo "service compose image is empty" >&2 + exit 1 +fi + +image_no_digest="${SERVICE_COMPOSE_IMAGE%@*}" +image_tag="${image_no_digest##*:}" +if [[ -z "${image_tag}" || "${image_no_digest}" == "${image_tag}" ]]; then + echo "invalid service image ref: ${SERVICE_COMPOSE_IMAGE}" >&2 + exit 1 +fi cd "${PLAYBOOK_DIR}" @@ -13,7 +31,6 @@ args=( -i inventory.ini deploy_xworkmate_bridge_vhosts.yml -l "${TARGET_HOST}" - -e "xworkmate_bridge_artifact_path=${XWORKMATE_BRIDGE_ARTIFACT_PATH}" ) if [[ "${RUN_APPLY}" != "true" ]]; then @@ -21,4 +38,7 @@ if [[ "${RUN_APPLY}" != "true" ]]; then fi ANSIBLE_CONFIG="${PWD}/ansible.cfg" \ +SERVICE_COMPOSE_IMAGE="${SERVICE_COMPOSE_IMAGE}" \ +GHCR_USERNAME="${GHCR_USERNAME:-}" \ +GHCR_PASSWORD="${GHCR_PASSWORD:-}" \ "${args[@]}" diff --git a/scripts/github-actions/validate-deploy.sh b/scripts/github-actions/validate-deploy.sh index bd357cd..09e2098 100644 --- a/scripts/github-actions/validate-deploy.sh +++ b/scripts/github-actions/validate-deploy.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +IMAGE_REF="${1:?image_ref is required}" + normalize_url() { local value="$1" if [[ "${value}" =~ ^https:([^/].*)$ ]]; then @@ -27,12 +29,31 @@ websocket_probe_url() { printf '%s\n' "${value}" } -BASE_URL="$(normalize_url "${BRIDGE_SERVER_URL:-${1:-https://xworkmate-bridge.svc.plus}}")" -OPENCLAW_HTTP_PROBE_URL="$(websocket_probe_url "${OPENCLAW_URL:-${2:-wss://openclaw.svc.plus}}")" -CODEX_RPC_URL="$(normalize_url "${CODEX_RPC_URL:-${3:-https://acp-server.svc.plus/codex/acp/rpc}}")" -OPENCODE_RPC_URL="$(normalize_url "${OPENCODE_RPC_URL:-${4:-https://acp-server.svc.plus/opencode/acp/rpc}}")" -GEMINI_RPC_URL="$(normalize_url "${GEMINI_RPC_URL:-${5:-https://acp-server.svc.plus/gemini/acp/rpc}}")" -AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-${INTERNAL_SERVICE_TOKEN:-${6:-}}}" +image_ref="$(printf '%s' "${IMAGE_REF}" | tr -d '\n' | xargs)" +if [[ -z "${image_ref}" ]]; then + echo "image_ref is required" >&2 + exit 1 +fi + +image_no_digest="${image_ref%@*}" +tag="${image_no_digest##*:}" +if [[ "${image_no_digest}" == "${tag}" ]]; then + tag="" +fi + +commit="" +version="${tag}" + +if [[ "${tag}" =~ ^[0-9a-f]{40}$ ]]; then + commit="${tag}" +fi + +BASE_URL="$(normalize_url "${BRIDGE_SERVER_URL:-${2:-https://xworkmate-bridge.svc.plus}}")" +OPENCLAW_HTTP_PROBE_URL="$(websocket_probe_url "${OPENCLAW_URL:-${3:-wss://openclaw.svc.plus}}")" +CODEX_RPC_URL="$(normalize_url "${CODEX_RPC_URL:-${4:-https://acp-server.svc.plus/codex/acp/rpc}}")" +OPENCODE_RPC_URL="$(normalize_url "${OPENCODE_RPC_URL:-${5:-https://acp-server.svc.plus/opencode/acp/rpc}}")" +GEMINI_RPC_URL="$(normalize_url "${GEMINI_RPC_URL:-${6:-https://acp-server.svc.plus/gemini/acp/rpc}}")" +AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-${INTERNAL_SERVICE_TOKEN:-${7:-}}}" curl_common=( --silent @@ -93,6 +114,36 @@ probe_safe_http_endpoint() { esac } +ping_json="$( + curl \ + "${curl_common[@]}" \ + "${BASE_URL}/api/ping" +)" + +PING_JSON="${ping_json}" python3 - "${image_ref}" "${tag}" "${commit}" "${version}" <<'PY' +import json +import os +import sys + +image_ref, tag, commit, version = sys.argv[1:5] +payload = json.loads(os.environ["PING_JSON"]) + +if payload.get("status") != "ok": + raise SystemExit("ping status not ok") + +if payload.get("image") != image_ref: + raise SystemExit(f"expected image {image_ref!r}, got {payload.get('image')!r}") + +if tag and payload.get("tag") != tag: + raise SystemExit(f"expected tag {tag!r}, got {payload.get('tag')!r}") + +if commit and payload.get("commit") != commit: + raise SystemExit(f"expected commit {commit!r}, got {payload.get('commit')!r}") + +if version and payload.get("version") != version: + raise SystemExit(f"expected version {version!r}, got {payload.get('version')!r}") +PY + bridge_root="$(curl "${curl_common[@]}" "${auth_headers[@]}" "${BASE_URL}/")" grep -qi 'xworkmate-bridge' <<<"${bridge_root}"