diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5e71408..1256aaf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -114,12 +114,42 @@ jobs: . - name: Run unit tests (EUnit) - run: > - docker run --rm eventhub-tests:latest - rebar3 eunit --sname ci_eunit --verbose + id: eunit + run: | + set -euo pipefail + mkdir -p ci-out/eunit ci-out/eunit-html + set +e + docker run --rm \ + -v "$PWD/ci-out/eunit:/app/logs/test/eunit" \ + eventhub-tests:latest \ + rebar3 eunit --sname ci_eunit --verbose + EUNIT_RC=$? + set -e + python3 scripts/junit-xml-to-html.py ci-out/eunit -o ci-out/eunit-html --title "EUnit CI" || true + exit "${EUNIT_RC}" - name: Run API tests (local CT) - run: docker run --rm eventhub-tests:latest + id: ct + run: | + set -euo pipefail + mkdir -p ci-out/ct + docker run --rm \ + -v "$PWD/ci-out/ct:/app/logs/test/ct" \ + eventhub-tests:latest + + - name: Publish HTML reports (ci-reports) + if: always() + run: | + set -euo pipefail + RUN_ID="${GITHUB_RUN_NUMBER:-local}" + mkdir -p /var/eventhub/ci-reports || sudo mkdir -p /var/eventhub/ci-reports || true + if [[ -d ci-out/eunit-html ]] && compgen -G 'ci-out/eunit-html/*' >/dev/null; then + bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" eunit ci-out/eunit-html || true + fi + if [[ -d ci-out/ct ]] && compgen -G 'ci-out/ct/*' >/dev/null; then + bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" ct ci-out/ct || true + fi + bash scripts/prune-ci-reports.sh || true - name: Push tested eventhub image if: github.event_name == 'push' diff --git a/.gitignore b/.gitignore index 3690937..671f9cb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ erl_crash.dump .rebar logs data +ci-out .idea *.iml rebar3.crashdump diff --git a/docker/ci-reports/nginx.conf b/docker/ci-reports/nginx.conf new file mode 100644 index 0000000..c7aecd3 --- /dev/null +++ b/docker/ci-reports/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + autoindex on; + charset utf-8; + + location / { + try_files $uri $uri/ =404; + } + + location ~* /allure/ { + try_files $uri $uri/ /index.html =404; + } +} diff --git a/docker/docker-compose.swarm.yml b/docker/docker-compose.swarm.yml index 887015e..836a02f 100644 --- a/docker/docker-compose.swarm.yml +++ b/docker/docker-compose.swarm.yml @@ -90,6 +90,19 @@ services: labels: - "traefik.enable=true" + # ================== CI HTML reports (local runner) ================== + ci-reports: + image: nginx:1.27-alpine + volumes: + - "${CI_REPORTS_HOST_DIR:-/var/eventhub/ci-reports}:/usr/share/nginx/html:ro" + - "./ci-reports/nginx.conf:/etc/nginx/conf.d/default.conf:ro" + networks: + - eventhub-net + deploy: + replicas: 1 + restart_policy: + condition: any + # ================== Admin UI ================== admin-ui: image: eventhub-admin-ui:latest diff --git a/docker/traefik/dynamic_conf.yml b/docker/traefik/dynamic_conf.yml index 3c08599..3f78c46 100644 --- a/docker/traefik/dynamic_conf.yml +++ b/docker/traefik/dynamic_conf.yml @@ -116,6 +116,17 @@ http: tls: true service: "client-ui-service" + ci-reports: + rule: "Host(`ci-reports.dev.eventhub.local`)" + entryPoints: ["web"] + middlewares: ["redirect-to-https"] + service: "ci-reports" + ci-reports-secure: + rule: "Host(`ci-reports.dev.eventhub.local`)" + entryPoints: ["websecure"] + tls: true + service: "ci-reports" + services: api: failover: @@ -172,3 +183,8 @@ http: loadbalancer: servers: - url: "http://client-ui:80" + + ci-reports: + loadbalancer: + servers: + - url: "http://ci-reports:80" diff --git a/rebar.config b/rebar.config index 9d919ec..46de3e6 100644 --- a/rebar.config +++ b/rebar.config @@ -49,6 +49,11 @@ {verbose, true} % Print more info to console ]}. +{eunit_opts, [ + verbose, + {report, {eunit_surefire, [{dir, "logs/test/eunit"}]}} +]}. + {ct_compile_opts, [ {i, "include"}, % Include directory {d, 'DEBUG'} % Define macros diff --git a/scripts/junit-xml-to-html.py b/scripts/junit-xml-to-html.py new file mode 100644 index 0000000..03d257c --- /dev/null +++ b/scripts/junit-xml-to-html.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Convert Surefire/JUnit XML (eunit_surefire) into a simple HTML report.""" +from __future__ import annotations + +import argparse +import html +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def load_suites(paths: list[Path]) -> list[ET.Element]: + suites: list[ET.Element] = [] + for path in paths: + try: + root = ET.parse(path).getroot() + except ET.ParseError as exc: + print(f"skip {path}: {exc}", file=sys.stderr) + continue + if root.tag == "testsuite": + suites.append(root) + elif root.tag == "testsuites": + suites.extend(list(root.findall("testsuite"))) + return suites + + +def render(suites: list[ET.Element], title: str) -> str: + total = failures = errors = skipped = 0 + rows: list[str] = [] + for suite in suites: + s_name = suite.attrib.get("name", "suite") + total += int(suite.attrib.get("tests", "0") or 0) + failures += int(suite.attrib.get("failures", "0") or 0) + errors += int(suite.attrib.get("errors", "0") or 0) + skipped += int(suite.attrib.get("skipped", "0") or 0) + for case in suite.findall("testcase"): + name = case.attrib.get("name", "?") + classname = case.attrib.get("classname", s_name) + time_s = case.attrib.get("time", "") + fail = case.find("failure") + err = case.find("error") + skip = case.find("skipped") + if fail is not None: + status, detail = "FAIL", fail.attrib.get("message") or (fail.text or "") + elif err is not None: + status, detail = "ERROR", err.attrib.get("message") or (err.text or "") + elif skip is not None: + status, detail = "SKIP", skip.attrib.get("message") or "" + else: + status, detail = "PASS", "" + cls = status.lower() + rows.append( + "{status}{suite}{case}" + "{time}
{detail}
".format( + cls=html.escape(cls), + status=html.escape(status), + suite=html.escape(classname), + case=html.escape(name), + time=html.escape(time_s), + detail=html.escape(detail.strip()[:4000]), + ) + ) + + ok = total - failures - errors - skipped + return f""" + + + +{html.escape(title)} + + + +

