# Brings the compose examples up and asserts they become HEALTHY.
#
# Why this exists: no workflow in this repo had ever run `docker compose` at
# all. Two examples were exercised -- `conformance.yml` runs
# `examples/provider_math/server.py`, `task-relay-smoke.yml` runs
# `examples/task_upstream/server.py` -- but both run the Python processes, never
# the compose file beside them. So `quickstart` and `otel-collector` shipped in
# a state where they exited 1 before serving anything, and `task_upstream`
# shipped a healthcheck that reported unhealthy forever while its gateway was
# fine (#966, #967).
#
# All four defects that fix cleaned up are invisible to a process on 127.0.0.1
# and obvious the moment a container actually runs:
#
#   * the missing `--unsafe-no-auth`: the auth guard fires on a non-loopback
#     bind, and the process smokes bind 127.0.0.1
#   * the missing `MCP_CONFIG`: a mount is a container concept; a process is
#     handed `--config`
#   * `curl` in the healthcheck: it is on the runner and NOT in `python:3.14-slim`
#   * `/health` instead of `/health/ready`: the healthcheck is what asks for it,
#     and nothing ran the healthcheck
#
# HEALTHY, not running. "The container is running" was already true for
# `task_upstream` while its healthcheck failed on every probe, so that is the
# assertion that would not have caught anything.
name: Examples (compose)

on:
  pull_request:
    paths:
      - "examples/**"
      - "Dockerfile"
      - ".github/workflows/examples-compose.yml"
  push:
    branches: [main]
    paths:
      - "examples/**"
      - "Dockerfile"
  workflow_dispatch: {}

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

env:
  # Built from this tree and tagged as the name the examples already reference,
  # so they exercise the code in the PR rather than whatever `:latest` happens
  # to be in the registry. A base-image change is exactly what removed `curl`
  # from under these healthchecks, and a published tag would have hidden it.
  HANGAR_IMAGE: ghcr.io/mcp-hangar/mcp-hangar:latest

