OXYGENHUB
# c23d519168d8 oxygenflow / crates / flow / src / runner.rs
10.0 KiB · Rust · last changed Pinned subrepos in job workspaces: the claim lists them, the runner fetches each with the job token and exports its pinned state at its path, inside the scope hash 1 month ago source blame history raw
1
//! `flow runner` (docs/m0.md §5): claim a job from OxygenHub, fetch the
2
//! repository into a cache with the job's token, write the state into a
3
//! workspace, run it in its tier, stream the log, report the receipt.
4
//! One job at a time.
5
6
use crate::home::{Home, RunnerConfig};
7
use crate::{microvm, workspace};
8
use anyhow::{bail, Context, Result};
9
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, Record, MAX_RECORD_TEXT};
10
use flow_core::{scope_tree_hash, Receipt};
11
use oxy_core::hash::ChangeHash;
12
use oxy_core::repo::Repo;
13
use std::path::PathBuf;
14
use std::time::{Duration, Instant};
15
16
pub struct Hub {
17
    base: String,
18
    token: String,
19
    agent: ureq::Agent,
20
}
21
22
impl Hub {
23
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
24
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
25
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
26
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
27
    }
28
29
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
30
        let url = format!("{}/api/v1{path}", self.base);
31
        match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) {
32
            Ok(r) if r.status() == 204 => Ok(None),
33
            Ok(r) => Ok(Some(r)),
34
            Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
35
            Err(e) => bail!("{url}: {e}"),
36
        }
37
    }
38
39
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
40
        match self.post("/flow/claim", req)? {
41
            Some(r) => Ok(Some(r.into_json()?)),
42
            None => Ok(None),
43
        }
44
    }
45
46
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
47
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
48
        Ok(())
49
    }
50
51
    pub fn finish(&self, job: i64, f: &Finish) -> Result<()> {
52
        self.post(&format!("/flow/jobs/{job}/finish"), f)?;
53
        Ok(())
54
    }
55
}
56
57
pub fn serve(home: &Home) -> Result<()> {
58
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
59
    let hub = Hub::new(&cfg)?;
60
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
61
    let req = ClaimRequest { runner: name.clone(), runtimes: vec!["microvm".into()] };
62
    eprintln!("flow runner {name}: polling {}", cfg.hub);
63
    let mut idle = Duration::from_secs(2);
64
    loop {
65
        match hub.claim(&req) {
66
            Ok(Some(claim)) => {
67
                idle = Duration::from_secs(2);
68
                let id = claim.job_id;
69
                eprintln!("job {id}: {} {} on {}", claim.repo, claim.job.name, claim.heads.first().map(|h| &h[..12]).unwrap_or("-"));
70
                let finish = run_claim(home, &hub, &cfg, &claim);
71
                let finish =
72
                    finish.unwrap_or_else(|e| Finish { exit_code: None, duration_ms: 0, receipt: None, error: Some(format!("{e:#}")) });
73
                eprintln!(
74
                    "job {id}: exit {:?}{}",
75
                    finish.exit_code,
76
                    finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
77
                );
78
                if let Err(e) = hub.finish(id, &finish) {
79
                    eprintln!("job {id}: reporting failed: {e:#}");
80
                }
81
            }
82
            Ok(None) => std::thread::sleep(idle),
83
            Err(e) => {
84
                eprintln!("claim: {e:#}");
85
                std::thread::sleep(idle);
86
                idle = (idle * 2).min(Duration::from_secs(60));
87
            }
88
        }
89
    }
90
}
91
92
/// Batches records to the hub every second (or every 200 lines).
93
struct LogSink<'a> {
94
    hub: &'a Hub,
95
    job: i64,
96
    buf: Vec<Record>,
97
    last: Instant,
98
}
99
100
impl LogSink<'_> {
101
    fn push(&mut self, mut r: Record) {
102
        if r.text.len() > MAX_RECORD_TEXT {
103
            let mut cut = MAX_RECORD_TEXT;
104
            while !r.text.is_char_boundary(cut) {
105
                cut -= 1;
106
            }
107
            r.text.truncate(cut);
108
        }
109
        self.buf.push(r);
110
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
111
            self.flush();
112
        }
113
    }
114
115
    fn flush(&mut self) {
116
        if !self.buf.is_empty() {
117
            if let Err(e) = self.hub.log(self.job, std::mem::take(&mut self.buf)) {
118
                eprintln!("job {}: log: {e:#}", self.job);
119
            }
120
        }
121
        self.last = Instant::now();
122
    }
123
}
124
125
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, claim: &Claim) -> Result<Finish> {
126
    let started = Instant::now();
127
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now() };
128
    let note =
129
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
130
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
131
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
132
    let repo = cached_repo(home, cfg, claim)?;
133
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
134
    let _ = std::fs::remove_dir_all(&scratch);
