OXYGENHUB
# 5dead1f4f26d oxygenhub / crates / hub / src / flow.rs
39.1 KiB · Rust · last changed Admin runner settings: job time limits; each job runs under its own 1 month ago source blame history raw
42fa9554 Andy McCall ↶ 1
//! OxygenFlow on the hub (hub spec §13.2): runs started by pushes, the
2
//! runner API, job tokens and the checks that mirror jobs. Pipelines are
3
//! parsed by `flow-core`, the same code `flow check` runs.
4
5
use crate::api::ApiError;
6
use crate::db::{self, flow as fdb};
7
use crate::repos::RepoName;
8
use crate::Shared;
9
use axum::extract::{Path, State};
10
use axum::http::{HeaderMap, StatusCode};
11
use axum::response::{IntoResponse, Response};
12
use axum::Json;
13
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, MAX_RECORDS, MAX_RECORD_TEXT};
14
use flow_core::{Pipeline, PIPELINE_PATH};
15
use oxy_core::hash::ChangeHash;
16
17
/// Prefix of job tokens, so the wire protocol can tell them from personal tokens.
18
pub const JOB_TOKEN_PREFIX: &str = "flowjob_";
19
/// A job token is refused this long after its job started, whatever its state.
20
pub const JOB_TOKEN_MAX_AGE_MS: i64 = 2 * 3600 * 1000;
21
9008bf6c Andy McCall ↶ 22
/// Whether pushes to this repository start runs: its own setting when a
23
/// site administrator made one, else hub.toml's `[flow] repos`.
24
pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool {
25
    match row.flow.as_str() {
26
        "on" => true,
27
        "off" => false,
28
        _ => app.config.flow.allows(&row.owner, &row.name),
29
    }
30
}
31
42fa9554 Andy McCall ↶ 32
/// A bookmark moved by a push: start a run when the repository is allowed
33
/// and the new state has a pipeline.
34
pub async fn on_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, state: &str, heads: &[ChangeHash], actor: &str) {
9008bf6c Andy McCall ↶ 35
    if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) {
42fa9554 Andy McCall ↶ 36
        return;
37
    }
d7ce41ac Andy McCall ↶ 38
    let touched = touched_since_last_run(app, row, bookmark, heads).await;
39
    if let Err(e) = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await {
1edac4b6 Andy McCall ↶ 40
        tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name);
41
    }
42
}
43
44
/// "Run pipeline" (hub spec §13.2): run a bookmark's current state without
45
/// moving it. Returns the new run's number.
46
pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> {
47
    if !enabled(app, row) {
48
        return Err("OxygenFlow is not enabled for this repository".into());
49
    }
50
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
51
    let b = bookmark.to_string();
52
    let found = app
53
        .repos
54
        .read(&repo, move |r, _| {
55
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
56
            Ok(Some((st, r.store().get_state(&st)?.heads)))
57
        })
58
        .await
59
        .map_err(|e| e.to_string())?;
60
    let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) };
61
    if heads.is_empty() {
62
        return Err(format!("{bookmark} has no changes yet"));
63
    }
64
    start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual")
65
        .await?
66
        .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}"))
67
}
68
4f692533 Andy McCall ↶ 69
/// "Run pipeline" on one committed change: the state whose only head is
70
/// `change`, whatever bookmarks hold it. Returns the new run's number.
71
pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> {
72
    if !enabled(app, row) {
73
        return Err("OxygenFlow is not enabled for this repository".into());
74
    }
75
    let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?;
76
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
df15b747 Andy McCall ↶ 77
    let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change_meta(&h).is_ok())).await.map_err(|e| e.to_string())?;
4f692533 Andy McCall ↶ 78
    if !known {
79
        return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())]));
80
    }
81
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode());
82
    start_run(app, row, "", &state.to_hex(), &[h], actor, "manual")
83
        .await?
84
        .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}"))
