#!/usr/bin/env python3 """Verify that a deployed dashboard is usable, not merely alive.""" from __future__ import annotations import argparse import http.cookiejar import json import os import sys import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass class DeploymentVerificationError(RuntimeError): """A public deployment failed one observable user-flow check.""" @dataclass(frozen=True) class Response: status: int url: str content_type: str body: bytes def _request(opener: urllib.request.OpenerDirector, request: str | urllib.request.Request, label: str) -> Response: try: with opener.open(request, timeout=10) as response: return Response( status=response.status, url=response.geturl(), content_type=response.headers.get_content_type(), body=response.read(), ) except urllib.error.HTTPError as error: raise DeploymentVerificationError(f"{label} returned HTTP {error.code}") from None except (OSError, urllib.error.URLError, TimeoutError): raise DeploymentVerificationError(f"{label} could not be reached") from None def verify_deployment(base_url: str, access_token: str) -> dict[str, str]: """Exercise liveness, readiness, sign-in, PWA scope, and mobile Home.""" parsed = urllib.parse.urlsplit(base_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise DeploymentVerificationError("deployment URL must be absolute HTTP(S)") if not access_token: raise DeploymentVerificationError("operator access token is required") base_url = base_url.rstrip("/") + "/" base_path = urllib.parse.urlsplit(base_url).path opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) health = _request(opener, urllib.parse.urljoin(base_url, "healthz"), "liveness") if health.status != 200: raise DeploymentVerificationError(f"liveness returned HTTP {health.status}") readiness = _request(opener, urllib.parse.urljoin(base_url, "readyz"), "readiness") if readiness.status != 200: raise DeploymentVerificationError(f"readiness returned HTTP {readiness.status}") public_entry = _request(opener, base_url, "public entry") public_text = public_entry.body.decode("utf-8", errors="replace") if public_entry.content_type != "text/html" or 'name="access_token"' not in public_text: raise DeploymentVerificationError("public entry did not render the operator sign-in form") manifest_response = _request( opener, urllib.parse.urljoin(base_url, "manifest.webmanifest"), "manifest" ) try: manifest = json.loads(manifest_response.body) except (UnicodeDecodeError, json.JSONDecodeError): raise DeploymentVerificationError("manifest did not return valid JSON") from None manifest_scope = urllib.parse.urljoin(manifest_response.url, str(manifest.get("scope", ""))) manifest_start = urllib.parse.urljoin(manifest_response.url, str(manifest.get("start_url", ""))) expected = urllib.parse.urlsplit(base_url) resolved_scope = urllib.parse.urlsplit(manifest_scope) resolved_start = urllib.parse.urlsplit(manifest_start) if ( (resolved_scope.scheme, resolved_scope.netloc, resolved_scope.path) != (expected.scheme, expected.netloc, expected.path) or (resolved_start.scheme, resolved_start.netloc, resolved_start.path) != (expected.scheme, expected.netloc, expected.path) ): raise DeploymentVerificationError("manifest scope or start URL escaped the deployment subpath") encoded = json.dumps({ "access_token": access_token, "device_label": "Deployment smoke verifier", }).encode() login_request = urllib.request.Request( urllib.parse.urljoin(base_url, "api/v1/session"), data=encoded, headers={"Content-Type": "application/json", "Accept": "application/json"}, method="POST", ) authenticated = _request(opener, login_request, "operator sign-in") try: authenticated_payload = json.loads(authenticated.body) except (UnicodeDecodeError, json.JSONDecodeError): raise DeploymentVerificationError("operator sign-in did not return valid JSON") from None if authenticated_payload.get("authenticated") is not True: raise DeploymentVerificationError("operator sign-in was not accepted") home = _request(opener, base_url, "authenticated Home") home_text = home.body.decode("utf-8", errors="replace") if home.content_type != "text/html" or ( 'id="mobile-task-dock"' not in home_text or 'id="new-issue"' not in home_text ): raise DeploymentVerificationError("operator sign-in did not reach the mobile dashboard Home") return { "health": "ok", "readiness": "ready", "public_entry": "login", "manifest_scope": base_path, "authenticated_home": "mobile", } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("url", help="Dashboard URL including its public subpath") args = parser.parse_args(argv) token = os.environ.get("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "") try: result = verify_deployment(args.url, token) except DeploymentVerificationError as error: print(f"deployment verification failed: {error}", file=sys.stderr) return 1 print(json.dumps(result, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())