Bladeren bron

feat: deps.lock — fetch-deps.sh verifies staged deps against a committed fingerprint lock (regen via --regen-lock / REGEN_LOCK=1)

Alexander Korolev 1 maand geleden
bovenliggende
commit
3717c3a4de

+ 5 - 1
AGENTS.md

@@ -372,7 +372,11 @@ when the label matches a recursive SHA-256 of the dir.
 `KEY=VAL`. `build.sh` is a generic driver — it forwards every pin in
 `versions.env` as a `--build-arg` to the `Dockerfile` (no hardcoded variable
 list). Add a pin to `versions.env` and it is picked up automatically.
-`locks/` holds `--require-hashes` lockfiles; `checksums/` holds verified
+`locks/` holds `--require-hashes` lockfiles + `deps.lock` (the staged
+dep-subtree fingerprints `fetch-deps.sh` verifies every build against —
+dep commits, vendored-cutlass gitlink SHAs, `triton_kernels` content hash;
+regenerate via `--regen-lock` / `--build-arg REGEN_LOCK=1` after a pin cut);
+`checksums/` holds verified
 out-of-band downloads (e.g. tiktoken encodings). See
 `docker-images/glm52-nf3-hybrid/` for the reference cu130 / aarch64 / sm_121a
 stack; `docker-images/solar-open2-nvfp4/` is the same stack minus the b12x

+ 6 - 1
docker-images/glm53-jovian-r27/Dockerfile

@@ -34,6 +34,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
 COPY locks/apt-sources.list /tmp/apt-sources-snapshot.list
 COPY locks/apt-packages.txt /tmp/apt-packages.txt
 COPY locks/python-bootstrap.txt /tmp/python-bootstrap.txt
+COPY locks/deps.lock /tmp/deps.lock
 # APT_SOURCE (versions.env):
 #   snapshot (default, historical behavior): the pinned point-in-time mirror
 #       + exact package versions — strictly reproducible, but
@@ -101,9 +102,13 @@ RUN set -eux; \
     git config --global --add safe.directory /workspace/vllm
 
 # ---- All build sub-deps, cloned at pins the r27 tree itself declares ----
+# REGEN_LOCK=1: fetch-deps.sh rewrites /tmp/deps.lock from the staged state
+# (pin cuts) instead of verifying against it — export the stage to harvest
+# the new lock, then commit it.
 FROM vllm-clone AS deps-fetched
+ARG REGEN_LOCK=""
 COPY fetch-deps.sh /tmp/fetch-deps.sh
-RUN bash /tmp/fetch-deps.sh
+RUN if [ -n "$REGEN_LOCK" ]; then bash /tmp/fetch-deps.sh --regen-lock /tmp/deps.lock; else bash /tmp/fetch-deps.sh; fi
 
 FROM deps-fetched AS vllm-builder
 WORKDIR /workspace/vllm

+ 167 - 38
docker-images/glm53-jovian-r27/fetch-deps.sh

@@ -7,11 +7,22 @@
 # grep-verified against the cloned vLLM tree — if a pin has drifted from
 # what the tree expects, the mismatch aborts with the offending pin(s).
 #
+# Lock: after staging, the script verifies the staged state against
+# $DEPS_LOCK (default /tmp/deps.lock, shipped in the image dir as
+# locks/deps.lock and COPY'd by the Dockerfile). Every build therefore
+# proves: each dep is a clean checkout at the locked commit, every vendored
+# cutlass gitlink is populated at the locked SHA (r18's empty-stub failure
+# mode), and the non-git triton_kernels tree has the locked content hash.
+# Regenerate the lock after a pin cut: `--regen-lock <out>` (or in-build:
+# --build-arg REGEN_LOCK=1). `--emit-lock <out>` writes the lock from an
+# already-staged $DEPS without re-cloning (offline lock generation).
+#
 # Runs INSIDE the build container: /workspace/vllm must already contain the
 # gated r27 tree (see Dockerfile `vllm-clone` stage). Populates /deps2.
 set -euo pipefail
 WORK=${WORK:-/workspace/vllm}
 DEPS=${DEPS:-/deps2}
