diff --git a/.github/workflows/console-route-probe.yml b/.github/workflows/console-route-probe.yml new file mode 100644 index 0000000..7ceea74 --- /dev/null +++ b/.github/workflows/console-route-probe.yml @@ -0,0 +1,55 @@ +name: Public web probe + +on: + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: console-route-probe + cancel-in-progress: false + +jobs: + probe: + runs-on: ubuntu-latest + timeout-minutes: 4 + outputs: + alert_label: ${{ steps.probe.outputs.alert_label }} + steps: + - name: Check out the route contract and probe + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + # Preserve the script's concise failure output before the next step marks + # the job red. The terminal notification job includes that output in its + # pipeline label. + - name: Probe public web routes + id: probe + continue-on-error: true + env: + PROBE_RETRY_DELAY_SECONDS: "15" + run: python3 scripts/probe_console_routes.py + + - name: Mark a persistent route failure + if: steps.probe.outcome == 'failure' + env: + ALERT_LABEL: ${{ steps.probe.outputs.alert_label }} + run: | + printf '%s\n' "${ALERT_LABEL:-public web probe failed}" >&2 + exit 1 + + notify: + needs: [probe] + if: ${{ !cancelled() && !contains(needs.*.result, 'cancelled') }} + permissions: + contents: read + actions: read + uses: mindsdb/github-actions/.github/workflows/notify-main-failure.yml@main + with: + env-name: ${{ needs.probe.outputs.alert_label || 'public web probe' }} + status: ${{ contains(needs.*.result, 'failure') && 'failed' || 'recovered' }} + secrets: inherit diff --git a/README.md b/README.md index 499626e..24479c6 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,79 @@ Use an action from this repo in your workflow like this: **NOTE: This needs to go AFTER any `actions/checkout` step for the current repo** +## Public web probe + +The `Public web probe` workflow checks public Console routes, Cowork, and the MindsHub website every +five minutes. It runs from a GitHub-hosted runner, so it tests the same edge routes a user reaches +without depending on either Kubernetes cluster. Every response must be exactly `200`, redirects are +not followed, and each endpoint must contain its own stable body marker. + +The monitoring tiers use the shortest cadence that each runner supports: + +| Tier | Cadence | Runner | Contract | +| --- | --- | --- | --- | +| Public uptime | 60 seconds | Cloudflare Health Checks | Production and staging Console, production Cowork, and the website public paths return 200 with their body markers; direct ALB and Pages checks remain non-paging diagnostics | +| Public route matrix | 5 minutes | This GitHub Actions workflow | Every endpoint below serves its expected body through the edge | +| Staging integration | Nightly | Each service repository | Existing deployed suites run with writes allowed | +| Production smoke | Nightly | `cowork-server` | A bounded authenticated GET-only selection runs without writes or model turns | +| Certificates | Existing | Prometheus | `CertificatesExpiringIn7Days` retains certificate coverage | + +An authenticated 15-minute browser journey is not scheduled by this work. It needs a separate +interaction contract and test plan before it can become an actionable signal. + +The first attempt covers 21 public endpoints: all 18 Console environment and route pairs, the +production and staging Cowork roots, and the MindsHub website root. If any endpoint fails, the +script waits 15 seconds and retries only those failures. A recovered retry keeps the run green and +sends no failure alert. An endpoint that fails twice makes the run red and passes every persistent +failure to `notify-main-failure.yml`, which posts to the engineering Slack channel. That message +names each failing environment, route, and observed result. A `200` response without the configured +marker is reported as either `status 200 missing SPA marker` or +`status 200 missing website marker`. The next successful run uses that reusable workflow's +existing recovery lookup, so routine green runs stay silent. + +The reviewable source of truth is [`config/console-route-probe.json`](config/console-route-probe.json). +The Console matrix crosses these environments: + + +- `production`: `https://console.mindshub.ai` +- `staging`: `https://console.staging.mindshub.ai` + + +with these routes: + + +- `/` +- `/home` +- `/cowork` +- `/cowork-web` +- `/login` +- `/settings` +- `/billing` +- `/projects` +- `/assets/` + + +The same configuration checks three roots separately because they do not serve the nine Console +routes: + + +- `production Cowork`: `https://cowork.mindshub.ai/` requires `
` +- `staging Cowork`: `https://cowork.staging.mindshub.ai/` requires `` +- `production website`: `https://mindshub.ai/` requires `` + + +Run the same probe locally without changing its production configuration: + +```bash +python3 scripts/probe_console_routes.py --retry-delay-seconds 0 +``` + +ENG-2317 is the activation gate. Do not merge or promote this workflow to the `github-actions` +`main` branch, and do not manually dispatch it, until ENG-2317 is deployed to production and the +local command above passes all 18 Console environment and route pairs and all 21 public endpoints. A +merge to `main` enables the cron; a merge or dispatch before that gate can send an immediate +failure or recovery message through the real Slack integration. + ## Release-train reusable workflows Four reusable workflows automate the weekly `staging → main` release cycle. diff --git a/config/console-route-probe.json b/config/console-route-probe.json new file mode 100644 index 0000000..1a36cfc --- /dev/null +++ b/config/console-route-probe.json @@ -0,0 +1,47 @@ +{ + "spa_marker": "", + "environments": [ + { + "name": "production", + "base_url": "https://console.mindshub.ai" + }, + { + "name": "staging", + "base_url": "https://console.staging.mindshub.ai" + } + ], + "routes": [ + "/", + "/home", + "/cowork", + "/cowork-web", + "/login", + "/settings", + "/billing", + "/projects", + "/assets/" + ], + "standalone_endpoints": [ + { + "name": "production Cowork", + "base_url": "https://cowork.mindshub.ai", + "route": "/", + "body_marker": "", + "marker_label": "SPA" + }, + { + "name": "staging Cowork", + "base_url": "https://cowork.staging.mindshub.ai", + "route": "/", + "body_marker": "", + "marker_label": "SPA" + }, + { + "name": "production website", + "base_url": "https://mindshub.ai", + "route": "/", + "body_marker": "", + "marker_label": "website" + } + ] +} diff --git a/scripts/probe_console_routes.py b/scripts/probe_console_routes.py new file mode 100644 index 0000000..e115fab --- /dev/null +++ b/scripts/probe_console_routes.py @@ -0,0 +1,457 @@ +"""Probe public Console routes, Cowork, and the website with one-retry debounce. + +The endpoints and body markers live in ``config/console-route-probe.json`` so +operators can review the whole coverage contract without reading this module. +Only endpoints that fail the first attempt are retried. A transient failure is +therefore visible in the run log but does not fail the workflow or alert Slack. +""" + +from __future__ import annotations + +import argparse +import json +import os +import time +import urllib.error +import urllib.request +from collections.abc import Callable, Iterable, Sequence +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + + +DEFAULT_CONFIG_PATH = ( + Path(__file__).resolve().parents[1] / "config" / "console-route-probe.json" +) +DEFAULT_ALERT_LABEL = "public web probe" +MAX_BODY_BYTES = 64 * 1024 +MAX_NETWORK_ERROR_CHARS = 64 +MAX_ALERT_LABEL_CHARS = 2_000 + + +@dataclass(frozen=True) +class Environment: + """One named Console deployment.""" + + name: str + base_url: str + + +@dataclass(frozen=True) +class Endpoint: + """One public URL and the body marker it must return.""" + + environment: str + base_url: str + route: str + body_marker: str + marker_label: str + + @property + def url(self) -> str: + return f"{self.base_url.rstrip('/')}{self.route}" + + +@dataclass(frozen=True) +class ProbeConfig: + """Validated route-probe configuration.""" + + spa_marker: str + environments: tuple[Environment, ...] + routes: tuple[str, ...] + standalone_endpoints: tuple[Endpoint, ...] + + @property + def console_endpoints(self) -> tuple[Endpoint, ...]: + return tuple( + Endpoint( + environment.name, + environment.base_url, + route, + self.spa_marker, + "SPA", + ) + for environment in self.environments + for route in self.routes + ) + + @property + def endpoints(self) -> tuple[Endpoint, ...]: + return (*self.console_endpoints, *self.standalone_endpoints) + + +@dataclass(frozen=True) +class FetchResult: + """The response fields needed to decide whether an endpoint is healthy.""" + + status: int | None + body: str = "" + error: str | None = None + + +@dataclass(frozen=True) +class Failure: + """One endpoint that did not return its expected body marker.""" + + endpoint: Endpoint + reason: str + + @property + def summary(self) -> str: + return f"{self.endpoint.environment} {self.endpoint.route} {self.reason}" + + +@dataclass(frozen=True) +class ProbeOutcome: + """Both attempts, retained so the CLI can explain transient recovery.""" + + endpoint_count: int + first_failures: tuple[Failure, ...] + final_failures: tuple[Failure, ...] + + @property + def passed(self) -> bool: + return not self.final_failures + + +Fetch = Callable[[Endpoint, float], FetchResult] +Sleeper = Callable[[float], None] + + +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Return redirects to the caller instead of following them.""" + + def redirect_request(self, *args: Any, **kwargs: Any) -> None: + return None + + +def _require_string(value: object, field: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError(f"{field} must be a non-empty string") + return value + + +def _require_list(value: object, field: str) -> list[object]: + if not isinstance(value, list) or not value: + raise ValueError(f"{field} must be a non-empty list") + return value + + +def _require_object(value: object, field: str) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError(f"{field} must be an object") + return value + + +def _require_https_origin(value: object, field: str) -> str: + origin = _require_string(value, field) + parsed = urlsplit(origin) + if parsed.scheme != "https" or not parsed.netloc or parsed.path not in ("", "/"): + raise ValueError(f"{field} must be an HTTPS origin") + if parsed.query or parsed.fragment or parsed.username or parsed.password: + raise ValueError(f"{field} must be an HTTPS origin") + return origin.rstrip("/") + + +def _require_route(value: object, field: str) -> str: + route = _require_string(value, field) + if not route.startswith("/") or "?" in route or "#" in route: + raise ValueError( + f"{field} must be an absolute path without a query or fragment" + ) + return route + + +def load_config(path: Path = DEFAULT_CONFIG_PATH) -> ProbeConfig: + """Load and validate the operator-owned route matrix.""" + + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("probe config must be a JSON object") + + spa_marker = _require_string(raw.get("spa_marker"), "spa_marker") + raw_environments = _require_list(raw.get("environments"), "environments") + raw_routes = _require_list(raw.get("routes"), "routes") + raw_standalone_endpoints = _require_list( + raw.get("standalone_endpoints"), "standalone_endpoints" + ) + + environments: list[Environment] = [] + for index, value in enumerate(raw_environments): + if not isinstance(value, dict): + raise ValueError(f"environments[{index}] must be an object") + name = _require_string(value.get("name"), f"environments[{index}].name") + base_url = _require_https_origin( + value.get("base_url"), f"environments[{index}].base_url" + ) + environments.append(Environment(name=name, base_url=base_url)) + + if len({environment.name for environment in environments}) != len(environments): + raise ValueError("environment names must be unique") + if len({environment.base_url for environment in environments}) != len(environments): + raise ValueError("environment base URLs must be unique") + + routes: list[str] = [] + for index, value in enumerate(raw_routes): + routes.append(_require_route(value, f"routes[{index}]")) + if len(set(routes)) != len(routes): + raise ValueError("routes must be unique") + + standalone_endpoints: list[Endpoint] = [] + for index, value in enumerate(raw_standalone_endpoints): + field = f"standalone_endpoints[{index}]" + endpoint = _require_object(value, field) + standalone_endpoints.append( + Endpoint( + environment=_require_string(endpoint.get("name"), f"{field}.name"), + base_url=_require_https_origin( + endpoint.get("base_url"), f"{field}.base_url" + ), + route=_require_route(endpoint.get("route"), f"{field}.route"), + body_marker=_require_string( + endpoint.get("body_marker"), f"{field}.body_marker" + ), + marker_label=_require_string( + endpoint.get("marker_label"), f"{field}.marker_label" + ), + ) + ) + + endpoint_names = [environment.name for environment in environments] + endpoint_names.extend(endpoint.environment for endpoint in standalone_endpoints) + if len(set(endpoint_names)) != len(endpoint_names): + raise ValueError("endpoint names must be unique") + + config = ProbeConfig( + spa_marker=spa_marker, + environments=tuple(environments), + routes=tuple(routes), + standalone_endpoints=tuple(standalone_endpoints), + ) + if len({endpoint.url for endpoint in config.endpoints}) != len(config.endpoints): + raise ValueError("configured endpoint URLs must be unique") + return config + + +def _decode_body(payload: bytes) -> str: + return payload.decode("utf-8", errors="replace") + + +def fetch_endpoint(endpoint: Endpoint, timeout_seconds: float) -> FetchResult: + """GET one endpoint with TLS validation and redirects disabled.""" + + opener = urllib.request.build_opener(NoRedirectHandler()) + request = urllib.request.Request( + endpoint.url, + headers={ + "Accept": "text/html", + "User-Agent": "MindsHub-public-web-probe/1.0", + }, + method="GET", + ) + try: + with opener.open(request, timeout=timeout_seconds) as response: + return FetchResult( + status=int(response.getcode()), + body=_decode_body(response.read(MAX_BODY_BYTES)), + ) + except urllib.error.HTTPError as error: + return FetchResult( + status=int(error.code), + body=_decode_body(error.read(MAX_BODY_BYTES)), + ) + except (urllib.error.URLError, TimeoutError, OSError) as error: + return FetchResult(status=None, error=_compact(str(error))) + + +def _compact(value: str, limit: int = 160) -> str: + compacted = " ".join(value.split()) or "unknown error" + compacted = compacted.replace("\\", "/").replace('"', "'") + return compacted[:limit] + + +def evaluate(endpoint: Endpoint, result: FetchResult) -> Failure | None: + """Apply the endpoint's exact-status and body-marker contract.""" + + if result.error is not None: + return Failure( + endpoint, + f"network error: {_compact(result.error, limit=MAX_NETWORK_ERROR_CHARS)}", + ) + if result.status != 200: + status = "unknown" if result.status is None else str(result.status) + return Failure(endpoint, f"status {status}") + if endpoint.body_marker not in result.body: + return Failure(endpoint, f"status 200 missing {endpoint.marker_label} marker") + return None + + +def check_endpoints( + endpoints: Sequence[Endpoint], + *, + timeout_seconds: float, + max_workers: int, + fetcher: Fetch, +) -> tuple[Failure, ...]: + """Probe endpoints concurrently while preserving configuration order.""" + + if not endpoints: + return () + worker_count = min(max_workers, len(endpoints)) + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = [ + executor.submit(fetcher, endpoint, timeout_seconds) + for endpoint in endpoints + ] + results = [future.result() for future in futures] + + failures = ( + failure + for endpoint, result in zip(endpoints, results, strict=True) + if (failure := evaluate(endpoint, result)) is not None + ) + return tuple(failures) + + +def run_probe( + config: ProbeConfig, + *, + retry_delay_seconds: float, + timeout_seconds: float, + max_workers: int, + fetcher: Fetch = fetch_endpoint, + sleeper: Sleeper = time.sleep, +) -> ProbeOutcome: + """Run the full matrix once, then retry only the failed endpoints.""" + + endpoints = config.endpoints + first_failures = check_endpoints( + endpoints, + timeout_seconds=timeout_seconds, + max_workers=max_workers, + fetcher=fetcher, + ) + if not first_failures: + return ProbeOutcome(len(endpoints), (), ()) + + sleeper(retry_delay_seconds) + retry_endpoints = tuple(failure.endpoint for failure in first_failures) + final_failures = check_endpoints( + retry_endpoints, + timeout_seconds=timeout_seconds, + max_workers=max_workers, + fetcher=fetcher, + ) + return ProbeOutcome(len(endpoints), first_failures, final_failures) + + +def format_alert_label(failures: Sequence[Failure]) -> str: + """Fit every actionable failure into the notifier's bounded label.""" + + if not failures: + return DEFAULT_ALERT_LABEL + + label_prefix = "public routes: " + separator = "; " + full_label = label_prefix + separator.join(failure.summary for failure in failures) + if len(full_label) <= MAX_ALERT_LABEL_CHARS: + return full_label + + # A broad DNS or TLS outage can put the maximum-length network detail on all + # 21 endpoints. The full details remain in the workflow log; the Slack label + # keeps every endpoint identity and its actionable result category. + compact_summaries = separator.join( + f"{failure.endpoint.environment} {failure.endpoint.route} " + f"{_compact_alert_reason(failure.reason)}" + for failure in failures + ) + compact_label = label_prefix + compact_summaries + if len(compact_label) > MAX_ALERT_LABEL_CHARS: + raise ValueError("failure identities exceed the alert-label size limit") + return compact_label + + +def _compact_alert_reason(reason: str) -> str: + network_prefix = "network error" + if reason.startswith(f"{network_prefix}: "): + return network_prefix + return reason + + +def write_github_output(path: str | None, *, alert_label: str) -> None: + """Expose a single-line label to the terminal notification job.""" + + if not path: + return + with Path(path).open("a", encoding="utf-8") as output: + output.write( + f"alert_label={_compact(alert_label, limit=MAX_ALERT_LABEL_CHARS)}\n" + ) + + +def _print_failures(prefix: str, failures: Iterable[Failure]) -> None: + print(prefix) + for failure in failures: + print(f" - {failure.summary}") + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_PATH) + parser.add_argument( + "--retry-delay-seconds", + type=float, + default=float(os.environ.get("PROBE_RETRY_DELAY_SECONDS", "15")), + ) + parser.add_argument("--timeout-seconds", type=float, default=10.0) + parser.add_argument("--max-workers", type=int, default=8) + parser.add_argument("--github-output", default=os.environ.get("GITHUB_OUTPUT")) + args = parser.parse_args(argv) + if args.retry_delay_seconds < 0: + parser.error("--retry-delay-seconds must be non-negative") + if args.timeout_seconds <= 0: + parser.error("--timeout-seconds must be positive") + if args.max_workers <= 0: + parser.error("--max-workers must be positive") + return args + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + try: + config = load_config(args.config) + except (OSError, json.JSONDecodeError, ValueError) as error: + label = _compact(f"public routes: configuration error: {error}") + write_github_output(args.github_output, alert_label=label) + print(label) + return 2 + + outcome = run_probe( + config, + retry_delay_seconds=args.retry_delay_seconds, + timeout_seconds=args.timeout_seconds, + max_workers=args.max_workers, + ) + if outcome.passed: + write_github_output(args.github_output, alert_label=DEFAULT_ALERT_LABEL) + if outcome.first_failures: + _print_failures( + "First attempt failed; every failed endpoint recovered on retry:", + outcome.first_failures, + ) + print( + f"PASS: {outcome.endpoint_count} public endpoints returned status 200 " + "and their expected body marker." + ) + return 0 + + label = format_alert_label(outcome.final_failures) + write_github_output(args.github_output, alert_label=label) + _print_failures("FAIL: endpoints failed both attempts:", outcome.final_failures) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_probe_console_routes.py b/tests/test_probe_console_routes.py new file mode 100644 index 0000000..2d7b6cf --- /dev/null +++ b/tests/test_probe_console_routes.py @@ -0,0 +1,597 @@ +"""Tests for the public Console, Cowork, and website probe contract.""" + +import importlib.util +import json +import re +import sys +from collections import Counter +from io import BytesIO +from pathlib import Path +from urllib.error import HTTPError + +import pytest +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = ROOT / "scripts" / "probe_console_routes.py" +CONFIG_PATH = ROOT / "config" / "console-route-probe.json" +README_PATH = ROOT / "README.md" +WORKFLOW_PATH = ROOT / ".github" / "workflows" / "console-route-probe.yml" + +_spec = importlib.util.spec_from_file_location("probe_console_routes", SCRIPT_PATH) +probe = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = probe +_spec.loader.exec_module(probe) + + +EXPECTED_ENVIRONMENTS = ( + ("production", "https://console.mindshub.ai"), + ("staging", "https://console.staging.mindshub.ai"), +) +EXPECTED_ROUTES = ( + "/", + "/home", + "/cowork", + "/cowork-web", + "/login", + "/settings", + "/billing", + "/projects", + "/assets/", +) +CONSOLE_MARKER = '' +EXPECTED_STANDALONE_ENDPOINTS = ( + ( + "production Cowork", + "https://cowork.mindshub.ai", + "/", + CONSOLE_MARKER, + "SPA", + ), + ( + "staging Cowork", + "https://cowork.staging.mindshub.ai", + "/", + CONSOLE_MARKER, + "SPA", + ), + ( + "production website", + "https://mindshub.ai", + "/", + '', + "website", + ), +) +EXPECTED_CONSOLE_ENDPOINT_COUNT = len(EXPECTED_ENVIRONMENTS) * len(EXPECTED_ROUTES) +EXPECTED_ENDPOINT_COUNT = EXPECTED_CONSOLE_ENDPOINT_COUNT + len( + EXPECTED_STANDALONE_ENDPOINTS +) +SPA_SHELL = """""" +WEBSITE_PAGE = ( + "" + '' + "" +) +NGINX_PAGE = """