Skip to main content

choir_node/
queue_api.rs

1//! Driving the node's merge queue from outside (D5, D68).
2//!
3//! [`crate::Platform::run_proposal_queue`] is the round; this is how
4//! anybody asks for one. A single endpoint, `POST /api/queue/run`, and
5//! deliberately no timer: a node that spends CI on its own schedule
6//! surprises whoever pays for it, and a round that only a clock can
7//! start is a round no test can reach without waiting on one. An
8//! operator's cron, a hook, or a person decides the cadence.
9//!
10//! The queue's trees are made here rather than by the operator, because
11//! the one contract that matters cannot be checked once it is wrong:
12//! the tree a round speculates in must share the served repository's
13//! object store, or the log names merge commits git cannot see and
14//! [`crate::Platform::reconcile_git_refs`] compensates the whole round
15//! back out. A `git worktree` of the bare repo satisfies it by
16//! construction; a clone that looks identical does not.
17
18use std::path::{Path, PathBuf};
19
20use choir_queue::differential_ledger::CommandSpec;
21
22/// What a node needs before it can run a round.
23///
24/// Set by `--queue-tree` and `--ci-command`. Absent, `/api/queue/run`
25/// answers 501: a node with no CI command has no way to decide whether
26/// a candidate is good, and a queue that landed everything unchecked
27/// would be a worse `git push`.
28#[derive(Debug, Clone)]
29pub struct QueueConfig {
30    /// Scratch root the queue owns. Worktrees are made under it, one
31    /// per `repo:branch`, and reused between rounds.
32    pub tree: PathBuf,
33    /// What to run against each candidate state.
34    pub command: CommandSpec,
35}
36
37/// Where one target's two trees live.
38///
39/// Two, not one. The speculator forces its tree to each candidate state
40/// in turn, and the executor gives every job in a batch its own tree
41/// (D18); sharing one directory between them would have the executor
42/// checking out over the merge in progress.
43struct Trees {
44    speculation: PathBuf,
45    jobs: PathBuf,
46}
47
48impl QueueConfig {
49    fn trees(&self, repo: &str, branch: &str) -> Trees {
50        // The repo name carries a slash and a `.git`; neither is a
51        // legal path component here, so it is flattened rather than
52        // joined, which also keeps a repo called `a/b` from colliding
53        // with one called `a-b` in the same root.
54        let slug = format!(
55            "{}@{}",
56            repo.replace(['/', '\\'], "_"),
57            branch.replace(['/', '\\'], "_")
58        );
59        let base = self.tree.join(slug);
60        Trees {
61            speculation: base.join("speculation"),
62            jobs: base.join("jobs"),
63        }
64    }
65}
66
67/// Ensures `at` is a worktree of the bare repository at `repo`.
68///
69/// Idempotent: an existing worktree is reused, because the alternative
70/// is making one per round and paying a full checkout each time. A
71/// directory that exists but is not a worktree of this repository is an
72/// error rather than something to repair, since removing whatever a
73/// caller put there is not this function's decision to make.
74fn ensure_worktree(repo: &Path, at: &Path, branch: &str) -> Result<(), String> {
75    if at.join(".git").exists() {
76        return Ok(());
77    }
78    if at.exists() {
79        return Err(format!(
80            "{} exists and is not a worktree of this repository",
81            at.display()
82        ));
83    }
84    if let Some(parent) = at.parent() {
85        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
86    }
87    let out = std::process::Command::new("git")
88        .args(["worktree", "add", "--detach"])
89        .arg(at)
90        .arg(branch)
91        .current_dir(repo)
92        .output()
93        .map_err(|e| format!("spawn git: {e}"))?;
94    if out.status.success() {
95        Ok(())
96    } else {
97        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
98    }
99}
100
101/// The rounds currently running, by `<repo>:<branch>`.
102///
103/// A second request for a target already in flight is refused rather
104/// than queued behind it. Two overlapping rounds would each read the
105/// branch, speculate from it, and race to land: the loser's whole round
106/// is void by construction, so the CI it spent was wasted before it
107/// started. Refusing says so immediately.
108#[derive(Debug, Default)]
109pub struct InFlight(std::sync::Mutex<std::collections::BTreeSet<String>>);
110
111impl InFlight {
112    /// Claims `target`, or reports that somebody else holds it.
113    fn claim(&self, target: &str) -> Option<Claim<'_>> {
114        let mut held = self.0.lock().expect("in-flight lock");
115        held.insert(target.to_string()).then(|| Claim {
116            owner: self,
117            target: target.to_string(),
118        })
119    }
120}
121
122/// Releases its target when the round ends, however it ends.
123struct Claim<'a> {
124    owner: &'a InFlight,
125    target: String,
126}
127
128impl Drop for Claim<'_> {
129    fn drop(&mut self) {
130        self.owner
131            .0
132            .lock()
133            .expect("in-flight lock")
134            .remove(&self.target);
135    }
136}
137
138/// Runs one round for the target named in `body`, as JSON.
139///
140/// # Errors
141///
142/// Answered as a status and a JSON body rather than returned: a
143/// malformed request, a repository or branch that does not exist, a
144/// round already in flight for that target, or a tree that cannot be
145/// made.
146pub fn run(
147    root: &Path,
148    platform: &crate::Platform,
149    config: &QueueConfig,
150    in_flight: &InFlight,
151    body: &[u8],
152) -> (u16, String) {
153    let request: serde_json::Value = match serde_json::from_slice(body) {
154        Ok(value) => value,
155        Err(error) => return bad(400, &format!("body is not json: {error}")),
156    };
157    let (Some(repo), Some(branch)) = (request["repo"].as_str(), request["branch"].as_str()) else {
158        return bad(400, "both `repo` and `branch` are required");
159    };
160    // A branch name is a path component of a ref and of a directory
161    // here, so the two characters that would make it neither are
162    // refused before either is built from it.
163    if branch.contains("..") || branch.starts_with('/') {
164        return bad(400, "that is not a branch name");
165    }
166
167    let target = format!("{repo}:refs/heads/{branch}");
168    let Some(_claim) = in_flight.claim(&target) else {
169        return bad(409, "a round for that target is already running");
170    };
171
172    let bare = root.join(repo);
173    if !bare.exists() {
174        return bad(404, "no such repository");
175    }
176    if platform.proposal_round(repo, branch).is_none() {
177        return bad(404, "no such branch on that repository");
178    }
179
180    let trees = config.trees(repo, branch);
181    if let Err(why) = ensure_worktree(&bare, &trees.speculation, branch) {
182        return bad(500, &format!("the queue has no tree to work in: {why}"));
183    }
184
185    let mut ci = choir_queue::worktree::WorktreeRunner::new(bare, trees.jobs);
186    let template = choir_queue::JobTemplate {
187        command: {
188            let mut argv = Vec::with_capacity(config.command.args.len() + 1);
189            argv.push(config.command.program.clone());
190            argv.extend(config.command.args.iter().cloned());
191            argv
192        },
193        environment: choir_queue::differential_ledger::effective_environment(&config.command.env),
194        deadline: config
195            .command
196            .timeout_seconds
197            .map(std::time::Duration::from_secs),
198        // Speculative by definition: a shared cache written from a
199        // state nobody approved is the CREEP shape, and a queue open to
200        // proposals from outside is exactly where it is reached.
201        may_write_cache: false,
202    };
203
204    let Some(report) =
205        platform.run_proposal_queue(repo, branch, &trees.speculation, &mut ci, template)
206    else {
207        return bad(404, "no such branch on that repository");
208    };
209    let body = serde_json::json!({
210        "format_version": 1,
211        "repo": repo,
212        "branch": branch,
213        "merged": report.merged,
214        "rejected": report.rejected.iter()
215            .map(|(id, why)| serde_json::json!({ "id": id, "why": format!("{why:?}") }))
216            .collect::<Vec<_>>(),
217        "tip": report.final_state,
218        "stalled": report.provider_error,
219        "unreported_checks": report.unreported_checks,
220    });
221    (200, body.to_string())
222}
223
224fn bad(status: u16, reason: &str) -> (u16, String) {
225    (status, serde_json::json!({ "error": reason }).to_string())
226}