85
}
86
1edac4b6 Andy McCall ↶ 87
/// Re-run a finished run: the same state, bookmark and pipeline, as a new
88
/// run whose checks replace the old ones. Returns the new run's number.
89
pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> {
90
    if !enabled(app, row) {
91
        return Err("OxygenFlow is not enabled for this repository".into());
92
    }
93
    if run.live() {
94
        return Err(format!("run #{} is still {}", run.number, run.status));
95
    }
96
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
97
    if heads.is_empty() {
98
        return Err("the run names no changes".into());
99
    }
100
    start_run(app, row, &run.bookmark, &run.state, &heads, actor, &format!("rerun:{}", run.number))
101
        .await?
102
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
103
}
104
d7ce41ac Andy McCall ↶ 105
/// The paths the changes a push brought touch: the changes in `heads`'
106
/// state that the bookmark's previous run did not have, and the files their
107
/// edits and tree operations name, as the new state places them. `None`
108
/// (run every job) for the bookmark's first run, or when it cannot be told.
109
async fn touched_since_last_run(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Option<Vec<String>> {
110
    let last = fdb::latest_on(&app.db, row.id, bookmark).await.ok()??;
111
    let old: Vec<ChangeHash> = last.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
112
    if old.is_empty() {
113
        return None;
114
    }
115
    let repo = RepoName::new(&row.owner, &row.name)?;
116
    let new = heads.to_vec();
117
    app.repos
118
        .read(&repo, move |r, cache| {
119
            let fresh = r.negotiate(&old, &new)?;
120
            let loaded = cache.load(r, &new)?;
121
            let by_node: std::collections::HashMap<oxy_core::ids::NodeId, &String> =
122
                loaded.snapshot.files.iter().map(|(p, f)| (f.node, p)).collect();
123
            let mut paths = std::collections::BTreeSet::new();
124
            for h in fresh {
125
                let (c, _) = r.store().get_change_lazy(&h)?;
126
                let nodes = c.edits.iter().map(|e| e.node()).chain(c.tree_ops.iter().map(|o| o.node()));
127
                for n in nodes {
128
                    if let Some(p) = by_node.get(&n) {
129
                        paths.insert((*p).clone());
130
                    }
131
                }
132
            }
133
            Ok(paths.into_iter().collect::<Vec<String>>())
134
        })
135
        .await
136
        .ok()
137
}
138
139
/// Whether a job runs for a push that touched `touched`: always without a
140
/// scope or without knowing what was touched, else when a path matches.
141
fn in_scope(scope: &[String], touched: Option<&[String]>) -> bool {
142
    let Some(touched) = touched else { return true };
143
    if scope.is_empty() {
144
        return true;
145
    }
146
    let mut b = globset::GlobSetBuilder::new();
147
    for g in scope {
148
        match globset::Glob::new(g) {
149
            Ok(g) => {
150
                b.add(g);
151
            }
152
            Err(_) => return true,
153
        }
154
    }
155
    let Ok(set) = b.build() else { return true };
156
    touched.iter().any(|p| set.is_match(p))
157
}
158
159
/// [`start_run`] for a push: jobs whose `scope` the push did not touch are
160
/// left out of the run, unless a job that runs depends on them.
161
#[allow(clippy::too_many_arguments)]
162
pub async fn start_run_scoped(
163
    app: &Shared,
164
    row: &db::repos::RepoRow,
165
    bookmark: &str,
166
    state: &str,
167
    heads: &[ChangeHash],
168
    actor: &str,
169
    trigger: &str,
170
    touched: Option<&[String]>,
171
) -> Result<Option<i64>, String> {
172
    start_run_with(app, row, bookmark, state, heads, actor, trigger, touched).await
173
}
174
1edac4b6 Andy McCall ↶ 175
/// Start a run of the pipeline in the state `heads` make (whose state
176
/// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline"
177
/// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account
178
/// whose access the run's subrepo reads are checked against. Returns the
179
/// run's number, or `None` when the state has no pipeline. The caller
180
/// checks that Flow is enabled.
181
pub async fn start_run(
182
    app: &Shared,
183
    row: &db::repos::RepoRow,
184
    bookmark: &str,
185
    state: &str,
186
    heads: &[ChangeHash],
187
    actor: &str,
188
    trigger: &str,
189
) -> Result<Option<i64>, String> {
d7ce41ac Andy McCall ↶ 190
    start_run_with(app, row, bookmark, state, heads, actor, trigger, None).await
191
}
192
193
#[allow(clippy::too_many_arguments)]
194
async fn start_run_with(
195
    app: &Shared,
196
    row: &db::repos::RepoRow,
197
    bookmark: &str,
198
    state: &str,
199
    heads: &[ChangeHash],
200
    actor: &str,
201
    trigger: &str,
202
    touched: Option<&[String]>,
203
) -> Result<Option<i64>, String> {
1edac4b6 Andy McCall ↶ 204
    let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
42fa9554 Andy McCall ↶ 205
    let h = heads.to_vec();
206
    let text = app
207
        .repos
208
        .read(&repo, move |r, cache| {
209
            let loaded = cache.load(r, &h)?;
210
            Ok(match loaded.snapshot.files.get(PIPELINE_PATH).map(|f| &f.content) {
211
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
212
                _ => None,
213
            })
214
        })
1edac4b6 Andy McCall ↶ 215
        .await
216
        .map_err(|e| format!("reading the pipeline: {e}"))?;
217
    let Some(text) = text else { return Ok(None) };
42fa9554 Andy McCall ↶ 218
    let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
219
    hex.sort();
220
    let heads_s = hex.join("+");
1edac4b6 Andy McCall ↶ 221
    let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None };
222
    let id = match Pipeline::parse(&text) {
42fa9554 Andy McCall ↶ 223
        Err(problems) => {
224
            let msg = problems.to_string();
1edac4b6 Andy McCall ↶ 225
            db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())?
42fa9554 Andy McCall ↶ 226
        }
227
        Ok(p) => {
228
            let depths = p.depths();
d7ce41ac Andy McCall ↶ 229
            // Jobs out of the push's scope stay out, unless a kept job
230
            // needs them.
231
            let mut keep: std::collections::HashSet<String> =
232
                p.order().into_iter().filter(|j| in_scope(&j.scope, touched)).map(|j| j.name.clone()).collect();
233
            loop {
234
                let needed: Vec<String> = p
235
                    .order()
236
                    .into_iter()
237
                    .filter(|j| keep.contains(&j.name))
238
                    .flat_map(|j| j.depends_on.iter().cloned())
239
                    .filter(|d| !keep.contains(d))
240
                    .collect();
241
                if needed.is_empty() {
242
                    break;
243
                }
244
                keep.extend(needed);
245
            }
42fa9554 Andy McCall ↶ 246
            let jobs: Vec<fdb::NewJob> = p
247
                .order()
248
                .into_iter()
d7ce41ac Andy McCall ↶ 249
                .filter(|j| keep.contains(&j.name))
42fa9554 Andy McCall ↶ 250
                .map(|j| {
251
                    let sb = &p.sandboxes[&j.sandbox];
252
                    fdb::NewJob {
253
                        name: j.name.clone(),
254
                        depth: depths[j.name.as_str()] as i64,
255
                        depends: j.depends_on.clone(),
256
                        spec: serde_json::to_string(j).unwrap_or_default(),
257
                        sandbox: serde_json::to_string(sb).unwrap_or_default(),
258
                        runtime: sb.runtime.as_str().into(),
259
                    }
260
                })
261
                .collect();
d7ce41ac Andy McCall ↶ 262
            if jobs.is_empty() && touched.is_some() {
263
                // Nothing this push touched is any job's business.
264
                return Ok(None);
265
            }
42fa9554 Andy McCall ↶ 266
            let hash = hex::encode(p.hash());
1edac4b6 Andy McCall ↶ 267
            let id =
268
                db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?;
269
            if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await {
42fa9554 Andy McCall ↶ 270
                for j in &jobs {
a0d4bbfb Andy McCall ↶ 271
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
42fa9554 Andy McCall ↶ 272
                }
273
            }
274
            id
275
        }
276
    };