{html.escape(title)}

+

+ tests: {total} + pass: {ok} + fail: {failures} + error: {errors} + skip: {skipped} +

+ + + +{''.join(rows) if rows else ''} + +
StatusSuiteCaseTimeDetail
No testcases
+ + +""" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("xml_dir", type=Path, help="Directory with TEST-*.xml") + ap.add_argument("-o", "--out", type=Path, required=True, help="Output HTML file or dir") + ap.add_argument("--title", default="EUnit report") + args = ap.parse_args() + + xml_files = sorted(args.xml_dir.glob("TEST-*.xml")) + sorted(args.xml_dir.glob("*.xml")) + # de-dupe + seen = set() + unique: list[Path] = [] + for p in xml_files: + if p.resolve() in seen: + continue + seen.add(p.resolve()) + unique.append(p) + + suites = load_suites(unique) + out = args.out + if out.suffix.lower() != ".html": + out.mkdir(parents=True, exist_ok=True) + out = out / "index.html" + else: + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(render(suites, args.title), encoding="utf-8") + print(f"wrote {out} ({len(suites)} suites, {len(unique)} xml files)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prune-ci-reports.sh b/scripts/prune-ci-reports.sh new file mode 100644 index 0000000..167c294 --- /dev/null +++ b/scripts/prune-ci-reports.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Удаляет каталоги отчётов старше CI_REPORTS_KEEP_DAYS (default 14). +# Usage: prune-ci-reports.sh [/var/eventhub/ci-reports] +set -euo pipefail + +ROOT="${1:-${CI_REPORTS_DIR:-/var/eventhub/ci-reports}}" +KEEP_DAYS="${CI_REPORTS_KEEP_DAYS:-14}" + +if [[ ! -d "${ROOT}" ]]; then + echo "ci-reports: ${ROOT} отсутствует — пропуск" + exit 0 +fi + +echo "ci-reports prune: root=${ROOT} keep_days=${KEEP_DAYS}" +find "${ROOT}" -mindepth 2 -maxdepth 2 -type d -mtime "+${KEEP_DAYS}" -print -exec rm -rf {} + 2>/dev/null || true +echo "ci-reports prune: done" diff --git a/scripts/publish-ci-report.sh b/scripts/publish-ci-report.sh new file mode 100644 index 0000000..4039213 --- /dev/null +++ b/scripts/publish-ci-report.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Publish HTML CI report tree to local runner host and print HTTPS URL. +# Usage: publish-ci-report.sh +# repo: EventHubBack | EventHubFront | … +# run_id: usually GITHUB_RUN_NUMBER or sha +# kind: eunit | ct | allure | e2e-ift | e2e-stage | … +# src_dir: directory with HTML to copy +set -euo pipefail + +REPO="${1:?repo}" +RUN_ID="${2:?run_id}" +KIND="${3:?kind}" +SRC="${4:?src_dir}" + +REPORTS_DIR="${CI_REPORTS_DIR:-/var/eventhub/ci-reports}" +STAND="${RUNNER_STAND:-}" + +detect_stand() { + if [[ -n "${CI_REPORTS_BASE_URL:-}" ]]; then + echo "" + return 0 + fi + if [[ -n "${STAND}" ]]; then + echo "${STAND}" + return 0 + fi + if getent hosts ci-reports.ift.eventhub.local >/dev/null 2>&1 \ + || getent ahostsv4 ci-reports.ift.eventhub.local >/dev/null 2>&1; then + # Prefer ift if this host resolves ift reports (IFT runner DNS) + if [[ -f /opt/eventhub-ift/.env ]] || [[ -d /opt/eventhub-ift ]]; then + echo "ift" + return 0 + fi + fi + if [[ -d /opt/eventhub-ift ]]; then + echo "ift" + return 0 + fi + echo "dev" +} + +if [[ -n "${CI_REPORTS_BASE_URL:-}" ]]; then + BASE_URL="${CI_REPORTS_BASE_URL%/}" +else + STAND="$(detect_stand)" + BASE_URL="https://ci-reports.${STAND}.eventhub.local" +fi + +if [[ ! -d "${SRC}" ]]; then + echo "publish-ci-report: src missing: ${SRC}" >&2 + exit 1 +fi + +DEST="${REPORTS_DIR}/${REPO}/${RUN_ID}/${KIND}" +mkdir -p "${REPORTS_DIR}/${REPO}/${RUN_ID}" +rm -rf "${DEST}" +mkdir -p "${DEST}" +cp -a "${SRC}/." "${DEST}/" + +# Convenience: CT puts index under ct_run.*; expose DEST/index.html +if [[ ! -f "${DEST}/index.html" ]]; then + CT_INDEX="$(find "${DEST}" -type f -name index.html 2>/dev/null | head -n 1 || true)" + if [[ -n "${CT_INDEX}" ]]; then + REL="${CT_INDEX#"${DEST}/"}" + cat > "${DEST}/index.html" < + + + +CT report + +

Open Common Test report

+ +EOF + fi +fi + +# Refresh run index +INDEX="${REPORTS_DIR}/${REPO}/${RUN_ID}/index.html" +{ + echo "" + echo "${REPO} #${RUN_ID}" + echo "" + echo "" + echo "

${REPO} — run ${RUN_ID}

" + echo "
    " + for d in "${REPORTS_DIR}/${REPO}/${RUN_ID}"/*/; do + [[ -d "${d}" ]] || continue + name="$(basename "${d}")" + if [[ -f "${d}index.html" ]]; then + echo "
  • ${name}
  • " + else + echo "
  • ${name}/
  • " + fi + done + echo "
" +} > "${INDEX}" + +URL="${BASE_URL}/${REPO}/${RUN_ID}/${KIND}/" +INDEX_URL="${BASE_URL}/${REPO}/${RUN_ID}/" +echo "Report: ${URL}" +echo "Report index: ${INDEX_URL}" +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "### Test report" + echo "- [${KIND}](${URL})" + echo "- [index](${INDEX_URL})" + } >> "${GITHUB_STEP_SUMMARY}" +fi diff --git a/scripts/run-stand-api-tests.sh b/scripts/run-stand-api-tests.sh index 63fae27..78c2ba7 100644 --- a/scripts/run-stand-api-tests.sh +++ b/scripts/run-stand-api-tests.sh @@ -114,8 +114,13 @@ if [[ "${have_image}" -ne 1 ]]; then . fi +CT_OUT="${CT_REPORT_DIR:-$(pwd)/ci-out/ct-${STAND}}" +mkdir -p "${CT_OUT}" + +set +e docker run --rm --network=host \ ${docker_add_hosts[@]+"${docker_add_hosts[@]}"} \ + -v "${CT_OUT}:/app/logs/test/ct" \ -e CT_MODE=remote \ -e "API_HOST=${API_HOST}" \ -e "ADMIN_API_HOST=${ADMIN_API_HOST}" \ @@ -130,5 +135,18 @@ docker run --rm --network=host \ -e "ADMIN_SUPPORT_EMAIL=${ADMIN_SUPPORT_EMAIL:-}" \ -e "ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD:-}" \ "${IMAGE}" +CT_RC=$? +set -e + +if [[ -d "${CT_OUT}" ]] && compgen -G "${CT_OUT}/*" >/dev/null; then + RUN_ID="${GITHUB_RUN_NUMBER:-local}" + mkdir -p /var/eventhub/ci-reports 2>/dev/null || true + bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" "e2e-${STAND}" "${CT_OUT}" || true +fi + +if [[ "${CT_RC}" -ne 0 ]]; then + echo "=== API CT FAILED (${STAND}) rc=${CT_RC} ===" >&2 + exit "${CT_RC}" +fi echo "=== API CT passed (${STAND}) ==="