jobs:
  compose-up:
    # Named explicitly so the matrix's `call` JSON stays out of the check name.
    name: compose-up (${{ matrix.example }}, ${{ matrix.service }})
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      # Unique to this run and attempt. The otel-collector example puts it on
      # the OTLP resource as `service.instance.id`, so the Collector assertion
      # matches this run's telemetry and nothing else.
      HANGAR_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.example }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - example: quickstart
            service: mcp-hangar
            call: '{"mcp_server": "everything", "tool": "echo", "arguments": {"message": "quickstart"}}'
          - example: otel-collector
            service: mcp-hangar
            call: '{"mcp_server": "math", "tool": "add", "arguments": {"a": 2, "b": 3}}'
          - example: task_upstream
            service: mcp-hangar

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

      - name: Build the image under test
        run: docker build -t "$HANGAR_IMAGE" .

      - name: Validate the Collector config at the pinned version
        if: matrix.example == 'otel-collector'
        working-directory: examples/${{ matrix.example }}
        run: |
          set -euo pipefail
          # The image is read from docker-compose.yml, so this validates the
          # version the example runs, not a second pin that could drift from it.
          image="$(docker compose config --images | grep '^otel/opentelemetry-collector-contrib:')"
          docker run --rm -v "$PWD:/cfg:ro" "$image" validate --config=/cfg/otel-collector-config.yaml
          echo "valid at $image"

      - name: Bring ${{ matrix.example }} up and wait for healthy
        working-directory: examples/${{ matrix.example }}
        run: |
          set -euo pipefail
          # `--wait` fails the step when a service with a healthcheck does not
          # reach healthy. Only the gateway is started: the dashboards and
          # collectors beside it are not what this job is asserting, and pulling
          # them costs minutes.
          # The quickstart's provider is a service of its own, and the gateway
          # is useless without it; the other examples still start one service.
          extra=""
          [ "${{ matrix.example }}" = "quickstart" ] && extra="everything"
          # shellcheck disable=SC2086
          docker compose up --detach --wait --wait-timeout 180 "${{ matrix.service }}" $extra

      - name: Assert it is healthy, not merely up
        working-directory: examples/${{ matrix.example }}
        run: |
          set -euo pipefail
          # Asserted separately from `--wait` on purpose: `--wait`'s treatment of
          # a service without a healthcheck has changed between compose
          # releases, and a silent pass here would restore exactly the blind
          # spot this workflow exists to close.
          id="$(docker compose ps -q "${{ matrix.service }}")"
          status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$id")"
          echo "health=$status"
          if [ "$status" != "healthy" ]; then
            echo "::error::${{ matrix.example }}: expected healthy, got '$status'"
            exit 1
          fi

      - name: Assert the mounted config was actually read
        working-directory: examples/${{ matrix.example }}
        run: |
          set -euo pipefail
          # Health does not cover this, and that is not a guess: reverting the
          # `MCP_CONFIG` fix from #966 leaves the gateway perfectly HEALTHY while
          # it ignores the config.yaml the example mounts and boots with
          # `config_path: null`. Every example here mounts a config, so every one
          # of them must be seen loading it.
          # Written to a file first, deliberately. `docker compose logs | grep -q`
          # under `set -o pipefail` fails even when the line IS there: grep exits
          # at the first match, the log producer takes SIGPIPE, and pipefail
          # reports the pipeline as failed. That is exactly how this step failed
          # on its first run while the log it was searching contained the line.
          docker compose logs --no-color "${{ matrix.service }}" > /tmp/gateway.log
          if ! grep -q "loading_config_from_file" /tmp/gateway.log; then
            echo "::error::${{ matrix.example }}: the gateway never logged loading_config_from_file -- the mounted config was ignored (is MCP_CONFIG set?)"
            exit 1
          fi

      # Health plus a loaded config still says nothing about whether the
      # provider in that config exists. The quickstart declared an image and a
      # module that had never existed and passed both of the checks above
      # (#1096), so this one CALLS the provider: config -> Docker socket ->
      # container start -> tool invocation, end to end.
      #
      # Three things this assertion got wrong before it worked, all in the
      # direction of passing when it should fail:
      #   * `tools/list` -- the default topology answers it with the `hangar_*`
      #     meta-API, complete whether or not a provider works.
      #   * `isError` -- a hangar_call whose batch fails returns
      #     `isError: false` with `"success": false` in the payload, so the
      #     outer flag says nothing about the call.
      #   * the envelope -- four headers and two `_meta` keys must agree, and
      #     each disagreement is a 400 rather than a wrong answer.
      - name: Assert the configured provider can actually be called
        if: matrix.call
        working-directory: examples/${{ matrix.example }}
        env:
          # The one call to make, from the matrix: `mcp_server`, `tool`, `arguments`.
          CALL: ${{ matrix.call }}
        run: |
          set -euo pipefail
          python3 - <<'PY'
          import json, os, sys, time, urllib.error, urllib.request

          # `mcp.shared.inbound`: `_meta` must carry protocolVersion AND
          # clientCapabilities; the MCP-Protocol-Version header must equal the
          # envelope's version; Mcp-Method must equal the body's method; and
          # for a name-bearing method Mcp-Name must equal `params.name`.
          BODY = json.dumps({
              "jsonrpc": "2.0", "id": 1, "method": "tools/call",
              "params": {
                  "name": "hangar_call",
                  "arguments": {"calls": [json.loads(os.environ["CALL"])]},
                  "_meta": {
                      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
                      "io.modelcontextprotocol/clientCapabilities": {},
                  },
              },
          }).encode()
          HEADERS = {
              "Content-Type": "application/json",
              "Accept": "application/json, text/event-stream",
              "MCP-Protocol-Version": "2026-07-28",
              "Mcp-Method": "tools/call",
              "Mcp-Name": "hangar_call",
          }

          def batch_succeeded(result: dict) -> bool:
              """The batch's own verdict, not the envelope's."""
              payload = result.get("structuredContent")
              if payload is None:
                  blocks = result.get("content") or []
                  if not blocks:
                      return False
                  payload = json.loads(blocks[0]["text"])
              return bool(payload.get("success"))

          last = ""
          for _attempt in range(12):
              try:
                  request = urllib.request.Request(
                      "http://localhost:8080/mcp", data=BODY, headers=HEADERS, method="POST"
                  )
                  with urllib.request.urlopen(request, timeout=60) as response:
                      raw = response.read().decode()
              except urllib.error.HTTPError as exc:
                  # The body carries the reason; the status alone does not.
                  last = f"HTTP {exc.code}: {exc.read().decode()[:400]}"
              except (urllib.error.URLError, OSError) as exc:
                  last = f"{type(exc).__name__}: {exc}"
              else:
                  payload = raw
                  if "data:" in payload:
                      payload = [ln[5:].strip() for ln in payload.splitlines() if ln.startswith("data:")][-1]
                  answer = json.loads(payload)
                  result = answer.get("result") or {}
                  if "error" not in answer and not result.get("isError") and batch_succeeded(result):
                      print("provider answered:", json.dumps(result)[:400])
                      sys.exit(0)
                  last = payload[:400]
              # A cold start pulls the provider image, so early attempts fail
              # for a reason that goes away.
              time.sleep(10)

          print(f"::error::the configured provider never answered; last: {last[:600]}")
          sys.exit(1)
          PY

      # Health, a loaded config and a successful call still say nothing about
      # telemetry, which is what the otel-collector example is for. The call
      # above must show up in the Collector's `file` exporter output: OTLP JSON,
      # one export request per line, `{"resourceSpans": [...]}` or
      # `{"resourceLogs": [...]}`. Arrival is asynchronous (Hangar's batch
      # processors, then the Collector's), so this polls to a deadline, and it
      # matches this run's `service.instance.id` and the call itself, never a
      # count of what arrived.
      - name: Assert the call's span and audit record reached the Collector
        if: matrix.example == 'otel-collector'
        working-directory: examples/${{ matrix.example }}
        env:
          CALL: ${{ matrix.call }}
        run: |
          set -euo pipefail
          python3 - <<'PY'
          import json, os, subprocess, sys, time

          RUN_ID = os.environ["HANGAR_RUN_ID"]
          CALL = json.loads(os.environ["CALL"])
          SOURCE = "otel-collector:/otel-output/telemetry.jsonl"
          COPY = os.path.join(os.environ["RUNNER_TEMP"], "telemetry.jsonl")
          DEADLINE_S = 120
          deadline = time.monotonic() + DEADLINE_S

          def attributes(items):
              """OTLP JSON `[{"key": k, "value": {"stringValue": v}}]` -> {k: v}."""
              return {i["key"]: next(iter(i.get("value", {}).values()), None) for i in items or []}

          def this_run(resource):
              found = attributes(resource.get("attributes"))
              return found.get("service.name") == "mcp-hangar" and found.get("service.instance.id") == RUN_ID

          def about_the_call(found):
              return found.get("mcp.server.id") == CALL["mcp_server"] and found.get("gen_ai.tool.name") == CALL["tool"]

          def scan(path):
              spans, records = [], []
              with open(path) as lines:
                  for line in lines:
                      try:
                          request = json.loads(line)
                      except json.JSONDecodeError:
                          continue  # still being written; the next poll reads it whole
                      for rs in request.get("resourceSpans", []):
                          if not this_run(rs.get("resource", {})):
                              continue
                          for ss in rs.get("scopeSpans", []):
                              # Hangar's own instrumentation, not the MCP SDK's.
                              if ss.get("scope", {}).get("name", "").startswith("mcp_hangar."):
                                  spans += [s for s in ss.get("spans", []) if about_the_call(attributes(s.get("attributes")))]
                      for rl in request.get("resourceLogs", []):
                          if not this_run(rl.get("resource", {})):
                              continue
                          for sl in rl.get("scopeLogs", []):
                              if sl.get("scope", {}).get("name") != "mcp_hangar.audit":
                                  continue
                              for record in sl.get("logRecords", []):
                                  found = attributes(record.get("attributes"))
                                  if (found.get("mcp.event.name") == "tool_invocation"
                                          and found.get("mcp.tool.status") == "success"
                                          and about_the_call(found)):
                                      records.append(record)
              return spans, records

          spans, records, last = [], [], ""
          while True:
              copied = subprocess.run(["docker", "compose", "cp", SOURCE, COPY], capture_output=True, text=True)
              if copied.returncode == 0:
                  spans, records = scan(COPY)
              else:
                  last = copied.stderr.strip()
              if spans and records:
                  break
              if time.monotonic() > deadline:
                  print(f"::error::within {DEADLINE_S}s the Collector received {len(spans)} matching span(s) and "
                        f"{len(records)} matching tool_invocation record(s) for service.instance.id={RUN_ID}. {last}")
                  sys.exit(1)
              time.sleep(2)  # poll interval, bounded by the deadline above

          print(f"service.instance.id={RUN_ID}")
          for span in spans:
              print("span:", span["name"], "trace", span["traceId"], json.dumps(attributes(span.get("attributes"))))
          for record in records:
              print("audit:", record["body"].get("stringValue"), "trace", record.get("traceId"),
                    json.dumps(attributes(record.get("attributes"))))
          PY

      - name: Show the gateway log on failure
        if: failure()
        working-directory: examples/${{ matrix.example }}
        run: docker compose logs --no-color --tail 200 "${{ matrix.service }}"

      - name: Logs on failure
        if: failure()
        working-directory: examples/${{ matrix.example }}
        run: docker compose logs --no-color --tail=120 || true

      - name: Tear down
        if: always()
        working-directory: examples/${{ matrix.example }}
        run: docker compose down --volumes --remove-orphans || true