1edac4b6 Andy McCall ↶ 277
    let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?;
278
    Ok(Some(run.number))
42fa9554 Andy McCall ↶ 279
}
280
a0d4bbfb Andy McCall ↶ 281
/// Record a job's check; it links to the run's page by the run's number.
282
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
283
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
42fa9554 Andy McCall ↶ 284
    let name = format!("flow/{job}");
285
    let c = db::hooks::upsert_check(
286
        &app.db,
287
        row.id,
288
        db::hooks::CheckReport { state, name: &name, status, summary, url: &url, reporter: None, seal: "" },
289
    )
290
    .await;
291
    if let Ok(c) = c {
292
        let api = crate::api::check_of(&c);
293
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
294
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
295
    }
296
}
297
298
/// The check status a job's status shows as.
299
fn check_status(job_status: &str) -> &'static str {
300
    match job_status {
301
        "queued" => "queued",
302
        "running" => "running",
303
        "passed" => "passed",
304
        "skipped" => "skipped",
305
        _ => "failed",
306
    }
307
}
308
b6b7e4cf Andy McCall ↶ 309
/// The runner a bearer token belongs to: a registered runner, or one
310
/// configured with a hub.toml token (tracked from its first request). A
311
/// disabled runner is refused.
312
async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
42fa9554 Andy McCall ↶ 313
    let Some(t) = oxy_server::bearer_token(headers) else {
314
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
b6b7e4cf Andy McCall ↶ 315
    };
316
    let hash = blake3::hash(t.as_bytes()).to_hex().to_string();
317
    let runner = match db::runners::by_token(&app.db, &hash).await? {
318
        Some(r) => r,
319
        None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => {
320
            db::runners::configured(&app.db, &hash, "runner").await?
321
        }
322
        None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())),
42fa9554 Andy McCall ↶ 323
    };
b6b7e4cf Andy McCall ↶ 324
    if runner.state == "disabled" {
325
        return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name)));
42fa9554 Andy McCall ↶ 326
    }
b6b7e4cf Andy McCall ↶ 327
    Ok(runner)
