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}