+LOCK=${DEPS_LOCK:-/tmp/deps.lock}
 git config --global protocol.version 0
 
 fetch() { # repo ref dst
@@ -39,37 +50,40 @@ gate() { # file:pattern ...  — every pin must appear verbatim in the tree
 }
 
 # ---- 1. Verify our pins against the tree's own declarations ----
-gate CMakeLists.txt:nvidia/cutlass.git \
-     CMakeLists.txt:v4.4.2 \
-     deepgemm.cmake:deepseek-ai/DeepGEMM \
-     deepgemm.cmake:8b1392b978f5a03c828dd1711090d7fb50958b8a \
-     flashkda.cmake:3b225bf26bb8e218928a1fe14751cb48cf31d11b \
-     flashmla.cmake:a8f794d1251cbfd88a5011445dd5582289c727e4 \
-     fmha_sm100.cmake:087c161814d4d9c735b46c21212a09e5f8eb92fa \
-     qutlass.cmake:e74319e3405ce6d71965732880f5dc1f52371f64 \
-     tml_fa4.cmake:b206834606ed5b5f21f8eed6b0683f528ea9cf7d \
-     vllm_flash_attn.cmake:f3e1a4f74c99145c0717709860bf765de1703779 \
-     vllm_flash_attn.cmake:vllm-project/flash-attention \
-     triton_kernels.cmake:triton-lang/triton \
-     triton_kernels.cmake:v3.5.1
-
-mkdir -p "$DEPS"
+stage_pins() {
+    gate CMakeLists.txt:nvidia/cutlass.git \
+         CMakeLists.txt:v4.4.2 \
+         deepgemm.cmake:deepseek-ai/DeepGEMM \
+         deepgemm.cmake:8b1392b978f5a03c828dd1711090d7fb50958b8a \
+         flashkda.cmake:3b225bf26bb8e218928a1fe14751cb48cf31d11b \
+         flashmla.cmake:a8f794d1251cbfd88a5011445dd5582289c727e4 \
+         fmha_sm100.cmake:087c161814d4d9c735b46c21212a09e5f8eb92fa \
+         qutlass.cmake:e74319e3405ce6d71965732880f5dc1f52371f64 \
+         tml_fa4.cmake:b206834606ed5b5f21f8eed6b0683f528ea9cf7d \
+         vllm_flash_attn.cmake:f3e1a4f74c99145c0717709860bf765de1703779 \
+         vllm_flash_attn.cmake:vllm-project/flash-attention \
+         triton_kernels.cmake:triton-lang/triton \
+         triton_kernels.cmake:v3.5.1
+}
 
 # ---- 2. Clone deps at the tree's pins ----
-fetch https://github.com/nvidia/cutlass.git v4.4.2 "$DEPS/cutlass"
-fetch https://github.com/vllm-project/flash-attention.git \
-      f3e1a4f74c99145c0717709860bf765de1703779 "$DEPS/flash-attention"
-fetch https://github.com/vllm-project/MSA.git \
-      087c161814d4d9c735b46c21212a09e5f8eb92fa "$DEPS/MSA"
-fetch https://github.com/vllm-project/FlashMLA.git \
-      a8f794d1251cbfd88a5011445dd5582289c727e4 "$DEPS/FlashMLA"
-fetch https://github.com/vllm-project/FlashKDA.git \
-      3b225bf26bb8e218928a1fe14751cb48cf31d11b "$DEPS/FlashKDA"
-fetch https://github.com/IST-DASLab/qutlass.git \
-      e74319e3405ce6d71965732880f5dc1f52371f64 "$DEPS/qutlass"
-fetch https://github.com/vllm-project/tml-fa4.git \
-      b206834606ed5b5f21f8eed6b0683f528ea9cf7d "$DEPS/tml-fa4"
-fetch https://github.com/triton-lang/triton.git v3.5.1 "$DEPS/triton"
+stage_clones() {
+    mkdir -p "$DEPS"
+    fetch https://github.com/nvidia/cutlass.git v4.4.2 "$DEPS/cutlass"
+    fetch https://github.com/vllm-project/flash-attention.git \
+          f3e1a4f74c99145c0717709860bf765de1703779 "$DEPS/flash-attention"
+    fetch https://github.com/vllm-project/MSA.git \
+          087c161814d4d9c735b46c21212a09e5f8eb92fa "$DEPS/MSA"
+    fetch https://github.com/vllm-project/FlashMLA.git \
+          a8f794d1251cbfd88a5011445dd5582289c727e4 "$DEPS/FlashMLA"
+    fetch https://github.com/vllm-project/FlashKDA.git \
+          3b225bf26bb8e218928a1fe14751cb48cf31d11b "$DEPS/FlashKDA"
+    fetch https://github.com/IST-DASLab/qutlass.git \
+          e74319e3405ce6d71965732880f5dc1f52371f64 "$DEPS/qutlass"
+    fetch https://github.com/vllm-project/tml-fa4.git \
+          b206834606ed5b5f21f8eed6b0683f528ea9cf7d "$DEPS/tml-fa4"
+    fetch https://github.com/triton-lang/triton.git v3.5.1 "$DEPS/triton"
+}
 
 # ---- 3. Sub-module populations the tree's install steps require ----
 # Several deps vendor cutlass via a 160000 gitlink and their cmake install
@@ -79,7 +93,7 @@ fetch https://github.com/triton-lang/triton.git v3.5.1 "$DEPS/triton"
 # AMD-only trees (composable_kernel, aiter) are skipped explicitly — the
 # CUDA cmake path never references them. All gitlinks so far vendor
 # nvidia/cutlass; anything else aborts loudly, keeping the contract explicit.
-populate_gitlinks() {
+stage_gitlinks() {
     local d path sha
     for d in flash-attention MSA FlashMLA FlashKDA qutlass tml-fa4; do
         while IFS=$'\t' read -r path sha; do
@@ -102,14 +116,129 @@ populate_gitlinks() {
     done
     echo "[deps] gitlink populations verified"
 }
-populate_gitlinks
 
 # ---- 4. Triton into the layout the Dockerfile/build expects ----
-mv "$DEPS/triton/python/triton_kernels/triton_kernels" "$DEPS/triton_kernels"
-rm -rf "$DEPS/triton"
+stage_triton_layout() {
+    mv "$DEPS/triton/python/triton_kernels/triton_kernels" "$DEPS/triton_kernels"
+    rm -rf "$DEPS/triton"
+}
+
+stage_all() {
+    stage_pins
+    stage_clones
+    stage_gitlinks
+    stage_triton_layout
+    # ---- 5. Manifest ----
+    echo "[deps] staged:"
+    local d
+    for d in cutlass flash-attention MSA FlashMLA FlashKDA qutlass tml-fa4 triton_kernels; do
+        printf '  %-16s %s\n' "$d" "$(git -C "$DEPS/$d" rev-parse HEAD 2>/dev/null | cut -c1-12)"
+    done
+}
+
+# ---------------------------------------------------------------- lock ----
+# sha256 over the sorted file list (relpath + per-file sha256) — the
+# file-level fingerprint for non-git content (git checkouts fingerprint via
+# commit + clean worktree instead: git objects already hash every byte).
+blob_hash() {
+    (cd "$1" && find . -type f | sort | while IFS= read -r f; do sha256sum "$f"; done | sha256sum | cut -d' ' -f1)
+}
+
+emit_lock() { # out — write the lock from the staged $DEPS state
+    local out="$1" d path sha h
+    {
+        echo "# sm121 deps.lock — staged-content fingerprints for fetch-deps.sh"
+        echo "# generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+        echo "# format: dep <name> <commit> | gitlink <parent>/<path> <commit>"
+        echo "         | skip <parent>/<path> <commit> | blob <name> <sha256>"
+        echo "# regenerate: bash fetch-deps.sh --regen-lock <out> (in-build: --build-arg REGEN_LOCK=1)"
+        for d in cutlass flash-attention MSA FlashMLA FlashKDA qutlass tml-fa4; do
+            [ -e "$DEPS/$d/.git" ] || { echo "emit_lock: $d not staged" >&2; exit 1; }
+            echo "dep $d $(git -C "$DEPS/$d" rev-parse HEAD)"
+        done
+        for d in flash-attention MSA FlashMLA FlashKDA qutlass tml-fa4; do
+            while IFS=$'\t' read -r path sha; do
+                [ -n "${path:-}" ] || continue
+                case "$path" in
+                    */composable_kernel|composable_kernel|*/aiter|aiter)
+                        echo "skip $d/$path $sha" ;;
+                    *)
+                        echo "gitlink $d/$path $sha" ;;
+                esac
+            done < <(git -C "$DEPS/$d" ls-tree -r -t HEAD | awk '$1=="160000" {print $4"\t"$3}')
+        done
+        [ -d "$DEPS/triton_kernels" ] || { echo "emit_lock: triton_kernels missing (run stage_triton_layout first)" >&2; exit 1; }
+        h=$(blob_hash "$DEPS/triton_kernels")
+        echo "blob triton_kernels $h"
+    } > "$out"
+    echo "[deps] lock written: $out"
+}
+
+verify_lock() { # lock — abort the build if staged content != lock
+    local lock="$1"
+    if [ ! -f "$lock" ]; then
+        echo "[deps] no lock at $lock — nothing to verify against (staging unverified)"
+        return 0
+    fi
+    local kind name want got d fail=0
+    while read -r kind name want; do
+        case "$kind" in
+            dep | gitlink)
+                d="$DEPS/$name"
+                if [ ! -e "$d/.git" ]; then
+                    echo "LOCK FAIL: $kind $name — missing or not a git checkout (empty-stub r18 failure mode)" >&2
+                    fail=1
+                    continue
+                fi
+                got=$(git -C "$d" rev-parse HEAD 2>/dev/null) || got="<no HEAD>"
+                if [ "$got" != "$want" ]; then
+                    echo "LOCK FAIL: $kind $name @ ${got:0:12} != locked ${want:0:12}" >&2
+                    fail=1
+                    continue
+                fi
+                if [ -n "$(git -C "$d" status --porcelain 2>/dev/null)" ]; then
+                    echo "LOCK FAIL: $kind $name — dirty worktree (expected a clean checkout)" >&2
+                    fail=1
+                fi
+                ;;
+            skip)
+                d="$DEPS/${name%%/*}"
+                got=$(git -C "$d" ls-tree -r -t HEAD 2>/dev/null | awk -v p="${name#*/}" '$1=="160000" && $4==p {print $3}')
+                if [ "$got" != "$want" ]; then
+                    echo "LOCK FAIL: skip $name — gitlink changed (${got:-gone} vs locked ${want:0:12})" >&2
+                    fail=1
+                fi
+                ;;
+            blob)
+                got=$(blob_hash "$DEPS/$name" 2>/dev/null) || got="<unreadable>"
+                if [ "$got" != "$want" ]; then
+                    echo "LOCK FAIL: blob $name — content hash $got != locked $want" >&2
+                    fail=1
+                fi
+                ;;
+            *)
+                echo "LOCK FAIL: unknown line kind '$kind' in $lock" >&2
+                fail=1
+                ;;
+        esac
+    done < <(grep -v '^#' "$lock" | grep -v '^[[:space:]]*$' || true)
+    if [ "$fail" != 0 ]; then
+        echo "aborting: staged deps do not match $lock" >&2
+        exit 1
+    fi
+    echo "[deps] lock verified: staged content matches $lock"
+}
 