135
    std::fs::create_dir_all(scratch.join("tree"))?;
136
    let result = (|| -> Result<Finish> {
137
        let mut files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
138
        // Pinned subrepos (Oxygen spec §7.8), at their paths, as part of the
139
        // workspace and of the scope hash.
140
        for sub in &claim.subrepos {
141
            if sub.path.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == ".oxy") {
142
                bail!("subrepo path {:?} is not a clean relative path", sub.path);
143
            }
144
            note(
145
                &mut sink,
146
                format!(
147
                    "fetching subrepo {} at {} for {}",
148
                    sub.repo,
149
                    sub.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+"),
150
                    sub.path
151
                ),
152
            );
153
            let sub_repo = cached_repo_at(home, cfg, &sub.repo, &sub.clone_url, &claim.token)?;
154
            let sub_heads: Vec<ChangeHash> =
155
                sub.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad subrepo head")).collect::<Result<_>>()?;
156
            let dir = scratch.join("tree").join(&sub.path);
157
            std::fs::create_dir_all(&dir)?;
158
            let sub_files = workspace::export_state(&sub_repo, &sub_heads, &dir)?;
159
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
160
        }
161
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
162
        workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
163
        std::fs::remove_dir_all(scratch.join("tree"))?;
164
        note(
165
            &mut sink,
166
            format!(
167
                "booting a microVM: {} vCPU, {} MiB, rootfs {}",
168
                claim.sandbox.cpus,
169
                claim.sandbox.memory_mib,
170
                &claim.sandbox.rootfs[..12]
171
            ),
172
        );
173
        let outcome = microvm::run(
174
            home,
175
            &claim.sandbox,
176
            &claim.job,
177
            &scratch.join("workspace.ext4"),
178
            &scratch,
179
            Duration::from_secs(claim.timeout_s),
180
            &mut |r| sink.push(r),
181
        )?;
182
        let receipt = outcome.exit_code.map(|code| Receipt {
183
            job_name: claim.job.name.clone(),
184
            pipeline_hash: hex32(&claim.pipeline_hash),
185
            scope_tree_hash: scope_hash,
186
            inputs: vec![],
187
            environment: outcome.environment.hash(),
188
            outputs: vec![],
189
            exit_code: code as u32,
190
            duration_ms: outcome.duration_ms,
191
        });
192
        Ok(Finish {
193
            exit_code: outcome.exit_code,
194
            duration_ms: started.elapsed().as_millis() as u64,
195
            receipt,
196
            error: outcome.timed_out.then(|| "stopped at the time limit".to_string()),
197
        })
198
    })();
199
    let _ = std::fs::remove_dir_all(&scratch);
200
    sink.flush();
201
    result
202
}
203
204
fn hex32(s: &str) -> [u8; 32] {
205
    let mut out = [0; 32];
206
    if let Ok(v) = hex::decode(s) {
207
        if v.len() == 32 {
208
            out.copy_from_slice(&v);
209
        }
210
    }
211
    out
212
}
213
214
/// The runner's copy of a repository, fetched up to date with the job's token.
215
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
216
    cached_repo_at(home, cfg, &claim.repo, &claim.clone_url, &claim.token)
217
}
218
219
/// The runner's copy of `repo`, cloned or fetched with `token`.
220
fn cached_repo_at(home: &Home, cfg: &RunnerConfig, repo_name: &str, clone_url: &str, token: &str) -> Result<Repo> {
221
    let dir: PathBuf = home.root.join("repos").join(repo_name.replace('/', "__"));
222
    let set_remote = |repo: &mut Repo| -> Result<()> {
223
        repo.config.set("remote.origin.url", clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
224
        repo.config.set("remote.origin.token", token).map_err(|e| anyhow::anyhow!("{e}"))?;
225
        if let Some(ca) = &cfg.ca {
226
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
227
        }
228
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
229
    };
230
    if dir.join(".oxy").is_dir() {
231
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
232
        set_remote(&mut repo)?;
233
        repo.fetch("origin").map_err(|e| anyhow::anyhow!("fetch {clone_url}: {e}"))?;
234
        return Ok(repo);
235
    }
236
    // First job for this repository: clone with the job's token.
237
    std::env::set_var("OXY_TOKEN", token);
238
    if let Some(ca) = &cfg.ca {
239
        std::env::set_var("OXY_CA", ca);
240
    }
241
    let cloned = Repo::clone(clone_url, &dir);
242
    std::env::remove_var("OXY_TOKEN");
243
    std::env::remove_var("OXY_CA");
244
    let mut repo = cloned.map_err(|e| {
245
        let _ = std::fs::remove_dir_all(&dir);
246
        anyhow::anyhow!("clone {}: {e}", clone_url)
247
    })?;
248
    set_remote(&mut repo)?;
249
    Ok(repo)
250
}