|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""probe_gtc_upload.py — instrumented single-asset upload to a GitCode release. |
| 3 | +
|
| 4 | +TEMPORARY diagnostic tool for the recurring `publish-ecosystem` failure where |
| 5 | +the GitCode leg of tools/mirror_res.sh blows its 180s per-asset cap on the two |
| 6 | +largest tarballs (0.0.94 / 0.0.97 / 0.0.105 / 2026.7.28.2). It is a measurement |
| 7 | +harness, not a mirror: it reproduces exactly what tools/gtc `release upload` |
| 8 | +does, but times every stage separately and reports one JSON line per upload so |
| 9 | +the stages can be compared across transports and concurrency levels. |
| 10 | +
|
| 11 | +The upload path under test (identical to tools/gtc `_upload_one`): |
| 12 | +
|
| 13 | + 1. GET {api}/repos/{repo}/releases/{tag}/upload_url?file_name=X |
| 14 | + -> {"url": <presigned OBS PUT URL>, "headers": {...}} |
| 15 | + 2. PUT <presigned url> with the whole file as the body |
| 16 | + 3. (server-side) OBS calls back into the GitCode API to register the asset |
| 17 | +
|
| 18 | +Stage 3 is invisible to the client except when it fails, which is where the |
| 19 | +`obs_callback ... code:400 ... EOF` false negative comes from: the object is |
| 20 | +in OBS but the registration callback errored, so the PUT reports failure for |
| 21 | +an upload that actually landed. This tool always re-probes the public download |
| 22 | +URL afterwards, so `put_ok` and `serves` are reported independently and that |
| 23 | +false negative shows up as put_ok=false + serves=true. |
| 24 | +
|
| 25 | +Usage: |
| 26 | + probe_gtc_upload.py --repo xlings-res/mcpp --tag probe-x --file f.bin \ |
| 27 | + [--method urllib|curl] [--label NAME] |
| 28 | +
|
| 29 | +Auth: GITCODE_TOKEN env var (same as tools/gtc). |
| 30 | +Output: one JSON object per line on stdout; human notes on stderr. |
| 31 | +""" |
| 32 | +import argparse |
| 33 | +import json |
| 34 | +import os |
| 35 | +import subprocess |
| 36 | +import sys |
| 37 | +import time |
| 38 | +import urllib.error |
| 39 | +import urllib.parse |
| 40 | +import urllib.request |
| 41 | +from pathlib import Path |
| 42 | + |
| 43 | +API_BASE = os.environ.get("GITCODE_API_BASE", "https://api.gitcode.com/api/v5") |
| 44 | +DL_HOST = "https://gitcode.com" |
| 45 | + |
| 46 | + |
| 47 | +def get_upload_url(repo, tag, fname, token): |
| 48 | + """Stage 1 — ask GitCode for a presigned OBS PUT URL.""" |
| 49 | + url = (f"{API_BASE}/repos/{repo}/releases/{tag}/upload_url" |
| 50 | + f"?file_name={urllib.parse.quote(fname)}") |
| 51 | + req = urllib.request.Request( |
| 52 | + url, headers={"PRIVATE-TOKEN": token, "Accept": "application/json"}) |
| 53 | + t0 = time.monotonic() |
| 54 | + with urllib.request.urlopen(req, timeout=120) as r: |
| 55 | + body = r.read() |
| 56 | + dt = time.monotonic() - t0 |
| 57 | + info = json.loads(body) |
| 58 | + return info["url"], info.get("headers") or {}, dt |
| 59 | + |
| 60 | + |
| 61 | +def put_urllib(put_url, headers, path): |
| 62 | + """Stage 2, transport A — exactly what tools/gtc does today: read the |
| 63 | + whole file into memory and hand it to urllib as a single PUT body.""" |
| 64 | + with open(path, "rb") as f: |
| 65 | + data = f.read() |
| 66 | + req = urllib.request.Request(put_url, data=data, headers=headers, method="PUT") |
| 67 | + t0 = time.monotonic() |
| 68 | + try: |
| 69 | + with urllib.request.urlopen(req, timeout=1800) as r: |
| 70 | + status = r.status |
| 71 | + resp = r.read().decode(errors="replace")[:300] |
| 72 | + except urllib.error.HTTPError as e: |
| 73 | + status = e.code |
| 74 | + resp = e.read().decode(errors="replace")[:300] |
| 75 | + except Exception as e: # noqa: BLE001 - report, don't raise |
| 76 | + status = -1 |
| 77 | + resp = f"{type(e).__name__}: {e}"[:300] |
| 78 | + return status, resp, time.monotonic() - t0, {} |
| 79 | + |
| 80 | + |
| 81 | +def put_curl(put_url, headers, path): |
| 82 | + """Stage 2, transport B — same presigned URL, but curl streams the file |
| 83 | + from disk instead of buffering it in the process. curl also reports its own |
| 84 | + connect/TLS/first-byte breakdown, which is what separates "the network is |
| 85 | + slow" from "the client is slow".""" |
| 86 | + fmt = ("%{http_code} %{speed_upload} %{time_namelookup} %{time_connect} " |
| 87 | + "%{time_appconnect} %{time_pretransfer} %{time_starttransfer} %{time_total}") |
| 88 | + cmd = ["curl", "-sS", "-X", "PUT", "-T", path, "--max-time", "1800", "-o", "/tmp/put_body", |
| 89 | + "-w", fmt] |
| 90 | + for k, v in headers.items(): |
| 91 | + cmd += ["-H", f"{k}: {v}"] |
| 92 | + cmd.append(put_url) |
| 93 | + t0 = time.monotonic() |
| 94 | + p = subprocess.run(cmd, capture_output=True, text=True) |
| 95 | + dt = time.monotonic() - t0 |
| 96 | + detail, status, resp = {}, -1, (p.stderr or "")[:300] |
| 97 | + if p.returncode == 0 and p.stdout.strip(): |
| 98 | + parts = p.stdout.split() |
| 99 | + try: |
| 100 | + status = int(parts[0]) |
| 101 | + detail = { |
| 102 | + "curl_speed_upload_Bps": float(parts[1]), |
| 103 | + "curl_t_namelookup": float(parts[2]), |
| 104 | + "curl_t_connect": float(parts[3]), |
| 105 | + "curl_t_appconnect": float(parts[4]), |
| 106 | + "curl_t_pretransfer": float(parts[5]), |
| 107 | + "curl_t_starttransfer": float(parts[6]), |
| 108 | + "curl_t_total": float(parts[7]), |
| 109 | + } |
| 110 | + except (IndexError, ValueError): |
| 111 | + pass |
| 112 | + try: |
| 113 | + resp = Path("/tmp/put_body").read_text(errors="replace")[:300] |
| 114 | + except OSError: |
| 115 | + resp = "" |
| 116 | + else: |
| 117 | + detail["curl_exit"] = p.returncode |
| 118 | + return status, resp, dt, detail |
| 119 | + |
| 120 | + |
| 121 | +def serves(repo, tag, fname): |
| 122 | + """Independent truth: does the public download URL actually return bytes? |
| 123 | + Ranged GET — HEAD lies on GitCode (returns a redirect stub).""" |
| 124 | + url = f"{DL_HOST}/{repo}/releases/download/{tag}/{urllib.parse.quote(fname)}" |
| 125 | + p = subprocess.run( |
| 126 | + ["curl", "-sSL", "-o", "/dev/null", "-w", "%{http_code}", "-r", "0-0", url], |
| 127 | + capture_output=True, text=True) |
| 128 | + return p.stdout.strip(), url |
| 129 | + |
| 130 | + |
| 131 | +def main(): |
| 132 | + ap = argparse.ArgumentParser() |
| 133 | + ap.add_argument("--repo", required=True) |
| 134 | + ap.add_argument("--tag", required=True) |
| 135 | + ap.add_argument("--file", required=True) |
| 136 | + ap.add_argument("--method", choices=("urllib", "curl"), default="urllib") |
| 137 | + ap.add_argument("--label", default="") |
| 138 | + ap.add_argument("--no-verify", action="store_true") |
| 139 | + args = ap.parse_args() |
| 140 | + |
| 141 | + token = os.environ.get("GITCODE_TOKEN", "") |
| 142 | + if not token: |
| 143 | + sys.exit("GITCODE_TOKEN is empty") |
| 144 | + |
| 145 | + path = Path(args.file) |
| 146 | + size = path.stat().st_size |
| 147 | + fname = path.name |
| 148 | + |
| 149 | + put_url, headers, t_url = get_upload_url(args.repo, args.tag, fname, token) |
| 150 | + obs_host = urllib.parse.urlparse(put_url).netloc |
| 151 | + |
| 152 | + put = put_urllib if args.method == "urllib" else put_curl |
| 153 | + status, resp, t_put, detail = put(put_url, headers, str(path)) |
| 154 | + |
| 155 | + rec = { |
| 156 | + "label": args.label or fname, |
| 157 | + "method": args.method, |
| 158 | + "file": fname, |
| 159 | + "size_bytes": size, |
| 160 | + "size_mb": round(size / 1048576, 2), |
| 161 | + "obs_host": obs_host, |
| 162 | + "t_upload_url_s": round(t_url, 3), |
| 163 | + "t_put_s": round(t_put, 2), |
| 164 | + "put_status": status, |
| 165 | + "put_ok": status == 200, |
| 166 | + "throughput_MBps": round(size / 1048576 / t_put, 3) if t_put > 0 else None, |
| 167 | + "put_response_head": resp.replace("\n", " ")[:200], |
| 168 | + } |
| 169 | + rec.update(detail) |
| 170 | + |
| 171 | + if not args.no_verify: |
| 172 | + # Deliberately independent of put_ok: this is the check that exposes |
| 173 | + # the obs_callback false negative (put_ok=false while serves=200/206). |
| 174 | + code, url = serves(args.repo, args.tag, fname) |
| 175 | + rec["verify_code"] = code |
| 176 | + rec["serves"] = code in ("200", "206") |
| 177 | + rec["download_url"] = url |
| 178 | + |
| 179 | + print(json.dumps(rec), flush=True) |
| 180 | + sys.stderr.write( |
| 181 | + f"[probe] {rec['label']}: {rec['size_mb']}MB via {args.method} " |
| 182 | + f"-> put={rec['put_status']} in {rec['t_put_s']}s " |
| 183 | + f"({rec['throughput_MBps']} MB/s) serves={rec.get('serves')}\n") |
| 184 | + |
| 185 | + |
| 186 | +if __name__ == "__main__": |
| 187 | + main() |
0 commit comments