types.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. use super::ExtraEntity;
  2. use crate::pre_serve::BeforeServeTable;
  3. use serde::Deserialize;
  4. use std::collections::HashMap;
  5. #[derive(Clone, Debug, Deserialize)]
  6. pub struct Node {
  7. pub name: String,
  8. pub ssh_ip: String,
  9. /// InfiniBand IP; optional (falls back to `ssh_ip`).
  10. #[serde(default)]
  11. pub ib_ip: Option<String>,
  12. pub rank: u32,
  13. #[serde(default)]
  14. pub host_key: Option<String>,
  15. #[serde(default)]
  16. pub entity: Option<String>,
  17. }
  18. #[derive(Clone, Debug, Deserialize)]
  19. pub struct NcclConfig {
  20. pub ib_hca: String,
  21. pub socket_ifname: String,
  22. /// RoCE GID index: absent = auto-detect per node (`NCCL_IB_GID_INDEX`
  23. /// resolved from the RoCE-v2 IPv4 GID at serve time); Some(n) = pinned.
  24. #[serde(default)]
  25. pub ib_gid_index: Option<u32>,
  26. pub ib_disable: u32,
  27. #[serde(default)]
  28. pub gloo_socket_ifname: Option<String>,
  29. #[serde(default)]
  30. pub ucx_net_devices: Option<String>,
  31. #[serde(default)]
  32. pub nccl_cross_nic: Option<u32>,
  33. #[serde(default)]
  34. pub nccl_proto: Option<String>,
  35. #[serde(default)]
  36. pub nccl_p2p_level: Option<String>,
  37. }
  38. #[derive(Clone, Debug, Deserialize)]
  39. pub struct Cluster {
  40. pub name: String,
  41. /// Master IB IP; optional (falls back to rank-0 node's effective IP).
  42. #[serde(default)]
  43. pub master_ib_ip: Option<String>,
  44. pub nodes: Vec<Node>,
  45. #[serde(default)]
  46. pub extra_entities: Vec<ExtraEntity>,
  47. }
  48. #[derive(Clone, Deserialize)]
  49. pub struct Config {
  50. /// SSH user; may be empty — a `cluster_sources` file can provide it.
  51. #[serde(default)]
  52. pub ssh_user: String,
  53. #[serde(default)]
  54. pub ssh_key_path: std::path::PathBuf,
  55. #[serde(default)]
  56. pub clusters: Vec<Cluster>,
  57. #[serde(default)]
  58. pub nccl: HashMap<String, NcclConfig>,
  59. #[serde(default)]
  60. pub hf_cache_dir: Option<String>,
  61. #[serde(default = "default_init_port")]
  62. pub init_port: u16,
  63. #[serde(default)]
  64. pub before_serve: HashMap<String, BeforeServeTable>,
  65. #[serde(default)]
  66. pub exec_aliases: HashMap<String, String>,
  67. #[serde(default)]
  68. pub home_assistant: Option<HomeAssistantConfig>,
  69. /// RouterOS devices for graceful `power off` (see [`MikrotikConfig`]).
  70. #[serde(default)]
  71. pub mikrotik: Option<MikrotikConfig>,
  72. #[serde(default)]
  73. pub global_mods_dir: Option<std::path::PathBuf>,
  74. /// Drop page cache before starting containers.
  75. #[serde(default = "default_drop_caches")]
  76. pub drop_caches: bool,
  77. /// Mount-alias registry: recipe `[[extra_mounts]]` `alias` resolves here; the
  78. /// alias wins over the recipe's `host` default. Keeps recipes portable.
  79. #[serde(default)]
  80. pub mount_aliases: HashMap<String, String>,
  81. /// Extra recipe dirs (each `<name>/recipe.toml`) searched after `./recipes` (`~` expanded).
  82. #[serde(default)]
  83. pub recipe_sources: Vec<String>,
  84. /// Ordered git recipe repos (first entry = highest priority on a
  85. /// recipe-name collision). Cloned/pulled into a local cache by
  86. /// `crate::recipe_repos` and appended to the recipe search path in the
  87. /// same order; archive state lives in the separate `repos.json`
  88. /// registry, not here.
  89. #[serde(default)]
  90. pub recipe_repos: Vec<RecipeRepo>,
  91. /// Extra image-build dirs searched after `./docker-images`: repo roots
  92. /// (with `docker-images/`) or dirs of image dirs; `~` expanded.
  93. #[serde(default)]
  94. pub docker_image_sources: Vec<String>,
  95. /// Extra cluster def files (`[[clusters]]`, `[nccl.<name>]`); this file wins.
  96. #[serde(default)]
  97. pub cluster_sources: Vec<String>,
  98. /// Optional image-registry push target: after a successful local build
  99. /// the image is pushed as `{base}/{image-name}:{tag}` on the build node.
  100. #[serde(default)]
  101. pub registry: Option<RegistryConfig>,
  102. }
  103. /// `[registry]` — where freshly built images are pushed (the build node
  104. /// must already be authenticated: `docker login <host>` once on the node).
  105. #[derive(Clone, Debug, Deserialize)]
  106. pub struct RegistryConfig {
  107. /// Repository base, e.g. `ghcr.io/kilork` → push ref
  108. /// `ghcr.io/kilork/<image-name>:<tag>`.
  109. pub base: String,
  110. }
  111. /// One `[[recipe_repos]]` entry: a git repo holding `recipes/` (and
  112. /// `docker-images/` build contexts), cloned/pulled by the launcher into a
  113. /// local cache and merged into the recipe set. List order is priority:
  114. /// a recipe name present in an earlier repo shadows the later one.
  115. #[derive(Clone, Debug, Deserialize)]
  116. pub struct RecipeRepo {
  117. /// Git URL (https/ssh/`file://`).
  118. pub url: String,
  119. /// Optional private-repo credential (deploy key / PAT); embedded in the
  120. /// clone URL only inside the local cache, never committed.
  121. #[serde(default)]
  122. pub token: Option<String>,
  123. }
  124. #[derive(Clone, Deserialize)]
  125. pub struct HomeAssistantConfig {
  126. pub url: String,
  127. pub token: String,
  128. #[serde(default = "default_shutdown_delay")]
  129. pub shutdown_delay_seconds: u64,
  130. }
  131. /// `[mikrotik]` — RouterOS devices shut down gracefully over the RouterOS
  132. /// API (default port 8728) during `power off`. A device's `name` is
  133. /// referenced verbatim from a cluster's `extra_entities` (or a node's
  134. /// `entity`); names not present here keep going through Home Assistant.
  135. #[derive(Clone, Deserialize)]
  136. pub struct MikrotikConfig {
  137. pub devices: Vec<MikrotikDevice>,
  138. }
  139. #[derive(Clone, Deserialize)]
  140. pub struct MikrotikDevice {
  141. /// Entity-style name used in cluster `extra_entities`.
  142. pub name: String,
  143. pub address: String,
  144. pub username: String,
  145. pub password: String,
  146. #[serde(default = "default_mikrotik_port")]
  147. pub api_port: u16,
  148. /// Optional HA relay entity that powers the router on (the API cannot
  149. /// wake an off device); without it, `power on` tells the operator to
  150. /// plug it in manually.
  151. #[serde(default)]
  152. pub on_entity: Option<String>,
  153. }
  154. fn default_mikrotik_port() -> u16 {
  155. 8728
  156. }
  157. fn default_shutdown_delay() -> u64 {
  158. 30
  159. }
  160. fn default_init_port() -> u16 {
  161. 25000
  162. }
  163. pub(crate) fn default_port() -> u16 {
  164. 30000
  165. }
  166. pub(crate) fn default_engine() -> String {
  167. "vllm".to_string()
  168. }
  169. pub(crate) fn default_shm_size() -> String {
  170. String::new()
  171. }
  172. pub(crate) fn default_tensor_parallel_size() -> u32 {
  173. 1
  174. }
  175. pub(crate) fn default_sync_image() -> bool {
  176. true
  177. }
  178. fn default_drop_caches() -> bool {
  179. true
  180. }
  181. /// Recipe-level build config: `versions` — a map of `versions.env` pin
  182. /// overrides merged over the image's pins before build (after the profile).
  183. #[derive(Clone, Debug, Default, Deserialize)]
  184. pub struct RecipeBuild {
  185. /// Pins merged over the image's `versions.env` (recipe wins). Keys are
  186. /// `versions.env` variable names; values the exact version/commit.
  187. #[serde(default)]
  188. pub versions: HashMap<String, String>,
  189. }
  190. /// A composed model directory: the launcher builds a `/tmp` dir per-node
  191. /// with symlinks into the container-side HF cache (`/root/.cache/...` —
  192. /// dangling on the host, resolved by the HF-cache mount in-container) +
  193. /// generated files, then bind-mounts it — combining multiple HF repos (a
  194. /// base checkpoint + an expert overlay) without mutating the HF cache.
  195. #[derive(Clone, Debug, Default, Deserialize)]
  196. pub struct ComposedModel {
  197. /// Container-side mount target for the composed dir (e.g. `/root/model`).
  198. /// `recipe.model` is rewritten to this path at serve time.
  199. pub mount: String,
  200. /// Source repos to symlink into the composed dir. Later entries win
  201. /// (overwrite earlier symlinks), so order matters for overlays.
  202. #[serde(default)]
  203. pub sources: Vec<ComposedSource>,
  204. /// Real files (not symlinks) to write into the composed dir.
  205. #[serde(default)]
  206. pub files: Vec<ComposedFile>,
  207. /// Real files HARDLINKED from a per-node local host path into the
  208. /// composed dir (e.g. a local NVMe copy of a table shard instead of the
  209. /// shared-NAS version the repo symlinks would point at). Later local
  210. /// files win over earlier sources. Must be on the node's local disk
  211. /// (same filesystem as /tmp) — hardlinked, not copied.
  212. #[serde(default)]
  213. pub local_files: Vec<ComposedLocalFile>,
  214. }
  215. /// A local file hardlinked into a [`ComposedModel`] dir (per node).
  216. /// Used to serve a shard from node-local storage (NVMe) while the rest of
  217. /// the model stays symlinked into the shared HF cache.
  218. #[derive(Clone, Debug, Default, Deserialize)]
  219. pub struct ComposedLocalFile {
  220. /// Host path on each node (leading `~`/`~/` expanded to the SSH user's
  221. /// home). Must exist on every launch node.
  222. pub host: String,
  223. /// Optional path inside the composed dir; defaults to the host basename.
  224. #[serde(default)]
  225. pub name: Option<String>,
  226. }
  227. /// A source repo for [`ComposedModel`]. The repo's HF cache snapshot is
  228. /// symlinked into the composed dir, optionally filtered by `glob` and
  229. /// `exclude`.
  230. #[derive(Clone, Debug, Default, Deserialize)]
  231. pub struct ComposedSource {
  232. /// HF Hub repo id (e.g. `aidendle94/GLM-5.2-MXFP4-Experts-GPTQ`).
  233. pub repo: String,
  234. /// Optional glob to filter which snapshot files are symlinked (e.g.
  235. /// `L*.safetensors` — only expert weight files). Empty/omitted = all
  236. /// files. Only glob-safe characters are allowed (alphanumeric + `*?._-/`).
  237. #[serde(default)]
  238. pub glob: Option<String>,
  239. /// Optional list of path patterns to skip (matched against the file's
  240. /// path relative to the snapshot root, via shell `case`). Lets a later
  241. /// source overlay skip specific files so an earlier source's version
  242. /// survives — e.g. `exclude = ["L5.safetensors"]` keeps the base
  243. /// checkpoint's `L5.safetensors` instead of the overlay's. Same allowed
  244. /// characters as `glob` (alphanumeric + `*?._-/`).
  245. #[serde(default)]
  246. pub exclude: Option<Vec<String>>,
  247. }
  248. /// A generated file written into the composed dir (not a symlink).
  249. #[derive(Clone, Debug, Default, Deserialize)]
  250. pub struct ComposedFile {
  251. /// Filename in the composed dir (e.g. `nf4_layers.json`).
  252. pub name: String,
  253. /// Raw file content (written verbatim via base64).
  254. pub content: String,
  255. }
  256. impl Config {
  257. pub fn resolved_hf_cache_dir(&self) -> std::path::PathBuf {
  258. if let Some(ref dir) = self.hf_cache_dir {
  259. return std::path::PathBuf::from(dir);
  260. }
  261. let home = if self.ssh_user == "root" {
  262. "/root".to_string()
  263. } else {
  264. format!("/home/{}", self.ssh_user)
  265. };
  266. std::path::PathBuf::from(format!("{home}/.cache/huggingface"))
  267. }
  268. }
  269. /// A recipe-level extra bind-mount for `docker run`.
  270. ///
  271. /// Two mutually exclusive kinds:
  272. /// - **Static** (`alias`/`host`): host path is known at recipe-write time.
  273. /// `alias` resolves against `Config.mount_aliases` and, when present there,
  274. /// overrides `host`. If the alias is absent from config, `host` is used as
  275. /// the default. `~` (and `~/`) at the start of the resolved path is expanded
  276. /// to the remote SSH user's home. At least one of `alias` or `host` must be
  277. /// set.
  278. /// - **Model path reference** (`repo` + optional `subfolder`): NOT a bind-mount.
  279. /// The path is resolved **at serve time, per node**, from the node's HF
  280. /// cache: `<hf_cache>/hub/<model_cache_dir_name(repo)>/snapshots/<refs/main>/<subfolder>`.
  281. /// `refs/main` always points at the latest commit, so the path survives
  282. /// `huggingface-cli download` updates — no fragile symlink. The recipe
  283. /// references this path via a `{{<container>}}` placeholder in args/env
  284. /// (e.g. `--speculative-config`), which the launcher substitutes per-node
  285. /// before building the docker command. The full HF cache is already mounted
  286. /// at `/root/.cache/huggingface`, so the resolved path's symlinks resolve
  287. /// correctly (a separate bind-mount would break them).
  288. /// If the subfolder is absent on a node, serve aborts with a hint to
  289. /// `huggingface-cli download <repo>` (the launcher never downloads).
  290. #[derive(Clone, Debug, Default, Deserialize)]
  291. pub struct ExtraMount {
  292. /// Name resolved via `Config.mount_aliases` (overrides `host`); else the
  293. /// fallback `host` path; both mutually exclusive with `repo`.
  294. #[serde(default)]
  295. pub alias: Option<String>,
  296. #[serde(default)]
  297. pub host: Option<String>,
  298. /// HF Hub repo id, e.g. `aidendle94/GLM-5.2-Hybrid-FP8-MXFP4`. When set,
  299. /// the path is resolved at serve time from the node's HF cache and
  300. /// substituted into args/env via `{{<container>}}` (so `alias`/`host`
  301. /// must be empty).
  302. #[serde(default)]
  303. pub repo: Option<String>,
  304. /// Subfolder within the repo snapshot (e.g. `mtp-draft`).
  305. /// Empty/omitted = the whole snapshot dir.
  306. #[serde(default)]
  307. pub subfolder: Option<String>,
  308. /// For static mounts: the container-side mount target. For repo-based
  309. /// path references: the placeholder name (e.g. `mtp_draft`) that gets
  310. /// substituted as `{{mtp_draft}}` in args/env.
  311. pub container: String,
  312. /// Mount read-only (`:ro`). Only for static mounts.
  313. #[serde(default)]
  314. pub read_only: bool,
  315. /// If set, inject `-e <env>=<container path>` so recipes don't hardcode
  316. /// the path in two places (mount + env). Only for static mounts.
  317. #[serde(default)]
  318. pub env: Option<String>,
  319. }
  320. /// Directory name a repo id maps to inside `<hf_cache>/hub/`.
  321. /// `org/name` -> `models--org--name` (HF convention: prefix `models--`,
  322. /// replace `/` with `--`). Pure & deterministic; used by the model-subfolder
  323. /// mount resolver.
  324. pub fn model_cache_dir_name(repo: &str) -> String {
  325. format!("models--{}", repo.replace('/', "--"))
  326. }
  327. /// Expand a leading `~` / `~/` to the remote SSH user's home directory.
  328. /// Paths without a leading `~` are returned unchanged. Used for host paths
  329. /// before they are sent to `docker run -v`.
  330. pub fn expand_tilde(path: &str, ssh_user: &str) -> String {
  331. let home = if ssh_user == "root" {
  332. "/root".to_string()
  333. } else {
  334. format!("/home/{}", ssh_user)
  335. };
  336. if let Some(rest) = path.strip_prefix("~/") {
  337. format!("{}/{}", home, rest)
  338. } else if path == "~" {
  339. home
  340. } else {
  341. path.to_string()
  342. }
  343. }
  344. /// Resolve a recipe `ExtraMount`'s host path: the config alias wins over the
  345. /// recipe `host` default; if neither is set, error. `~` is expanded.
  346. pub fn resolve_mount_host(
  347. alias: Option<&str>,
  348. host: Option<&str>,
  349. mount_aliases: &HashMap<String, String>,
  350. ssh_user: &str,
  351. container: &str,
  352. ) -> anyhow::Result<String> {
  353. let raw = alias
  354. .and_then(|a| {
  355. if a.trim().is_empty() {
  356. None
  357. } else {
  358. mount_aliases.get(a).map(String::as_str)
  359. }
  360. })
  361. .or_else(|| host.filter(|h| !h.trim().is_empty()))
  362. .ok_or_else(|| {
  363. anyhow::anyhow!(
  364. "extra_mount '{}' has neither a resolvable alias nor a host path",
  365. container
  366. )
  367. })?;
  368. Ok(expand_tilde(raw, ssh_user))
  369. }
  370. /// Deployment target — how the recipe is launched on each node.
  371. /// `docker` (default): `docker run` with the recipe's `entrypoint`.
  372. /// `tmux`: a detached `tmux` session running the engine directly (no Docker).
  373. /// Docker-only features (mods, composed_model, image sync) are rejected
  374. /// when `deploy = "tmux"`.
  375. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Deserialize)]
  376. #[serde(rename_all = "lowercase")]
  377. pub enum DeployTarget {
  378. #[default]
  379. Docker,
  380. Tmux,
  381. /// Bare SSH command on the head node (rank 0) driving an engine's own
  382. /// cluster manager (DGPP's `dgpp-cluster up/down`). Head-coordinated:
  383. /// the manager brings up the peers itself.
  384. Native,
  385. }
  386. #[cfg(test)]
  387. mod tests {
  388. use super::{expand_tilde, model_cache_dir_name, resolve_mount_host};
  389. use std::collections::HashMap;
  390. #[test]
  391. fn model_cache_dir_name_maps_repo_id() {
  392. assert_eq!(
  393. model_cache_dir_name("aidendle94/GLM-5.2-Hybrid-FP8-MXFP4"),
  394. "models--aidendle94--GLM-5.2-Hybrid-FP8-MXFP4"
  395. );
  396. assert_eq!(model_cache_dir_name("org/name"), "models--org--name");
  397. assert_eq!(model_cache_dir_name("bare"), "models--bare");
  398. }
  399. #[test]
  400. fn expand_tilde_user_and_root() {
  401. assert_eq!(expand_tilde("~/.cache", "kilork"), "/home/kilork/.cache");
  402. assert_eq!(expand_tilde("~/", "kilork"), "/home/kilork/");
  403. assert_eq!(expand_tilde("~", "root"), "/root");
  404. assert_eq!(expand_tilde("/abs/path", "kilork"), "/abs/path");
  405. assert_eq!(expand_tilde("rel/path", "kilork"), "rel/path");
  406. }
  407. #[test]
  408. fn resolve_mount_alias_wins_over_host() {
  409. let aliases = HashMap::from([("jit_cache".to_string(), "/mnt/nas/jit".to_string())]);
  410. // alias present in config → alias wins, host ignored
  411. let r = resolve_mount_host(Some("jit_cache"), Some("~/.cache/jit"), &aliases, "u", "/c");
  412. assert_eq!(r.unwrap(), "/mnt/nas/jit");
  413. }
  414. #[test]
  415. fn resolve_mount_falls_back_to_host_with_tilde() {
  416. let aliases = HashMap::new();
  417. // alias not in config → host default used, with ~ expansion
  418. let r = resolve_mount_host(
  419. Some("jit_cache"),
  420. Some("~/.cache/jit"),
  421. &aliases,
  422. "kilork",
  423. "/c",
  424. );
  425. assert_eq!(r.unwrap(), "/home/kilork/.cache/jit");
  426. }
  427. #[test]
  428. fn resolve_mount_host_only() {
  429. let aliases = HashMap::new();
  430. let r = resolve_mount_host(None, Some("/var/cache"), &aliases, "u", "/c");
  431. assert_eq!(r.unwrap(), "/var/cache");
  432. }
  433. #[test]
  434. fn resolve_mount_neither_errors() {
  435. let aliases = HashMap::new();
  436. let r = resolve_mount_host(None, None, &aliases, "u", "/c");
  437. assert!(r.is_err());
  438. }
  439. }