42fa9554 Andy McCall ↶ 328
}
329
b6b7e4cf Andy McCall ↶ 330
/// `POST /api/v1/flow/runners/register`: a one-time registration token
331
/// from Admin → Runners buys a runner credential, binding the runner's
332
/// signing identity (hub spec §13.2).
333
pub async fn register(
334
    State(app): State<Shared>,
335
    Json(req): Json<flow_core::api::Register>,
336
) -> Result<Json<flow_core::api::Registered>, ApiError> {
337
    let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?;
338
    let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?;
339
    let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect();
340
    if name.is_empty() {
341
        return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into()));
342
    }
343
    let credential = format!("flowrunner_{}", crate::auth::random_hex(32));
344
    let hash = blake3::hash(credential.as_bytes()).to_hex().to_string();
345
    let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string();
346
    let runner =
347
        db::runners::register(&app.db, &reg, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| {
348
            ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into())
349
        })?;
350
    db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?;
351
    tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]);
352
    Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential }))
353
}
354
42fa9554 Andy McCall ↶ 355
/// `POST /api/v1/flow/claim`.
356
pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 357
    let me = runner_auth(&app, &headers).await?;
358
    let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect();
359
    db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?;
eef677c4 Andy McCall ↶ 360
    if let Some(holding) = &req.holding {
361
        end_jobs_not_held(&app, &me, holding).await?;
362
    }
b6b7e4cf Andy McCall ↶ 363
    // A draining runner finishes what it has and takes nothing new.
364
    if me.state == "draining" {
365
        return Ok(StatusCode::NO_CONTENT.into_response());
366
    }
367
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
42fa9554 Andy McCall ↶ 368
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
b0612e7d Andy McCall ↶ 369
    let room = fdb::Room {
370
        memory_mib: req.free_memory_mib,
371
        cpus: req.free_cpus,
372
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
373
    };
c4c87f48 Andy McCall ↶ 374
    // The labels it reports now, or those it registered with.
375
    let labels: Vec<String> = match &req.info {
376
        Some(i) => i.labels.clone(),
377
        None => me.label_list().into_iter().map(String::from).collect(),
378
    };
379
    let Some(job) =
380
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
381
    else {
42fa9554 Andy McCall ↶ 382
        return Ok(StatusCode::NO_CONTENT.into_response());
383
    };
b6b7e4cf Andy McCall ↶ 384
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
42fa9554 Andy McCall ↶ 385
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
386
    let row =
387
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
a0d4bbfb Andy McCall ↶ 388
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
42fa9554 Andy McCall ↶ 389
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
5dead1f4 Andy McCall ↶ 390
    // The job's limit: its own timeout, else its sandbox's, else the site
391
    // default, never past the site maximum; recorded for the reaper.
392
    let limits = limits(&app).await;
393
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
394
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
395
    let timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
396
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
42fa9554 Andy McCall ↶ 397
    let claim = Claim {
398
        job_id: job.id,
399
        run_id: run.id,
400
        repo: format!("{}/{}", row.owner, row.name),
401
        clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
402
        token: secret,
403
        heads: run.head_list().into_iter().map(String::from).collect(),
404
        pipeline_hash: run.pipeline_hash.clone(),
5dead1f4 Andy McCall ↶ 405
        job: spec,
406
        sandbox,
407
        timeout_s,
fd3db644 Andy McCall ↶ 408
        subrepos: vec![],
9a065bc0 Andy McCall ↶ 409
        state: run.state.clone(),
410
        bookmark: run.bookmark.clone(),
411
        run_number: run.number,
42fa9554 Andy McCall ↶ 412
    };
fd3db644 Andy McCall ↶ 413
    let mut claim = claim;
414
    let (inputs, ids) = subrepo_inputs(&app, &row, &run).await;
415
    if !ids.is_empty() {
416
        fdb::set_also_read(&app.db, job.id, &ids).await?;
417
    }
418
    claim.subrepos = inputs;
42fa9554 Andy McCall ↶ 419
    Ok(Json(claim).into_response())
