| 32 |
32 |
|
/// under the daemon host's version. This runs before any target task spawns, so
|
| 33 |
33 |
|
/// a mixed-source release is stopped before a single artifact is built.
|
| 34 |
34 |
|
///
|
|
35 |
+ |
/// "All" means all: a host that did not report is a refusal, not an abstention.
|
|
36 |
+ |
/// Comparing only the hosts that answered proves agreement among those, which is
|
|
37 |
+ |
/// not the claim the barrier is making.
|
|
38 |
+ |
///
|
| 35 |
39 |
|
/// Returns the branch each host was on before it was pinned, for
|
| 36 |
40 |
|
/// [`restore_branches`] to put back once the build settles. A host that is
|
| 37 |
41 |
|
/// ALREADY detached has no branch to return to, and is refused: that state means
|
| 53 |
57 |
|
let tag = cfg.tag_for(version);
|
| 54 |
58 |
|
|
| 55 |
59 |
|
// The distinct hosts across the requested targets (order-stable).
|
|
60 |
+ |
//
|
|
61 |
+ |
// A target whose host the topology cannot resolve is refused rather than
|
|
62 |
+ |
// skipped. Skipping it would leave that target out of the barrier while it
|
|
63 |
+ |
// still builds, which is the shape of hole this whole preflight exists to
|
|
64 |
+ |
// close: agreement proven among some hosts reads as agreement among all.
|
|
65 |
+ |
// `resolve_targets` rejects an unbuildable target before `/build`, so this
|
|
66 |
+ |
// is unreachable through the API and cheap to state anyway.
|
| 56 |
67 |
|
let mut hosts: Vec<String> = Vec::new();
|
| 57 |
68 |
|
for t in targets {
|
| 58 |
|
- |
if let Some(h) = state.topo.host_for(*t)
|
| 59 |
|
- |
&& !hosts.contains(&h.name)
|
| 60 |
|
- |
{
|
|
69 |
+ |
let h = state
|
|
70 |
+ |
.topo
|
|
71 |
+ |
.host_for(*t)
|
|
72 |
+ |
.ok_or_else(|| anyhow::anyhow!("release preflight: no host can build {t}"))?;
|
|
73 |
+ |
if !hosts.contains(&h.name) {
|
| 61 |
74 |
|
hosts.push(h.name.clone());
|
| 62 |
75 |
|
}
|
| 63 |
76 |
|
}
|
|
77 |
+ |
anyhow::ensure!(
|
|
78 |
+ |
!hosts.is_empty(),
|
|
79 |
+ |
"release preflight: no build hosts for v{version}; refusing to build a release \
|
|
80 |
+ |
nothing was pinned for"
|
|
81 |
+ |
);
|
| 64 |
82 |
|
|
| 65 |
83 |
|
let mut shas: Vec<(String, String)> = Vec::new();
|
| 66 |
84 |
|
let mut branches: Vec<(String, String)> = Vec::new();
|
| 78 |
96 |
|
.await
|
| 79 |
97 |
|
.with_context(|| format!("release preflight: reading branch on `{host}`"))?;
|
| 80 |
98 |
|
let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
99 |
+ |
// `symbolic-ref -q` exits non-zero BOTH on a detached HEAD and when it
|
|
100 |
+ |
// could not read the repo at all (wrong path, no checkout, no git).
|
|
101 |
+ |
// Those need opposite responses, so a stderr that says anything is
|
|
102 |
+ |
// reported as itself rather than folded into the detached-HEAD advice.
|
|
103 |
+ |
let why = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
|
104 |
+ |
anyhow::ensure!(
|
|
105 |
+ |
out.status.success() || why.is_empty(),
|
|
106 |
+ |
"release preflight: reading the branch of `{repo}` on `{host}` failed: {why}"
|
|
107 |
+ |
);
|
| 81 |
108 |
|
anyhow::ensure!(
|
| 82 |
109 |
|
out.status.success() && !branch.is_empty(),
|
| 83 |
110 |
|
"release preflight: `{repo}` on `{host}` is on a detached HEAD, so there is \
|
| 153 |
180 |
|
shas.push((host.clone(), sha));
|
| 154 |
181 |
|
}
|
| 155 |
182 |
|
|
| 156 |
|
- |
// The barrier: every host must be on the same commit.
|
| 157 |
|
- |
if let Some((first_host, first_sha)) = shas.first() {
|
| 158 |
|
- |
let mismatch: Vec<String> = shas
|
| 159 |
|
- |
.iter()
|
| 160 |
|
- |
.filter(|(_, sha)| sha != first_sha)
|
| 161 |
|
- |
.map(|(h, sha)| format!("{h}={}", short(sha)))
|
| 162 |
|
- |
.collect();
|
| 163 |
|
- |
anyhow::ensure!(
|
| 164 |
|
- |
mismatch.is_empty(),
|
| 165 |
|
- |
"release preflight: build hosts are on different commits for v{version} \
|
| 166 |
|
- |
({}={}, {}); refusing to build a release from mixed sources",
|
| 167 |
|
- |
first_host,
|
| 168 |
|
- |
short(first_sha),
|
| 169 |
|
- |
mismatch.join(", "),
|
| 170 |
|
- |
);
|
| 171 |
|
- |
}
|
|
183 |
+ |
// The barrier: every participating host must have reported, and every report
|
|
184 |
+ |
// must be the same commit. Reporting is checked first and separately from
|
|
185 |
+ |
// agreement, because a barrier that compares only what it received cannot
|
|
186 |
+ |
// tell unanimity from silence — three hosts agreeing while a fourth was
|
|
187 |
+ |
// never asked is exactly the mixed-source release this refuses.
|
|
188 |
+ |
let missing: Vec<&str> = hosts
|
|
189 |
+ |
.iter()
|
|
190 |
+ |
.filter(|h| !shas.iter().any(|(sh, _)| sh == *h))
|
|
191 |
+ |
.map(String::as_str)
|
|
192 |
+ |
.collect();
|
|
193 |
+ |
anyhow::ensure!(
|
|
194 |
+ |
missing.is_empty(),
|
|
195 |
+ |
"release preflight: {} did not report a commit for v{version}; refusing to build \
|
|
196 |
+ |
a release the barrier cannot vouch for",
|
|
197 |
+ |
missing.join(", "),
|
|
198 |
+ |
);
|
|
199 |
+ |
let empty_shas: Vec<&str> = shas
|
|
200 |
+ |
.iter()
|
|
201 |
+ |
.filter(|(_, sha)| sha.is_empty())
|
|
202 |
+ |
.map(|(h, _)| h.as_str())
|
|
203 |
+ |
.collect();
|
|
204 |
+ |
anyhow::ensure!(
|
|
205 |
+ |
empty_shas.is_empty(),
|
|
206 |
+ |
"release preflight: `git rev-parse HEAD` returned nothing on {} for v{version}",
|
|
207 |
+ |
empty_shas.join(", "),
|
|
208 |
+ |
);
|
|
209 |
+ |
|
|
210 |
+ |
let (first_host, first_sha) = &shas[0];
|
|
211 |
+ |
let mismatch: Vec<String> = shas
|
|
212 |
+ |
.iter()
|
|
213 |
+ |
.filter(|(_, sha)| sha != first_sha)
|
|
214 |
+ |
.map(|(h, sha)| format!("{h}={}", short(sha)))
|
|
215 |
+ |
.collect();
|
|
216 |
+ |
anyhow::ensure!(
|
|
217 |
+ |
mismatch.is_empty(),
|
|
218 |
+ |
"release preflight: build hosts are on different commits for v{version} \
|
|
219 |
+ |
({}={}, {}); refusing to build a release from mixed sources",
|
|
220 |
+ |
first_host,
|
|
221 |
+ |
short(first_sha),
|
|
222 |
+ |
mismatch.join(", "),
|
|
223 |
+ |
);
|
| 172 |
224 |
|
Ok(branches)
|
| 173 |
225 |
|
}
|
| 174 |
226 |
|
|
| 2277 |
2329 |
|
assert_eq!(status, "ok", "a pinned build should succeed ({error})");
|
| 2278 |
2330 |
|
}
|
| 2279 |
2331 |
|
|
|
2332 |
+ |
/// The barrier refuses a target no host can build, instead of leaving it out
|
|
2333 |
+ |
/// of the pin and letting the remaining hosts vouch for the release.
|
|
2334 |
+ |
///
|
|
2335 |
+ |
/// The hole this closes is silence reading as agreement: the comparison used
|
|
2336 |
+ |
/// to run over the hosts that reported, so a target dropped for want of a
|
|
2337 |
+ |
/// host still built while the other hosts' unanimity looked like proof the
|
|
2338 |
+ |
/// whole release came from one commit.
|
|
2339 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
2340 |
+ |
async fn release_preflight_refuses_a_target_no_host_can_build() {
|
|
2341 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
2342 |
+ |
let repo = tmp.path().join("demo");
|
|
2343 |
+ |
init_git_app(&repo, "0.0.1", Some("v0.0.1"));
|
|
2344 |
+ |
let mut cfg = Config::for_tests(tmp.path());
|
|
2345 |
+ |
cfg.pin_release_sha = true;
|
|
2346 |
+ |
let pool = crate::db::open(&cfg.db_path).await.unwrap();
|
|
2347 |
+ |
// The topology declares fw13 (linux/x86_64) only, so macOS has no host.
|
|
2348 |
+ |
let state = test_state(pool.clone(), one_host_topo(&repo), cfg);
|
|
2349 |
+ |
let err = start_build(
|
|
2350 |
+ |
state,
|
|
2351 |
+ |
AppId::new("demo"),
|
|
2352 |
+ |
Version::parse("0.0.1").unwrap(),
|
|
2353 |
+ |
vec!["macos/aarch64".parse().unwrap()],
|
|
2354 |
+ |
)
|
|
2355 |
+ |
.await
|
|
2356 |
+ |
.unwrap_err();
|
|
2357 |
+ |
let msg = format!("{err:#}");
|
|
2358 |
+ |
assert!(
|
|
2359 |
+ |
msg.contains("no host can build macos/aarch64"),
|
|
2360 |
+ |
"the error must name the unbuildable target, got: {msg}"
|
|
2361 |
+ |
);
|
|
2362 |
+ |
let builds: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM builds")
|
|
2363 |
+ |
.fetch_one(&pool)
|
|
2364 |
+ |
.await
|
|
2365 |
+ |
.unwrap();
|
|
2366 |
+ |
assert_eq!(builds, 0, "a refused preflight writes no build row");
|
|
2367 |
+ |
}
|
|
2368 |
+ |
|
| 2280 |
2369 |
|
/// The preflight refuses a host whose tree has uncommitted changes to
|
| 2281 |
2370 |
|
/// tracked files.
|
| 2282 |
2371 |
|
///
|