Add environment artifact: export_and_publish.py
Browse files- export_and_publish.py +232 -0
export_and_publish.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export an environment-only image and upload an explicit allowlist to a private HF repo."""
|
| 2 |
+
import argparse
|
| 3 |
+
import gzip
|
| 4 |
+
import hashlib
|
| 5 |
+
import io
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import shutil
|
| 10 |
+
import subprocess
|
| 11 |
+
import tarfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PartsWriter(io.RawIOBase):
|
| 18 |
+
def __init__(self, directory, prefix, limit=1024**3):
|
| 19 |
+
self.directory, self.prefix, self.limit = directory, prefix, limit
|
| 20 |
+
self.files = []
|
| 21 |
+
self.current = None
|
| 22 |
+
self.used = 0
|
| 23 |
+
|
| 24 |
+
def writable(self):
|
| 25 |
+
return True
|
| 26 |
+
|
| 27 |
+
def write(self, data):
|
| 28 |
+
length = len(data)
|
| 29 |
+
view = memoryview(data)
|
| 30 |
+
while view:
|
| 31 |
+
if self.current is None or self.used == self.limit:
|
| 32 |
+
if self.current:
|
| 33 |
+
self.current.close()
|
| 34 |
+
path = self.directory / f"{self.prefix}.part-{len(self.files):03d}"
|
| 35 |
+
self.current = path.open("xb")
|
| 36 |
+
self.files.append(path)
|
| 37 |
+
self.used = 0
|
| 38 |
+
take = min(len(view), self.limit - self.used)
|
| 39 |
+
self.current.write(view[:take])
|
| 40 |
+
self.used += take
|
| 41 |
+
view = view[take:]
|
| 42 |
+
return length
|
| 43 |
+
|
| 44 |
+
def close(self):
|
| 45 |
+
if self.current:
|
| 46 |
+
self.current.close()
|
| 47 |
+
super().close()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def sha256(path):
|
| 51 |
+
digest = hashlib.sha256()
|
| 52 |
+
with path.open("rb") as source:
|
| 53 |
+
for block in iter(lambda: source.read(1024 * 1024), b""):
|
| 54 |
+
digest.update(block)
|
| 55 |
+
return digest.hexdigest()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
METADATA_FILES = {
|
| 59 |
+
"Dockerfile", "smoke_test.py", "environment-inventory.json", "requirements.snapshot.txt",
|
| 60 |
+
"prepare_environment.py", "export_and_publish.py", "model_setup.md", "validation.json", "README.md",
|
| 61 |
+
}
|
| 62 |
+
PART_PATTERN = re.compile(r"codiffact-env_20261006-cu128\.tar\.gz\.part-([0-9]{3})")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def validate_release(directory, release):
|
| 66 |
+
seen, numbers = set(), []
|
| 67 |
+
for item in release["files"]:
|
| 68 |
+
name = item["file"]
|
| 69 |
+
match = PART_PATTERN.fullmatch(name)
|
| 70 |
+
assert name in METADATA_FILES or match, "Unapproved upload filename"
|
| 71 |
+
assert name not in seen, "Duplicate artifact"
|
| 72 |
+
seen.add(name)
|
| 73 |
+
path = directory / name
|
| 74 |
+
assert path.is_file() and not path.is_symlink()
|
| 75 |
+
assert path.resolve().parent == directory.resolve()
|
| 76 |
+
assert path.stat().st_size == item["bytes"] and sha256(path) == item["sha256"]
|
| 77 |
+
if match:
|
| 78 |
+
numbers.append(int(match.group(1)))
|
| 79 |
+
assert METADATA_FILES <= seen and sorted(numbers) == list(range(len(numbers))) and numbers
|
| 80 |
+
actual_parts = {p.name for p in directory.glob("*.part-*")}
|
| 81 |
+
assert actual_parts == {name for name in seen if PART_PATTERN.fullmatch(name)}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def main():
|
| 85 |
+
parser = argparse.ArgumentParser()
|
| 86 |
+
parser.add_argument("--image", required=True)
|
| 87 |
+
parser.add_argument("--repo", required=True)
|
| 88 |
+
parser.add_argument("--token-file", type=Path, required=True)
|
| 89 |
+
parser.add_argument("--context", type=Path, required=True)
|
| 90 |
+
parser.add_argument("--out", type=Path, required=True)
|
| 91 |
+
parser.add_argument("--resume-upload", action="store_true")
|
| 92 |
+
args = parser.parse_args()
|
| 93 |
+
tokens = set(re.findall(r"(?<![A-Za-z0-9_])hf_[A-Za-z0-9]+(?![A-Za-z0-9_])", args.token_file.read_text()))
|
| 94 |
+
if len(tokens) != 1:
|
| 95 |
+
raise ValueError("Expected one token in the supplied token file")
|
| 96 |
+
token = next(iter(tokens))
|
| 97 |
+
token_bytes = token.encode()
|
| 98 |
+
from huggingface_hub import HfApi
|
| 99 |
+
|
| 100 |
+
api = HfApi(token=token)
|
| 101 |
+
identity = api.whoami()
|
| 102 |
+
assert args.repo.split("/")[0] == identity["name"], "Only publish to the authenticated user's namespace"
|
| 103 |
+
root = Path(__file__).resolve().parents[2]
|
| 104 |
+
manifest_path = args.out / "environment-release.json"
|
| 105 |
+
if args.resume_upload:
|
| 106 |
+
release = json.loads(manifest_path.read_text())
|
| 107 |
+
assert release["repo"] == args.repo and release["image_tag"] == args.image
|
| 108 |
+
for item in release["files"]:
|
| 109 |
+
assert sha256(args.out / item["file"]) == item["sha256"]
|
| 110 |
+
else:
|
| 111 |
+
assert not args.out.exists(), "Use a fresh output directory or --resume-upload"
|
| 112 |
+
info = json.loads(subprocess.check_output(["docker", "image", "inspect", args.image], text=True))[0]
|
| 113 |
+
assert not any("TOKEN=" in value or "PROXY=" in value.upper() for value in info["Config"].get("Env", []))
|
| 114 |
+
inventory = json.loads((args.context / "environment-inventory.json").read_text())
|
| 115 |
+
assert not any(any(part in item["file"].split("/") for part in (".git", ".cache", "hfkey.txt")) for item in inventory["files"])
|
| 116 |
+
args.out.mkdir(parents=True)
|
| 117 |
+
validations = {}
|
| 118 |
+
for mode in ("cpu", "gpu"):
|
| 119 |
+
cmd = ["docker", "run", "--rm", "--network", "none"]
|
| 120 |
+
if mode == "gpu":
|
| 121 |
+
cmd.extend(["--gpus", "device=0"])
|
| 122 |
+
cmd.extend([args.image, "python", "/opt/codiffact/smoke_test.py"])
|
| 123 |
+
if mode == "gpu":
|
| 124 |
+
cmd.append("--gpu")
|
| 125 |
+
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
| 126 |
+
validations[mode] = json.loads(result.stdout.strip().splitlines()[-1])
|
| 127 |
+
print("OFFLINE_VALIDATION", mode, "passed", flush=True)
|
| 128 |
+
(args.out / "validation.json").write_text(json.dumps(validations, indent=2))
|
| 129 |
+
prefix = "codiffact-env_20261006-cu128.tar.gz"
|
| 130 |
+
writer = PartsWriter(args.out, prefix)
|
| 131 |
+
process = subprocess.Popen(["docker", "save", args.image], stdout=subprocess.PIPE)
|
| 132 |
+
image_secret_scan_tail = b""
|
| 133 |
+
try:
|
| 134 |
+
with gzip.GzipFile(filename="", mode="wb", fileobj=writer, compresslevel=1, mtime=0) as compressed:
|
| 135 |
+
for block in iter(lambda: process.stdout.read(4 * 1024 * 1024), b""):
|
| 136 |
+
scan = image_secret_scan_tail + block
|
| 137 |
+
assert token_bytes not in scan, "Token detected; export aborted"
|
| 138 |
+
image_secret_scan_tail = scan[-len(token_bytes):]
|
| 139 |
+
compressed.write(block)
|
| 140 |
+
assert process.wait() == 0, "docker save failed"
|
| 141 |
+
finally:
|
| 142 |
+
if process.poll() is None:
|
| 143 |
+
process.terminate()
|
| 144 |
+
process.wait()
|
| 145 |
+
writer.close()
|
| 146 |
+
files = list(writer.files)
|
| 147 |
+
for name in ("Dockerfile", "smoke_test.py", "environment-inventory.json", "requirements.snapshot.txt"):
|
| 148 |
+
destination = args.out / name
|
| 149 |
+
shutil.copy2(args.context / name, destination)
|
| 150 |
+
files.append(destination)
|
| 151 |
+
for name in ("prepare_environment.py", "export_and_publish.py"):
|
| 152 |
+
destination = args.out / name
|
| 153 |
+
shutil.copy2(Path(__file__).parent / name, destination)
|
| 154 |
+
files.append(destination)
|
| 155 |
+
shutil.copy2(root / "docs/model_setup.md", args.out / "model_setup.md")
|
| 156 |
+
files.extend([args.out / "model_setup.md", args.out / "validation.json"])
|
| 157 |
+
body = (
|
| 158 |
+
"---\ntags:\n- docker\n- environment\n- codiffact\n---\n\n"
|
| 159 |
+
"# CoDiffAct environment-only image\n\n"
|
| 160 |
+
"Private Linux/amd64 Docker archive. Contains installed runtime dependencies; no project, datasets, pretrained weights or credentials. "
|
| 161 |
+
"Each dependency retains its own license; this card does not relicense them.\n\n"
|
| 162 |
+
"Download all parts, verify SHA256SUMS, then load:\n\n```bash\n"
|
| 163 |
+
"sha256sum -c SHA256SUMS\n"
|
| 164 |
+
"cat codiffact-env_20261006-cu128.tar.gz.part-* | gzip -dc | docker load\n"
|
| 165 |
+
f"docker run --rm --network none --gpus all {args.image} python /opt/codiffact/smoke_test.py --gpu\n"
|
| 166 |
+
"```\n\n"
|
| 167 |
+
"See model_setup.md for public model sources, paths and mounts. validation.json records offline dependency, OSMesa, audio/video and CUDA smoke checks, "
|
| 168 |
+
"not full model inference/training. A compatible host NVIDIA driver and NVIDIA Container Toolkit are required.\n\n"
|
| 169 |
+
"The exact installed-prefix snapshot is embedded in the archive. requirements.snapshot.txt is an inventory, not a universal pip installation lock. "
|
| 170 |
+
"Rebuilding with prepare_environment.py requires the recorded source environment; Dockerfile alone is not a complete from-PyPI recipe.\n"
|
| 171 |
+
)
|
| 172 |
+
(args.out / "README.md").write_text(body)
|
| 173 |
+
files.append(args.out / "README.md")
|
| 174 |
+
for path in files:
|
| 175 |
+
if ".part-" not in path.name:
|
| 176 |
+
assert token_bytes not in path.read_bytes(), "Token detected in upload material"
|
| 177 |
+
release = {
|
| 178 |
+
"repo": args.repo, "private": True, "image_tag": args.image,
|
| 179 |
+
"image_id": info["Id"], "uncompressed_image_bytes": info["Size"],
|
| 180 |
+
"base_image": "nvidia/cuda@sha256:001469ea0f3dec85a1ca929aeea3b58ae369d4c11228b10aec1f642bb6ca7a6f",
|
| 181 |
+
"files": [{"file": p.name, "bytes": p.stat().st_size, "sha256": sha256(p)} for p in files],
|
| 182 |
+
"no_user_token_in_export": True,
|
| 183 |
+
}
|
| 184 |
+
manifest_path.write_text(json.dumps(release, indent=2))
|
| 185 |
+
sums = [f"{item['sha256']} {item['file']}" for item in release["files"]]
|
| 186 |
+
sums.append(f"{sha256(manifest_path)} {manifest_path.name}")
|
| 187 |
+
(args.out / "SHA256SUMS").write_text("\n".join(sums) + "\n")
|
| 188 |
+
print("EXPORT_COMPLETE", sum(p.stat().st_size for p in writer.files), "bytes", flush=True)
|
| 189 |
+
validate_release(args.out, release)
|
| 190 |
+
api.create_repo(repo_id=args.repo, repo_type="model", private=True, exist_ok=True)
|
| 191 |
+
assert api.repo_info(args.repo).private, "Refusing to upload to a public repository"
|
| 192 |
+
allowed = [item["file"] for item in release["files"]] + ["SHA256SUMS", manifest_path.name]
|
| 193 |
+
for name in ("SHA256SUMS", manifest_path.name):
|
| 194 |
+
assert not (args.out / name).is_symlink()
|
| 195 |
+
assert token_bytes not in (args.out / name).read_bytes()
|
| 196 |
+
parts = sorted(name for name in allowed if ".part-" in name)
|
| 197 |
+
metadata = sorted(set(allowed) - set(parts))
|
| 198 |
+
remote = {s.rfilename: s for s in api.repo_info(args.repo, files_metadata=True).siblings}
|
| 199 |
+
assert not ({name for name in remote if ".part-" in name} - set(parts)), "Old remote parts present; use a new version repository"
|
| 200 |
+
for name in parts:
|
| 201 |
+
previous = getattr(remote.get(name), "lfs", None)
|
| 202 |
+
assert not previous or previous.sha256 == sha256(args.out / name), "Never overwrite another image release; use a new version repository"
|
| 203 |
+
for name in parts + metadata:
|
| 204 |
+
path = args.out / name
|
| 205 |
+
expected = sha256(path)
|
| 206 |
+
existing = remote.get(name)
|
| 207 |
+
lfs = getattr(existing, "lfs", None)
|
| 208 |
+
if lfs and lfs.sha256 == expected:
|
| 209 |
+
print("ALREADY_UPLOADED", name, flush=True)
|
| 210 |
+
continue
|
| 211 |
+
print("UPLOADING", name, path.stat().st_size, flush=True)
|
| 212 |
+
api.upload_file(path_or_fileobj=str(path), path_in_repo=name, repo_id=args.repo,
|
| 213 |
+
repo_type="model", commit_message=f"Add environment artifact: {name}")
|
| 214 |
+
final = api.repo_info(args.repo, files_metadata=True)
|
| 215 |
+
remote = {s.rfilename: s for s in final.siblings}
|
| 216 |
+
for name in allowed:
|
| 217 |
+
assert name in remote, name
|
| 218 |
+
assert remote[name].size == (args.out / name).stat().st_size, name
|
| 219 |
+
lfs = getattr(remote[name], "lfs", None)
|
| 220 |
+
if lfs:
|
| 221 |
+
assert lfs.sha256 == sha256(args.out / name), name
|
| 222 |
+
receipt = {"repo": args.repo, "private": final.private, "commit": final.sha, "files": allowed, "complete": True}
|
| 223 |
+
(args.out.parent / "upload_receipt.json").write_text(json.dumps(receipt, indent=2))
|
| 224 |
+
print("HF_UPLOAD_VERIFIED", args.repo, final.sha, flush=True)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
if __name__ == "__main__":
|
| 228 |
+
try:
|
| 229 |
+
main()
|
| 230 |
+
except Exception as error:
|
| 231 |
+
print("FAILED", type(error).__name__, "See local build/validation state; secret values suppressed.", flush=True)
|
| 232 |
+
raise SystemExit(1)
|