-# ---- 5. Manifest ----
-echo "[deps] staged:"
-for d in cutlass flash-attention MSA FlashMLA FlashKDA qutlass tml-fa4 triton_kernels; do
-    printf '  %-16s %s\n' "$d" "$(git -C "$DEPS/$d" rev-parse HEAD 2>/dev/null | cut -c1-12)"
-done
+case "${1:-}" in
+    --regen-lock)
+        stage_all
+        emit_lock "${2:?usage: fetch-deps.sh --regen-lock <out>}"
+        ;;
+    --emit-lock)
+        emit_lock "${2:?usage: fetch-deps.sh --emit-lock <out>}"
+        ;;
+    *)
+        stage_all
+        verify_lock "$LOCK"
+        ;;
+esac

+ 20 - 0
docker-images/glm53-jovian-r27/locks/deps.lock

@@ -0,0 +1,20 @@
+# sm121 deps.lock — staged-content fingerprints for fetch-deps.sh
+# generated: 2026-09-20T14:07:31Z
+# format: dep <name> <commit> | gitlink <parent>/<path> <commit>
+         | skip <parent>/<path> <commit> | blob <name> <sha256>
+# regenerate: bash fetch-deps.sh --regen-lock <out> (in-build: --build-arg REGEN_LOCK=1)
+dep cutlass da5e086dab31d63815acafdac9a9c5893b1c69e2
+dep flash-attention f3e1a4f74c99145c0717709860bf765de1703779
+dep MSA 087c161814d4d9c735b46c21212a09e5f8eb92fa
+dep FlashMLA a8f794d1251cbfd88a5011445dd5582289c727e4
+dep FlashKDA 3b225bf26bb8e218928a1fe14751cb48cf31d11b
+dep qutlass e74319e3405ce6d71965732880f5dc1f52371f64
+dep tml-fa4 b206834606ed5b5f21f8eed6b0683f528ea9cf7d
+skip flash-attention/csrc/composable_kernel 13f6d635653bd5ffbfcac8577f1ef09590c23d78
+gitlink flash-attention/csrc/cutlass 62750a2b75c802660e4894434dc55e839f322277
+skip flash-attention/third_party/aiter 3b2e6f48ce97e1d494e8b3f1af5c65f74e304b28
+gitlink MSA/python/fmha_sm100/cutlass eb61c911471867a5fd2466bfd8f29306cea6ebf8
+gitlink FlashMLA/csrc/cutlass 147f5673d0c1c3dcf66f78d677fd647e4a020219
+gitlink FlashKDA/cutlass 5c149f52a436782210263fb2f19b354443a61c6a
+gitlink qutlass/third_party/cutlass b2ca083d2bb96c41d9b3c5a930637c641f6669bf
+blob triton_kernels f2494268489322deff9bf9f5324633de2fa8289acd4a64b7858165ba04f7d2c7