fd3db644 Andy McCall ↶ 420
}
421
25091e5c Andy McCall ↶ 422
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
423
async fn pins_in(
424
    app: &Shared,
425
    name: crate::repos::RepoName,
426
    heads: Vec<oxy_core::hash::ChangeHash>,
427
) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> {
428
    app.repos
fd3db644 Andy McCall ↶ 429
        .read(&name, move |repo, cache| {
430
            let loaded = cache.load(repo, &heads)?;
431
            let mut out = Vec::new();
432
            for (path, f) in &loaded.snapshot.files {
433
                if f.kind != oxy_core::objects::NodeKind::Subrepo {
434
                    continue;
435
                }
436
                if let oxy_core::state::FileContent::Opaque { blobs } = &f.content {
437
                    if let Some(b) = blobs.first() {
438
                        if let Ok(oxy_core::subrepo::Pin::Oxy { repo: id, heads, .. }) = repo
439
                            .read_blob(b)
440
                            .map_err(crate::repos::RepoError::Oxy)
441
                            .and_then(|bytes| oxy_core::subrepo::Pin::decode(&bytes).map_err(|e| crate::repos::RepoError::Oxy(e.into())))
442
                        {
443
                            out.push((path.clone(), id, heads));
444
                        }
445
                    }
446
                }
447
            }
448
            Ok(out)
449
        })
450
        .await
25091e5c Andy McCall ↶ 451
        .unwrap_or_default()
452
}
453
454
/// How deep subrepos of subrepos are followed.
455
const MAX_SUBREPO_DEPTH: usize = 3;
456
457
/// The Oxygen subrepos the run's state pins, and those their states pin in
458
/// turn (parents first, paths joined), that live on this hub and that the
459
/// run's pusher may read: what the job's workspace needs beside its own
460
/// tree. A pin the hub cannot serve is left out, with what it pins; the job
461
/// then fails on the missing files with an ordinary error.
462
async fn subrepo_inputs(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) -> (Vec<flow_core::api::SubrepoInput>, Vec<i64>) {
463
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return (vec![], vec![]) };
464
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
fd3db644 Andy McCall ↶ 465
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
25091e5c Andy McCall ↶ 466
    let rows = db::repos::list(&app.db).await.unwrap_or_default();
fd3db644 Andy McCall ↶ 467
    let mut inputs = Vec::new();
468
    let mut ids = Vec::new();
25091e5c Andy McCall ↶ 469
    // (path prefix, repository, heads, depth), parents before children.
470
    let mut queue = std::collections::VecDeque::from([(String::new(), name, heads, 0usize)]);
471
    while let Some((prefix, name, heads, depth)) = queue.pop_front() {
472
        if depth == MAX_SUBREPO_DEPTH {
473
            continue;
474
        }
475
        for (path, id, pinned) in pins_in(app, name, heads).await {
476
            let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") };
477
            for other in &rows {
478
                let Some(other_name) = crate::repos::RepoName::new(&other.owner, &other.name) else { continue };
479
                let matches = app.repos.read(&other_name, move |r, _| Ok(r.id().ok() == Some(id))).await.unwrap_or(false);
480
                if !matches {
481
                    continue;
482
                }
483
                let role = crate::auth::roles::role_for(&app.db, pusher.as_ref(), other).await.ok().flatten();
484
                if crate::auth::roles::allows(role, crate::auth::Access::Read) {
485
                    inputs.push(flow_core::api::SubrepoInput {
486
                        path: path.clone(),
487
                        repo: format!("{}/{}", other.owner, other.name),
488
                        clone_url: format!("{}/{}/{}", app.config.base_url(), other.owner, other.name),
489
                        heads: pinned.iter().map(|h| h.to_hex()).collect(),
490
                    });
491
                    if !ids.contains(&other.id) {
492
                        ids.push(other.id);
493
                    }
494
                    queue.push_back((path.clone(), other_name, pinned.clone(), depth + 1));
495
                }
496
                break;
fd3db644 Andy McCall ↶ 497
            }
498
        }
499
    }
500
    (inputs, ids)
42fa9554 Andy McCall ↶ 501
}
502
503
async fn running_job(app: &Shared, id: i64) -> Result<fdb::Job, ApiError> {
504
    match fdb::job(&app.db, id).await? {
505
        Some(j) if j.status == "running" => Ok(j),
506
        Some(_) => Err(ApiError(StatusCode::CONFLICT, "this job is not running".into())),
507
        None => Err(ApiError(StatusCode::NOT_FOUND, "no such job".into())),
508
    }
509
}
510
511
/// `POST /api/v1/flow/jobs/{id}/log`.
512
pub async fn log(
513
    State(app): State<Shared>,
514
    headers: HeaderMap,
515
    Path(id): Path<i64>,
516
    Json(mut batch): Json<LogBatch>,
517
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 518
    runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 519
    let job = running_job(&app, id).await?;
520
    for r in &mut batch.records {
521
        if r.text.len() > MAX_RECORD_TEXT {
522
            let mut cut = MAX_RECORD_TEXT;
523
            while !r.text.is_char_boundary(cut) {
524
                cut -= 1;
525
            }
526
            r.text.truncate(cut);
527
        }
528
        if !matches!(r.stream.as_str(), "out" | "err" | "console" | "flow") {
529
            r.stream = "console".into();
530
        }
531
    }
532
    let kept = fdb::append_log(&app.db, job.id, &batch.records, MAX_RECORDS as i64).await?;
533
    if kept < batch.records.len() && job.log_count < MAX_RECORDS as i64 {
534
        tracing::info!("flow: job {} reached the log limit", job.id);
535
    }
536
    Ok(StatusCode::NO_CONTENT)
537
}
538
a3c8288b Andy McCall ↶ 539
/// Largest artifact the hub keeps; the runner refuses larger ones first.
540
pub const MAX_ARTIFACT_BYTES: usize = 512 << 20;
541
542
/// `POST /api/v1/flow/jobs/{id}/artifacts/{name}`: the bytes of an artifact
543
/// the job declares (OxygenFlow spec §5.2), kept as a blob of the run's
544
/// repository. Answers `{"blob"}`.
545
pub async fn artifact(
546
    State(app): State<Shared>,
547
    headers: HeaderMap,
548
    Path((id, name)): Path<(i64, String)>,
549
    body: axum::body::Bytes,
550
) -> Result<Response, ApiError> {
b6b7e4cf Andy McCall ↶ 551
    runner_auth(&app, &headers).await?;
a3c8288b Andy McCall ↶ 552
    let job = running_job(&app, id).await?;
553
    let spec: flow_core::Job =
554
        serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?;
555
    let Some(out) = spec.outputs.iter().find(|o| o.name == name) else {
556
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no artifact {name:?}", job.name)));
557
    };
558
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
559
    let row =
560
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
561
    let repo = RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into()))?;
