compose.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. //! Composed model directories: build a /tmp dir per-node with symlinks into
  2. //! the HF cache (container-side absolute paths) + generated files + hardlinks
  3. //! from node-local files, then bind-mount it into the container. Lets a
  4. //! recipe combine multiple HF repos (e.g. a base checkpoint + an expert
  5. //! overlay), and serve selected files from node-local storage (NVMe) instead
  6. //! of the shared NAS (local_files), without mutating the HF cache.
  7. //!
  8. //! Symlinks point to `/root/.cache/huggingface/...` (the container-side HF
  9. //! cache mount path), NOT the host-side path. They are dangling on the host
  10. //! (the path `/root/.cache/...` doesn't exist there) but resolve correctly
  11. //! inside the container, where the HF cache is mounted at that exact path.
  12. //! This avoids the host↔container mount-root mismatch without requiring
  13. //! relative-path gymnastics across different mount roots.
  14. use super::shq;
  15. use crate::config::{ComposedLocalFile, ComposedModel, ComposedSource, expand_tilde};
  16. use crate::ssh::run_ssh;
  17. use anyhow::{Context, Result};
  18. use base64::Engine;
  19. use base64::engine::general_purpose::STANDARD;
  20. use std::path::Path;
  21. use tracing::info;
  22. /// Build a composed model directory on a node. Creates
  23. /// `/tmp/sm121_<recipe>_<rank>/` with symlinks into the HF cache
  24. /// (container-side absolute paths) + generated files. Returns
  25. /// `(host_path, container_path)` for the bind-mount.
  26. pub(crate) async fn build_composed_dir_on_node(
  27. config: &crate::config::Config,
  28. node: &crate::config::Node,
  29. recipe_name: &str,
  30. rank: u32,
  31. composed: &ComposedModel,
  32. hf_cache: &Path,
  33. dry_run: bool,
  34. ) -> Result<(String, String)> {
  35. let host_path = format!("/tmp/sm121_{}_{}", recipe_name, rank);
  36. let container_path = composed.mount.clone();
  37. if dry_run {
  38. println!(
  39. "{}",
  40. fluent_i18n::t!("composed-dir-dry-run", {
  41. "host" => host_path,
  42. "node" => node.name.clone(),
  43. "mount" => container_path,
  44. "sources" => composed.sources.len() as u32,
  45. "files" => composed.files.len() as u32,
  46. "local" => composed.local_files.len() as u32,
  47. })
  48. );
  49. return Ok((host_path, container_path));
  50. }
  51. let cache_str = hf_cache.display().to_string();
  52. // Create the composed dir.
  53. run_ssh(config, node, format!("mkdir -p {}", shq(&host_path)), false).await?;
  54. // Symlink each source's snapshot files into the composed dir.
  55. for source in &composed.sources {
  56. symlink_source(config, node, source, &host_path, &cache_str).await?;
  57. }
  58. // Hardlink local files (later wins over the symlinks above). These come
  59. // from node-local storage (e.g. an NVMe copy of a table shard) and must
  60. // live on the same filesystem as the composed dir (/tmp) to hardlink.
  61. for lf in &composed.local_files {
  62. link_local_file(config, node, lf, &host_path).await?;
  63. }
  64. // Write generated files (base64-encoded to avoid quoting issues).
  65. for file in &composed.files {
  66. let b64 = STANDARD.encode(file.content.as_bytes());
  67. run_ssh(
  68. config,
  69. node,
  70. format!(
  71. "printf '%s' '{}' | base64 -d > {}/{}",
  72. b64,
  73. shq(&host_path),
  74. shq(&file.name),
  75. ),
  76. false,
  77. )
  78. .await?;
  79. }
  80. info!(
  81. "{}",
  82. fluent_i18n::t!("composed-dir-built", {
  83. "host" => host_path,
  84. "node" => node.name.clone(),
  85. "sources" => composed.sources.len() as u32,
  86. "files" => composed.files.len() as u32,
  87. "local" => composed.local_files.len() as u32,
  88. })
  89. );
  90. Ok((host_path, container_path))
  91. }
  92. /// Resolve a source repo's snapshot on the node and symlink its files into
  93. /// the composed dir. Symlink targets are container-side absolute paths
  94. /// (`/root/.cache/huggingface/...`) so they resolve inside the container.
  95. ///
  96. /// With no `glob`, the ENTIRE snapshot hierarchy is symlinked recursively
  97. /// (every file in every subdir, real dirs) — so a later source can overlay
  98. /// individual files at any path. With a `glob`, only matching top-level (or
  99. /// explicit-subdir) entries are symlinked.
  100. async fn symlink_source(
  101. config: &crate::config::Config,
  102. node: &crate::config::Node,
  103. source: &ComposedSource,
  104. host_path: &str,
  105. cache_str: &str,
  106. ) -> Result<()> {
  107. let model_dir = crate::config::model_cache_dir_name(&source.repo);
  108. let refs_path = format!("{}/hub/{}/refs/main", cache_str, model_dir);
  109. let glob = source.glob.as_deref();
  110. // Validate the glob: only allow glob-safe characters to prevent shell
  111. // injection (the glob is inserted raw into the shell `for` statement).
  112. if let Some(g) = glob
  113. && !g
  114. .chars()
  115. .all(|c| c.is_ascii_alphanumeric() || "*?._-/".contains(c))
  116. {
  117. anyhow::bail!(
  118. "composed_model source '{}': glob '{}' contains invalid characters \
  119. (allowed: alphanumeric, * ? . _ - /)",
  120. source.repo,
  121. g
  122. );
  123. }
  124. // Build a shell `case` clause to skip excluded paths. Each entry is
  125. // matched against the file's path relative to the snapshot root (e.g.
  126. // `L5.safetensors`, or `subdir/file.bin`). Same character set as glob.
  127. let exclude_clause = match &source.exclude {
  128. None => String::new(),
  129. Some(entries) if entries.is_empty() => String::new(),
  130. Some(entries) => {
  131. for e in entries {
  132. if e.is_empty()
  133. || !e
  134. .chars()
  135. .all(|c| c.is_ascii_alphanumeric() || "*?._-/".contains(c))
  136. {
  137. anyhow::bail!(
  138. "composed_model source '{}': exclude entry '{}' is invalid \
  139. (allowed: alphanumeric, * ? . _ - /)",
  140. source.repo,
  141. e
  142. );
  143. }
  144. }
  145. format!("case \"$rel\" in {}) continue ;; esac; ", entries.join("|"))
  146. }
  147. };
  148. // Enumerate: no glob → recursive `find` (whole hierarchy, every file in
  149. // every subdir); glob → shell glob match (one level, `/` allowed for a
  150. // specific subdir). Both feed the same per-file body below.
  151. let enumerate: String = match glob {
  152. None => String::from(
  153. "find \"$snap\" -mindepth 1 \\( -type l -o -type f \\) | while IFS= read -r f; do",
  154. ),
  155. Some(g) => format!("for f in \"$snap\"/{}; do", g),
  156. };
  157. // One SSH round-trip: resolve refs/main, then symlink each matching file.
  158. // The symlink TARGET is the CONTAINER-side path (/root/.cache/huggingface/...)
  159. // not the host-side path — the link is dangling on the host but resolves
  160. // inside the container where the HF cache is mounted at that exact path.
  161. // `ln -snf`: -s symlink, -n no-deref (treat existing symlink as a file,
  162. // don't dive into the dir it points to), -f force (overwrite earlier).
  163. //
  164. // Paths are assembled via `printf` (not embedded in double-quoted
  165. // assignments) so shq's single-quote wrapping doesn't become literal
  166. // characters in the path — same pattern as resolve_path_ref_on_node.
  167. //
  168. // Relative paths are preserved: a glob like `grids/*.json` creates
  169. // `$host/grids/L10.json` (with the subdir), and the symlink target is
  170. // `$csnap/grids/L10.json` (matching structure). With no glob, `find`
  171. // recurses so `mtp-draft/foo.bin` etc. are symlinked individually.
  172. // `mkdir -p` creates parent dirs as needed (real dirs, not symlinks —
  173. // so later sources can overlay files at any path).
  174. let cmd = format!(
  175. "ref=\"$(cat {refs_q} 2>/dev/null)\" && test -n \"$ref\" && \
  176. snap=\"$(printf '%s/hub/%s/snapshots/%s' {cache_q} {model_q} \"$ref\")\" && \
  177. test -d \"$snap\" && \
  178. csnap=\"$(printf '/root/.cache/huggingface/hub/%s/snapshots/%s' {model_q} \"$ref\")\" && \
  179. {enumerate} \
  180. [ -e \"$f\" ] || [ -L \"$f\" ] || continue; \
  181. rel=\"${{f#\"$snap\"/}}\"; \
  182. {exclude_clause}\
  183. parent=\"$(dirname {host_q}/\"$rel\")\"; \
  184. mkdir -p \"$parent\" && \
  185. ln -snf \"$csnap/$rel\" {host_q}/\"$rel\"; \
  186. done",
  187. refs_q = shq(&refs_path),
  188. cache_q = shq(cache_str),
  189. model_q = shq(&model_dir),
  190. host_q = shq(host_path),
  191. enumerate = enumerate,
  192. exclude_clause = exclude_clause,
  193. );
  194. run_ssh(config, node, cmd, false).await.with_context(|| {
  195. format!(
  196. "composed_model source '{}' not found in HF cache on '{}' \
  197. — run `huggingface-cli download {}` on that node",
  198. source.repo, node.name, source.repo
  199. )
  200. })?;
  201. Ok(())
  202. }
  203. /// Hardlink a node-local file into the composed dir. Runs after the repo
  204. /// symlink pass, so a local file overwrites the repo's symlink at the same
  205. /// name (local wins — that is the point: e.g. an NVMe table shard instead of
  206. /// the shared-NAS version). Hardlinked, NOT copied — a sparse local copy
  207. /// stays sparse and costs zero extra disk — so the host path must be on the
  208. /// same filesystem as the composed dir (/tmp).
  209. async fn link_local_file(
  210. config: &crate::config::Config,
  211. node: &crate::config::Node,
  212. lf: &ComposedLocalFile,
  213. host_path: &str,
  214. ) -> Result<()> {
  215. let host = expand_tilde(&lf.host, &config.ssh_user);
  216. let name = lf
  217. .name
  218. .as_deref()
  219. .filter(|n| !n.is_empty())
  220. .or_else(|| host.rsplit('/').next())
  221. .filter(|s| !s.is_empty())
  222. .ok_or_else(|| anyhow::anyhow!("composed_model local_file with empty host path"))?;
  223. if name.starts_with('/')
  224. || name.contains("..")
  225. || !name
  226. .chars()
  227. .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
  228. {
  229. anyhow::bail!(
  230. "composed_model local_file '{}': name '{}' is invalid \
  231. (allowed: alphanumeric . _ - ; flat name, no directories)",
  232. host,
  233. name
  234. );
  235. }
  236. // Existence check + hardlink in one round-trip; `ln -f` replaces an
  237. // earlier symlink at the same path.
  238. let cmd = format!(
  239. "test -e {h} && ln -f {h} {d}/{n}",
  240. h = shq(&host),
  241. d = shq(host_path),
  242. n = shq(name),
  243. );
  244. run_ssh(config, node, cmd, false).await.with_context(|| {
  245. format!(
  246. "composed_model local_file: '{}' not found on '{}' — a per-node \
  247. local path is required (and it must share a filesystem with /tmp to hardlink)",
  248. host, node.name
  249. )
  250. })?;
  251. Ok(())
  252. }