OXYGENHUB

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(&registered).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