562
    let content = blake3::hash(&body).to_hex().to_string();
563
    let size = body.len() as i64;
564
    let blob = app
565
        .repos
566
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
567
        .await
568
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the artifact: {e}")))?;
569
    let a = fdb::Artifact { job_id: job.id, name, path: out.path.clone(), blob: blob.to_hex(), size, content, created_ms: db::now_ms() };
570
    fdb::add_artifact(&app.db, &a).await?;
571
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
572
}
573
42fa9554 Andy McCall ↶ 574
/// `POST /api/v1/flow/jobs/{id}/finish`.
575
pub async fn finish(
576
    State(app): State<Shared>,
577
    headers: HeaderMap,
578
    Path(id): Path<i64>,
579
    Json(f): Json<Finish>,
580
) -> Result<StatusCode, ApiError> {
b6b7e4cf Andy McCall ↶ 581
    let me = runner_auth(&app, &headers).await?;
42fa9554 Andy McCall ↶ 582
    let job = running_job(&app, id).await?;
583
    let status = match (f.exit_code, &f.error) {
584
        (Some(0), None) => "passed",
585
        (Some(_), _) => "failed",
586
        (None, _) => "error",
587
    };
588
    let (receipt, receipt_hash) = match &f.receipt {
589
        Some(r) if r.job_name == job.name && Some(r.exit_code as i32) == f.exit_code => {
a3c8288b Andy McCall ↶ 590
            // The receipt names exactly the artifacts uploaded, in name order.
591
            let kept: Vec<String> = fdb::artifacts(&app.db, job.id).await?.into_iter().map(|a| a.blob).collect();
592
            if r.outputs.iter().map(hex::encode).collect::<Vec<_>>() != kept {
593
                return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt names other outputs than the artifacts uploaded".into()));
594
            }
42fa9554 Andy McCall ↶ 595
            (serde_json::to_string(r).unwrap_or_default(), hex::encode(r.hash()))
596
        }
597
        Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "the receipt does not match the job".into())),
598
        None => (String::new(), String::new()),
599
    };
600
    let error = f.error.clone().unwrap_or_default();
601
    let changed = fdb::finish(
602
        &app.db,
603
        job.id,
604
        fdb::Outcome { status, exit_code: f.exit_code.map(i64::from), error: &error, receipt: &receipt, receipt_hash: &receipt_hash },
605
    )
606
    .await?;
607
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
608
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
cc19697f Andy McCall ↶ 609
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
b6b7e4cf Andy McCall ↶ 610
                let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await {
cc19697f Andy McCall ↶ 611
                    Ok((attest, signer)) => (attest, signer, String::new()),
612
                    Err(e) => {
613
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
614
                        (String::new(), String::new(), e)
615
                    }
616
                };
617
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
618
            }
42fa9554 Andy McCall ↶ 619
        }
620
    }
070a7b64 Andy McCall ↶ 621
    report_changed(&app, job.run_id, changed).await?;
42fa9554 Andy McCall ↶ 622
    Ok(StatusCode::NO_CONTENT)
