Register runners with a token; describe them in claims f98a8f7881e0
flow runner register HUB --token ... sends the runner's signing identity, architecture, OS, version and labels, gets back its own credential, and writes runner.toml (owner-only), which stands in for flow.toml's [runner]. The key is then bound at registration. Every claim carries the runner's description (RunnerInfo) for the hub's Runners page. [runner] labels are new in flow.toml. Runners configured with hub.toml tokens keep working. closes #12 refs andymccall/oxygenhub#41
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 11:18 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
f98a8f7881e0c9f44c43ffe288a8debb388daa7ea8d4687b3366603b2e8eb0e5- Depends on
- 1b2229dab1a4 47c1d64dec8a 9e8f4a56630b bb643c252515 e0918b887850
- Depended on by
- f3b5b86e23ef 0d663e91c598 3554afefb6aa 85275e1ef3b7 35f7a849bcc5 62829d15ef09 b11e30f5031d e2c125610f43 c58efc6a74db 1daca0b6bb8c 8c0a4e3a8a30 3007d8f011fa 2d025bbf9842
- In bookmarks
- main newer-pipeline push-uses-login
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 18 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/flow-core/src/api.rs +59 −0
| … | 18 unchanged lines |
||
| 19 | 19 | /// vCPUs the runner has free for another job. Absent: no limit. |
|
| 20 | 20 | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 21 | 21 | pub free_cpus: Option<u32>, |
|
| 22 | + | /// What the runner is, for the hub's Runners page. |
|
| 23 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 24 | + | pub info: Option<RunnerInfo>, |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | /// A runner's description: shown on the hub's Runners page, and what |
|
| 28 | + | /// scheduling by label and architecture will match. |
|
| 29 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
|
| 30 | + | pub struct RunnerInfo { |
|
| 31 | + | /// `x86_64`, `aarch64`, `riscv64`, … |
|
| 32 | + | pub arch: String, |
|
| 33 | + | pub os: String, |
|
| 34 | + | /// The `flow` version. |
|
| 35 | + | pub version: String, |
|
| 36 | + | /// From flow.toml `[runner] labels`. |
|
| 37 | + | #[serde(default)] |
|
| 38 | + | pub labels: Vec<String>, |
|
| 39 | + | /// Jobs at once, and the memory (MiB) and vCPUs they share. |
|
| 40 | + | pub max_jobs: u32, |
|
| 41 | + | #[serde(default)] |
|
| 42 | + | pub memory_mib: Option<u32>, |
|
| 43 | + | #[serde(default)] |
|
| 44 | + | pub cpus: Option<u32>, |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | impl RunnerInfo { |
|
| 48 | + | /// This machine and build, with the given labels and capacity. |
|
| 49 | + | pub fn here(labels: Vec<String>, max_jobs: u32, memory_mib: Option<u32>, cpus: Option<u32>) -> RunnerInfo { |
|
| 50 | + | RunnerInfo { |
|
| 51 | + | arch: std::env::consts::ARCH.into(), |
|
| 52 | + | os: std::env::consts::OS.into(), |
|
| 53 | + | version: env!("CARGO_PKG_VERSION").into(), |
|
| 54 | + | labels, |
|
| 55 | + | max_jobs, |
|
| 56 | + | memory_mib, |
|
| 57 | + | cpus, |
|
| 58 | + | } |
|
| 59 | + | } |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | /// `POST /api/v1/flow/runners/register`: exchange a one-time registration |
|
| 63 | + | /// token from the hub for a runner credential, binding the runner's |
|
| 64 | + | /// signing identity to it. |
|
| 65 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 66 | + | pub struct Register { |
|
| 67 | + | pub token: String, |
|
| 68 | + | pub name: String, |
|
| 69 | + | /// The runner's Oxygen identity, hex of its encoding: its key signs |
|
| 70 | + | /// every receipt, and the hub accepts no other for this runner. |
|
| 71 | + | pub identity: String, |
|
| 72 | + | pub info: RunnerInfo, |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 76 | + | pub struct Registered { |
|
| 77 | + | pub id: i64, |
|
| 78 | + | pub name: String, |
|
| 79 | + | /// The runner's bearer token from now on; shown once. |
|
| 80 | + | pub credential: String, |
|
| 22 | 81 | } |
|
| 23 | 82 | ||
| 24 | 83 | /// A claimed job: everything the runner needs, nothing more. |
|
| … | 55 unchanged lines |
modified crates/flow/src/home.rs +14 −1
| … | 36 unchanged lines |
||
| 37 | 37 | pub init: Pinned, |
|
| 38 | 38 | } |
|
| 39 | 39 | ||
| 40 | + | /// Where `flow runner register` keeps the runner's hub and credential. |
|
| 41 | + | pub const RUNNER_TOML: &str = "runner.toml"; |
|
| 42 | + | ||
| 40 | 43 | #[derive(Clone, Debug, Deserialize)] |
|
| 41 | 44 | pub struct RunnerConfig { |
|
| 42 | 45 | pub hub: String, |
|
| … | 3 unchanged lines |
||
| 46 | 49 | /// Jobs run at once (default 1). |
|
| 47 | 50 | #[serde(default)] |
|
| 48 | 51 | pub max_jobs: Option<usize>, |
|
| 52 | + | /// Labels the runner offers, e.g. `gpu`, `big-disk`. |
|
| 53 | + | #[serde(default)] |
|
| 54 | + | pub labels: Vec<String>, |
|
| 49 | 55 | /// Memory (MiB) and vCPUs the jobs running at once may use together. |
|
| 50 | 56 | #[serde(default)] |
|
| 51 | 57 | pub memory_mib: Option<u32>, |
|
| … | 24 unchanged lines |
||
| 76 | 82 | }, |
|
| 77 | 83 | }; |
|
| 78 | 84 | let file = root.join("flow.toml"); |
|
| 79 | - | let config = if file.is_file() { |
|
| 85 | + | let mut config: Config = if file.is_file() { |
|
| 80 | 86 | let text = std::fs::read_to_string(&file).with_context(|| format!("read {}", file.display()))?; |
|
| 81 | 87 | toml::from_str(&text).with_context(|| format!("parse {}", file.display()))? |
|
| 82 | 88 | } else { |
|
| 83 | 89 | Config::default() |
|
| 84 | 90 | }; |
|
| 91 | + | // `flow runner register` writes the runner's hub and credential to |
|
| 92 | + | // runner.toml, which stands in for a [runner] section flow.toml lacks. |
|
| 93 | + | let registered = root.join(RUNNER_TOML); |
|
| 94 | + | if config.runner.is_none() && registered.is_file() { |
|
| 95 | + | let text = std::fs::read_to_string(®istered).with_context(|| format!("read {}", registered.display()))?; |
|
| 96 | + | config.runner = Some(toml::from_str(&text).with_context(|| format!("parse {}", registered.display()))?); |
|
| 97 | + | } |
|
| 85 | 98 | for d in ["cas", "work", "repos"] { |
|
| 86 | 99 | std::fs::create_dir_all(root.join(d)).with_context(|| format!("create {}", root.join(d).display()))?; |
|
| 87 | 100 | } |
|
| … | 96 unchanged lines |
modified crates/flow/src/main.rs +49 −3
| … | 27 unchanged lines |
||
| 28 | 28 | } |
|
| 29 | 29 | ||
| 30 | 30 | #[derive(Subcommand)] |
|
| 31 | + | enum RunnerCmd { |
|
| 32 | + | /// Register this runner with a hub using a one-time token from its Admin → Runners page |
|
| 33 | + | Register { |
|
| 34 | + | /// The hub, e.g. https://earth.example:7343 |
|
| 35 | + | hub: String, |
|
| 36 | + | /// The registration token (read from stdin when not given) |
|
| 37 | + | #[arg(long)] |
|
| 38 | + | token: Option<String>, |
|
| 39 | + | /// The runner's name (default: the host name) |
|
| 40 | + | #[arg(long)] |
|
| 41 | + | name: Option<String>, |
|
| 42 | + | /// The hub's CA certificate, when self-signed |
|
| 43 | + | #[arg(long)] |
|
| 44 | + | ca: Option<PathBuf>, |
|
| 45 | + | /// A label the runner offers (repeatable) |
|
| 46 | + | #[arg(long)] |
|
| 47 | + | label: Vec<String>, |
|
| 48 | + | }, |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | #[derive(Subcommand)] |
|
| 31 | 52 | enum Cmd { |
|
| 32 | 53 | /// Validate a pipeline and print its jobs and hash |
|
| 33 | 54 | Check { |
|
| … | 17 unchanged lines |
||
| 51 | 72 | /// Minutes before a job is stopped |
|
| 52 | 73 | #[arg(long, default_value_t = 60)] |
|
| 53 | 74 | timeout: u64, |
|
| 75 | + | }, |
|
| 76 | + | /// Claim and run jobs for the hub in flow.toml's [runner] (or the |
|
| 77 | + | /// runner.toml `flow runner register` wrote) |
|
| 78 | + | Runner { |
|
| 79 | + | #[command(subcommand)] |
|
| 80 | + | cmd: Option<RunnerCmd>, |
|
| 54 | 81 | }, |
|
| 55 | - | /// Claim and run jobs for the hub in flow.toml's [runner] |
|
| 56 | - | Runner, |
|
| 57 | 82 | /// Follow the checks of a pushed state on OxygenHub until they finish: |
|
| 58 | 83 | /// exit 0 when all passed, 1 when one failed, 2 at the time limit. |
|
| 59 | 84 | /// Uses $OXY_TOKEN and $OXY_CA as oxy does. |
|
| … | 54 unchanged lines |
||
| 114 | 139 | Cmd::Run { jobs, dir, timeout } => { |
|
| 115 | 140 | let home = Home::locate(cli.home.as_deref())?; |
|
| 116 | 141 | run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60)) |
|
| 142 | + | } |
|
| 143 | + | Cmd::Runner { cmd: Some(RunnerCmd::Register { hub, token, name, ca, label }) } => { |
|
| 144 | + | let home = Home::locate(cli.home.as_deref())?; |
|
| 145 | + | if std::env::var_os("OXY_HOME").is_none() { |
|
| 146 | + | std::env::set_var("OXY_HOME", home.root.join("oxy")); |
|
| 147 | + | } |
|
| 148 | + | let name = name.unwrap_or_else(|| { |
|
| 149 | + | std::fs::read_to_string("/etc/hostname").map(|h| h.trim().to_string()).unwrap_or_else(|_| "flow".into()) |
|
| 150 | + | }); |
|
| 151 | + | let token = match token { |
|
| 152 | + | Some(t) => t, |
|
| 153 | + | None => { |
|
| 154 | + | eprintln!("Paste the registration token from the hub's Admin → Runners, then press Enter:"); |
|
| 155 | + | let mut line = String::new(); |
|
| 156 | + | std::io::stdin().read_line(&mut line)?; |
|
| 157 | + | line.trim().to_string() |
|
| 158 | + | } |
|
| 159 | + | }; |
|
| 160 | + | let got = runner::register(&home, &hub, &token, &name, ca, label)?; |
|
| 161 | + | println!("registered as {} (runner {}); start it with `flow runner`", got.name, got.id); |
|
| 162 | + | Ok(()) |
|
| 117 | 163 | } |
|
| 118 | - | Cmd::Runner => { |
|
| 164 | + | Cmd::Runner { cmd: None } => { |
|
| 119 | 165 | let home = Home::locate(cli.home.as_deref())?; |
|
| 120 | 166 | // The runner's oxy state (identity, user config) stays in its home. |
|
| 121 | 167 | if std::env::var_os("OXY_HOME").is_none() { |
|
| … | 155 unchanged lines |
modified crates/flow/src/runner.rs +117 −2
| … | 5 unchanged lines |
||
| 6 | 6 | use crate::home::{Home, RunnerConfig, RunnerKey}; |
|
| 7 | 7 | use crate::{microvm, workspace}; |
|
| 8 | 8 | use anyhow::{bail, Context, Result}; |
|
| 9 | - | use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, Record, MAX_RECORD_TEXT}; |
|
| 9 | + | use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, Record, Register, Registered, RunnerInfo, MAX_RECORD_TEXT}; |
|
| 10 | 10 | use flow_core::{scope_tree_hash, Receipt}; |
|
| 11 | 11 | use oxy_core::hash::ChangeHash; |
|
| 12 | 12 | use oxy_core::repo::Repo; |
|
| … | 54 unchanged lines |
||
| 67 | 67 | pub fn finish(&self, job: i64, f: &Finish) -> Result<()> { |
|
| 68 | 68 | self.post(&format!("/flow/jobs/{job}/finish"), f)?; |
|
| 69 | 69 | Ok(()) |
|
| 70 | + | } |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | /// `flow runner register`: exchange a one-time registration token from |
|
| 74 | + | /// the hub for this runner's credential, binding the runner's signing key, |
|
| 75 | + | /// and keep hub, credential and name in runner.toml. |
|
| 76 | + | pub fn register(home: &Home, hub: &str, token: &str, name: &str, ca: Option<PathBuf>, labels: Vec<String>) -> Result<Registered> { |
|
| 77 | + | if home.config.runner.is_some() && !home.root.join(crate::home::RUNNER_TOML).is_file() { |
|
| 78 | + | bail!("flow.toml already has a [runner] section; remove it to register this runner instead"); |
|
| 79 | + | } |
|
| 80 | + | let key = home.runner_key(name)?; |
|
| 81 | + | let hub = hub.trim_end_matches('/').to_string(); |
|
| 82 | + | let tls = oxy_core::protocol::tls_client_config(ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?; |
|
| 83 | + | let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build(); |
|
| 84 | + | let req = Register { |
|
| 85 | + | token: token.trim().to_string(), |
|
| 86 | + | name: name.to_string(), |
|
| 87 | + | identity: hex::encode(key.identity.encode()), |
|
| 88 | + | info: RunnerInfo::here(labels.clone(), 1, None, None), |
|
| 89 | + | }; |
|
| 90 | + | let url = format!("{hub}/api/v1/flow/runners/register"); |
|
| 91 | + | let got: Registered = match agent.post(&url).send_json(&req) { |
|
| 92 | + | Ok(r) => r.into_json()?, |
|
| 93 | + | Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()), |
|
| 94 | + | Err(e) => bail!("{url}: {e}"), |
|
| 95 | + | }; |
|
| 96 | + | let mut text = format!( |
|
| 97 | + | "# Written by `flow runner register`; the credential is secret.\nhub = {hub:?}\ntoken = {:?}\nname = {:?}\n", |
|
| 98 | + | got.credential, got.name |
|
| 99 | + | ); |
|
| 100 | + | if let Some(ca) = &ca { |
|
| 101 | + | text.push_str(&format!("ca = {:?}\n", ca.display().to_string())); |
|
| 102 | + | } |
|
| 103 | + | if !labels.is_empty() { |
|
| 104 | + | text.push_str(&format!("labels = {labels:?}\n")); |
|
| 105 | + | } |
|
| 106 | + | let path = home.root.join(crate::home::RUNNER_TOML); |
|
| 107 | + | { |
|
| 108 | + | use std::io::Write; |
|
| 109 | + | use std::os::unix::fs::OpenOptionsExt; |
|
| 110 | + | let tmp = path.with_extension("toml.tmp"); |
|
| 111 | + | let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?; |
|
| 112 | + | f.write_all(text.as_bytes())?; |
|
| 113 | + | f.sync_all()?; |
|
| 114 | + | std::fs::rename(&tmp, &path)?; |
|
| 70 | 115 | } |
|
| 116 | + | Ok(got) |
|
| 71 | 117 | } |
|
| 72 | 118 | ||
| 73 | 119 | /// Waits before trying a hub request again after a server error. |
|
| … | 60 unchanged lines |
||
| 134 | 180 | std::thread::sleep(Duration::from_millis(500)); |
|
| 135 | 181 | continue; |
|
| 136 | 182 | }; |
|
| 137 | - | let req = ClaimRequest { runner: name.clone(), runtimes: vec!["microvm".into()], free_memory_mib, free_cpus }; |
|
| 183 | + | let req = ClaimRequest { |
|
| 184 | + | runner: name.clone(), |
|
| 185 | + | runtimes: vec!["microvm".into()], |
|
| 186 | + | free_memory_mib, |
|
| 187 | + | free_cpus, |
|
| 188 | + | info: Some(RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)), |
|
| 189 | + | }; |
|
| 138 | 190 | match hub.claim(&req) { |
|
| 139 | 191 | Ok(Some(claim)) => { |
|
| 140 | 192 | idle = Duration::from_secs(2); |
|
| … | 229 unchanged lines |
||
| 370 | 422 | } |
|
| 371 | 423 | }); |
|
| 372 | 424 | (format!("http://{addr}"), seen) |
|
| 425 | + | } |
|
| 426 | + | ||
| 427 | + | /// A hub that answers one request with `body` (status 200) and hands |
|
| 428 | + | /// back what it was sent. |
|
| 429 | + | fn fake_json_hub(body: &'static str) -> (String, std::sync::mpsc::Receiver<String>) { |
|
| 430 | + | use std::io::{Read, Write}; |
|
| 431 | + | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
|
| 432 | + | let addr = listener.local_addr().unwrap(); |
|
| 433 | + | let (tx, rx) = std::sync::mpsc::channel(); |
|
| 434 | + | std::thread::spawn(move || { |
|
| 435 | + | let (mut s, _) = listener.accept().unwrap(); |
|
| 436 | + | let mut req = Vec::new(); |
|
| 437 | + | let mut buf = [0u8; 65536]; |
|
| 438 | + | loop { |
|
| 439 | + | let n = s.read(&mut buf).unwrap_or(0); |
|
| 440 | + | if n == 0 { |
|
| 441 | + | break; |
|
| 442 | + | } |
|
| 443 | + | req.extend_from_slice(&buf[..n]); |
|
| 444 | + | let text = String::from_utf8_lossy(&req); |
|
| 445 | + | if let Some(end) = text.find("\r\n\r\n") { |
|
| 446 | + | let len = text[..end] |
|
| 447 | + | .lines() |
|
| 448 | + | .find_map(|l| { |
|
| 449 | + | l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)) |
|
| 450 | + | }) |
|
| 451 | + | .unwrap_or(0); |
|
| 452 | + | if req.len() >= end + 4 + len { |
|
| 453 | + | break; |
|
| 454 | + | } |
|
| 455 | + | } |
|
| 456 | + | } |
|
| 457 | + | tx.send(String::from_utf8_lossy(&req).into_owned()).unwrap(); |
|
| 458 | + | let _ = write!( |
|
| 459 | + | s, |
|
| 460 | + | "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", |
|
| 461 | + | body.len() |
|
| 462 | + | ); |
|
| 463 | + | }); |
|
| 464 | + | (format!("http://{addr}"), rx) |
|
| 465 | + | } |
|
| 466 | + | ||
| 467 | + | #[test] |
|
| 468 | + | fn registering_binds_the_key_and_keeps_the_credential_private() { |
|
| 469 | + | use std::os::unix::fs::PermissionsExt; |
|
| 470 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 471 | + | let home = Home::locate(Some(dir.path())).unwrap(); |
|
| 472 | + | let (hub, sent) = fake_json_hub(r#"{"id":7,"name":"plex","credential":"flowrunner_secret"}"#); |
|
| 473 | + | let got = register(&home, &hub, " reg-token ", "plex", None, vec!["gpu".into()]).unwrap(); |
|
| 474 | + | assert_eq!((got.id, got.name.as_str()), (7, "plex")); |
|
| 475 | + | let req = sent.recv().unwrap(); |
|
| 476 | + | assert!(req.starts_with("POST /api/v1/flow/runners/register"), "{req}"); |
|
| 477 | + | let body: Register = serde_json::from_str(&req[req.find("\r\n\r\n").unwrap() + 4..]).unwrap(); |
|
| 478 | + | assert_eq!((body.token.as_str(), body.info.labels.clone()), ("reg-token", vec!["gpu".to_string()])); |
|
| 479 | + | let key = home.runner_key("plex").unwrap(); |
|
| 480 | + | assert_eq!(body.identity, hex::encode(key.identity.encode()), "the runner's signing identity"); |
|
| 481 | + | let path = dir.path().join(crate::home::RUNNER_TOML); |
|
| 482 | + | assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); |
|
| 483 | + | // The runner then starts from runner.toml. |
|
| 484 | + | let again = Home::locate(Some(dir.path())).unwrap(); |
|
| 485 | + | let cfg = again.config.runner.unwrap(); |
|
| 486 | + | assert_eq!((cfg.hub.as_str(), cfg.token.as_str(), cfg.name.as_deref()), (hub.as_str(), "flowrunner_secret", Some("plex"))); |
|
| 487 | + | assert_eq!(cfg.labels, ["gpu"]); |
|
| 373 | 488 | } |
|
| 374 | 489 | ||
| 375 | 490 | fn hub_at(base: String) -> Hub { |
|
| … | 36 unchanged lines |
modified docs/m0.md +7 −0
| … | 127 unchanged lines |
||
| 128 | 128 | hashed as `BLAKE3("oxy1 receipt " || len || 0x00 || body)`, the `receipt` |
|
| 129 | 129 | object of the Oxygen spec (§14.6). |
|
| 130 | 130 | ||
| 131 | + | **Registering a runner.** |
|
| 132 | + | - **Token:** a hub administrator creates a one-time registration token on Admin → Runners. |
|
| 133 | + | - **Registering:** `flow runner register https://hub --token … [--name N] [--ca PATH] [--label L]…` sends the runner's signing identity, architecture, OS, version and labels (`POST /api/v1/flow/runners/register`). It gets back the runner's own credential and writes hub, credential, name, CA and labels to `runner.toml` (owner-only). That file stands in for a `[runner]` section `flow.toml` lacks, and `flow runner` then starts from it. |
|
| 134 | + | - **Key:** registration binds the key; the hub accepts receipts from that runner signed by no other. |
|
| 135 | + | - **Claims:** every claim carries the runner's description (`info`: architecture, OS, version, labels, capacity) for the Runners page. |
|
| 136 | + | - **Old configuration:** runners configured with a `hub.toml` token still work. Their key is bound on their first signed receipt. |
|
| 137 | + | ||
| 131 | 138 | **Signed receipts.** A runner keeps a composite key in its home |
|
| 132 | 139 | (`runner.key`, owner-only, and `runner.identity`), made on first start and |
|
| 133 | 140 | named after the runner. After each job it binds the receipt to the state of |
|
| … | 46 unchanged lines |