File size: 6,780 Bytes
76838d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
promote_bundle.py — promote a candidate model bundle to deploy/.

Steps:
  1. Validate completeness (model.onnx, names.json, postprocess_config.json, model_card.json).
  2. Require a passing gate_report.json (from quality_gate.py) unless --force.
  3. Copy to deploy/<version>/ and refresh deploy/latest/ (real directory, no symlink —
     git and HF Spaces both handle plain dirs reliably).
  4. Regenerate CHECKSUMS.sha256 over the shipped files.
  5. Append an entry to model_registry/registry.json (traceability across versions).

Usage:
  python ml/scripts/promote_bundle.py --candidate artifacts/v20260703 [--version v20260703]
"""
from __future__ import annotations

import argparse
import datetime as dt
import hashlib
import json
import re
import shutil
import sys
from pathlib import Path
from typing import List

VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")

REQUIRED_FILES = ["model.onnx", "names.json", "postprocess_config.json", "model_card.json"]
OPTIONAL_FILES = ["weight_priors.json", "gate_report.json", "calibration_temp.json", "label_map.json"]


def validate_bundle(candidate: Path) -> List[str]:
    """Return a list of problems; empty list means the bundle is shippable."""
    problems = []
    if not candidate.is_dir():
        return [f"candidate dir not found: {candidate}"]
    for name in REQUIRED_FILES:
        if not (candidate / name).exists():
            problems.append(f"missing required file: {name}")
    # names.json must parse to a non-empty list
    names_p = candidate / "names.json"
    if names_p.exists():
        try:
            names = json.loads(names_p.read_text(encoding="utf-8"))
            if not isinstance(names, (list, dict)) or not names:
                problems.append("names.json is empty or not a list/dict")
        except Exception as e:
            problems.append(f"names.json unparseable: {e}")
    return problems


def gate_passed(candidate: Path) -> bool:
    """A gate report is only valid if it passed AND belongs to exactly this
    model.onnx — a stale report from an earlier export must never promote a
    newer, never-gated model."""
    p = candidate / "gate_report.json"
    if not p.exists():
        return False
    try:
        report = json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        return False
    if not report.get("passed"):
        return False
    report_hash = report.get("model_sha256")
    if not report_hash:
        print("PROMOTE: gate_report.json has no model_sha256 — re-run quality_gate.py "
              "so the report is bound to the current model.onnx.")
        return False
    model_p = candidate / "model.onnx"
    if not model_p.exists():
        return False
    actual = hashlib.sha256(model_p.read_bytes()).hexdigest()
    if actual != report_hash:
        print("PROMOTE: gate_report.json belongs to a DIFFERENT model.onnx "
              "(stale report after re-export). Re-run quality_gate.py.")
        return False
    return True


def write_checksums(bundle_dir: Path) -> None:
    lines = []
    for f in sorted(bundle_dir.iterdir()):
        if f.name == "CHECKSUMS.sha256" or not f.is_file():
            continue
        h = hashlib.sha256(f.read_bytes()).hexdigest()
        lines.append(f"{h}  {f.name}")
    (bundle_dir / "CHECKSUMS.sha256").write_text("\n".join(lines) + "\n", encoding="utf-8")


def copy_bundle(candidate: Path, dest: Path) -> None:
    if dest.exists():
        shutil.rmtree(dest)
    dest.mkdir(parents=True)
    for name in REQUIRED_FILES + OPTIONAL_FILES:
        src = candidate / name
        if src.exists():
            shutil.copy2(src, dest / name)
    write_checksums(dest)


def update_registry(registry_path: Path, version: str, deployed_dir: Path) -> None:
    registry = {"models": []}
    if registry_path.exists():
        try:
            registry = json.loads(registry_path.read_text(encoding="utf-8"))
        except Exception:
            pass
    registry.setdefault("models", [])
    registry["models"].append({
        "model_version": version,
        "created_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
        "artifacts_dir": str(deployed_dir),
        "files": sorted(p.name for p in deployed_dir.iterdir() if p.is_file()),
        "promoted_by": "promote_bundle.py",
    })
    registry_path.parent.mkdir(parents=True, exist_ok=True)
    registry_path.write_text(json.dumps(registry, indent=2, ensure_ascii=False), encoding="utf-8")


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description="Promote a candidate bundle to deploy/.")
    ap.add_argument("--candidate", required=True, type=Path)
    ap.add_argument("--version", default=None, help="version name; defaults to the candidate dir name")
    ap.add_argument("--deploy-root", default="deploy", type=Path)
    ap.add_argument("--registry", default="model_registry/registry.json", type=Path)
    ap.add_argument("--force", action="store_true", help="promote even without a passing gate report")
    args = ap.parse_args(argv)

    candidate = args.candidate
    version = args.version or candidate.resolve().name

    # Guard rails: a bad version name must never turn the rmtree in
    # copy_bundle() against the deploy root or an unrelated directory.
    if not version or not VERSION_RE.match(version):
        print(f"PROMOTE FAIL — invalid version name {version!r} "
              "(use e.g. v20260703-1200; pass --version explicitly).")
        return 1
    if version == "latest":
        print("PROMOTE FAIL — version must not be 'latest' (reserved for the active bundle).")
        return 1
    versioned_check = (args.deploy_root / version).resolve()
    if versioned_check == args.deploy_root.resolve() or versioned_check == candidate.resolve():
        print("PROMOTE FAIL — refusing: target directory equals deploy root or the candidate itself.")
        return 1

    problems = validate_bundle(candidate)
    if problems:
        print("PROMOTE FAIL — bundle incomplete:")
        for p in problems:
            print(f"  - {p}")
        return 1

    if not gate_passed(candidate) and not args.force:
        print("PROMOTE FAIL — no passing gate_report.json in candidate. "
              "Run quality_gate.py first, or use --force (not recommended).")
        return 1

    versioned = args.deploy_root / version
    latest = args.deploy_root / "latest"
    copy_bundle(candidate, versioned)
    copy_bundle(candidate, latest)
    update_registry(args.registry, version, versioned)

    print(f"PROMOTED {candidate} -> {versioned} and {latest}")
    print("Next: commit the new bundle + registry, push, and let CI deploy the Space.")
    return 0


if __name__ == "__main__":
    sys.exit(main())