070a7b64 Andy McCall ↶ 623
}
624
625
/// Report the checks of jobs whose status changed.
626
async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> {
627
    let Some(run) = fdb::run_by_id(&app.db, run_id).await? else {
628
        return Ok(());
629
    };
630
    let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else {
631
        return Ok(());
632
    };
633
    for j in changed {
634
        let summary = match j.status.as_str() {
635
            "passed" => format!("passed in {}", duration(&j)),
636
            "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)),
637
            "skipped" => "skipped: a dependency did not pass".into(),
638
            _ => format!("could not run: {}", j.error),
639
        };
640
        report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
641
    }
642
    Ok(())
643
}
644
5dead1f4 Andy McCall ↶ 645
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
646
/// Settings, else from `hub.toml`.
647
pub struct Limits {
648
    /// For a job whose pipeline names none.
649
    pub default_s: u64,
650
    /// The most a pipeline may ask for.
651
    pub max_s: u64,
652
    /// Grace past a job's limit before the hub gives it up: a runner stops
653
    /// a job at its limit and reports it within moments, so one still
654
    /// running this long after is one the runner no longer reports (it was
655
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
656
    pub grace_ms: i64,
657
}
658
659
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
660
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
661
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
662
663
pub async fn limits(app: &Shared) -> Limits {
664
    let minutes = |key: &'static str| async move {
665
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
666
    };
667
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
668
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
669
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
670
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
671
}
070a7b64 Andy McCall ↶ 672
5dead1f4 Andy McCall ↶ 673
/// End every job still running past its time limit plus the grace:
070a7b64 Andy McCall ↶ 674
/// it is marked an error with the reason, the jobs that depended on it are
675
/// skipped and its run settled, as when a runner reports an error.
676
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
5dead1f4 Andy McCall ↶ 677
    let limits = limits(app).await;
678
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
070a7b64 Andy McCall ↶ 679
    let n = lost.len();
680
    for job in lost {
681
        let error = format!(
682
            "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it",
683
            (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000,
684
            if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) },
685
        );
686
        let changed =
687
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
688
                .await?;
689
        tracing::warn!("flow: job {} {error}", job.id);
690
        report_changed(app, job.run_id, changed).await?;
691
    }
692
    Ok(n)
eef677c4 Andy McCall ↶ 693
}
694
695
/// End the jobs running on `runner` that it does not hold (oxygenhub#71):
696
/// it restarted since claiming them, or never received the claim. A
697
/// runner claims one job at a time, so a job it holds is always listed.
698
async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> {
699
    for job in fdb::running_on(&app.db, runner.id).await? {
700
        if holding.contains(&job.id) {
701
            continue;
702
        }
703
        let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name);
704
        let changed =
705
            fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
706
                .await?;
707
        tracing::warn!("flow: job {} {error}", job.id);
708
        report_changed(app, job.run_id, changed).await?;
709
    }
710
    Ok(())
