Spaces:
Running on RTX PRO 6000
Running on RTX PRO 6000
Download pipeline/bake.py from nvidia/simready-central: direct link, hf CLI and curl.
- Browser
- Download file 47 kB
-
https://huggingface.co/spaces/nvidia/simready-central/resolve/main/pipeline/bake.py
- Command line
-
hf download hf://spaces/nvidia/simready-central/pipeline/bake.py
-
curl -L -o bake.py https://huggingface.co/spaces/nvidia/simready-central/resolve/main/pipeline/bake.py
47 kB
| """Stage 4 — build (and optionally push) images with the data pre-loaded. | |
| Staging happens here, on the host, so the Dockerfiles stay simple: | |
| * settings + mappings are merged into one `<index>.create.json` per index; | |
| the dump's own `index.knn.derived_source` (a spelling OpenSearch rejects on | |
| create) is dropped and every k-NN index is created with derived source off | |
| (keep_derived_source_off). | |
| * the elasticdump data file is converted to `_bulk` chunks (to_bulk.py), | |
| since the OpenSearch image has no Node to run elasticdump with. | |
| """ | |
| from __future__ import annotations | |
| import gzip | |
| import json | |
| import os | |
| import shutil | |
| import subprocess | |
| from pathlib import Path | |
| from typing import Dict, List, Optional | |
| from . import hf_addressing, openapi_spec | |
| from .artifact_store import space_artifact_secrets | |
| from .config import ( | |
| BUILD_DIR, | |
| REPO_ROOT, | |
| Config, | |
| SIGLIP2_NVCF_ENDPOINT_DEFAULT, | |
| SIGLIP2_NVCF_FUNCTION_ID_DEFAULT, | |
| TRITONSERVER_BASE_IMAGE_DEFAULT, | |
| ) | |
| from .enumerate_assets import load_manifest, save_manifest | |
| from .export import NEO4J_ARTIFACTS, OS_ARTIFACTS | |
| from .to_bulk import convert | |
| BAKE_DIR = BUILD_DIR / "bake" | |
| DOCKER_DIR = REPO_ROOT / "docker" | |
| def _run(cmd: List[str], timeout: int = 900, **kwargs) -> subprocess.CompletedProcess: | |
| print(" $ " + " ".join(cmd[:8]) + (" ..." if len(cmd) > 8 else "")) | |
| return subprocess.run(cmd, check=True, timeout=timeout, **kwargs) | |
| def _read_json_gz(path: Path) -> Dict: | |
| """Load a gzipped elasticdump artifact, undoing double JSON encoding. | |
| elasticdump is inconsistent about this: `--type=settings` writes the body as | |
| a JSON *string* containing JSON, while `--type=mapping` writes a plain | |
| object. Upstream loader.sh normalises both with a `try fromjson catch .` | |
| step; this is the same defence, applied to whichever form we get. | |
| """ | |
| with gzip.open(path, "rt") as fh: | |
| doc = json.load(fh) | |
| for _ in range(3): | |
| if not isinstance(doc, str): | |
| break | |
| try: | |
| doc = json.loads(doc) | |
| except json.JSONDecodeError: | |
| break | |
| if not isinstance(doc, dict): | |
| raise SystemExit(f"{path.name}: expected a JSON object, got {type(doc).__name__}") | |
| return doc | |
| def _unwrap(doc: Dict, key: str) -> Dict: | |
| """elasticdump writes {"<index>": {"settings": ...}}; loader.sh also accepts | |
| the bare {"settings": ...} form. Handle both, like loader.sh does.""" | |
| if key in doc: | |
| return doc[key] | |
| if len(doc) == 1: | |
| inner = next(iter(doc.values())) | |
| if isinstance(inner, dict) and key in inner: | |
| return inner[key] | |
| raise SystemExit(f"could not find {key!r} in dump: keys={list(doc)[:5]}") | |
| def build_create_body(settings_path: Path, mapping_path: Path, | |
| number_of_shards: Optional[int] = None) -> Dict: | |
| """`number_of_shards`, when given, replaces the dumped value: it is fixed | |
| at index creation, so this is the only point a run indexed with another | |
| count (e.g. upstream's default 20) can be reloaded with fewer shards.""" | |
| settings = _unwrap(_read_json_gz(settings_path), "settings") | |
| mappings = _unwrap(_read_json_gz(mapping_path), "mappings") | |
| index_settings = settings.get("index") | |
| if isinstance(index_settings, dict): | |
| # Present in the dump, rejected on create; keep_derived_source_off below | |
| # sets the value. The dump spells it as a literal dotted key mapping to an object: | |
| # "knn.derived_source": {"enabled": "false"} | |
| # but tolerate the nested spelling as well. | |
| index_settings.pop("knn.derived_source", None) | |
| index_settings.pop("knn.derived_source.enabled", None) | |
| if isinstance(index_settings.get("knn"), dict): | |
| index_settings["knn"].pop("derived_source", None) | |
| # Cluster-assigned values that cannot be supplied on create. | |
| for key in ("uuid", "creation_date", "creation_date_string", "provided_name", | |
| "version", "resize", "routing", "history"): | |
| index_settings.pop(key, None) | |
| if number_of_shards is not None: | |
| settings.setdefault("index", {})["number_of_shards"] = str(number_of_shards) | |
| body = {"settings": settings, "mappings": mappings} | |
| keep_derived_source_off(body) | |
| return body | |
| def keep_derived_source_off(body: Dict) -> None: | |
| """Create a k-NN index with `index.knn.derived_source.enabled: false`, as usdsearch's workers do (and its loader.sh | |
| since usdsearch !307). With it on (the OpenSearch 3.x default), k-NN before 3.10 can give a document with 150 or | |
| more nested docs another document's nested vectors on read, and a partial update or an export then stores them | |
| (opensearch-project/k-NN#3623; run 20261012-9d9f42a: one vector on 1,399 of 1,404 documents). Every restore uses | |
| this, the Space's seed included. RESTORE_KNN_DERIVED_SOURCE=on leaves the 3.x default.""" | |
| if os.environ.get("RESTORE_KNN_DERIVED_SOURCE", "").strip().lower() in ("on", "true", "1"): | |
| return | |
| index_settings = body.setdefault("settings", {}).setdefault("index", {}) | |
| if str(index_settings.get("knn", "")).lower() == "true" or '"knn_vector"' in json.dumps(body.get("mappings", {})): | |
| index_settings["knn.derived_source.enabled"] = "false" | |
| print(" k-NN derived source off for this index") | |
| def stage_opensearch(cfg: Config, manifest: Dict) -> Path: | |
| seed_dir = BAKE_DIR / "opensearch" / "seed" | |
| if seed_dir.exists(): | |
| shutil.rmtree(seed_dir) | |
| seed_dir.mkdir(parents=True) | |
| artifacts = manifest.get("opensearch_artifacts") or {} | |
| if not artifacts: | |
| raise SystemExit("manifest has no opensearch_artifacts — run `export` first.") | |
| ordered: List[str] = [] | |
| for index in sorted(artifacts): | |
| print(f" staging {index}") | |
| body = build_create_body( | |
| OS_ARTIFACTS / f"{index}.settings.json.gz", | |
| OS_ARTIFACTS / f"{index}.mapping.json.gz", | |
| number_of_shards=cfg.opensearch_number_of_shards, | |
| ) | |
| (seed_dir / f"{index}.create.json").write_text(json.dumps(body)) | |
| chunks = convert( | |
| OS_ARTIFACTS / f"{index}.data.json.gz", | |
| seed_dir, | |
| index, | |
| chunk_docs=cfg.get_int("BULK_CHUNK_DOCS", 500), | |
| ) | |
| print(f" {len(chunks)} bulk chunk(s)") | |
| ordered.append(index) | |
| (seed_dir / "indexes.txt").write_text("\n".join(ordered) + "\n") | |
| context = BAKE_DIR / "opensearch" | |
| for name in ("Dockerfile.opensearch-baked", "opensearch-seed.sh", "check_bulk.py"): | |
| shutil.copy2(DOCKER_DIR / name, context / name) | |
| return context | |
| def stage_neo4j(manifest: Dict) -> Path: | |
| context = BAKE_DIR / "neo4j" | |
| dumps = context / "dumps" | |
| if dumps.exists(): | |
| shutil.rmtree(dumps) | |
| dumps.mkdir(parents=True) | |
| artifact = manifest.get("neo4j_artifact") | |
| if not artifact: | |
| raise SystemExit("manifest has no neo4j_artifact — run `export` first.") | |
| src = NEO4J_ARTIFACTS / artifact["file"] | |
| dest = dumps / "neo4j.dump" | |
| with gzip.open(src, "rb") as fh, dest.open("wb") as out: | |
| shutil.copyfileobj(fh, out) | |
| print(f" staged {dest.name} ({dest.stat().st_size:,} bytes)") | |
| for name in ("Dockerfile.neo4j-baked", "neo4j-seeded-entrypoint.sh"): | |
| shutil.copy2(DOCKER_DIR / name, context / name) | |
| return context | |
| def _image_names(cfg: Config) -> Dict[str, str]: | |
| registry = cfg.get("REGISTRY", "nvcr.io/m3sujtetvf5w/usdsearch").rstrip("/") | |
| names = { | |
| "opensearch": f"{registry}/{cfg.get('OPENSEARCH_IMAGE_NAME', 'opensearch-simready-central')}", | |
| "neo4j": f"{registry}/{cfg.get('NEO4J_IMAGE_NAME', 'neo4j-simready-central')}", | |
| } | |
| # The combined single-container MVP image (docker/Dockerfile.hf-space) | |
| # always routes siglip2 to NVCF — it has no local Triton — so it's only | |
| # buildable when the run itself is already NVCF-backed. cfg is the same | |
| # object bake/verify/push all load from pipeline.env, so this stays | |
| # consistent across those stages without threading a separate flag. | |
| if cfg.siglip2_nvcf: | |
| names["hf_space"] = f"{registry}/{cfg.get('HF_SPACE_IMAGE_NAME', 'hf-space-simready-central')}" | |
| # The GPU variant (docker/Dockerfile.hf-space-gpu, local siglip2-triton | |
| # instead of NVCF) is a separate, optional deploy target — independent | |
| # of SIGLIP2_BACKEND/siglip2_nvcf above, since it serves the same | |
| # already-indexed corpus regardless of how the indexing stack itself | |
| # computed embeddings. Off by default (HF_SPACE_BUILD_GPU_VARIANT): | |
| # existing pipelines shouldn't pay for an extra ~15 GB build/push they | |
| # didn't ask for. | |
| if cfg.hf_space_gpu_variant_enabled: | |
| names["hf_space_gpu"] = f"{registry}/{cfg.get('HF_SPACE_GPU_IMAGE_NAME', 'hf-space-simready-central-gpu')}" | |
| return names | |
| #: A repo pinned at more revisions than this gets ONE rule instead of one per | |
| #: revision (url_mapping_rules). Above every repo on the submission lists of | |
| #: 2026-10-15 (most: 19), so their rules are unchanged. | |
| COLLAPSE_REVISIONS_ABOVE = 32 | |
| #: USDSEARCH_URL_MAPPING_RULES is one environment string in the Space image. | |
| #: Linux refuses any single env or argv string over 128 KiB (MAX_ARG_STRLEN), | |
| #: so past it every process in the container fails to start; stop the bake | |
| #: well before that. | |
| URL_MAPPING_RULES_MAX_BYTES = 100_000 | |
| def url_mapping_rules(hf_repos: List[List[str]], asset_counts: Optional[List[List]] = None) -> List[Dict]: | |
| """deepsearch-api's USDSEARCH_URL_MAPPING_RULES for the HF repos a run | |
| indexed (manifest["hf_repos"]: host, repo_type, repo_id, revision): each | |
| repo's hf:// prefix -> its browsable huggingface.co file-viewer prefix. | |
| A pinned revision maps `hf://.../resolve/<sha>/` to `.../blob/<sha>/`, so | |
| the links show exactly what was indexed. `.../blob/main/`, the public form | |
| an unpinned corpus emits, stays accepted inbound as an alias (never | |
| emitted), so links handed out before the pin still resolve. Unpinned | |
| (`main`) keeps the plain `hf://.../<repo>/` -> `.../blob/main/` rule. | |
| A repo pinned at several revisions (submission lists pin each package | |
| separately) gives the alias to one of them only — inbound matching is | |
| first-match, so a second copy would never be reached anyway. It goes to | |
| the revision with the most packages (`asset_counts`, manifest | |
| ["hf_repo_asset_counts"]: [host, repo_type, repo_id, revision, n]), the | |
| one most legacy links point into; without counts, the first listed. | |
| A repo pinned at more than COLLAPSE_REVISIONS_ABOVE revisions (one upload | |
| commit per package: 657 for PalatialSim on 2026-10-15) gets one rule, | |
| `hf://.../resolve/` -> `.../blob/`, which carries every revision through | |
| unchanged, so the rules stop growing with the pins. It has no | |
| `.../blob/main/` alias: search_utils refuses a public prefix that is a | |
| prefix of another, and `.../blob/` is one of `.../blob/main/`. | |
| """ | |
| counts = {tuple(row[:4]): row[4] for row in asset_counts or []} | |
| alias_revision: Dict[tuple, str] = {} | |
| pinned: Dict[tuple, int] = {} | |
| unpinned = set() | |
| for host, repo_type, repo_id, revision in hf_repos: | |
| repo = (host, repo_type, repo_id) | |
| if not hf_addressing.looks_like_sha(revision): | |
| unpinned.add(repo) | |
| continue | |
| pinned[repo] = pinned.get(repo, 0) + 1 | |
| best = alias_revision.get(repo) | |
| if best is None or counts.get((*repo, revision), 0) > counts.get((*repo, best), 0): | |
| alias_revision[repo] = revision | |
| collapsed = {repo for repo, n in pinned.items() if n > COLLAPSE_REVISIONS_ABOVE and repo not in unpinned} | |
| emitted = set() | |
| rules = [] | |
| for host, repo_type, repo_id, revision in hf_repos: | |
| repo = (host, repo_type, repo_id) | |
| prefix = hf_addressing.REPO_TYPE_PREFIX.get(repo_type, "") | |
| web = f"https://{host}/" + "/".join(p for p in (prefix, repo_id) if p) | |
| if repo in collapsed: | |
| if repo in emitted: | |
| continue | |
| emitted.add(repo) | |
| rules.append({ | |
| "internal": hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id, | |
| revision="", is_folder=True) + "resolve/", | |
| "public": f"{web}/blob/", | |
| }) | |
| continue | |
| rule = { | |
| "internal": hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id, | |
| revision=revision, is_folder=True), | |
| "public": f"{web}/blob/{revision}/", | |
| } | |
| if alias_revision.get((host, repo_type, repo_id)) == revision: | |
| rule["aliases"] = [f"{web}/blob/main/"] | |
| rules.append(rule) | |
| return rules | |
| def hf_space_build_args(cfg: Config, manifest: Dict) -> Dict[str, str]: | |
| """The `docker/Dockerfile.hf-space` ARGs this run needs, shared between | |
| the local/CI `docker build` (bake.run, as --build-arg) and pipeline/ | |
| hf_space.py's deploy stage (as HF Space "Variables" — Hugging Face | |
| passes any Space Variable whose name matches a Dockerfile ARG through as | |
| a build-arg on its own build, same mechanism, different trigger). One | |
| dict so the two build-arg lists can't silently drift apart. | |
| """ | |
| storage_backend = manifest.get("storage_backend", "s3") | |
| args = { | |
| # No OPENSEARCH_VERSION: the Space Dockerfiles pin their own | |
| # (HF_SPACE_OPENSEARCH_VERSION) — their jar patches are tied to it. | |
| "ARTIFACT_S3_URI": cfg.require("ARTIFACT_S3_URI"), | |
| "ARTIFACT_S3_REGION": cfg.get("ARTIFACT_S3_REGION", "us-east-2"), | |
| "CORPUS_NAME": manifest["corpus_name"], | |
| "ES_NAME": manifest["es_name"], | |
| # Search never makes a live storage-backend call at serve time (the | |
| # Dockerfile bakes verification off — see its own comment), but the | |
| # backend *type* still has to match what the corpus was indexed | |
| # under: it gates a client-side URL/result code path unrelated to | |
| # verification, and getting it wrong silently returns zero search | |
| # results rather than erroring. Verified live. | |
| "STORAGE_BACKEND_TYPE": storage_backend, | |
| # Gates the Explorer UI's download feature (nginx.conf.template's | |
| # __DOWNLOAD_ENABLED__) — true only when this image actually runs a | |
| # live hf-storage-api server for it to call (storage_api backend); | |
| # see docs/hf-space-deployment.md. | |
| "DOWNLOAD_ENABLED": "true" if storage_backend == "storage_api" else "false", | |
| "SIGLIP2_NVCF_ENDPOINT": cfg.get("SIGLIP2_NVCF_ENDPOINT", SIGLIP2_NVCF_ENDPOINT_DEFAULT), | |
| "SIGLIP2_NVCF_FUNCTION_ID": cfg.get("SIGLIP2_NVCF_FUNCTION_ID", SIGLIP2_NVCF_FUNCTION_ID_DEFAULT), | |
| } | |
| # The hf:// -> https:// link rewrite (url_mapping_rules) comes from the | |
| # repos this run actually indexed, at their pinned revision, so it can't | |
| # go stale when the corpus is re-pinned. As a Space Variable it is both | |
| # the build-arg and the runtime env var. Off for an s3:// corpus. | |
| rules = url_mapping_rules(manifest.get("hf_repos") or [], manifest.get("hf_repo_asset_counts")) if storage_backend == "storage_api" else [] | |
| rules_json = json.dumps(rules, separators=(",", ":")) | |
| if len(rules_json.encode()) > URL_MAPPING_RULES_MAX_BYTES: | |
| raise ValueError( | |
| f"USDSEARCH_URL_MAPPING_RULES would be {len(rules_json.encode())} bytes ({len(rules)} rules), over " | |
| f"{URL_MAPPING_RULES_MAX_BYTES}: Linux refuses an env string past 128 KiB, so no process in the Space " | |
| f"would start. Lower COLLAPSE_REVISIONS_ABOVE or ask the submitter to pin fewer commits.") | |
| args["USDSEARCH_URL_MAPPING_MODE"] = "rules" if rules else "off" | |
| args["USDSEARCH_URL_MAPPING_RULES"] = rules_json | |
| # S3_STORAGE_* is only ever meaningful for an S3-sourced corpus, and | |
| # threading a source bucket name through for an HF-sourced one would be | |
| # actively misleading. | |
| if storage_backend != "storage_api": | |
| args["S3_STORAGE_BUCKET_NAME"] = cfg.get("S3_STORAGE_BUCKET_NAME", "") | |
| args["S3_STORAGE_REGION_NAME"] = cfg.get("S3_STORAGE_REGION_NAME", "us-west-2") | |
| return args | |
| def hf_space_gpu_build_args(cfg: Config, manifest: Dict) -> Dict[str, str]: | |
| """The `docker/Dockerfile.hf-space-gpu` ARGs this run needs — same shape | |
| as hf_space_build_args() above, minus every NVCF-only key (that | |
| Dockerfile has no SIGLIP2_NVCF_* ARGs at all: SigLIP2 comes from the | |
| local siglip2-triton process it bakes in, not NVCF), plus the bare | |
| Triton base image, overridable in case a newer NGC tritonserver tag | |
| needs picking up without a Dockerfile edit. The SigLIP2 model weights | |
| themselves are not a build-arg — they're vendored source | |
| (docker/hf-space/siglip2-model/, see scripts/extract_siglip2_model.sh), | |
| committed like every other file this Dockerfile COPYs. | |
| """ | |
| args = hf_space_build_args(cfg, manifest) | |
| args.pop("SIGLIP2_NVCF_ENDPOINT", None) | |
| args.pop("SIGLIP2_NVCF_FUNCTION_ID", None) | |
| args["TRITONSERVER_BASE_IMAGE"] = cfg.get("TRITONSERVER_BASE_IMAGE", TRITONSERVER_BASE_IMAGE_DEFAULT) | |
| return args | |
| def image_tags(cfg: Config, manifest: Dict) -> Dict[str, str]: | |
| # <corpus name>-<run_id> — in CI, run_id is itself | |
| # <branch slug>-<workflow run id>-<short commit sha> (see | |
| # nvcf-pipeline.yml's "Compute version" step), so the full tag traces | |
| # a published image back to the exact corpus, branch, CI run and commit | |
| # that built it. Locally, run_id falls back to enumerate_assets.py's | |
| # `YYYYMMDD-<corpus sha prefix>`. | |
| version = f"{manifest['corpus_name']}-{manifest['run_id']}" | |
| return {name: f"{image}:{version}" for name, image in _image_names(cfg).items()} | |
| def latest_tags(cfg: Config, manifest: Dict) -> Dict[str, str]: | |
| """`<corpus name>-latest` — updated only when `push` actually publishes.""" | |
| tag = f"{manifest['corpus_name']}-latest" | |
| return {name: f"{image}:{tag}" for name, image in _image_names(cfg).items()} | |
| def run(cfg: Config, push: bool = False) -> int: | |
| manifest = load_manifest() | |
| tags = image_tags(cfg, manifest) | |
| common = [ | |
| "--build-arg", f"RUN_ID={manifest['run_id']}", | |
| "--build-arg", f"CORPUS_SHA256={manifest['corpus_sha256']}", | |
| "--build-arg", f"USDSEARCH_GIT_SHA={manifest.get('usdsearch_git_sha', 'unknown')}", | |
| ] | |
| print("Staging OpenSearch seed ...") | |
| os_context = stage_opensearch(cfg, manifest) | |
| print(f"\nBuilding {tags['opensearch']}") | |
| _run([ | |
| "docker", "build", | |
| "-f", str(os_context / "Dockerfile.opensearch-baked"), | |
| "-t", tags["opensearch"], | |
| "--build-arg", f"OPENSEARCH_VERSION={cfg.get('OPENSEARCH_VERSION', '3.8.0')}", | |
| "--build-arg", f"MAIN_INDEX={cfg.main_index}", | |
| "--build-arg", f"DOC_COUNT={manifest.get('base_docs_indexed', 0)}", | |
| *common, str(os_context), | |
| ]) | |
| if "hf_space" in tags: | |
| print(f"\nBuilding {tags['hf_space']} (single-container MVP: opensearch+redis+deepsearch-api, " | |
| f"for Hugging Face Spaces)") | |
| # Fails fast here, before the Neo4j build, if no NVCF token is in the | |
| # environment — same fallback chain as the indexing/runtime nvcf | |
| # overlays (config.py's siglip2_nvcf_env), so a missing token is | |
| # reported the same way everywhere rather than surfacing later as an | |
| # opaque docker build failure. No AWS-credential check here: this | |
| # image no longer bakes the index in at build time (see the | |
| # Dockerfile's header) — the artifact-bucket keys are only needed at | |
| # `verify_baked`'s smoke-test `docker run`, and at the real Space's | |
| # own runtime. | |
| cfg.siglip2_nvcf_env() | |
| # Build context is REPO_ROOT here (not DOCKER_DIR, unlike the | |
| # opensearch/neo4j builds above): this Dockerfile COPYs pipeline/ in | |
| # directly (its runtime seed step needs it) rather than reusing | |
| # another already-built image's tag, since HF's own builder must be | |
| # able to build it standalone with only public base images. | |
| build_arg_flags = [ | |
| flag | |
| for key, value in hf_space_build_args(cfg, manifest).items() | |
| for flag in ("--build-arg", f"{key}={value}") | |
| ] | |
| _run([ | |
| "docker", "build", | |
| "-f", str(DOCKER_DIR / "Dockerfile.hf-space"), | |
| "-t", tags["hf_space"], | |
| *build_arg_flags, | |
| *common, str(REPO_ROOT), | |
| ]) | |
| else: | |
| print("\nSIGLIP2_BACKEND=local — skipping the HF Space image (it is NVCF-only by " | |
| "design; set SIGLIP2_BACKEND=nvcf to build it — see docker/Dockerfile.hf-space)") | |
| if "hf_space_gpu" in tags: | |
| print(f"\nBuilding {tags['hf_space_gpu']} (single-container MVP + local siglip2-triton, " | |
| f"for a GPU-tier Hugging Face Space — see docker/Dockerfile.hf-space-gpu)") | |
| # No NVCF token needed here (this variant never calls NVCF) — only | |
| # the same build-arg dict shape as the NVCF image, minus its | |
| # NVCF-only keys. Build context is REPO_ROOT, same reasoning as the | |
| # NVCF hf_space build above. | |
| build_arg_flags = [ | |
| flag | |
| for key, value in hf_space_gpu_build_args(cfg, manifest).items() | |
| for flag in ("--build-arg", f"{key}={value}") | |
| ] | |
| _run([ | |
| "docker", "build", | |
| "-f", str(DOCKER_DIR / "Dockerfile.hf-space-gpu"), | |
| "-t", tags["hf_space_gpu"], | |
| *build_arg_flags, | |
| *common, str(REPO_ROOT), | |
| ]) | |
| print("\nStaging Neo4j dump ...") | |
| neo_context = stage_neo4j(manifest) | |
| print(f"\nBuilding {tags['neo4j']}") | |
| _run([ | |
| "docker", "build", | |
| "-f", str(neo_context / "Dockerfile.neo4j-baked"), | |
| "-t", tags["neo4j"], | |
| "--build-arg", f"NEO4J_VERSION={cfg.get('NEO4J_VERSION', '5.23.0-community')}", | |
| "--build-arg", f"NODE_COUNT={manifest.get('neo4j_node_count', 0)}", | |
| "--build-arg", f"RELATIONSHIP_COUNT={manifest.get('neo4j_relationship_count', 0)}", | |
| *common, str(neo_context), | |
| ]) | |
| manifest["images"] = tags | |
| (BUILD_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") | |
| print("\nVerifying the baked data from a fresh container ...") | |
| if verify_baked(cfg, tags, manifest) != 0: | |
| return 1 | |
| if push: | |
| # Only reached after `verify` has passed; run_pipeline never pushes | |
| # before the smoke test. | |
| for name, tag in tags.items(): | |
| print(f"\nPushing {tag}") | |
| _run(["docker", "push", tag]) | |
| print("\nbuilt:") | |
| for name, tag in tags.items(): | |
| print(f" {name}: {tag}") | |
| if not push: | |
| print("\nNot pushed. `run_pipeline.py verify` runs the smoke test; push with") | |
| print("`run_pipeline.py push` once it passes.") | |
| return 0 | |
| def download_smoke(base_url: str, timeout_s: float = 180.0) -> bool: | |
| """A storage_api corpus's download route answers for a real asset: search | |
| one hit, then ask /download/asset for its manifest (sizes only, no bytes) — | |
| once as the default whole-package download (package-download, through | |
| hf-storage-api's folder listing: scope "package", the asset itself among | |
| the files) and once as scope=dependencies (forwarded to deepsearch-api's | |
| own asset + dependencies bundle). Polls, because hf-storage-api and | |
| package-download start only once the seed restored the catalog.""" | |
| import time | |
| import httpx | |
| def preview(asset_url: str, **params): | |
| resp = httpx.get(f"{base_url}/download/asset", | |
| params={"asset_url": asset_url, "manifest_only": "true", **params}, timeout=60.0) | |
| return resp.json() if resp.status_code == 200 else None | |
| deadline = time.monotonic() + timeout_s | |
| while time.monotonic() < deadline: | |
| try: | |
| hits = httpx.get(f"{base_url}/search", params={"query": "box", "limit": 1}, timeout=15.0).json() | |
| asset_url = hits[0]["url"] if hits else None | |
| if asset_url: | |
| package = preview(asset_url) | |
| deps = preview(asset_url, scope="dependencies") | |
| if (package and package.get("scope") == "package" and (package.get("file_count") or 0) > 0 | |
| and asset_url in {f["url"] for f in package["files"]} | |
| and deps and (deps.get("file_count") or 0) > 0): | |
| return True | |
| except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError, AttributeError): | |
| pass | |
| time.sleep(3) | |
| return False | |
| def public_url_smoke(base_url: str, public_prefixes: List[str], timeout_s: float = 180.0) -> tuple: | |
| """Every asset URL the Space hands out is a public huggingface.co link | |
| under this run's URL-mapping rule: v2 search `url`s, v3 hybrid | |
| `base_key`s and a download manifest's file URLs. A bare `https://` check | |
| is not enough. The stale hand-set rule emitted | |
| `https://.../blob/main/resolve/<sha>/...` (a 404), which passes it, so | |
| each URL must start with one of `public_prefixes` | |
| (url_mapping_rules()'s `public`s). Under a collapsed repo's `.../blob/` | |
| the next segment must be a commit sha, or that stale form would pass. | |
| Returns (ok, detail). Polls like download_smoke(): the seed restores the | |
| index after /health is up.""" | |
| import re | |
| import time | |
| import httpx | |
| def under(u): | |
| return any(u.startswith(p) and (not p.endswith("/blob/") or re.match(r"[0-9a-f]{40}/", u[len(p):])) | |
| for p in public_prefixes) | |
| def bad(urls): | |
| return [u for u in urls if not (isinstance(u, str) and u.startswith("https://") and under(u))] | |
| detail = "no URLs returned" | |
| deadline = time.monotonic() + timeout_s | |
| while time.monotonic() < deadline: | |
| try: | |
| v2 = httpx.get(f"{base_url}/search", params={"description": "", "limit": 25, "return_images": "false"}, | |
| timeout=30.0).json() | |
| v2 = v2 if isinstance(v2, list) else v2.get("hits", []) | |
| v3 = httpx.post(f"{base_url}/search_hybrid", json={"description": "", "limit": 25, "return_images": False}, | |
| timeout=30.0).json().get("hits", []) | |
| urls = [h.get("url") for h in v2] + [(h.get("source") or {}).get("base_key") for h in v3] | |
| if urls and urls[0]: | |
| files = httpx.get(f"{base_url}/download/asset", params={"asset_url": urls[0], "manifest_only": "true"}, | |
| timeout=60.0).json().get("files", []) | |
| urls += [f.get("url") for f in files] | |
| wrong = bad(urls) | |
| if not wrong and v2 and v3 and files: | |
| return True, f"{len(urls)} URLs, all under {', '.join(public_prefixes)}" | |
| if wrong: | |
| return False, f"{len(wrong)}/{len(urls)} URLs not under {', '.join(public_prefixes)}, e.g. {wrong[0]}" | |
| detail = f"incomplete response (v2 {len(v2)}, v3 {len(v3)}, download files {len(files)})" | |
| except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError, AttributeError) as exc: | |
| detail = f"{type(exc).__name__}: {exc}" | |
| time.sleep(3) | |
| return False, detail | |
| def api_docs_smoke(base_url: str, container: str, timeout_s: float = 180.0) -> tuple: | |
| """The Space's API docs (/openapi.json, which /docs and /redoc load) match the API it serves: | |
| - deepsearch-api's and asset-graph-service's own specs, read inside the container, have no route the docs | |
| neither document nor leave out on purpose, and still have every documented one (openapi_spec.route_problems: | |
| an API added, renamed or removed upstream needs a decision here first); | |
| - /openapi.json is pipeline/openapi_spec.py's merge of exactly those specs, not deepsearch-api's own; | |
| - every documented operation, called through nginx without parameters (GET) or with ``{}`` (POST), reaches its | |
| app: a JSON answer (a 200, or the app's own 400/422), never the Explorer's HTML, nginx's 404/405 or a 5xx. | |
| Returns (ok, detail). Polls: the openapi-spec program writes the file once both apps are up.""" | |
| import time | |
| import httpx | |
| def internal(url): | |
| out = subprocess.run(["docker", "exec", container, "curl", "-sf", url], | |
| capture_output=True, text=True, timeout=60) | |
| return json.loads(out.stdout) if out.returncode == 0 else None | |
| detail = "no spec yet" | |
| deadline = time.monotonic() + timeout_s | |
| while time.monotonic() < deadline: | |
| try: | |
| search, ags = internal(openapi_spec.SEARCH_URL), internal(openapi_spec.AGS_URL) | |
| served = httpx.get(f"{base_url}/openapi.json", timeout=30.0).json() | |
| if search and ags and served.get("info", {}).get("title") == openapi_spec.TITLE: | |
| break | |
| detail = "/openapi.json is still deepsearch-api's own spec" | |
| except (httpx.HTTPError, ValueError, subprocess.SubprocessError) as exc: | |
| detail = f"{type(exc).__name__}: {exc}" | |
| time.sleep(3) | |
| else: | |
| return False, detail | |
| problems = openapi_spec.route_problems(search, ags) | |
| if problems: | |
| return False, f"{len(problems)} route(s) the docs don't cover: " + "; ".join(problems) | |
| expected = openapi_spec.merge(search, ags) | |
| if set(served["paths"]) != set(expected["paths"]): | |
| return False, f"/openapi.json paths differ from the merge: {sorted(set(served['paths']) ^ set(expected['paths']))}" | |
| wrong = [] | |
| for path, item in served["paths"].items(): | |
| for method in item: | |
| if method not in ("get", "post"): | |
| continue | |
| try: | |
| resp = httpx.request(method.upper(), f"{base_url}{path}", json={} if method == "post" else None, | |
| timeout=60.0) | |
| ctype = resp.headers.get("content-type", "") | |
| if resp.status_code in (404, 405) or resp.status_code >= 500 or not ctype.startswith("application/json"): | |
| wrong.append(f"{method.upper()} {path} -> {resp.status_code} {ctype}") | |
| except httpx.HTTPError as exc: | |
| wrong.append(f"{method.upper()} {path} -> {type(exc).__name__}") | |
| if wrong: | |
| return False, f"{len(wrong)} documented operation(s) don't reach their app: " + "; ".join(wrong) | |
| return True, f"{len(served['paths'])} documented paths, each reaching its app" | |
| def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int: | |
| """Check the data from a fresh `docker run`, not from the build log. | |
| This is what catches the VOLUME trap: a Dockerfile that writes into a | |
| declared VOLUME builds green and ships an empty database, and only starting | |
| the built image reveals it. | |
| """ | |
| ok = True | |
| print(" opensearch ...") | |
| result = subprocess.run( | |
| ["docker", "run", "--rm", "-d", "-p", "19200:9200", tags["opensearch"]], | |
| capture_output=True, text=True, check=True, timeout=60, | |
| ) | |
| container = result.stdout.strip() | |
| try: | |
| import time | |
| import httpx | |
| expected = manifest.get("base_docs_indexed", 0) | |
| count = -1 | |
| shards_ready = False | |
| for _ in range(90): | |
| try: | |
| response = httpx.get(f"http://localhost:19200/{cfg.main_index}/_count", timeout=5.0) | |
| if response.status_code < 400: | |
| body = response.json() | |
| shards = body.get("_shards", {}) | |
| shards_ready = shards.get("successful") == shards.get("total") | |
| count = int(body["count"]) | |
| # A fresh container recovers its shards from the baked | |
| # segments over several seconds. OpenSearch answers | |
| # `_count` as soon as any shard is open, and | |
| # `allow_partial_search_results` defaults to true, so a | |
| # response with `shards_ready=False` can silently | |
| # undercount rather than error — keep polling instead of | |
| # trusting the first HTTP 200. | |
| if shards_ready and count == expected: | |
| break | |
| except httpx.HTTPError: | |
| pass | |
| time.sleep(2) | |
| print(f" {cfg.main_index}: {count} docs (expected {expected}, shards ready: {shards_ready})") | |
| if count != expected or count == 0: | |
| print(" FAIL: baked OpenSearch image does not hold the expected documents") | |
| ok = False | |
| finally: | |
| subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30) | |
| print(" neo4j ...") | |
| result = subprocess.run( | |
| ["docker", "run", "--rm", "-d", "-p", "17687:7687", tags["neo4j"]], | |
| capture_output=True, text=True, check=True, timeout=60, | |
| ) | |
| container = result.stdout.strip() | |
| try: | |
| import time | |
| from neo4j import GraphDatabase | |
| expected = manifest.get("neo4j_node_count", 0) | |
| nodes = -1 | |
| # A bounded wall-clock deadline, not a bare iteration count: the | |
| # driver is given no timeouts below by default, so a single stuck | |
| # connection attempt (e.g. the server accepting TCP but stalling | |
| # mid-handshake) can block far longer than the intended ~3 minutes | |
| # of polling — 90 iterations at an unbounded per-attempt cost was | |
| # exactly the shape of a run that hung here for 44 minutes and | |
| # blew straight through this step's own 10-minute CI timeout | |
| # without ever failing cleanly. | |
| deadline = time.monotonic() + 180 | |
| while time.monotonic() < deadline: | |
| try: | |
| driver = GraphDatabase.driver( | |
| "bolt://localhost:17687", auth=None, | |
| connection_timeout=5.0, connection_acquisition_timeout=5.0, | |
| ) | |
| with driver.session() as session: | |
| nodes = session.run("MATCH (n) RETURN count(n) AS c").single()["c"] | |
| driver.close() | |
| break | |
| except Exception: # noqa: BLE001 - the server is simply not up yet | |
| time.sleep(2) | |
| print(f" nodes: {nodes} (expected {expected})") | |
| if expected > 0 and nodes != expected: | |
| print(" FAIL: baked Neo4j image does not hold the expected graph") | |
| ok = False | |
| finally: | |
| subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30) | |
| if "hf_space" in tags: | |
| print(" hf-space (combined MVP image) ...") | |
| # The AWS keys here exercise the same runtime restore | |
| # (pipeline/hf_space_seed.py, via supervisord) a real Space does — | |
| # this smoke test is what actually proves that path works, not just | |
| # that the image builds. A longer deadline than the other images' | |
| # health checks: startup now includes a real S3 download + bulk | |
| # load, not just a process coming up. RESTORE_RUN_ID pins the restore | |
| # to this run: latest.json may name another one (`upload --no-latest`). | |
| token = cfg.siglip2_nvcf_env()["SIGLIP2_TRITON_SERVER_AUTH_TOKEN"] | |
| aws = space_artifact_secrets() # the read-only keys the Space itself gets | |
| result = subprocess.run( | |
| ["docker", "run", "--rm", "-d", "-p", "17860:7860", | |
| "-e", f"SIGLIP2_TRITON_SERVER_AUTH_TOKEN={token}", | |
| *(flag for key, value in aws.items() for flag in ("-e", f"{key}={value}")), | |
| "-e", f"RESTORE_RUN_ID={manifest['run_id']}", | |
| tags["hf_space"]], | |
| capture_output=True, text=True, check=True, timeout=60, | |
| ) | |
| container = result.stdout.strip() | |
| try: | |
| import time | |
| import httpx | |
| healthy = False | |
| deadline = time.monotonic() + 300 | |
| while time.monotonic() < deadline: | |
| try: | |
| if httpx.get("http://localhost:17860/health", timeout=5.0).status_code == 200: | |
| healthy = True | |
| break | |
| except httpx.HTTPError: | |
| pass | |
| time.sleep(2) | |
| print(f" /health responded: {healthy}") | |
| if not healthy: | |
| print(" FAIL: hf-space image never became healthy") | |
| ok = False | |
| # /health only proves deepsearch-api/OpenSearch came up — it says | |
| # nothing about neo4j/asset-graph-service, which have their own | |
| # offline-load startup path (pipeline/hf_space_seed.py | |
| # --component=neo4j) that can fail silently as far as /health is | |
| # concerned, and which routinely takes longer than deepsearch-api's | |
| # own startup (a multi-hundred-MB neo4j-admin load, run offline, | |
| # before that program even execs into `neo4j console`) — so this | |
| # has to poll, not fire once right after /health passes. Exercise | |
| # a real graph read through nginx, the same way a client actually | |
| # would: search for a real asset url, then ask | |
| # asset-graph-service for its prims. | |
| graph_ok = False | |
| if healthy: | |
| graph_deadline = time.monotonic() + 180 | |
| while time.monotonic() < graph_deadline and not graph_ok: | |
| try: | |
| search_resp = httpx.get( | |
| "http://localhost:17860/search", params={"query": "box", "limit": 1}, timeout=10.0, | |
| ) | |
| results = search_resp.json() | |
| asset_url = results[0]["url"] if results else None | |
| if asset_url: | |
| prims_resp = httpx.get( | |
| "http://localhost:17860/asset_graph/usd/prims", | |
| params={"asset_url": asset_url}, timeout=10.0, | |
| ) | |
| prims = prims_resp.json() | |
| graph_ok = prims_resp.status_code == 200 and isinstance(prims, list) and len(prims) > 0 | |
| except (httpx.HTTPError, ValueError): | |
| pass | |
| if not graph_ok: | |
| time.sleep(3) | |
| print(f" asset-graph-service responded with real prims: {graph_ok}") | |
| if not graph_ok: | |
| print(" FAIL: hf-space image's neo4j/asset-graph-service restore did not produce queryable data") | |
| ok = False | |
| # Recorded independently of `ok`/ the other images' checks — | |
| # deploy-space (pipeline/hf_space.py) gates on this specifically, | |
| # not on the separate full-stack `verify` stage's smoke_passed, | |
| # since that stage also exercises asset-graph-service and other | |
| # components the hf_space image doesn't even include. | |
| download_ok = True | |
| if healthy and manifest.get("storage_backend") == "storage_api": | |
| download_ok = download_smoke("http://localhost:17860") | |
| print(f" /download/asset package + dependencies manifests for a real asset: {download_ok}") | |
| if not download_ok: | |
| print(" FAIL: hf-space image's download route (package-download / hf-storage-api) does not answer") | |
| ok = False | |
| # Returned asset URLs are this run's public huggingface.co links | |
| # (USDSEARCH_URL_MAPPING_RULES), so a stale or missing rule can't ship. | |
| urls_ok = True | |
| rules = url_mapping_rules(manifest.get("hf_repos") or [], manifest.get("hf_repo_asset_counts")) | |
| if healthy and manifest.get("storage_backend") == "storage_api" and rules: | |
| urls_ok, detail = public_url_smoke("http://localhost:17860", [r["public"] for r in rules]) | |
| print(f" returned asset URLs are public https:// links: {urls_ok} ({detail})") | |
| if not urls_ok: | |
| print(" FAIL: hf-space image returns asset URLs outside this run's public URL mapping") | |
| ok = False | |
| # /docs and /redoc show the API the Space serves (api_docs_smoke): an API added, renamed or removed | |
| # in usdsearch fails here until pipeline/openapi_spec.py and nginx.conf.template account for it. | |
| docs_ok = False | |
| if healthy: | |
| docs_ok, detail = api_docs_smoke("http://localhost:17860", container) | |
| print(f" /openapi.json documents the served API: {docs_ok} ({detail})") | |
| if not docs_ok: | |
| print(" FAIL: hf-space image's API docs don't match its API (docs/development.md, " | |
| "\"The Space's API docs\")") | |
| ok = False | |
| manifest["hf_space_smoke_passed"] = healthy and graph_ok and download_ok and urls_ok and docs_ok | |
| save_manifest(manifest) | |
| finally: | |
| subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30) | |
| if "hf_space_gpu" in tags: | |
| print(" hf-space-gpu (local siglip2-triton variant) ...") | |
| # Unlike every other smoke test in this function, this one needs an | |
| # actual GPU on the machine running `bake` — `--gpus all` fails fast | |
| # and loudly if there isn't one, rather than hanging, which is the | |
| # right failure mode here: this is why this variant is opt-in | |
| # (HF_SPACE_BUILD_GPU_VARIANT) and why the existing nightly CI | |
| # workflow (no-GPU-runner, per docs/plans/index-regeneration- | |
| # pipeline.md §12) doesn't enable it. No NVCF token needed. AWS keys | |
| # still are — the index restore path is identical to the NVCF | |
| # variant's. | |
| aws = space_artifact_secrets() # the read-only keys the Space itself gets | |
| result = subprocess.run( | |
| ["docker", "run", "--rm", "-d", "-p", "17861:7860", "--gpus", "all", | |
| *(flag for key, value in aws.items() for flag in ("-e", f"{key}={value}")), | |
| "-e", f"RESTORE_RUN_ID={manifest['run_id']}", | |
| tags["hf_space_gpu"]], | |
| capture_output=True, text=True, check=True, timeout=60, | |
| ) | |
| container = result.stdout.strip() | |
| try: | |
| import time | |
| import httpx | |
| healthy = False | |
| # Longer deadline than the NVCF variant's: this one also has to | |
| # load ~3.6 GB of ONNX weights onto the GPU before deepsearch-api | |
| # execs uvicorn (see supervisord-gpu.conf) — not measured live | |
| # yet (see docker/Dockerfile.hf-space-gpu's header), budgeted | |
| # generously rather than guessed tight. | |
| deadline = time.monotonic() + 600 | |
| while time.monotonic() < deadline: | |
| try: | |
| if httpx.get("http://localhost:17861/health", timeout=5.0).status_code == 200: | |
| healthy = True | |
| break | |
| except httpx.HTTPError: | |
| pass | |
| time.sleep(2) | |
| print(f" /health responded: {healthy}") | |
| if not healthy: | |
| print(" FAIL: hf-space-gpu image never became healthy") | |
| ok = False | |
| embedding_ok = False | |
| if healthy: | |
| try: | |
| search_resp = httpx.get( | |
| "http://localhost:17861/search", params={"query": "box", "limit": 1}, timeout=15.0, | |
| ) | |
| results = search_resp.json() | |
| # A non-empty, non-erroring hybrid-search response through | |
| # nginx is proof the local siglip2-triton text-embedding | |
| # round trip actually worked, not just that the process | |
| # answered /health — same "exercise a real call, not just | |
| # readiness" reasoning as the NVCF variant's asset-graph | |
| # check above. | |
| embedding_ok = search_resp.status_code == 200 and isinstance(results, list) and len(results) > 0 | |
| except (httpx.HTTPError, ValueError): | |
| pass | |
| print(f" local siglip2-triton search round trip: {embedding_ok}") | |
| if not embedding_ok: | |
| print(" FAIL: hf-space-gpu image's local siglip2-triton embedding path did not " | |
| "return real results") | |
| ok = False | |
| download_ok = True | |
| if healthy and manifest.get("storage_backend") == "storage_api": | |
| download_ok = download_smoke("http://localhost:17861") | |
| print(f" /download/asset manifest for a real asset: {download_ok}") | |
| if not download_ok: | |
| print(" FAIL: hf-space-gpu image's download route (hf-storage-api) does not answer") | |
| ok = False | |
| # Returned asset URLs are this run's public huggingface.co links | |
| # (USDSEARCH_URL_MAPPING_RULES), so a stale or missing rule can't ship. | |
| urls_ok = True | |
| rules = url_mapping_rules(manifest.get("hf_repos") or [], manifest.get("hf_repo_asset_counts")) | |
| if healthy and manifest.get("storage_backend") == "storage_api" and rules: | |
| urls_ok, detail = public_url_smoke("http://localhost:17861", [r["public"] for r in rules]) | |
| print(f" returned asset URLs are public https:// links: {urls_ok} ({detail})") | |
| if not urls_ok: | |
| print(" FAIL: hf-space-gpu image returns asset URLs outside this run's public URL mapping") | |
| ok = False | |
| # /docs and /redoc show the API the Space serves (api_docs_smoke): an API added, renamed or removed | |
| # in usdsearch fails here until pipeline/openapi_spec.py and nginx.conf.template account for it. | |
| docs_ok = False | |
| if healthy: | |
| docs_ok, detail = api_docs_smoke("http://localhost:17861", container) | |
| print(f" /openapi.json documents the served API: {docs_ok} ({detail})") | |
| if not docs_ok: | |
| print(" FAIL: hf-space-gpu image's API docs don't match its API (docs/development.md, " | |
| "\"The Space's API docs\")") | |
| ok = False | |
| manifest["hf_space_gpu_smoke_passed"] = healthy and embedding_ok and download_ok and urls_ok and docs_ok | |
| save_manifest(manifest) | |
| finally: | |
| subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30) | |
| return 0 if ok else 1 | |