|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Mock GitHub API + Slack workflow webhook for update-canvas.sh flow tests. |
| 3 | +
|
| 4 | +Scenario is selected with the MOCK_SCENARIO env var: |
| 5 | + normal - dev's newest deployment succeeded; prd's newest failed, an older one succeeded. |
| 6 | + degraded - dev's newest deployment has no statuses yet, the next one failed, none |
| 7 | + succeeded; prd has no deployments. |
| 8 | + empty - no deployments in either environment. |
| 9 | + gh_error - the deployments endpoint returns HTTP 500. |
| 10 | + webhook_fail - deployment data as in normal, but the webhook responds HTTP 500. |
| 11 | +Every request is appended to MOCK_LOG as one JSON line: {"path": ..., "body": ...}, |
| 12 | +except GET /ping (a readiness probe) which is answered 200 and not logged. |
| 13 | +""" |
| 14 | +import json |
| 15 | +import os |
| 16 | +import re |
| 17 | +import sys |
| 18 | +from http.server import BaseHTTPRequestHandler, HTTPServer |
| 19 | +from urllib.parse import urlparse, parse_qs |
| 20 | + |
| 21 | +SCENARIO = os.environ.get("MOCK_SCENARIO", "normal") |
| 22 | +LOG_PATH = os.environ["MOCK_LOG"] |
| 23 | + |
| 24 | +DEPLOYMENTS = { |
| 25 | + "dev": [ |
| 26 | + { |
| 27 | + "id": 2, |
| 28 | + "ref": "refs/heads/main", |
| 29 | + "sha": "aaaabbbbccccddddeeeeffff0000111122223333", |
| 30 | + "payload": {"dockerImage": "quay.io/decentraland/pulse-server:aaaabbbbccccddddeeeeffff0000111122223333"}, |
| 31 | + }, |
| 32 | + ], |
| 33 | + "prd": [ |
| 34 | + { |
| 35 | + "id": 12, |
| 36 | + "ref": "refs/tags/v0.9.3", |
| 37 | + "sha": "9999888877776666555544443333222211110000", |
| 38 | + "payload": {"dockerImage": "quay.io/decentraland/pulse-server:latest"}, |
| 39 | + }, |
| 40 | + { |
| 41 | + "id": 11, |
| 42 | + "ref": "refs/tags/v0.9.2", |
| 43 | + "sha": "1234567890abcdef1234567890abcdef12345678", |
| 44 | + "payload": {"dockerImage": "quay.io/decentraland/pulse-server:59791d9"}, |
| 45 | + }, |
| 46 | + ], |
| 47 | +} |
| 48 | + |
| 49 | +DEPLOYMENTS_DEGRADED = { |
| 50 | + "dev": [ |
| 51 | + { |
| 52 | + "id": 21, |
| 53 | + "ref": "refs/heads/feat/hotfix", |
| 54 | + "sha": "ccccddddeeeeffff0000111122223333aaaabbbb", |
| 55 | + "payload": {"dockerImage": "quay.io/decentraland/pulse-server:hotfix"}, |
| 56 | + }, |
| 57 | + { |
| 58 | + "id": 22, |
| 59 | + "ref": "refs/heads/main", |
| 60 | + "sha": "bbbbccccddddeeeeffff0000111122223333aaaa", |
| 61 | + "payload": {"dockerImage": "quay.io/decentraland/pulse-server:latest"}, |
| 62 | + }, |
| 63 | + ], |
| 64 | + "prd": [], |
| 65 | +} |
| 66 | + |
| 67 | +STATUSES = { |
| 68 | + 2: {"state": "success", "created_at": "2026-07-18T10:00:00Z"}, |
| 69 | + 12: {"state": "failure", "created_at": "2026-07-19T08:30:00Z"}, |
| 70 | + 11: {"state": "success", "created_at": "2026-07-15T17:11:32Z"}, |
| 71 | + # 21 deliberately absent: a just-created deployment with no statuses yet. |
| 72 | + 22: {"state": "failure", "created_at": "2026-07-19T09:00:00Z"}, |
| 73 | +} |
| 74 | + |
| 75 | + |
| 76 | +class Handler(BaseHTTPRequestHandler): |
| 77 | + def _respond(self, payload, status=200): |
| 78 | + body = json.dumps(payload).encode() |
| 79 | + self.send_response(status) |
| 80 | + self.send_header("Content-Type", "application/json") |
| 81 | + self.send_header("Content-Length", str(len(body))) |
| 82 | + self.end_headers() |
| 83 | + self.wfile.write(body) |
| 84 | + |
| 85 | + def _record(self, path, body): |
| 86 | + with open(LOG_PATH, "a", encoding="utf-8") as f: |
| 87 | + f.write(json.dumps({"path": path, "body": body}, ensure_ascii=False) + "\n") |
| 88 | + |
| 89 | + def do_GET(self): |
| 90 | + parsed = urlparse(self.path) |
| 91 | + if parsed.path == "/ping": |
| 92 | + self._respond({"ok": True}) |
| 93 | + return |
| 94 | + self._record(parsed.path, self.path) |
| 95 | + if re.fullmatch(r"/repos/[^/]+/[^/]+/deployments", parsed.path): |
| 96 | + environment = parse_qs(parsed.query).get("environment", [""])[0] |
| 97 | + if SCENARIO == "gh_error": |
| 98 | + self._respond({"message": "boom"}, status=500) |
| 99 | + elif SCENARIO == "empty": |
| 100 | + self._respond([]) |
| 101 | + elif SCENARIO == "degraded": |
| 102 | + self._respond(DEPLOYMENTS_DEGRADED.get(environment, [])) |
| 103 | + else: |
| 104 | + self._respond(DEPLOYMENTS.get(environment, [])) |
| 105 | + return |
| 106 | + match = re.fullmatch(r"/repos/[^/]+/[^/]+/deployments/(\d+)/statuses", parsed.path) |
| 107 | + if match: |
| 108 | + status = STATUSES.get(int(match.group(1))) |
| 109 | + self._respond([status] if status else []) |
| 110 | + return |
| 111 | + self._respond({"message": "not found"}, status=404) |
| 112 | + |
| 113 | + def do_POST(self): |
| 114 | + length = int(self.headers.get("Content-Length", 0)) |
| 115 | + body = json.loads(self.rfile.read(length) or b"{}") |
| 116 | + parsed = urlparse(self.path) |
| 117 | + self._record(parsed.path, body) |
| 118 | + if parsed.path == "/webhook": |
| 119 | + if SCENARIO == "webhook_fail": |
| 120 | + self._respond({"error": "trigger_failed"}, status=500) |
| 121 | + else: |
| 122 | + self._respond({"ok": True}) |
| 123 | + return |
| 124 | + self._respond({"message": "not found"}, status=404) |
| 125 | + |
| 126 | + def log_message(self, *args): |
| 127 | + pass |
| 128 | + |
| 129 | + |
| 130 | +if __name__ == "__main__": |
| 131 | + HTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever() |
0 commit comments