070a7b64 Andy McCall ↶ 711
}
712
713
/// Look for lost jobs once a minute.
714
pub async fn reaper(app: Shared) {
715
    loop {
716
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
717
        if let Err(e) = reap_lost_jobs(&app).await {
718
            tracing::warn!("flow: looking for lost jobs: {e}");
719
        }
720
    }
cc19697f Andy McCall ↶ 721
}
722
723
/// Check a runner's signed receipt for a job of `run` and keep it in the
724
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
725
/// name, or why it was not kept. It must be the job's receipt, for the
726
/// run's state and repository, signed by the key the runner's token first
727
/// signed with.
728
pub async fn keep_receipt(
729
    app: &Shared,
b6b7e4cf Andy McCall ↶ 730
    runner: &db::runners::Runner,
cc19697f Andy McCall ↶ 731
    row: &db::repos::RepoRow,
732
    run: &fdb::Run,
733
    receipt: &flow_core::Receipt,
734
    signed_hex: &str,
735
) -> Result<(String, String), String> {
736
    use oxy_core::receipt::SignedReceipt;
737
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
738
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
739
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
740
    if sr.receipt.hash().0 != receipt.hash() {
741
        return Err("it signs another receipt than the job's".into());
742
    }
743
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
744
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
745
    if sr.state() != state {
746
        return Err("it is for another state than the run's".into());
747
    }
748
    let key = sr.runner.hash().to_hex();
b6b7e4cf Andy McCall ↶ 749
    let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
cc19697f Andy McCall ↶ 750
    if bound != key {
751
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
752
    }
753
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
754
    let signer = sr.runner.name.clone();
755
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
756
    Ok((attest.to_hex(), signer))
42fa9554 Andy McCall ↶ 757
}
758
759
/// A job's wall time, as the pages show it.
760
pub fn duration(j: &fdb::Job) -> String {
761
    match (j.started_ms, j.finished_ms) {
762
        (Some(a), Some(b)) => human_ms(b - a),
763
        (Some(a), None) => human_ms(db::now_ms() - a),
764
        _ => "–".into(),
765
    }
766
}
767
768
pub fn human_ms(ms: i64) -> String {
769
    let ms = ms.max(0);
770
    if ms < 1000 {
771
        format!("{ms} ms")
772
    } else if ms < 60_000 {
773
        format!("{:.1} s", ms as f64 / 1000.0)
774
    } else {
775
        format!("{} min {} s", ms / 60_000, (ms % 60_000) / 1000)
776
    }
777
}
bf3d0754 Andy McCall ↶ 778
779
/// Where a live run stands among the runners' work: what its viewers may
780
/// know of it. Jobs of other repositories are named only when those are
781
/// public; the rest are counted.
782
#[derive(Clone, Debug, Default, PartialEq, Eq)]
783
pub struct QueueInfo {
784
    /// 1-based place of the run's first queued job among all queued jobs.
785
    pub position: Option<usize>,
786
    /// Jobs running now, and queued jobs ahead of the run's first one.
787
    pub running: Vec<String>,
788
    pub ahead: Vec<String>,
789
    /// Of those, jobs in repositories the page may not name.
790
    pub hidden: usize,
791
}
792
793
impl QueueInfo {
794
    /// "3rd in line" and the like.
795
    pub fn place(&self) -> Option<String> {
796
        self.position.map(|n| {
797
            let suffix = match (n % 10, n % 100) {
798
                (1, 11) | (2, 12) | (3, 13) => "th",
799
                (1, _) => "st",
800
                (2, _) => "nd",
801
                (3, _) => "rd",
802
                _ => "th",
803
            };
804
            format!("{n}{suffix} in line")
805
        })
806
    }
807
}
808
809
/// The queue as `run` (of repository `repo_id`) sees it, from `queue`.
810
pub fn queue_info(queue: &[fdb::QueueEntry], repo_id: i64, run_id: i64) -> QueueInfo {
811
    let mut info = QueueInfo::default();
812
    let first = queue.iter().filter(|e| e.status == "queued").position(|e| e.run_id == run_id);
813
    info.position = first.map(|i| i + 1);
814
    let label = |e: &fdb::QueueEntry| format!("{}/{} #{} {}", e.owner, e.name, e.run_number, e.job);
815
    let mut queued_seen = 0;
816
    for e in queue {
817
        if e.run_id == run_id {
818
            continue;
819
        }
820
        let ahead = match e.status.as_str() {
821
            "running" => true,
822
            _ => {
823
                queued_seen += 1;
824
                first.is_some_and(|f| queued_seen <= f)
825
            }
826
        };
827
        if !ahead {
828
            continue;
829
        }
830
        if e.repo_id != repo_id && e.visibility != "public" {
831
            info.hidden += 1;
832
        } else if e.status == "running" {
833
            info.running.push(label(e));
834
        } else {
835
            info.ahead.push(label(e));
836
        }
837
    }
838
    info
839
}
840
841
#[cfg(test)]
842
mod queue_tests {
843
    use super::*;
844
845
    fn e(job_id: i64, run_id: i64, repo_id: i64, vis: &str, job: &str, status: &str) -> fdb::QueueEntry {
846
        fdb::QueueEntry {
847
            job_id,
848
            run_id,
849
            repo_id,
850
            owner: "o".into(),
851
            name: format!("r{repo_id}"),
852
            visibility: vis.into(),
853
            run_number: run_id,
854
            job: job.into(),
855
            status: status.into(),
b66e70b7 Andy McCall ↶ 856
            runner: String::new(),
c4c87f48 Andy McCall ↶ 857
            spec: String::new(),
858
            sandbox: String::new(),
bf3d0754 Andy McCall ↶ 859
        }
860
    }
861
862
    #[test]
863
    fn a_run_learns_its_place_and_what_is_ahead_of_it() {
864
        // Running first, then queued in claim order (as `fdb::queue` returns them).
865
        let q = vec![
866
            e(1, 10, 1, "public", "build", "running"),
867
            e(2, 11, 2, "private", "test", "running"),
868
            e(3, 10, 1, "public", "test", "queued"),
869
            e(4, 12, 3, "private", "lint", "queued"),
870
            e(5, 13, 4, "public", "build", "queued"),
871
            e(6, 13, 4, "public", "test", "queued"),
872
            e(7, 14, 5, "public", "late", "queued"),
873
        ];
874
        let info = queue_info(&q, 4, 13);
875
        assert_eq!(info.position, Some(3));
876
        assert_eq!(info.place().as_deref(), Some("3rd in line"));
877
        assert_eq!(info.running, ["o/r1 #10 build"]);
878
        assert_eq!(info.ahead, ["o/r1 #10 test"], "queued ahead, not behind");
879
        assert_eq!(info.hidden, 2, "private repositories are counted, not named");
880
        // A private repository's own jobs are named to its viewers.
881
        assert_eq!(queue_info(&q, 3, 12).ahead, ["o/r1 #10 test"]);
882
        assert_eq!(queue_info(&q, 3, 12).position, Some(2));
883
        // A run with nothing queued has no place.
884
        assert_eq!(queue_info(&q, 1, 99).position, None);
885
        let ordinal = |n| QueueInfo { position: Some(n), ..Default::default() }.place().unwrap();
886
        assert_eq!(
887
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
888
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
889
        );
890
    }
891
}