download-drivers: reuse an existing directory that differs only in case

Dell and GE manifests spell the same folder inconsistently - OptiPlex vs
Optiplex. Windows does not care, but the Samba share sits on a case-sensitive
filesystem, so a blind mkdir -p created a SECOND tree and the drivers split
between them.

That is exactly how the OptiPlex Micro 7020 pack went missing: the manifest asked
for OptiPlex/D13MLK while the 3.2 GB zip sat in Optiplex/D13MLK. PESetup found no
pack, logged a warning rather than an error, and the bay imaged with no network
drivers - so DNS failed at first boot and bulk enrollment could not reach the
CDN. Symptoms three steps from the cause.

mkdir_ci walks the path one component at a time and reuses whatever is already
there whatever its case, creating only genuinely new components. Callers must use
the RETURNED path, since it may differ in case from the requested one. Falls back
to plain mkdir -p rather than skipping a download if the resolve fails.

Both spellings still exist in the live catalogues; scripts/lint-driver-catalogue.py
reports a case mismatch as an error so the pair cannot silently drift again.
This commit is contained in:
cproudlock
2026-08-06 14:38:54 -04:00
parent 9d51c0b987
commit bb08392b84

View File

@@ -68,6 +68,53 @@ def ssh_cmd(host, cmd):
# Remote path resolver that is case-insensitive about EXISTING directories.
#
# Dell/GE manifests spell the same folder inconsistently ("OptiPlex" vs
# "Optiplex"). Windows does not care; the Samba share is backed by a
# case-sensitive Linux filesystem, so a blind "mkdir -p" creates a SECOND tree
# and the drivers split between them. That is exactly how the OptiPlex Micro
# 7020 package went missing: the manifest asked for OptiPlex/D13MLK while the
# 3.2 GB zip sat in Optiplex/D13MLK, so imaging installed no drivers at all -
# no NIC, no WiFi, and bulk enrollment could not reach the CDN.
#
# Walk the path one component at a time and reuse whatever is already there,
# whatever its case. Only genuinely new components get created.
CI_RESOLVE_SH = r"""
set -e
p="$1"; shift
for c in "$@"; do
[ -z "$c" ] && continue
if [ -d "$p/$c" ]; then
p="$p/$c"
else
m=$(ls -1 "$p" 2>/dev/null | awk -v c="$c" 'tolower($0)==tolower(c){print; exit}')
if [ -n "$m" ]; then p="$p/$m"; else mkdir -p "$p/$c"; p="$p/$c"; fi
fi
done
printf '%s' "$p"
"""
def mkdir_ci(host, target_dir):
"""mkdir -p that reuses existing dirs differing only in case.
Returns the path that actually exists on the server, which may differ in
case from target_dir. Callers must use the RETURNED path."""
parts = [c for c in target_dir.split("/") if c]
if not parts:
return target_dir
base = "/" if target_dir.startswith("/") else "."
r = ssh_cmd(host, "bash -s -- '%s' %s <<'CIEOF'\n%s\nCIEOF" % (
base, " ".join("'%s'" % c.replace("'", "'\''") for c in parts), CI_RESOLVE_SH))
out = (r.stdout or "").strip()
if r.returncode != 0 or not out:
# Fall back to the old behaviour rather than skipping the download.
ssh_cmd(host, "mkdir -p '%s'" % target_dir)
return target_dir
return out
def verify_sha256(filepath, expected): def verify_sha256(filepath, expected):
sha = hashlib.sha256() sha = hashlib.sha256()
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
@@ -324,7 +371,9 @@ def process_download(args, url, filename, sha256, size, target_dir, label, tmpdi
# Push zip to PXE server # Push zip to PXE server
print(f" [{label}] Pushing to {target_dir}/{zip_name}...") print(f" [{label}] Pushing to {target_dir}/{zip_name}...")
ssh_cmd(args.server, f"mkdir -p '{target_dir}'") # Reuse an existing directory that differs only in case, or the drivers
# split across two trees and imaging silently installs none of them.
target_dir = mkdir_ci(args.server, target_dir)
r = subprocess.run([ r = subprocess.run([
"rsync", "-a", "rsync", "-a",
"-e", f"sshpass -p {PXE_PASS} ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR", "-e", f"sshpass -p {PXE_PASS} ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR",