Spaces:
Sleeping
Sleeping
File size: 5,936 Bytes
6a75a66 a4f876a 6a75a66 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | """R0-3 β post-deploy live smoke gate.
Verifies the ARTIFACT, not the pipeline: after a deploy, converts the kitchen
reference photo (extracted from the committed bundle, so no binary assets in
the repo) against the LIVE Space and asserts the bundle carries the metric
stack. Catches stale images, partial rsyncs, and dependency regressions that
deploy "successfully" while serving old behaviour β both deploy traps hit in
June 2026 (unpromoted Vercel build; rsync size+mtime skip) would have failed
this gate within minutes instead of costing a photo test round.
Pure stdlib (no pip installs in CI).
Usage:
python verify_live_smoke.py [base_url]
# default base_url: https://modventures-room-visualizer.hf.space
Exit 0 = live Space serves the expected stack; non-zero = block/alert.
"""
import base64
import io
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
HERE = os.path.dirname(os.path.abspath(__file__))
BUNDLE = os.path.join(HERE, "data", "ref_kitchen.vizbundle.json")
DEFAULT_BASE = "https://modventures-room-visualizer.hf.space"
SPACE_API = "https://huggingface.co/api/spaces/modventures/room-visualizer"
BUILD_WAIT_S = 20 * 60 # Space docker rebuild after a push
CONVERT_RETRY_S = 10 * 60 # wake-from-sleep + model loads on first request
JOB_WAIT_S = 6 * 60
def get_json(url, timeout=30):
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.load(r)
def wait_for_space_ready():
"""Wait out a BUILDING stage; RUNNING/SLEEPING are both fine (a request
wakes a sleeping Space)."""
deadline = time.time() + BUILD_WAIT_S
last = None
while time.time() < deadline:
try:
stage = get_json(SPACE_API).get("runtime", {}).get("stage")
except Exception as exc:
stage = f"api-error: {exc}"
if stage != last:
print(f" space stage: {stage}", flush=True)
last = stage
if stage in ("RUNNING", "SLEEPING", "PAUSED"):
return stage
time.sleep(20)
raise SystemExit(f"FAIL: Space not ready within {BUILD_WAIT_S}s (stage={last})")
def post_convert(base, img_bytes):
boundary = uuid.uuid4().hex
body = (
f"--{boundary}\r\nContent-Disposition: form-data; "
f'name="file"; filename="smoke.jpg"\r\n'
f"Content-Type: image/jpeg\r\n\r\n"
).encode() + img_bytes + f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(
base + "/viz2d/convert",
data=body,
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
deadline = time.time() + CONVERT_RETRY_S
while True:
try:
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r)
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError) as exc:
if time.time() > deadline:
raise SystemExit(f"FAIL: /viz2d/convert unreachable: {exc}")
print(f" convert not ready ({exc}), retrying...", flush=True)
time.sleep(30)
def main():
base = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BASE
print(f"smoke target: {base}", flush=True)
img_bytes = base64.b64decode(json.load(open(BUNDLE))["pixels"])
print(f" reference photo: {len(img_bytes)} bytes (from committed kitchen bundle)")
wait_for_space_ready()
job = post_convert(base, img_bytes)
jid = job.get("id") or job.get("jobId")
if not jid:
raise SystemExit(f"FAIL: convert returned no job id: {job}")
print(f" job: {jid}", flush=True)
deadline = time.time() + JOB_WAIT_S
status = None
while time.time() < deadline:
status = get_json(f"{base}/viz2d/jobs/{jid}").get("status")
if status not in ("PENDING", "PROCESSING"):
break
time.sleep(5)
if status != "COMPLETED":
raise SystemExit(f"FAIL: job status {status}")
bundle = get_json(f"{base}/viz2d/jobs/{jid}/file", timeout=120)
segs = bundle.get("segments") or []
if not segs:
raise SystemExit("FAIL: no segments in live bundle")
seg = max(segs, key=lambda s: s.get("metadata", {}).get("surfacePixels", 0))
meta = seg.get("metadata", {})
plane = seg.get("plane") or {}
checks = [
("depthEnabled", meta.get("depthEnabled") is True),
# R1-5 β either metric plane source is correct: moge-plane when the
# point-map fit passes its gates, depth-plane otherwise.
("geometrySource in {depth-plane, moge-plane}",
plane.get("geometrySource") in ("depth-plane", "moge-plane")),
("metersPerUnit in [0.5, 2.0]",
isinstance(plane.get("metersPerUnit"), (int, float))
and 0.5 <= plane["metersPerUnit"] <= 2.0),
("plane size sane (0.5..20 m)",
0.5 <= plane.get("width", 0) <= 20 and 0.5 <= plane.get("height", 0) <= 20),
("shadeMap present", bool(seg.get("shadeMap"))),
("shadeRange present", bool(seg.get("shadeRange"))),
# R4-1 β the intrinsic model must actually engage; a silent fall back
# to the luminance heuristic (load failure, missing weights, trust
# prompt) is exactly the class of regression this gate exists for.
("shadingSource=intrinsic", meta.get("shadingSource") == "intrinsic"),
]
ok = True
for label, passed in checks:
print(f" [{'PASS' if passed else 'FAIL'}] {label}")
ok &= passed
if not ok:
print(f"\n plane: { {k: plane.get(k) for k in ('geometrySource', 'metersPerUnit', 'width', 'height')} }")
print(f" metadata: { {k: meta.get(k) for k in ('depthEnabled', 'segmenter')} }")
raise SystemExit("LIVE SMOKE FAILED β the deployed Space is not serving the expected stack")
print("\nLIVE SMOKE PASSED")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|