+ 114 - 57
src/docker/build_dir.rs

@@ -14,19 +14,25 @@ use std::path::{Path, PathBuf};
 
 /// Compute a SHA-256 checksum over all files in `dir` (recursive).
 /// Sorted by relative path to ensure reproducibility.
-/// Flatten an image overlay into `dst` and rewrite its Dockerfile:
-/// the `patches/` subtree plus every sub-directory the Dockerfile
-/// COPY/ADD-references is flattened to the bundle root (gists cannot
-/// carry directories).  Name collisions rename the flattened file to
-/// `{dir}-{name}` and the COPY line is rewritten to the new name; a
-/// collision under a glob source aborts the flatten for that directory
-/// (it stays nested, `nested = true` — the gist flow refuses it).
-/// Unreferenced sub-directories are always preserved nested.
+/// Flatten an image overlay into `dst` and rewrite its Dockerfile.
+///
+/// Only files the build actually references are shipped: every COPY/ADD
+/// source of the Dockerfile (incl. continuations), plus `versions.env`
+/// (read by the build driver from the context).  Referenced files from a
+/// sub-directory are flattened to the bundle root (gists cannot carry
+/// directories); name collisions rename the flattened file to `{dir}-{name}`
+/// and the COPY line is rewritten to the new name.  A collision under a
+/// glob source cannot be renamed — that directory stays nested
+/// (`nested = true`, the gist flow refuses it).  Unreferenced files and
+/// directories (e.g. `profiles/` launcher metadata, leftover sources) are
+/// not shipped at all.
 pub fn flatten_overlay(src_dir: &Path, dst: &Path, dockerfile: &str) -> Result<(String, bool)> {
     use std::collections::BTreeSet;
 
-    // 1) Sub-directories referenced by COPY/ADD sources (incl. continuations).
-    let mut referenced: BTreeSet<String> = BTreeSet::new();
+    // 1) Collect COPY/ADD sources: root names, and per-dir exact names + glob patterns.
+    let mut referenced_root: BTreeSet<String> = BTreeSet::new();
+    let mut exact: BTreeMap<String, BTreeSet<String>> = BTreeMap::new(); // dir -> rel paths
+    let mut globs: BTreeMap<String, Vec<String>> = BTreeMap::new(); // dir -> patterns
     let mut in_copy = false;
     for line in dockerfile.lines() {
         let t = line.trim_start();
@@ -35,75 +41,100 @@ pub fn flatten_overlay(src_dir: &Path, dst: &Path, dockerfile: &str) -> Result<(
         if !is_copy {
             continue;
         }
+        let is_first = t.starts_with("COPY ") || t.starts_with("ADD ");
         let body = t.trim_end_matches('\\');
-        let toks: Vec<&str> = body.split_whitespace().skip(1).collect();
-        for tok in &toks[..toks.len().saturating_sub(1)] {
+        let toks: Vec<&str> = body.split_whitespace().collect();
+        let n = toks.len();
+        // first line: [cmd, sources…, dest]; continuation: sources only
+        let (lo, hi) = if is_first {
+            (1, n.saturating_sub(1))
+        } else {
+            (0, n)
+        };
+        for tok in &toks[lo..hi] {
             if tok.starts_with('-') {
                 continue; // --from=… etc.
             }
             let parts: Vec<&str> = tok.split('/').collect();
             // parts[0].is_empty() => absolute path (/tmp/…) — not a build-dir ref
-            if parts.len() >= 2 && !parts[0].is_empty() && src_dir.join(parts[0]).is_dir() {
-                referenced.insert(parts[0].to_string());
+            if parts.len() == 1 {
+                referenced_root.insert(tok.to_string());
+            } else if !parts[0].is_empty() && src_dir.join(parts[0]).is_dir() {
+                let rel = parts[1..].join("/");
+                if tok.contains('*') || tok.contains('?') {
+                    globs.entry(parts[0].to_string()).or_default().push(rel);
+                } else {
+                    exact.entry(parts[0].to_string()).or_default().insert(rel);
+                }
             }
         }
     }
-    let flatten_dirs: BTreeSet<String> = referenced
-        .iter()
-        .cloned()
-        .chain(std::iter::once("patches".to_string()))
-        .collect();
+    // patches/ is always flattenable even without an explicit reference.
+    let dirs: Vec<String> = {
+        let mut set: BTreeSet<String> = exact.keys().cloned().collect();
+        for k in globs.keys() {
+            set.insert(k.clone());
+        }
+        set.insert("patches".to_string());
+        set.into_iter().collect()
+    };
 
-    // 2) Root files first (they define the collision set), except the driver.
+    // 2) Root files: only referenced ones (+ versions.env, the build driver's
+    //    build-arg source) — leftover top-level files are not shipped.
     for entry in std::fs::read_dir(src_dir)? {
         let entry = entry?;
         if entry.file_type()?.is_file() {
             let name = entry.file_name().to_string_lossy().to_string();
-            if name != "build.sh" && name != "Dockerfile" {
+            if name == "build.sh" || name == "Dockerfile" {
+                continue;
+            }
+            if name == "versions.env" || referenced_root.contains(&name) {
                 std::fs::copy(entry.path(), dst.join(&name))?;
             }
         }
     }
 
-    // 3) Flatten the referenced dirs (+ patches/); everything else nests.
+    // 3) Flatten the referenced dirs: only the referenced files.
     let mut nested = false;
     let mut flattened: BTreeSet<String> = BTreeSet::new(); // dirs actually flattened
     let mut renames: BTreeMap<String, String> = BTreeMap::new(); // "dir/file" -> new name
-    let mut globs: BTreeSet<String> = BTreeSet::new(); // dirs whose COPY source is a glob
-    for line in dockerfile.lines() {
-        let t = line.trim_start();
-        if t.starts_with("COPY ") || t.starts_with("ADD ") {
-            for tok in t.split_whitespace().skip(1) {
-                if tok.starts_with('-') {
-                    continue;
-                }
-                let parts: Vec<&str> = tok.split('/').collect();
-                if parts.len() >= 2 && flatten_dirs.contains(parts[0]) && tok.contains('*') {
-                    globs.insert(parts[0].to_string());
-                }
-            }
-        }
-    }
-    for dir in flatten_dirs.iter() {
+    for dir in dirs.iter() {
         let dir_path = src_dir.join(dir);
         if !dir_path.is_dir() {
-            continue; // referenced but absent — COPY will fail in docker, not here
+            continue; // referenced but absent — the COPY will fail in docker, not here
         }
+        let is_glob_dir = globs.contains_key(dir);
         let files: Vec<PathBuf> = std::fs::read_dir(&dir_path)?
             .filter_map(|e| e.ok())
             .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
             .map(|e| e.path())
             .collect();
-        let collides = files
+        let needed = |f: &Path| {
+            let rel = f
+                .strip_prefix(&dir_path)
+                .unwrap_or(f)
+                .to_string_lossy()
+                .to_string();
+            exact
+                .get(dir)
+                .map(|s| s.contains(&rel))
+                .unwrap_or(false)
+                || globs
+                    .get(dir)
+                    .map(|pats| pats.iter().any(|p| glob_match(p, &rel)))
+                    .unwrap_or(false)
+        };
+        let needed_files: Vec<PathBuf> = files.iter().filter(|f| needed(f)).cloned().collect();
+        let collides = needed_files
             .iter()
             .any(|f| dst.join(f.file_name().unwrap()).exists());
-        if collides && globs.contains(dir) {
+        if collides && is_glob_dir {
             nested = true;
             std::fs::create_dir_all(dst.join(dir))?;
             copy_tree(&dir_path, &dst.join(dir))?; // rename under a glob is impossible
             continue;
         }
-        for f in files {
+        for f in needed_files {
             let name = f.file_name().unwrap().to_string_lossy().to_string();
             let key = format!("{dir}/{name}");
             let new = if dst.join(&name).exists() {
@@ -117,21 +148,6 @@ pub fn flatten_overlay(src_dir: &Path, dst: &Path, dockerfile: &str) -> Result<(
         }
         flattened.insert(dir.clone());
     }
-    for entry in std::fs::read_dir(src_dir)? {
-        let entry = entry?;
-        let name = entry.file_name().to_string_lossy().to_string();
-        if !entry.file_type()?.is_dir() || flatten_dirs.contains(&name) {
-            continue;
-        }
-        // profiles/ is launcher metadata — its pins are already merged into
-        // the bundle's versions.env, so it is not re-shipped.
-        if name == "profiles" {
-            continue;
-        }
-        nested = true;
-        std::fs::create_dir_all(dst.join(&name))?;
-        copy_tree(&entry.path(), &dst.join(&name))?;
-    }
 
     // 4) Rewrite COPY/ADD sources for the flattened dirs (incl. continuations).
     let mut in_copy = false;
@@ -203,6 +219,36 @@ fn copy_tree(src_dir: &Path, dst_dir: &Path) -> Result<()> {
     Ok(())
 }
 
+/// Minimal glob match for COPY sources: `*` = any run of chars, `?` = one char.
+fn glob_match(pat: &str, name: &str) -> bool {
+    let p: Vec<char> = pat.chars().collect();
+    let n: Vec<char> = name.chars().collect();
+    let (mut i, mut j) = (0usize, 0usize);
+    let (mut star_i, mut star_j) = (0usize, 0usize);
+    let mut has_star = false;
+    while j < n.len() {
+        if i < p.len() && (p[i] == '?' || p[i] == n[j]) {
+            i += 1;
+            j += 1;
+        } else if i < p.len() && p[i] == '*' {
+            has_star = true;
+            star_i = i;
+            star_j = j;
+            i += 1;
+        } else if has_star {
+            star_j += 1;
+            i = star_i + 1;
+            j = star_j;
+        } else {
+            return false;
+        }
+    }
+    while i < p.len() && p[i] == '*' {
+        i += 1;
+    }
+    i == p.len()
+}
+
 pub fn compute_dir_checksum(dir: &Path) -> Result<String> {
     let mut hasher = Sha256::new();
     let mut entries: Vec<_> = Vec::new();
@@ -416,6 +462,17 @@ pub fn resolve_build_dir(
 mod tests {
     use super::*;
 
+    #[test]
+    fn glob_match_basics() {
+        assert!(glob_match("patch_vllm_*.py", "patch_vllm_topk.py"));
+        assert!(glob_match("patch_vllm_*.py", "patch_vllm_a.py"));
+        assert!(!glob_match("patch_vllm_*.py", "other.py"));
+        assert!(glob_match("a?c", "abc"));
+        assert!(!glob_match("a?c", "ac"));
+        assert!(glob_match("x", "x"));
+        assert!(glob_match("*", "anything/at-all"));
+    }
+
     #[test]
     fn parse_versions_env_quotes_are_normalized() {
         let raw = "A=\"x:tag@sha256:abc\"\nB='y'\nC=z\nE=\"half\nF='weird\"\n";

+ 13 - 12
src/publish.rs

@@ -230,34 +230,35 @@ mod tests {
         std::fs::write(src.join("apt-sources.list"), "root").unwrap();
         std::fs::write(src.join("locks/apt-sources.list"), "lock").unwrap(); // collision
         std::fs::write(src.join("locks/apt-packages.txt"), "pkg").unwrap();
+        std::fs::write(src.join("locks/python-bootstrap.txt"), "boot").unwrap(); // unreferenced -> not shipped
         std::fs::write(src.join("patches/p.patch"), "z").unwrap();
         std::fs::write(src.join("profile/x.env"), "y").unwrap();
         std::fs::create_dir_all(src.join("extra")).unwrap();
-        std::fs::write(src.join("extra/y.env"), "z2").unwrap(); // unreferenced -> nested
+        std::fs::write(src.join("extra/y.env"), "z2").unwrap(); // unreferenced dir -> not shipped
         std::fs::write(src.join("Dockerfile"), "f").unwrap();
-        let df = "FROM x\n# see patches/UPSTREAM.md\nCOPY locks/apt-sources.list /tmp/a.list\nCOPY locks/apt-packages.txt /tmp/b.txt \\\n  patches/p.patch\nCOPY profile/x.env /tmp/x\nRUN grep patches/ /tmp/x\n";
+        let df = "FROM x\n# see patches/UPSTREAM.md\nCOPY apt-sources.list /tmp/root-a.list\nCOPY locks/apt-sources.list /tmp/a.list\nCOPY locks/apt-packages.txt /tmp/b.txt \\\n  patches/p.patch\nCOPY profile/x.env /tmp/x\nRUN grep patches/ /tmp/x\n";
         let dst = tmp.join("dst");
         std::fs::create_dir_all(&dst).unwrap();
         let (flat, nested) = crate::docker::build_dir::flatten_overlay(&src, &dst, df).unwrap();
-        // referenced dirs flatten, unreferenced stays nested
-        assert!(nested, "unreferenced dir must stay nested");
-        assert!(dst.join("extra/y.env").exists());
-        assert!(
-            dst.join("locks-apt-sources.list").exists(),
-            "collision rename"
-        );
-        assert!(dst.join("apt-sources.list").exists());
+        assert!(!nested, "nothing nests anymore — unreferenced files are dropped");
+        // collision rename for the referenced locks file
+        assert!(dst.join("locks-apt-sources.list").exists(), "collision rename");
+        assert!(dst.join("apt-sources.list").exists(), "root file shipped");
         assert_eq!(
             std::fs::read_to_string(dst.join("apt-packages.txt")).unwrap(),
             "pkg"
         );
         assert_eq!(std::fs::read_to_string(dst.join("p.patch")).unwrap(), "z");
         assert!(dst.join("x.env").exists(), "profile dir flattened");
+        // unreferenced files / dirs are not shipped
+        assert!(!dst.join("python-bootstrap.txt").exists());
+        assert!(!dst.join("extra").exists());
         // Dockerfile rewritten: renamed file, prefix drops (root + continuation),
         // comments + non-COPY lines untouched
         assert!(flat.contains("COPY locks-apt-sources.list /tmp/a.list"));
         assert!(flat.contains("COPY apt-packages.txt /tmp/b.txt \\\n  p.patch"));
         assert!(flat.contains("COPY x.env /tmp/x"));
+        assert!(flat.contains("COPY apt-sources.list /tmp/root-a.list"));
         assert!(flat.contains("# see patches/UPSTREAM.md"));
         assert!(flat.contains("RUN grep patches/ /tmp/x"));
         assert!(!dst.join("Dockerfile").exists());
@@ -269,9 +270,9 @@ mod tests {
         let _ = std::fs::remove_dir_all(&tmp);
         let src = tmp.join("src");
         std::fs::create_dir_all(src.join("patches")).unwrap();
-        std::fs::write(src.join("a.py"), "root").unwrap(); // collides with patches/a.py
+        std::fs::write(src.join("a.py"), "root").unwrap(); // referenced + collides with patches/a.py
         std::fs::write(src.join("patches/a.py"), "patched").unwrap();
-        let df = "FROM x\nCOPY patches/*.py /tmp/\n";
+        let df = "FROM x\nCOPY a.py /tmp/a0.py\nCOPY patches/*.py /tmp/\n";
         let dst = tmp.join("dst");
         std::fs::create_dir_all(&dst).unwrap();
         let (flat, nested) = crate::docker::build_dir::flatten_overlay(&src, &dst, df).unwrap();