compose.rs 8.1 KB

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