|
|
@@ -0,0 +1,348 @@
|
|
|
+"""Reproduce the approved model assessment without changing production files.
|
|
|
+
|
|
|
+Uses the existing SSH alias for read-only baseline inspection and disposable,
|
|
|
+resource-limited OSRM containers. Synthetic XML and candidate Lua are generated
|
|
|
+only under an owned /tmp directory. No business database or credentials are read.
|
|
|
+"""
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+from pathlib import Path
|
|
|
+import subprocess
|
|
|
+import sys
|
|
|
+import urllib.request
|
|
|
+
|
|
|
+VALHALLA_SHA = "34639a33510eb0764da77650e91f6efbb70e8daa"
|
|
|
+MAP_REPO = Path("D:/QtwCode/foodie/Foodie_HCM_Map")
|
|
|
+OUTPUT = Path(__file__).with_name("model-probe-results.json")
|
|
|
+
|
|
|
+
|
|
|
+def remote_probe():
|
|
|
+ """This function executes on the SSH host, with the standard library only."""
|
|
|
+ import datetime
|
|
|
+ import math
|
|
|
+ import tempfile
|
|
|
+ import time
|
|
|
+ import urllib.error
|
|
|
+ import urllib.parse
|
|
|
+ import xml.etree.ElementTree as ET
|
|
|
+
|
|
|
+ image = "ghcr.io/project-osrm/osrm-backend:v5.27.1"
|
|
|
+ release = Path("/opt/foodie-map-tw-20260909")
|
|
|
+ production = ["foodie-map-tw-20260909-api-1", "foodie-map-tw-20260909-osrm-1",
|
|
|
+ "foodie-map-tw-20260909-osrm-motorcycle-1"]
|
|
|
+
|
|
|
+ def command(args):
|
|
|
+ completed = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=90)
|
|
|
+ if completed.returncode:
|
|
|
+ raise RuntimeError("Command failed: " + " ".join(args) + "\n" + completed.stdout.decode(errors="replace")[-4000:])
|
|
|
+ return completed.stdout
|
|
|
+
|
|
|
+ def fingerprints():
|
|
|
+ files = ["map_api/settings.py", "map_api/routing.py", "docker/osrm/profiles/motorcycle.lua"]
|
|
|
+ hashes = {p: hashlib.sha256((release / p).read_bytes()).hexdigest() for p in files}
|
|
|
+ for p in ("settings.py", "routing.py"):
|
|
|
+ data = command(["docker", "exec", production[0], "cat", "/app/" + p])
|
|
|
+ hashes["container:/app/" + p] = hashlib.sha256(data).hexdigest()
|
|
|
+ hashes["container:car.lua"] = hashlib.sha256(command(
|
|
|
+ ["docker", "exec", production[2], "cat", "/opt/car.lua"])).hexdigest()
|
|
|
+ hashes["built_profile"] = command(["docker", "exec", production[2], "cat",
|
|
|
+ "/data/profile.sha256"]).decode().strip()
|
|
|
+ hashes["pbf"] = command(["docker", "exec", production[2], "cat",
|
|
|
+ "/data/build.sha256"]).decode().strip()
|
|
|
+ return hashes
|
|
|
+
|
|
|
+ def read_url(url):
|
|
|
+ with urllib.request.urlopen(url, timeout=8) as response:
|
|
|
+ return json.load(response)
|
|
|
+
|
|
|
+ result = {"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
|
+ "image": image, "release": str(release), "before": fingerprints(),
|
|
|
+ "cases": [], "production_probes": {}, "errors": []}
|
|
|
+ baseline = (release / "docker/osrm/profiles/motorcycle.lua").read_text()
|
|
|
+ car_lua = command(["docker", "exec", production[2], "cat", "/opt/car.lua"])
|
|
|
+ result["combined_profile_matches_graph"] = hashlib.sha256(
|
|
|
+ car_lua + baseline.encode()).hexdigest() == result["before"]["built_profile"]
|
|
|
+ for name in ("config", "health/ready"):
|
|
|
+ result["production_probes"][name] = read_url("http://127.0.0.1:8081/api/" + name)
|
|
|
+ points = "pickup_lat=25.0330&pickup_lng=121.5654&drop_lat=25.0375&drop_lng=121.5637"
|
|
|
+ for profile in ("car", "motorcycle"):
|
|
|
+ for endpoint, params in (("route", points),
|
|
|
+ ("route/multi", "coords=25.0330,121.5654;25.0375,121.5637")):
|
|
|
+ route = read_url("http://127.0.0.1:8081/api/" + endpoint + "?" + params + "&profile=" + profile)
|
|
|
+ result["production_probes"][endpoint + ":" + profile] = {
|
|
|
+ k: route.get(k) for k in ("profile", "distance_meters", "duration_seconds", "traffic_aware")}
|
|
|
+
|
|
|
+ # Probe chosen by this assessment: keep access restrictions; penalize gravel
|
|
|
+ # through rate (not speed), and add a signal weight (not duration). Neither
|
|
|
+ # number is a copied Valhalla constant or an approved production calibration.
|
|
|
+ candidate = baseline.replace(" car.process_way(profile, proxy, result, relations)", """
|
|
|
+ car.process_way(profile, proxy, result, relations)
|
|
|
+ if way:get_value_by_key('surface') == 'gravel' then
|
|
|
+ if result.forward_rate and result.forward_rate > 0 then
|
|
|
+ result.forward_rate = result.forward_rate * 0.2
|
|
|
+ end
|
|
|
+ if result.backward_rate and result.backward_rate > 0 then
|
|
|
+ result.backward_rate = result.backward_rate * 0.2
|
|
|
+ end
|
|
|
+ end""").replace("return {setup=setup, process_way=process_way, process_node=car.process_node, process_turn=car.process_turn}", """
|
|
|
+local function process_turn(profile, turn)
|
|
|
+ car.process_turn(profile, turn)
|
|
|
+ if turn.has_traffic_light then turn.weight = turn.weight + 8 end
|
|
|
+ if math.abs(turn.angle) > 60 then turn.weight = turn.weight + 3 end
|
|
|
+end
|
|
|
+return {setup=setup, process_way=process_way, process_node=car.process_node, process_turn=process_turn}
|
|
|
+""")
|
|
|
+ if candidate == baseline or "turn.weight = turn.weight + 8" not in candidate:
|
|
|
+ raise RuntimeError("Unexpected baseline shape; no automatic production adaptation.")
|
|
|
+ result["candidate_sha256"] = hashlib.sha256(candidate.encode()).hexdigest()
|
|
|
+
|
|
|
+ root = ET.Element("osm", version="0.6", generator="foodie-isolated-model-assessment")
|
|
|
+ counter = 0
|
|
|
+ samples = {}
|
|
|
+
|
|
|
+ def node(lat, lon, tags=None):
|
|
|
+ nonlocal counter
|
|
|
+ counter += 1
|
|
|
+ element = ET.SubElement(root, "node", id=str(counter), lat=str(lat), lon=str(lon), version="1")
|
|
|
+ for key, value in (tags or {}).items():
|
|
|
+ ET.SubElement(element, "tag", k=key, v=value)
|
|
|
+ return counter
|
|
|
+
|
|
|
+ def way(ids, tags):
|
|
|
+ nonlocal counter
|
|
|
+ counter += 1
|
|
|
+ element = ET.SubElement(root, "way", id=str(counter), version="1")
|
|
|
+ for identifier in ids:
|
|
|
+ ET.SubElement(element, "nd", ref=str(identifier))
|
|
|
+ for key, value in tags.items():
|
|
|
+ ET.SubElement(element, "tag", k=key, v=value)
|
|
|
+ return counter
|
|
|
+
|
|
|
+ # Independent diamonds ensure a prohibited short road has a real legal
|
|
|
+ # detour. Inputs use exact shared junctions, avoiding arbitrary snap choices.
|
|
|
+ def diamond(name, tags, width=0.004, signal=False, detour=True):
|
|
|
+ lat, lon = 25.1 + len(samples) * 0.01, 121.5
|
|
|
+ a, b = node(lat, lon), node(lat + 0.002, lon)
|
|
|
+ mid = node(lat + 0.001, lon, {"highway": "traffic_signals"} if signal else {})
|
|
|
+ way([a, mid, b], {"highway": "residential", "name": name, **tags})
|
|
|
+ if detour:
|
|
|
+ c, d = node(lat, lon + width), node(lat + 0.002, lon + width)
|
|
|
+ way([a, c, d, b], {"highway": "residential", "name": name + "_detour"})
|
|
|
+ if signal:
|
|
|
+ branch = node(lat + 0.001, lon - 0.0003)
|
|
|
+ way([mid, branch], {"highway": "residential", "name": "signal_branch"})
|
|
|
+ samples[name] = [(lon, lat), (lon, lat + 0.002)]
|
|
|
+
|
|
|
+ diamond("motorcycle_no", {"motorcycle": "no"})
|
|
|
+ diamond("motorcar_no", {"motorcar": "no", "motorcycle": "yes"})
|
|
|
+ for road in ("motorway", "motorway_link", "trunk"):
|
|
|
+ diamond(road, {"highway": road})
|
|
|
+ diamond("trunk_permitted", {"highway": "trunk", "motorcycle": "yes"})
|
|
|
+ diamond("motorroad", {"motorroad": "yes"})
|
|
|
+ diamond("motorroad_permitted", {"motorroad": "yes", "motorcycle": "yes"})
|
|
|
+ diamond("oneway", {"oneway": "yes"})
|
|
|
+ diamond("oneway_override", {"oneway": "yes", "oneway:motorcycle": "no"})
|
|
|
+ diamond("speed_override", {"maxspeed:forward": "90", "maxspeed:motorcycle": "20"}, detour=False)
|
|
|
+ diamond("gravel_choice", {"highway": "primary", "surface": "gravel"}, width=0.001)
|
|
|
+ diamond("gravel_forced", {"highway": "primary", "surface": "gravel"}, detour=False)
|
|
|
+ diamond("signals", {}, signal=True, detour=False)
|
|
|
+ diamond("conditional_access", {"motorcycle:conditional": "no @ (00:00-24:00)"})
|
|
|
+
|
|
|
+ # Turn relation distinguishes real path exclusion from simply assigning a
|
|
|
+ # higher turn penalty. No two-stage-turn claim is made by this fixture.
|
|
|
+ def turn_case(name, restriction_key):
|
|
|
+ nonlocal counter
|
|
|
+ lat, lon = 25.1 + len(samples) * 0.01, 121.5
|
|
|
+ a, via, b = node(lat, lon), node(lat + 0.001, lon), node(lat + 0.001, lon + 0.001)
|
|
|
+ c, d = node(lat + 0.003, lon), node(lat + 0.003, lon + 0.001)
|
|
|
+ from_way = way([a, via], {"highway": "residential"})
|
|
|
+ to_way = way([via, b], {"highway": "residential"})
|
|
|
+ way([via, c, d, b], {"highway": "residential"})
|
|
|
+ if restriction_key:
|
|
|
+ counter += 1
|
|
|
+ relation = ET.SubElement(root, "relation", id=str(counter), version="1")
|
|
|
+ for kind, ref, role in (("way", from_way, "from"), ("node", via, "via"), ("way", to_way, "to")):
|
|
|
+ ET.SubElement(relation, "member", type=kind, ref=str(ref), role=role)
|
|
|
+ ET.SubElement(relation, "tag", k="type", v="restriction")
|
|
|
+ ET.SubElement(relation, "tag", k=restriction_key, v="no_right_turn")
|
|
|
+ samples[name] = [(lon, lat + 0.0001), (lon + 0.0009, lat + 0.001)]
|
|
|
+
|
|
|
+ turn_case("motorcycle_turn", "restriction:motorcycle")
|
|
|
+ turn_case("generic_turn", "restriction")
|
|
|
+ turn_case("unrestricted_turn", None)
|
|
|
+ # libosmium expects ordered entity types even though generated scenarios
|
|
|
+ # were appended independently. Keep nodes before ways before relations.
|
|
|
+ entities = sorted(list(root), key=lambda e: ({"node": 0, "way": 1, "relation": 2}[e.tag], int(e.attrib["id"])))
|
|
|
+ root[:] = entities
|
|
|
+ fixture = ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
|
+ samples["offroad"] = [samples["motorcycle_no"][0], (121.7, 25.7)]
|
|
|
+ samples["disconnected"] = [samples["motorcycle_no"][0], samples["trunk_permitted"][1]]
|
|
|
+ result["fixture_sha256"] = hashlib.sha256(fixture).hexdigest()
|
|
|
+ containers = []
|
|
|
+ urls = {}
|
|
|
+
|
|
|
+ def progress(message):
|
|
|
+ print(message, file=sys.stderr, flush=True)
|
|
|
+
|
|
|
+ def route(profile, case, reverse=False):
|
|
|
+ coords = samples[case][::-1] if reverse else samples[case]
|
|
|
+ coordinates = ";".join(f"{lon},{lat}" for lon, lat in coords)
|
|
|
+ url = urls[profile] + "/route/v1/driving/" + coordinates + "?overview=full&geometries=geojson&radiuses=10;10"
|
|
|
+ try:
|
|
|
+ data = read_url(url)
|
|
|
+ except urllib.error.HTTPError as error:
|
|
|
+ data = json.load(error)
|
|
|
+ data["test_profile"] = profile
|
|
|
+ return data
|
|
|
+
|
|
|
+ def check(name, condition, observations, kind="assertion"):
|
|
|
+ row = {"name": name, "kind": kind, "passed": bool(condition), "observations": observations}
|
|
|
+ result["cases"].append(row)
|
|
|
+ progress(f"{name}: {'PASS' if condition else 'FAIL'}")
|
|
|
+
|
|
|
+ def metric(data):
|
|
|
+ if data.get("code") != "Ok":
|
|
|
+ return {"code": data.get("code")}
|
|
|
+ return {k: data["routes"][0][k] for k in ("distance", "duration", "weight")}
|
|
|
+
|
|
|
+ def distance(data):
|
|
|
+ return data["routes"][0]["distance"] if data.get("code") == "Ok" else math.inf
|
|
|
+
|
|
|
+ try:
|
|
|
+ with tempfile.TemporaryDirectory(prefix="foodie-model-eval-", dir="/tmp") as temp:
|
|
|
+ workspace = Path(temp)
|
|
|
+ result["temporary_workspace"] = str(workspace)
|
|
|
+ for profile, lua in (("car", None), ("baseline", baseline), ("candidate", candidate)):
|
|
|
+ folder = workspace / profile
|
|
|
+ folder.mkdir()
|
|
|
+ (folder / "fixture.osm").write_bytes(fixture)
|
|
|
+ if lua:
|
|
|
+ (folder / "model.lua").write_text(lua)
|
|
|
+ profile_path = "/data/model.lua" if lua else "/opt/car.lua"
|
|
|
+ base_args = ["docker", "run", "--rm", "--cpus=0.5", "--memory=256m",
|
|
|
+ "-v", f"{folder}:/data", "--entrypoint", "sh", image]
|
|
|
+ progress("Building isolated " + profile)
|
|
|
+ build = "osrm-extract --threads 1 -p " + profile_path + " /data/fixture.osm && osrm-partition --threads 1 /data/fixture.osrm && osrm-customize --threads 1 /data/fixture.osrm"
|
|
|
+ command(base_args[:3] + ["--network", "none"] + base_args[3:] + ["-c", build])
|
|
|
+ identifier = command(["docker", "run", "-d", "--rm", "--cpus=0.5", "--memory=256m",
|
|
|
+ "-p", "127.0.0.1::5000", "-v", f"{folder}:/data:ro", image,
|
|
|
+ "osrm-routed", "--algorithm", "mld", "--threads", "1", "/data/fixture.osrm"]).decode().strip()
|
|
|
+ containers.append(identifier)
|
|
|
+ info = json.loads(command(["docker", "inspect", identifier]))[0]
|
|
|
+ port = info["NetworkSettings"]["Ports"]["5000/tcp"][0]["HostPort"]
|
|
|
+ urls[profile] = "http://127.0.0.1:" + port
|
|
|
+ for _ in range(30):
|
|
|
+ try:
|
|
|
+ read_url(urls[profile] + "/nearest/v1/driving/121.5,25.1")
|
|
|
+ break
|
|
|
+ except (urllib.error.URLError, TimeoutError):
|
|
|
+ time.sleep(0.2)
|
|
|
+ else:
|
|
|
+ raise RuntimeError("Isolated engine did not start")
|
|
|
+
|
|
|
+ for case, blocked in (("motorcycle_no", True), ("motorcar_no", False),
|
|
|
+ ("motorway", True), ("motorway_link", True), ("trunk", True),
|
|
|
+ ("trunk_permitted", False), ("motorroad", True),
|
|
|
+ ("motorroad_permitted", False)):
|
|
|
+ for profile in ("baseline", "candidate"):
|
|
|
+ data = route(profile, case)
|
|
|
+ ok = data.get("code") == "Ok" and (distance(data) > 700 if blocked else distance(data) < 300)
|
|
|
+ check(profile + ":" + case, ok, metric(data))
|
|
|
+ car, moto = route("car", "motorcycle_no"), route("baseline", "motorcycle_no")
|
|
|
+ check("car_vs_motorcycle_access", distance(car) < 300 < distance(moto),
|
|
|
+ {"car": metric(car), "motorcycle": metric(moto)})
|
|
|
+ for profile in ("baseline", "candidate"):
|
|
|
+ forward, backward = route(profile, "oneway"), route(profile, "oneway", True)
|
|
|
+ check(profile + ":oneway", distance(forward) < 300 and distance(backward) > 700,
|
|
|
+ {"forward": metric(forward), "reverse": metric(backward)})
|
|
|
+ override = route(profile, "oneway_override", True)
|
|
|
+ check(profile + ":oneway_motorcycle_override", distance(override) < 300, metric(override))
|
|
|
+ for turn in ("motorcycle_turn", "generic_turn"):
|
|
|
+ data = route(profile, turn)
|
|
|
+ check(profile + ":" + turn, distance(data) > 500 and data.get("code") == "Ok", metric(data))
|
|
|
+ speed_car, speed_moto = route("car", "speed_override"), route("baseline", "speed_override")
|
|
|
+ check("motorcycle_speed_limit", metric(speed_moto).get("duration", 0) > metric(speed_car).get("duration", 0),
|
|
|
+ {"car": metric(speed_car), "motorcycle": metric(speed_moto)})
|
|
|
+ for case in ("gravel_forced", "signals", "unrestricted_turn"):
|
|
|
+ base, proposed = route("baseline", case), route("candidate", case)
|
|
|
+ base_metric, proposed_metric = metric(base), metric(proposed)
|
|
|
+ ok = (base.get("code") == proposed.get("code") == "Ok" and
|
|
|
+ abs(base_metric["duration"] - proposed_metric["duration"]) < 0.2 and
|
|
|
+ proposed_metric["weight"] > base_metric["weight"])
|
|
|
+ check(case + ":weight_not_eta", ok, {"baseline": base_metric, "candidate": proposed_metric})
|
|
|
+ base, proposed = route("baseline", "gravel_choice"), route("candidate", "gravel_choice")
|
|
|
+ check("gravel_preference_changes_path", distance(base) < 300 and distance(proposed) > 350,
|
|
|
+ {"baseline": metric(base), "candidate": metric(proposed)})
|
|
|
+ for profile in ("baseline", "candidate"):
|
|
|
+ for case, expected in (("offroad", "NoSegment"), ("disconnected", "NoRoute")):
|
|
|
+ data = route(profile, case)
|
|
|
+ check(profile + ":" + case, data.get("code") == expected, metric(data))
|
|
|
+ conditional = route("baseline", "conditional_access")
|
|
|
+ # A diagnostic, not a hidden assertion that unsupported time rules work.
|
|
|
+ check("conditional_access_not_enforced", distance(conditional) < 300, metric(conditional), "limitation_probe")
|
|
|
+ repeated = [*samples["motorcycle_no"], samples["motorcycle_no"][0]]
|
|
|
+ coords = ";".join(f"{lon},{lat}" for lon, lat in repeated)
|
|
|
+ multi = read_url(urls["baseline"] + "/route/v1/driving/" + coords + "?overview=false")
|
|
|
+ check("ordered_three_stop_route", multi.get("code") == "Ok" and len(multi["routes"][0]["legs"]) == 2,
|
|
|
+ {"legs": len(multi.get("routes", [{}])[0].get("legs", [])), **metric(multi)})
|
|
|
+ except Exception as error:
|
|
|
+ result["errors"].append(str(error))
|
|
|
+ finally:
|
|
|
+ for identifier in containers:
|
|
|
+ # Only identifiers created by this run are stopped; no Compose
|
|
|
+ # lifecycle command can accidentally target a production service.
|
|
|
+ subprocess.run(["docker", "stop", "--time", "1", identifier], capture_output=True, timeout=15)
|
|
|
+ result["after"] = fingerprints()
|
|
|
+ result["production_files_unchanged"] = result["before"] == result["after"]
|
|
|
+ result["production_containers_running"] = all(
|
|
|
+ command(["docker", "inspect", "--format", "{{.State.Running}}", name]).decode().strip() == "true"
|
|
|
+ for name in production)
|
|
|
+ result["temporary_containers_removed"] = all(
|
|
|
+ subprocess.run(["docker", "inspect", identifier], capture_output=True).returncode != 0
|
|
|
+ for identifier in containers)
|
|
|
+ result["temporary_workspace_removed"] = not Path(result.get("temporary_workspace", "/nonexistent")).exists()
|
|
|
+ print(json.dumps(result, ensure_ascii=False))
|
|
|
+
|
|
|
+
|
|
|
+def main():
|
|
|
+ import inspect
|
|
|
+ sources = {}
|
|
|
+ for path in ("src/sif/motorcyclecost.cc", "src/sif/motorscootercost.cc", "LICENSE.md", "COPYING"):
|
|
|
+ url = f"https://raw.githubusercontent.com/valhalla/valhalla/{VALHALLA_SHA}/{path}"
|
|
|
+ with urllib.request.urlopen(url, timeout=20) as response:
|
|
|
+ data = response.read()
|
|
|
+ sources[path] = {"url": url, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
|
|
|
+ refs = {}
|
|
|
+ for ref in ("HEAD", "origin/taiwan-map-profiles"):
|
|
|
+ refs[ref] = subprocess.check_output(["git", "rev-parse", ref], cwd=MAP_REPO).decode().strip()
|
|
|
+ lua = subprocess.check_output(["git", "show", "origin/taiwan-map-profiles:docker/osrm/profiles/motorcycle.lua"], cwd=MAP_REPO)
|
|
|
+ branch_files = {}
|
|
|
+ for ref in refs:
|
|
|
+ branch_files[ref] = {}
|
|
|
+ for path in ("map_api/settings.py", "map_api/routing.py", "docker/osrm/profiles/motorcycle.lua"):
|
|
|
+ snapshot = subprocess.run(["git", "show", ref + ":" + path], cwd=MAP_REPO,
|
|
|
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
|
+ branch_files[ref][path] = hashlib.sha256(snapshot.stdout).hexdigest() if snapshot.returncode == 0 else None
|
|
|
+ references = {"valhalla_commit": VALHALLA_SHA, "sources": sources, "map_refs": refs, "branch_files": branch_files,
|
|
|
+ "branch_lua_sha256": hashlib.sha256(lua).hexdigest()}
|
|
|
+ source = "import hashlib,json,subprocess,sys,urllib.request\nfrom pathlib import Path\n" + inspect.getsource(remote_probe) + "\nremote_probe()\n"
|
|
|
+ process = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "tokyo-map", "python3 -"],
|
|
|
+ input=source, text=True, encoding="utf-8", stdout=subprocess.PIPE, timeout=360)
|
|
|
+ if process.returncode:
|
|
|
+ raise RuntimeError("Remote assessment failed before producing a complete result")
|
|
|
+ result = json.loads(process.stdout)
|
|
|
+ result["references"] = references
|
|
|
+ result["branch_lua_matches_deployment"] = references["branch_lua_sha256"] == result["before"]["docker/osrm/profiles/motorcycle.lua"]
|
|
|
+ # Generated machine evidence, not a manually edited source/configuration.
|
|
|
+ OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
+ failures = [row["name"] for row in result["cases"] if not row["passed"]]
|
|
|
+ print(json.dumps({"cases": len(result["cases"]), "failures": failures, "errors": result["errors"],
|
|
|
+ "files_unchanged": result["production_files_unchanged"], "evidence": str(OUTPUT)}, ensure_ascii=False))
|
|
|
+ if failures or result["errors"] or not all(result[key] for key in
|
|
|
+ ("combined_profile_matches_graph", "branch_lua_matches_deployment", "production_files_unchanged",
|
|
|
+ "production_containers_running", "temporary_containers_removed", "temporary_workspace_removed")):
|
|
|
+ sys.exit(1)
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|