| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
use std::time::Duration; |
| 8 |
|
| 9 |
use crate::constants::{BUILD_MAX_LOG_BYTES, BUILD_TIMEOUT_SECS}; |
| 10 |
use crate::db::{self, BuildStatus, DbBuild, DbBuildConfig}; |
| 11 |
use crate::AppState; |
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
const POST_RECEIVE_HOOK_TEMPLATE: &str = r#"#!/bin/bash |
| 24 |
REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)" |
| 25 |
LOG="$REPO_PATH/hooks/post-receive.log" |
| 26 |
REPO_NAME="$(basename "$REPO_PATH" .git)" |
| 27 |
OWNER="$(basename "$(dirname "$REPO_PATH")")" |
| 28 |
while read oldrev newrev refname; do |
| 29 |
case "$refname" in |
| 30 |
refs/tags/v[0-9]*) |
| 31 |
TAG="${refname#refs/tags/}" |
| 32 |
( exec >>"$LOG" 2>&1 |
| 33 |
echo "[$(date -u +%FT%TZ)] tag-push $OWNER/$REPO_NAME tag=$TAG" |
| 34 |
curl -sf -X POST \ |
| 35 |
-H "Authorization: Bearer __HMAC__" \ |
| 36 |
-H "Content-Type: application/json" \ |
| 37 |
-d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \ |
| 38 |
"http://localhost:3000/api/internal/builds/trigger" \ |
| 39 |
|| echo "[$(date -u +%FT%TZ)] FAILED builds/trigger exit=$?" |
| 40 |
) & |
| 41 |
;; |
| 42 |
refs/heads/*) |
| 43 |
BRANCH="${refname#refs/heads/}" |
| 44 |
( exec >>"$LOG" 2>&1 |
| 45 |
echo "[$(date -u +%FT%TZ)] branch-push $OWNER/$REPO_NAME branch=$BRANCH" |
| 46 |
curl -sf -X POST \ |
| 47 |
-H "Authorization: Bearer __HMAC__" \ |
| 48 |
-H "Content-Type: application/json" \ |
| 49 |
-d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \ |
| 50 |
"http://localhost:3000/api/internal/issues/process-push" \ |
| 51 |
|| echo "[$(date -u +%FT%TZ)] FAILED issues/process-push exit=$?" |
| 52 |
) & |
| 53 |
;; |
| 54 |
esac |
| 55 |
done |
| 56 |
"#; |
| 57 |
|
| 58 |
|
| 59 |
pub fn repo_hmac(token: &str, owner: &str, repo: &str) -> String { |
| 60 |
use hmac::{Hmac, Mac}; |
| 61 |
use sha2::Sha256; |
| 62 |
let mut mac = Hmac::<Sha256>::new_from_slice(token.as_bytes()) |
| 63 |
.expect("HMAC accepts any key length"); |
| 64 |
mac.update(format!("{owner}:{repo}").as_bytes()); |
| 65 |
hex::encode(mac.finalize().into_bytes()) |
| 66 |
} |
| 67 |
|
| 68 |
|
| 69 |
pub fn post_receive_hook(token: &str, owner: &str, repo: &str) -> String { |
| 70 |
let hmac = repo_hmac(token, owner, repo); |
| 71 |
POST_RECEIVE_HOOK_TEMPLATE.replace("__HMAC__", &hmac) |
| 72 |
} |
| 73 |
|
| 74 |
|
| 75 |
pub fn rust_target(os: &str, arch: &str) -> Option<&'static str> { |
| 76 |
match (os, arch) { |
| 77 |
("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), |
| 78 |
("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"), |
| 79 |
("darwin", "x86_64") => Some("x86_64-apple-darwin"), |
| 80 |
("darwin", "aarch64") => Some("aarch64-apple-darwin"), |
| 81 |
_ => None, |
| 82 |
} |
| 83 |
} |
| 84 |
|
| 85 |
|
| 86 |
fn build_host_for_target<'a>(config: &'a crate::config::Config, os: &str) -> Option<&'a str> { |
| 87 |
match os { |
| 88 |
"linux" => config.build_host_linux.as_deref(), |
| 89 |
"darwin" => config.build_host_darwin.as_deref(), |
| 90 |
_ => None, |
| 91 |
} |
| 92 |
} |
| 93 |
|
| 94 |
|
| 95 |
|
| 96 |
|
| 97 |
#[tracing::instrument(skip_all, name = "build_runner::dispatch")] |
| 98 |
pub async fn dispatch_pending_build(state: &AppState) { |
| 99 |
|
| 100 |
match db::builds::fail_stale_running_builds(&state.db, BUILD_TIMEOUT_SECS as i64).await { |
| 101 |
Ok(n) if n > 0 => { |
| 102 |
tracing::warn!(count = n, "marked stale running builds as failed"); |
| 103 |
} |
| 104 |
Err(e) => { |
| 105 |
tracing::error!(error = ?e, "failed to check stale builds"); |
| 106 |
} |
| 107 |
_ => {} |
| 108 |
} |
| 109 |
|
| 110 |
let build = match db::builds::claim_pending_build(&state.db).await { |
| 111 |
Ok(Some(b)) => b, |
| 112 |
Ok(None) => return, |
| 113 |
Err(e) => { |
| 114 |
tracing::error!(error = ?e, "failed to claim pending build"); |
| 115 |
return; |
| 116 |
} |
| 117 |
}; |
| 118 |
|
| 119 |
let config = match db::builds::get_build_config_by_app(&state.db, build.app_id).await { |
| 120 |
Ok(Some(c)) => c, |
| 121 |
Ok(None) => { |
| 122 |
tracing::error!(build_id = %build.id, "build config not found for pending build"); |
| 123 |
if let Err(e) = db::builds::update_build_status( |
| 124 |
&state.db, |
| 125 |
build.id, |
| 126 |
BuildStatus::Failed, |
| 127 |
Some("Build config not found"), |
| 128 |
) |
| 129 |
.await { |
| 130 |
tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (config not found)"); |
| 131 |
} |
| 132 |
return; |
| 133 |
} |
| 134 |
Err(e) => { |
| 135 |
tracing::error!(error = ?e, "failed to get build config"); |
| 136 |
return; |
| 137 |
} |
| 138 |
}; |
| 139 |
|
| 140 |
let state = state.clone(); |
| 141 |
tokio::spawn(async move { |
| 142 |
run_build(&state, &build, &config).await; |
| 143 |
}); |
| 144 |
} |
| 145 |
|
| 146 |
fn build_failure_message(succeeded: usize, failed: usize, first_error: Option<&str>) -> String { |
| 147 |
if succeeded == 0 { |
| 148 |
first_error.unwrap_or("no targets produced artifacts").to_string() |
| 149 |
} else { |
| 150 |
let total = succeeded + failed; |
| 151 |
format!("partial build failure ({succeeded}/{total} targets succeeded)") |
| 152 |
} |
| 153 |
} |
| 154 |
|
| 155 |
|
| 156 |
#[tracing::instrument(skip_all, name = "build_runner::run_build", fields(build_id = %build.id, version = %build.version))] |
| 157 |
async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { |
| 158 |
let mut artifact_keys: Vec<(String, String, String, String)> = Vec::new(); |
| 159 |
let mut failed_count: usize = 0; |
| 160 |
let mut first_error: Option<String> = None; |
| 161 |
|
| 162 |
for target_str in &config.targets { |
| 163 |
let Some((target_os, arch)): Option<(&str, &str)> = target_str.split_once('/') else { |
| 164 |
let msg = format!("invalid target format: {target_str}\n"); |
| 165 |
let _ = append_log_bounded(state, build.id, &msg).await; |
| 166 |
failed_count += 1; |
| 167 |
if first_error.is_none() { |
| 168 |
first_error = Some(format!("invalid target format: {target_str}")); |
| 169 |
} |
| 170 |
continue; |
| 171 |
}; |
| 172 |
|
| 173 |
let host = match build_host_for_target(&state.config, target_os) { |
| 174 |
Some(h) => h, |
| 175 |
None => { |
| 176 |
let msg = format!("no build host for {target_os}, skipping {target_str}\n"); |
| 177 |
tracing::warn!("{}", msg.trim()); |
| 178 |
let _ = append_log_bounded(state, build.id, &msg).await; |
| 179 |
failed_count += 1; |
| 180 |
if first_error.is_none() { |
| 181 |
first_error = Some(format!("no build host for {target_os}")); |
| 182 |
} |
| 183 |
continue; |
| 184 |
} |
| 185 |
}; |
| 186 |
|
| 187 |
match execute_target(state, build, config, host, target_os, arch).await { |
| 188 |
Ok((s3_key, signature)) => { |
| 189 |
artifact_keys.push((target_os.to_string(), arch.to_string(), s3_key, signature)); |
| 190 |
} |
| 191 |
Err(e) => { |
| 192 |
let msg = format!("target {target_str} failed: {e}\n"); |
| 193 |
tracing::error!("{}", msg.trim()); |
| 194 |
let _ = append_log_bounded(state, build.id, &msg).await; |
| 195 |
failed_count += 1; |
| 196 |
if first_error.is_none() { |
| 197 |
first_error = Some(e); |
| 198 |
} |
| 199 |
} |
| 200 |
} |
| 201 |
} |
| 202 |
|
| 203 |
if artifact_keys.is_empty() || failed_count > 0 { |
| 204 |
let err_msg = build_failure_message(artifact_keys.len(), failed_count, first_error.as_deref()); |
| 205 |
if let Err(e) = db::builds::update_build_status( |
| 206 |
&state.db, |
| 207 |
build.id, |
| 208 |
BuildStatus::Failed, |
| 209 |
Some(&err_msg), |
| 210 |
) |
| 211 |
.await { |
| 212 |
tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed"); |
| 213 |
} |
| 214 |
if let Some(ref wam) = state.wam { |
| 215 |
let title = format!("Build failed: {} v{}", build.tag, build.version); |
| 216 |
wam.create_ticket(&title, Some(&err_msg), "high", "build-failed", Some(&build.id.to_string())).await; |
| 217 |
} |
| 218 |
return; |
| 219 |
} |
| 220 |
|
| 221 |
|
| 222 |
let release_signature = artifact_keys |
| 223 |
.iter() |
| 224 |
.find(|(_, _, _, sig)| !sig.is_empty()) |
| 225 |
.map(|(_, _, _, sig)| sig.as_str()) |
| 226 |
.unwrap_or(""); |
| 227 |
|
| 228 |
|
| 229 |
let release = match db::ota::create_release( |
| 230 |
&state.db, |
| 231 |
build.app_id, |
| 232 |
&build.version, |
| 233 |
&format!("Automated build from tag {}", build.tag), |
| 234 |
release_signature, |
| 235 |
) |
| 236 |
.await |
| 237 |
{ |
| 238 |
Ok(r) => r, |
| 239 |
Err(e) => { |
| 240 |
let msg = format!("failed to create OTA release: {e}"); |
| 241 |
if let Err(e) = db::builds::update_build_status( |
| 242 |
&state.db, |
| 243 |
build.id, |
| 244 |
BuildStatus::Failed, |
| 245 |
Some(&msg), |
| 246 |
) |
| 247 |
.await { |
| 248 |
tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (release creation)"); |
| 249 |
} |
| 250 |
return; |
| 251 |
} |
| 252 |
}; |
| 253 |
|
| 254 |
|
| 255 |
for (target_os, arch, s3_key, _signature) in &artifact_keys { |
| 256 |
|
| 257 |
let file_size = if let Some(s3) = state.synckit_s3.as_ref() { |
| 258 |
s3.object_size(s3_key).await.ok().flatten().unwrap_or(0) |
| 259 |
} else { |
| 260 |
0 |
| 261 |
}; |
| 262 |
|
| 263 |
if let Err(e) = |
| 264 |
db::ota::create_artifact(&state.db, release.id, target_os, arch, s3_key, file_size) |
| 265 |
.await |
| 266 |
{ |
| 267 |
tracing::error!(error = ?e, "failed to record artifact"); |
| 268 |
} |
| 269 |
} |
| 270 |
|
| 271 |
|
| 272 |
if let Err(e) = db::builds::set_build_release(&state.db, build.id, release.id).await { |
| 273 |
tracing::error!(build_id = %build.id, release_id = %release.id, error = ?e, "failed to link build to release"); |
| 274 |
} |
| 275 |
|
| 276 |
|
| 277 |
if let Err(e) = db::builds::update_build_status( |
| 278 |
&state.db, |
| 279 |
build.id, |
| 280 |
BuildStatus::Succeeded, |
| 281 |
None, |
| 282 |
) |
| 283 |
.await { |
| 284 |
tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as succeeded"); |
| 285 |
} |
| 286 |
|
| 287 |
tracing::info!( |
| 288 |
build_id = %build.id, |
| 289 |
version = %build.version, |
| 290 |
artifacts = artifact_keys.len(), |
| 291 |
"build succeeded" |
| 292 |
); |
| 293 |
} |
| 294 |
|
| 295 |
|
| 296 |
async fn execute_target( |
| 297 |
state: &AppState, |
| 298 |
build: &DbBuild, |
| 299 |
config: &DbBuildConfig, |
| 300 |
host: &str, |
| 301 |
target_os: &str, |
| 302 |
arch: &str, |
| 303 |
) -> std::result::Result<(String, String), String> { |
| 304 |
let target = format!("{target_os}/{arch}"); |
| 305 |
let rust_triple = rust_target(target_os, arch) |
| 306 |
.ok_or_else(|| format!("unsupported target: {target}"))?; |
| 307 |
|
| 308 |
|
| 309 |
let repo = db::git_repos::get_repo_by_id(&state.db, config.repo_id) |
| 310 |
.await |
| 311 |
.map_err(|e| format!("failed to look up repo: {e}"))? |
| 312 |
.ok_or("repo not found")?; |
| 313 |
|
| 314 |
let repo_owner = db::users::get_user_by_id(&state.db, repo.user_id) |
| 315 |
.await |
| 316 |
.map_err(|e| format!("failed to look up repo owner: {e}"))? |
| 317 |
.ok_or("repo owner not found")?; |
| 318 |
|
| 319 |
let git_root = state |
| 320 |
.config |
| 321 |
.git_repos_path |
| 322 |
.as_deref() |
| 323 |
.ok_or("git_repos_path not configured")?; |
| 324 |
|
| 325 |
let clone_path = format!("{git_root}/{}/{}.git", repo_owner.username, repo.name); |
| 326 |
let build_dir = format!("/tmp/mnw-build-{}", build.id); |
| 327 |
|
| 328 |
|
| 329 |
let build_cmd = config |
| 330 |
.build_command |
| 331 |
.replace("{target}", rust_triple) |
| 332 |
.replace("{version}", &build.version); |
| 333 |
let artifact_path = config |
| 334 |
.artifact_path |
| 335 |
.replace("{target}", rust_triple) |
| 336 |
.replace("{version}", &build.version); |
| 337 |
|
| 338 |
|
| 339 |
validate_build_command(&build_cmd) |
| 340 |
.map_err(|e| format!("invalid build command: {e}"))?; |
| 341 |
validate_artifact_path(&artifact_path) |
| 342 |
.map_err(|e| format!("invalid artifact path: {e}"))?; |
| 343 |
|
| 344 |
|
| 345 |
|
| 346 |
|
| 347 |
|
| 348 |
let remote_script = format!( |
| 349 |
"set -e && \ |
| 350 |
git clone --depth 1 --branch {tag} {clone_path} {build_dir} && \ |
| 351 |
cd {build_dir} && \ |
| 352 |
{build_cmd} && \ |
| 353 |
test -f {artifact_path}", |
| 354 |
tag = shell_escape(&build.tag), |
| 355 |
clone_path = shell_escape(&clone_path), |
| 356 |
build_dir = shell_escape(&build_dir), |
| 357 |
build_cmd = build_cmd, |
| 358 |
artifact_path = shell_escape(&artifact_path), |
| 359 |
); |
| 360 |
|
| 361 |
let log_msg = format!("[{target}] building on {host}...\n"); |
| 362 |
let _ = append_log_bounded(state, build.id, &log_msg).await; |
| 363 |
|
| 364 |
|
| 365 |
let ssh_result = tokio::time::timeout( |
| 366 |
Duration::from_secs(BUILD_TIMEOUT_SECS), |
| 367 |
run_ssh_command(host, &remote_script), |
| 368 |
) |
| 369 |
.await; |
| 370 |
|
| 371 |
let output = match ssh_result { |
| 372 |
Ok(Ok(output)) => output, |
| 373 |
Ok(Err(e)) => { |
| 374 |
|
| 375 |
let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await; |
| 376 |
return Err(format!("SSH command failed: {e}")); |
| 377 |
} |
| 378 |
Err(_) => { |
| 379 |
let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await; |
| 380 |
return Err("build timed out".to_string()); |
| 381 |
} |
| 382 |
}; |
| 383 |
|
| 384 |
let _ = append_log_bounded(state, build.id, &format!("[{target}] {}\n", output.trim())).await; |
| 385 |
|
| 386 |
|
| 387 |
let s3_key = format!("ota/{}/{}/{target_os}/{arch}/artifact", build.app_id, build.version); |
| 388 |
|
| 389 |
|
| 390 |
let local_tmp = format!("/tmp/mnw-artifact-{}-{target_os}-{arch}", build.id); |
| 391 |
let scp_remote_path = format!( |
| 392 |
"{}/{}", |
| 393 |
build_dir.trim_end_matches('/'), |
| 394 |
artifact_path.trim_start_matches('/') |
| 395 |
); |
| 396 |
let scp_result = run_scp_download(host, &scp_remote_path, &local_tmp).await; |
| 397 |
|
| 398 |
|
| 399 |
let local_sig_tmp = format!("{local_tmp}.sig"); |
| 400 |
let scp_sig_result = |
| 401 |
run_scp_download(host, &format!("{scp_remote_path}.sig"), &local_sig_tmp).await; |
| 402 |
|
| 403 |
|
| 404 |
let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await; |
| 405 |
|
| 406 |
if let Err(e) = scp_result { |
| 407 |
|
| 408 |
|
| 409 |
|
| 410 |
|
| 411 |
|
| 412 |
let _ = tokio::fs::remove_file(&local_sig_tmp).await; |
| 413 |
return Err(format!("SCP download failed: {e}")); |
| 414 |
} |
| 415 |
|
| 416 |
|
| 417 |
let signature = if scp_sig_result.is_ok() { |
| 418 |
let sig = tokio::fs::read_to_string(&local_sig_tmp) |
| 419 |
.await |
| 420 |
.unwrap_or_default(); |
| 421 |
let _ = tokio::fs::remove_file(&local_sig_tmp).await; |
| 422 |
sig |
| 423 |
} else { |
| 424 |
String::new() |
| 425 |
}; |
| 426 |
|
| 427 |
|
| 428 |
|
| 429 |
|
| 430 |
|
| 431 |
|
| 432 |
|
| 433 |
let synckit_s3 = state |
| 434 |
.synckit_s3 |
| 435 |
.as_ref() |
| 436 |
.ok_or("SyncKit storage not configured")?; |
| 437 |
|
| 438 |
let upload_result = synckit_s3 |
| 439 |
.upload_multipart( |
| 440 |
&s3_key, |
| 441 |
"application/octet-stream", |
| 442 |
std::path::Path::new(&local_tmp), |
| 443 |
) |
| 444 |
.await |
| 445 |
.map_err(|e| format!("S3 multipart upload failed: {e}")); |
| 446 |
|
| 447 |
|
| 448 |
|
| 449 |
let _ = tokio::fs::remove_file(&local_tmp).await; |
| 450 |
|
| 451 |
upload_result?; |
| 452 |
|
| 453 |
if !signature.is_empty() { |
| 454 |
let _ = append_log_bounded( |
| 455 |
state, |
| 456 |
build.id, |
| 457 |
&format!("[{target}] uploaded to {s3_key} (signed)\n"), |
| 458 |
) |
| 459 |
.await; |
| 460 |
} else { |
| 461 |
let _ = append_log_bounded( |
| 462 |
state, |
| 463 |
build.id, |
| 464 |
&format!("[{target}] uploaded to {s3_key}\n"), |
| 465 |
) |
| 466 |
.await; |
| 467 |
} |
| 468 |
|
| 469 |
Ok((s3_key, signature)) |
| 470 |
} |
| 471 |
|
| 472 |
|
| 473 |
|
| 474 |
|
| 475 |
const BUILD_SSH_KNOWN_HOSTS: &str = "/etc/mnw/known_hosts"; |
| 476 |
|
| 477 |
|
| 478 |
async fn run_ssh_command(host: &str, command: &str) -> std::result::Result<String, String> { |
| 479 |
let mut args = vec!["-o", "ConnectTimeout=10", "-o", "BatchMode=yes"]; |
| 480 |
let known_hosts_arg; |
| 481 |
if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() { |
| 482 |
args.extend(["-o", "StrictHostKeyChecking=yes", "-o"]); |
| 483 |
known_hosts_arg = format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}"); |
| 484 |
args.push(&known_hosts_arg); |
| 485 |
} else { |
| 486 |
args.extend(["-o", "StrictHostKeyChecking=accept-new"]); |
| 487 |
} |
| 488 |
args.push(host); |
| 489 |
args.push(command); |
| 490 |
let output = tokio::process::Command::new("ssh") |
| 491 |
.args(&args) |
| 492 |
.output() |
| 493 |
.await |
| 494 |
.map_err(|e| format!("failed to spawn ssh: {e}"))?; |
| 495 |
|
| 496 |
if output.status.success() { |
| 497 |
Ok(String::from_utf8_lossy(&output.stdout).to_string()) |
| 498 |
} else { |
| 499 |
let stderr = String::from_utf8_lossy(&output.stderr); |
| 500 |
Err(format!( |
| 501 |
"exit code {}: {}", |
| 502 |
output.status.code().unwrap_or(-1), |
| 503 |
stderr.trim() |
| 504 |
)) |
| 505 |
} |
| 506 |
} |
| 507 |
|
| 508 |
|
| 509 |
async fn run_scp_download( |
| 510 |
host: &str, |
| 511 |
remote_path: &str, |
| 512 |
local_path: &str, |
| 513 |
) -> std::result::Result<(), String> { |
| 514 |
let remote = format!("{host}:{remote_path}"); |
| 515 |
let mut args: Vec<&str> = vec!["-o", "ConnectTimeout=10", "-o", "BatchMode=yes"]; |
| 516 |
let known_hosts_arg; |
| 517 |
if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() { |
| 518 |
args.extend(["-o", "StrictHostKeyChecking=yes", "-o"]); |
| 519 |
known_hosts_arg = format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}"); |
| 520 |
args.push(&known_hosts_arg); |
| 521 |
} else { |
| 522 |
args.extend(["-o", "StrictHostKeyChecking=accept-new"]); |
| 523 |
} |
| 524 |
args.push(&remote); |
| 525 |
args.push(local_path); |
| 526 |
let output = tokio::process::Command::new("scp") |
| 527 |
.args(&args) |
| 528 |
.output() |
| 529 |
.await |
| 530 |
.map_err(|e| format!("failed to spawn scp: {e}"))?; |
| 531 |
|
| 532 |
if output.status.success() { |
| 533 |
Ok(()) |
| 534 |
} else { |
| 535 |
let stderr = String::from_utf8_lossy(&output.stderr); |
| 536 |
Err(format!( |
| 537 |
"exit code {}: {}", |
| 538 |
output.status.code().unwrap_or(-1), |
| 539 |
stderr.trim() |
| 540 |
)) |
| 541 |
} |
| 542 |
} |
| 543 |
|
| 544 |
|
| 545 |
|
| 546 |
|
| 547 |
|
| 548 |
async fn append_log_bounded( |
| 549 |
state: &AppState, |
| 550 |
build_id: db::BuildId, |
| 551 |
line: &str, |
| 552 |
) -> crate::error::Result<()> { |
| 553 |
const TRUNCATED: &str = "[log truncated]\n"; |
| 554 |
if let Some((current_len, already_truncated)) = |
| 555 |
db::builds::get_build_log_size(&state.db, build_id, TRUNCATED).await? |
| 556 |
&& (current_len as usize) + line.len() > BUILD_MAX_LOG_BYTES |
| 557 |
{ |
| 558 |
if !already_truncated { |
| 559 |
tracing::warn!(build_id = %build_id, "Build log exceeded {} bytes, truncating", BUILD_MAX_LOG_BYTES); |
| 560 |
db::builds::append_build_log(&state.db, build_id, TRUNCATED).await?; |
| 561 |
} |
| 562 |
return Ok(()); |
| 563 |
} |
| 564 |
let sanitized = strip_ansi_escapes(line); |
| 565 |
db::builds::append_build_log(&state.db, build_id, &sanitized).await |
| 566 |
} |
| 567 |
|
| 568 |
|
| 569 |
|
| 570 |
fn strip_ansi_escapes(s: &str) -> String { |
| 571 |
let mut result = String::with_capacity(s.len()); |
| 572 |
let mut chars = s.chars(); |
| 573 |
while let Some(c) = chars.next() { |
| 574 |
if c == '\x1b' { |
| 575 |
|
| 576 |
|
| 577 |
|
| 578 |
if let Some(next) = chars.next() |
| 579 |
&& next == '[' |
| 580 |
{ |
| 581 |
|
| 582 |
for tail in chars.by_ref() { |
| 583 |
if tail.is_ascii_alphabetic() { |
| 584 |
break; |
| 585 |
} |
| 586 |
} |
| 587 |
} |
| 588 |
} else { |
| 589 |
result.push(c); |
| 590 |
} |
| 591 |
} |
| 592 |
result |
| 593 |
} |
| 594 |
|
| 595 |
|
| 596 |
|
| 597 |
|
| 598 |
|
| 599 |
|
| 600 |
pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> { |
| 601 |
if cmd.is_empty() { |
| 602 |
return Err("build command is empty".to_string()); |
| 603 |
} |
| 604 |
if cmd.len() > 1024 { |
| 605 |
return Err("build command too long (max 1024 chars)".to_string()); |
| 606 |
} |
| 607 |
for (i, c) in cmd.chars().enumerate() { |
| 608 |
match c { |
| 609 |
'a'..='z' | 'A'..='Z' | '0'..='9' => {} |
| 610 |
' ' | '-' | '_' | '.' | '/' | '=' | ':' | ',' | '+' | '{' | '}' | '@' => {} |
| 611 |
';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"' |
| 612 |
| '\n' | '\r' | '\0' => { |
| 613 |
return Err(format!( |
| 614 |
"shell metacharacter '{}' at position {} is not allowed", |
| 615 |
c.escape_default(), |
| 616 |
i |
| 617 |
)); |
| 618 |
} |
| 619 |
_ => { |
| 620 |
return Err(format!( |
| 621 |
"unexpected character '{}' at position {} is not allowed", |
| 622 |
c.escape_default(), |
| 623 |
i |
| 624 |
)); |
| 625 |
} |
| 626 |
} |
| 627 |
} |
| 628 |
Ok(()) |
| 629 |
} |
| 630 |
|
| 631 |
|
| 632 |
|
| 633 |
|
| 634 |
pub fn validate_artifact_path(path: &str) -> std::result::Result<(), String> { |
| 635 |
if path.is_empty() { |
| 636 |
return Err("artifact path is empty".to_string()); |
| 637 |
} |
| 638 |
if path.len() > 512 { |
| 639 |
return Err("artifact path too long (max 512 chars)".to_string()); |
| 640 |
} |
| 641 |
if path.starts_with('/') { |
| 642 |
return Err("artifact path must be relative".to_string()); |
| 643 |
} |
| 644 |
if path.contains("..") { |
| 645 |
return Err("artifact path must not contain '..'".to_string()); |
| 646 |
} |
| 647 |
for (i, c) in path.chars().enumerate() { |
| 648 |
match c { |
| 649 |
'a'..='z' | 'A'..='Z' | '0'..='9' => {} |
| 650 |
'-' | '_' | '.' | '/' | '{' | '}' | '+' => {} |
| 651 |
';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"' |
| 652 |
| ' ' | '\n' | '\r' | '\0' => { |
| 653 |
return Err(format!( |
| 654 |
"character '{}' at position {} is not allowed in artifact path", |
| 655 |
c.escape_default(), |
| 656 |
i |
| 657 |
)); |
| 658 |
} |
| 659 |
_ => { |
| 660 |
return Err(format!( |
| 661 |
"unexpected character '{}' at position {} is not allowed in artifact path", |
| 662 |
c.escape_default(), |
| 663 |
i |
| 664 |
)); |
| 665 |
} |
| 666 |
} |
| 667 |
} |
| 668 |
Ok(()) |
| 669 |
} |
| 670 |
|
| 671 |
|
| 672 |
fn shell_escape(s: &str) -> String { |
| 673 |
format!("'{}'", s.replace('\'', "'\\''")) |
| 674 |
} |
| 675 |
|
| 676 |
#[cfg(test)] |
| 677 |
mod tests { |
| 678 |
use super::*; |
| 679 |
|
| 680 |
#[test] |
| 681 |
fn build_failure_message_partial() { |
| 682 |
assert_eq!( |
| 683 |
build_failure_message(1, 2, Some("boom")), |
| 684 |
"partial build failure (1/3 targets succeeded)" |
| 685 |
); |
| 686 |
assert_eq!( |
| 687 |
build_failure_message(2, 1, Some("boom")), |
| 688 |
"partial build failure (2/3 targets succeeded)" |
| 689 |
); |
| 690 |
} |
| 691 |
|
| 692 |
#[test] |
| 693 |
fn build_failure_message_total_failure_uses_first_error() { |
| 694 |
assert_eq!(build_failure_message(0, 3, Some("ssh down")), "ssh down"); |
| 695 |
assert_eq!(build_failure_message(0, 0, None), "no targets produced artifacts"); |
| 696 |
} |
| 697 |
|
| 698 |
#[test] |
| 699 |
fn rust_target_mapping() { |
| 700 |
assert_eq!(rust_target("linux", "x86_64"), Some("x86_64-unknown-linux-gnu")); |
| 701 |
assert_eq!(rust_target("linux", "aarch64"), Some("aarch64-unknown-linux-gnu")); |
| 702 |
assert_eq!(rust_target("darwin", "x86_64"), Some("x86_64-apple-darwin")); |
| 703 |
assert_eq!(rust_target("darwin", "aarch64"), Some("aarch64-apple-darwin")); |
| 704 |
assert_eq!(rust_target("windows", "x86_64"), None); |
| 705 |
} |
| 706 |
|
| 707 |
#[test] |
| 708 |
fn hook_template_contains_hmac_not_raw_token() { |
| 709 |
let hook = post_receive_hook("secret-token-123", "alice", "myrepo"); |
| 710 |
let expected_hmac = repo_hmac("secret-token-123", "alice", "myrepo"); |
| 711 |
assert!(hook.contains(&expected_hmac), "hook should contain per-repo HMAC"); |
| 712 |
assert!(!hook.contains("secret-token-123"), "hook must not contain raw token"); |
| 713 |
assert!(!hook.contains("__HMAC__"), "placeholder should be replaced"); |
| 714 |
assert!(hook.contains("/api/internal/builds/trigger")); |
| 715 |
} |
| 716 |
|
| 717 |
#[test] |
| 718 |
fn repo_hmac_differs_per_repo() { |
| 719 |
let h1 = repo_hmac("token", "alice", "repo-a"); |
| 720 |
let h2 = repo_hmac("token", "alice", "repo-b"); |
| 721 |
assert_ne!(h1, h2, "different repos should produce different HMACs"); |
| 722 |
} |
| 723 |
|
| 724 |
#[test] |
| 725 |
fn shell_escape_basic() { |
| 726 |
assert_eq!(shell_escape("hello"), "'hello'"); |
| 727 |
assert_eq!(shell_escape("it's"), "'it'\\''s'"); |
| 728 |
} |
| 729 |
|
| 730 |
#[test] |
| 731 |
fn validate_build_command_accepts_safe_commands() { |
| 732 |
assert!(validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu").is_ok()); |
| 733 |
assert!(validate_build_command("make -j4").is_ok()); |
| 734 |
assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok()); |
| 735 |
} |
| 736 |
|
| 737 |
#[test] |
| 738 |
fn validate_build_command_rejects_injection() { |
| 739 |
assert!(validate_build_command("cargo build; curl evil.com").is_err()); |
| 740 |
assert!(validate_build_command("cargo build && rm -rf /").is_err()); |
| 741 |
assert!(validate_build_command("cargo build | tee log").is_err()); |
| 742 |
assert!(validate_build_command("$(whoami)").is_err()); |
| 743 |
assert!(validate_build_command("`whoami`").is_err()); |
| 744 |
assert!(validate_build_command("cargo build > /dev/null").is_err()); |
| 745 |
assert!(validate_build_command("").is_err()); |
| 746 |
} |
| 747 |
|
| 748 |
#[test] |
| 749 |
fn validate_artifact_path_accepts_safe_paths() { |
| 750 |
assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok()); |
| 751 |
assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok()); |
| 752 |
} |
| 753 |
|
| 754 |
#[test] |
| 755 |
fn validate_artifact_path_rejects_unsafe() { |
| 756 |
assert!(validate_artifact_path("/etc/passwd").is_err()); |
| 757 |
assert!(validate_artifact_path("../../../etc/passwd").is_err()); |
| 758 |
assert!(validate_artifact_path("path with spaces").is_err()); |
| 759 |
assert!(validate_artifact_path("$(whoami)").is_err()); |
| 760 |
assert!(validate_artifact_path("").is_err()); |
| 761 |
} |
| 762 |
} |
| 763 |
|