1use crate::cli::commands::{
2 ApiTargetOptions, RunnersAgentServeCmd, RunnersGroupsAccessCmd, RunnersHostsPreflightCmd,
3 RunnersHostsStatusCmd, RunnersLogsCmd, RunnersStatusCmd,
4};
5use crate::commands::api_request::resolve_request_url;
6use crate::config::{
7 global_runner_root_dir, resolve_github_oauth2_key, resolve_xbp_api_token, SshConfig,
8};
9use crate::strategies::{GitHubRunnersProjectConfig, XbpConfig};
10use crate::utils::{find_xbp_config_upwards, parse_config_with_auto_heal};
11use chrono::Utc;
12use colored::Colorize;
13use flate2::read::GzDecoder;
14use reqwest::{Client, Method, StatusCode};
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use std::env;
18use std::fs;
19use std::io::{self, Cursor};
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22use std::time::Duration;
23use tar::Archive;
24use tokio::time::sleep;
25use xbp_core::{
26 RunnerExecutionRuntime, RunnerHostPreflightRecord, RunnerHostRecord, RunnerInstallPhase,
27 RunnerJobRecord, RunnerPlatform, RunnerRegistrationState, RunnerRuntimeState,
28 RunnerServiceManager, RunnerServiceStatus, RunnerSyncState,
29};
30use zip::ZipArchive;
31
32#[derive(Debug, Deserialize)]
33struct RunnerHostListResponse {
34 runner_hosts: Vec<RunnerHostRecord>,
35}
36
37#[derive(Debug, Deserialize)]
38struct RunnerHostPreflightListResponse {
39 preflights: Vec<RunnerHostPreflightRecord>,
40}
41
42#[derive(Debug, Deserialize)]
43struct RunnerJobListResponse {
44 jobs: Vec<RunnerJobRecord>,
45}
46
47#[derive(Debug, Deserialize)]
48struct RunnerGroupRepositoryListResponse {
49 repositories: Vec<Value>,
50}
51
52#[derive(Debug, Clone, Deserialize)]
53struct RunnerApiReleaseResponse {
54 version: String,
55 file_name: String,
56 download_url: String,
57}
58
59#[derive(Debug, Deserialize)]
60struct RunnerApiTokenResponse {
61 token: String,
62 #[serde(rename = "expires_at")]
63 _expires_at: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
67struct RunnerGitHubListResponse {
68 runners: Vec<RunnerGitHubRunner>,
69}
70
71#[derive(Debug, Deserialize)]
72struct RunnerGitHubRunner {
73 id: i64,
74 name: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78struct LocalRunnerRuntimeMetadata {
79 #[serde(default)]
80 runner_host_id: Option<String>,
81 #[serde(default)]
82 hostname: Option<String>,
83 #[serde(default)]
84 runner_name_prefix: Option<String>,
85 #[serde(default)]
86 runner_name: Option<String>,
87 #[serde(default)]
88 runner_group: Option<String>,
89 #[serde(default)]
90 organization_login: Option<String>,
91 #[serde(default)]
92 github_installation_id: Option<String>,
93 #[serde(default)]
94 github_runner_id: Option<i64>,
95 #[serde(default)]
96 removed_runner_names: Vec<String>,
97 #[serde(default)]
98 removed_runner_ids: Vec<i64>,
99}
100
101#[derive(Debug, Clone, Default)]
102struct RunnerCleanupSummary {
103 removed_runner_names: Vec<String>,
104 removed_runner_ids: Vec<i64>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct LocalRunnerJobPayload {
109 pub runtime: RunnerExecutionRuntime,
110 #[serde(default)]
111 pub organization_login: Option<String>,
112 #[serde(default)]
113 pub github_installation_id: Option<String>,
114 #[serde(default)]
115 pub runner_group: Option<String>,
116 #[serde(default)]
117 pub labels: Vec<String>,
118 #[serde(default)]
119 pub runner_name: Option<String>,
120 #[serde(default)]
121 pub runner_version: Option<String>,
122 #[serde(default)]
123 pub ephemeral: bool,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct LocalRunnerJobResult {
128 pub runtime_root: String,
129 pub service_manager: RunnerServiceManager,
130 pub service_status: RunnerServiceStatus,
131 pub install_phase: RunnerInstallPhase,
132 pub registration_state: RunnerRegistrationState,
133 pub installed_version: Option<String>,
134 pub generated_files: Vec<String>,
135}
136
137pub async fn run_runner_host_preflight(cmd: &RunnersHostsPreflightCmd) -> Result<(), String> {
138 let host = fetch_runner_host_by_id(&cmd.runner_host_id, &cmd.target).await?;
139 let preflight = build_local_preflight(&host)?;
140 print_preflight(&host, &preflight);
141
142 if cmd.write_api {
143 let body = json!({
144 "runner_host_id": host.id,
145 "platform": preflight.platform,
146 "status": preflight.status,
147 "docker_available": preflight.docker_available,
148 "service_manager": preflight.service_manager,
149 "checks": preflight.checks,
150 "checked_at": preflight.checked_at,
151 });
152 let _: RunnerHostPreflightRecord = api_json(
153 Method::POST,
154 "/runner-host-preflights",
155 &cmd.target,
156 Some(body),
157 )
158 .await?;
159 println!(
160 "{}",
161 "Persisted preflight result to the control plane.".green()
162 );
163 }
164
165 Ok(())
166}
167
168pub async fn run_runner_host_status(cmd: &RunnersHostsStatusCmd) -> Result<(), String> {
169 let host = fetch_runner_host_by_id(&cmd.runner_host_id, &cmd.target).await?;
170 let response: RunnerHostPreflightListResponse = api_json(
171 Method::GET,
172 &format!("/runner-host-preflights?runner_host_id={}", host.id),
173 &cmd.target,
174 None,
175 )
176 .await?;
177
178 println!("{}", "Runner Host".bold());
179 println!(" id: {}", host.id);
180 println!(" hostname: {}", host.hostname);
181 println!(" platform: {}", host.platform);
182 println!(" status: {}", color_status(host.status.as_str()));
183 println!(" host kind: {}", host.host_kind);
184 println!(" prefix: {}", host.runner_name_prefix);
185 if let Some(preflight) = response.preflights.first() {
186 println!("{}", "Latest preflight".bold());
187 println!(" status: {}", color_status(preflight.status.as_str()));
188 println!(
189 " service manager: {}",
190 preflight
191 .service_manager
192 .map(|value| value.to_string())
193 .unwrap_or_else(|| "unknown".to_string())
194 );
195 println!(
196 " docker available: {}",
197 preflight
198 .docker_available
199 .map(|value| if value { "yes" } else { "no" })
200 .unwrap_or("unknown")
201 );
202 }
203 Ok(())
204}
205
206pub async fn run_runner_group_access(cmd: &RunnersGroupsAccessCmd) -> Result<(), String> {
207 let response: RunnerGroupRepositoryListResponse = api_json(
208 Method::GET,
209 &format!("/runner-groups/{}/repositories", cmd.runner_group_id),
210 &cmd.target,
211 None,
212 )
213 .await?;
214
215 println!("{}", "Runner Group Access".bold());
216 if response.repositories.is_empty() {
217 println!("No explicit repositories are stored for this runner group.");
218 return Ok(());
219 }
220
221 let rows = response
222 .repositories
223 .iter()
224 .map(|repository| {
225 vec![
226 read_json_string(repository, "repository_full_name"),
227 if repository
228 .get("allowed")
229 .and_then(Value::as_bool)
230 .unwrap_or(true)
231 {
232 "allowed".green().to_string()
233 } else {
234 "blocked".red().to_string()
235 },
236 ]
237 })
238 .collect::<Vec<_>>();
239 print_table(&["Repository", "Access"], &rows);
240 Ok(())
241}
242
243pub async fn run_runner_status(cmd: &RunnersStatusCmd) -> Result<(), String> {
244 let hosts_path = format!("/runner-hosts?organization_id={}", cmd.organization_id);
245 let jobs_path = {
246 let mut params = vec![("organization_id", Some(cmd.organization_id.as_str()))];
247 params.push(("runner_host_id", cmd.runner_host_id.as_deref()));
248 params.push(("runner_id", cmd.runner_id.as_deref()));
249 params.push(("daemon_id", cmd.daemon_id.as_deref()));
250 params.push(("status", cmd.status.as_deref()));
251 params.push(("phase", cmd.phase.as_deref()));
252 let limit = cmd.limit.to_string();
253 params.push(("limit", Some(limit.as_str())));
254 with_query("/runner-jobs", ¶ms)
255 };
256 let hosts: RunnerHostListResponse =
257 api_json(Method::GET, &hosts_path, &cmd.target, None).await?;
258 let jobs: RunnerJobListResponse = api_json(Method::GET, &jobs_path, &cmd.target, None).await?;
259
260 println!("{}", "Runner Hosts".bold());
261 let host_rows = hosts
262 .runner_hosts
263 .iter()
264 .filter(|host| {
265 cmd.runner_host_id
266 .as_deref()
267 .map(|runner_host_id| runner_host_id == host.id)
268 .unwrap_or(true)
269 })
270 .map(|host| {
271 vec![
272 host.id.clone(),
273 host.hostname.clone(),
274 host.platform.to_string(),
275 color_status(host.status.as_str()),
276 ]
277 })
278 .collect::<Vec<_>>();
279 print_table(&["Host ID", "Hostname", "Platform", "Status"], &host_rows);
280
281 println!();
282 println!("{}", "Runner Jobs".bold());
283 let job_rows = jobs
284 .jobs
285 .iter()
286 .filter(|job| {
287 cmd.runner_host_id
288 .as_deref()
289 .map(|runner_host_id| job.runner_host_id.as_deref() == Some(runner_host_id))
290 .unwrap_or(true)
291 })
292 .map(|job| {
293 vec![
294 job.id.clone(),
295 job.job_kind.to_string(),
296 color_status(job.status.as_str()),
297 job.phase
298 .map(|phase| phase.to_string())
299 .unwrap_or_else(|| "-".to_string()),
300 job.runner_host_id
301 .clone()
302 .unwrap_or_else(|| "-".to_string()),
303 ]
304 })
305 .collect::<Vec<_>>();
306 print_table(&["Job ID", "Kind", "Status", "Phase", "Host"], &job_rows);
307 Ok(())
308}
309
310pub async fn run_runner_logs(cmd: &RunnersLogsCmd) -> Result<(), String> {
311 let limit = cmd.limit.to_string();
312 let path = with_query(
313 "/runner-jobs",
314 &[
315 ("organization_id", cmd.organization_id.as_deref()),
316 ("runner_host_id", cmd.runner_host_id.as_deref()),
317 ("runner_id", cmd.runner_id.as_deref()),
318 ("daemon_id", cmd.daemon_id.as_deref()),
319 ("status", cmd.status.as_deref()),
320 ("phase", cmd.phase.as_deref()),
321 ("limit", Some(limit.as_str())),
322 ],
323 );
324 let jobs: RunnerJobListResponse = api_json(Method::GET, &path, &cmd.target, None).await?;
325
326 println!("{}", "Recent Runner Jobs".bold());
327 for job in jobs.jobs {
328 println!(
329 "{} {} {} {}",
330 job.id.cyan(),
331 job.job_kind.to_string().bold(),
332 color_status(job.status.as_str()),
333 job.phase
334 .map(|phase| phase.to_string())
335 .unwrap_or_else(|| "-".to_string())
336 );
337 if let Some(error_text) = job.error_text.as_deref().filter(|value| !value.is_empty()) {
338 println!(" {}", error_text.red());
339 }
340 }
341 Ok(())
342}
343
344pub async fn run_runner_agent(cmd: &RunnersAgentServeCmd) -> Result<(), String> {
345 loop {
346 let host = fetch_runner_host_by_id(&cmd.runner_host_id, &cmd.target).await?;
347 let preflight = build_local_preflight(&host)?;
348 let preflight_body = json!({
349 "runner_host_id": host.id,
350 "platform": preflight.platform,
351 "status": preflight.status,
352 "docker_available": preflight.docker_available,
353 "service_manager": preflight.service_manager,
354 "checks": preflight.checks,
355 "checked_at": preflight.checked_at,
356 });
357 let _: RunnerHostPreflightRecord = api_json(
358 Method::POST,
359 "/runner-host-preflights",
360 &cmd.target,
361 Some(preflight_body),
362 )
363 .await?;
364 let _: RunnerHostRecord = api_json(
365 Method::POST,
366 &format!("/runner-hosts/{}/heartbeat", host.id),
367 &cmd.target,
368 Some(json!({
369 "status": "online",
370 "capabilities": {
371 "service_manager": preflight.service_manager,
372 "docker_available": preflight.docker_available,
373 }
374 })),
375 )
376 .await?;
377
378 let claim_response = api_json_status(
379 Method::POST,
380 "/runner-jobs/claim",
381 &cmd.target,
382 Some(json!({
383 "runner_host_id": host.id,
384 "locked_by": host.hostname,
385 })),
386 )
387 .await?;
388
389 if claim_response.0 == StatusCode::NO_CONTENT {
390 if cmd.once {
391 println!("No runner jobs were available.");
392 return Ok(());
393 }
394 sleep(Duration::from_secs(cmd.interval_seconds)).await;
395 continue;
396 }
397
398 let job: RunnerJobRecord = serde_json::from_value(claim_response.1)
399 .map_err(|error| format!("Failed to decode claimed runner job: {}", error))?;
400 println!(
401 "{} {} {}",
402 "Claimed".green().bold(),
403 job.job_kind.to_string().bold(),
404 job.id.cyan()
405 );
406 let _ = api_json::<RunnerJobRecord>(
407 Method::PATCH,
408 &format!("/runner-jobs/{}", job.id),
409 &cmd.target,
410 Some(json!({
411 "status": "running",
412 "phase": "preflight",
413 "details": {},
414 })),
415 )
416 .await?;
417
418 match execute_local_runner_job(&host, &job).await {
419 Ok(result) => {
420 let _ = api_json::<RunnerJobRecord>(
421 Method::PATCH,
422 &format!("/runner-jobs/{}", job.id),
423 &cmd.target,
424 Some(json!({
425 "status": "succeeded",
426 "phase": result.install_phase,
427 "details": {
428 "runtime_root": result.runtime_root,
429 "service_manager": result.service_manager,
430 "service_status": result.service_status,
431 "registration_state": result.registration_state,
432 "installed_version": result.installed_version,
433 "generated_files": result.generated_files,
434 },
435 "error_text": null,
436 })),
437 )
438 .await?;
439 }
440 Err(error) => {
441 let _ = api_json::<RunnerJobRecord>(
442 Method::PATCH,
443 &format!("/runner-jobs/{}", job.id),
444 &cmd.target,
445 Some(json!({
446 "status": "failed",
447 "phase": "failed",
448 "details": {},
449 "error_text": error,
450 })),
451 )
452 .await?;
453 }
454 }
455
456 if cmd.once {
457 return Ok(());
458 }
459
460 sleep(Duration::from_secs(cmd.interval_seconds)).await;
461 }
462}
463
464pub fn enrich_runner_job_payload(
465 user_payload: Option<&str>,
466 job_kind: &str,
467) -> Result<Option<String>, String> {
468 let mut payload = user_payload
469 .map(|value| serde_json::from_str::<Value>(value).map_err(|error| error.to_string()))
470 .transpose()?
471 .unwrap_or_else(|| json!({}));
472
473 if !payload.is_object() {
474 return Err("Runner payload JSON must be an object.".to_string());
475 }
476
477 let defaults = load_project_runner_defaults()?;
478 let config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
479 let runtime = defaults
480 .as_ref()
481 .and_then(|defaults| defaults.preferred_runtime)
482 .or_else(|| {
483 config
484 .runners
485 .as_ref()
486 .and_then(|runners| runners.preferred_runtime)
487 })
488 .unwrap_or(RunnerExecutionRuntime::Native);
489
490 let object = payload.as_object_mut().expect("runner payload object");
491 object
492 .entry("runtime".to_string())
493 .or_insert_with(|| Value::String(runtime.as_str().to_string()));
494 if let Some(defaults) = defaults.as_ref() {
495 if let Some(group) = defaults.default_runner_group.as_deref() {
496 object
497 .entry("runner_group".to_string())
498 .or_insert_with(|| Value::String(group.to_string()));
499 }
500 if let Some(org) = defaults.default_organization.as_deref() {
501 object
502 .entry("organization_login".to_string())
503 .or_insert_with(|| Value::String(org.to_string()));
504 }
505 if !defaults.default_labels.is_empty() {
506 object.entry("labels".to_string()).or_insert_with(|| {
507 Value::Array(
508 defaults
509 .default_labels
510 .iter()
511 .map(|label| Value::String(label.clone()))
512 .collect(),
513 )
514 });
515 }
516 }
517 object
518 .entry("requested_action".to_string())
519 .or_insert_with(|| Value::String(job_kind.to_string()));
520
521 serde_json::to_string(&payload)
522 .map(Some)
523 .map_err(|error| format!("Failed to serialize runner payload: {}", error))
524}
525
526async fn execute_local_runner_job(
527 host: &RunnerHostRecord,
528 job: &RunnerJobRecord,
529) -> Result<LocalRunnerJobResult, String> {
530 let payload = parse_job_payload(job.payload.clone())?;
531 let runtime_root = runner_runtime_root(host, &payload)?;
532 fs::create_dir_all(&runtime_root).map_err(|error| {
533 format!(
534 "Failed to create runner runtime directory {}: {}",
535 runtime_root.display(),
536 error
537 )
538 })?;
539
540 match job.job_kind.as_str() {
541 "deploy" | "sync" => install_runner_runtime(host, job, &payload, &runtime_root).await,
542 "remove" => remove_runner_runtime(host, job, &payload, &runtime_root).await,
543 other => Err(format!("Unsupported runner job kind `{other}`")),
544 }
545}
546
547async fn install_runner_runtime(
548 host: &RunnerHostRecord,
549 job: &RunnerJobRecord,
550 payload: &LocalRunnerJobPayload,
551 runtime_root: &Path,
552) -> Result<LocalRunnerJobResult, String> {
553 let runner_name = resolve_runner_name(host, payload);
554 let service_manager = detect_service_manager(host.platform);
555 let organization_login = payload
556 .organization_login
557 .as_deref()
558 .filter(|value| !value.trim().is_empty())
559 .ok_or_else(|| "Runner payload is missing `organization_login`.".to_string())?;
560 let installation_id = payload
561 .github_installation_id
562 .as_deref()
563 .filter(|value| !value.trim().is_empty())
564 .ok_or_else(|| "Runner payload is missing `github_installation_id`.".to_string())?;
565 let mut generated_files = Vec::new();
566 let work_dir = runtime_root.join("work");
567 let config_dir = runtime_root.join("config");
568 let runner_dir = runtime_root.join("runner");
569 let log_path = runtime_root.join("runner.log");
570 let service_path = service_file_path(runtime_root, service_manager);
571 let cleanup;
572
573 fs::create_dir_all(&work_dir).map_err(|error| error.to_string())?;
574 fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
575
576 write_job_phase(
577 job,
578 RunnerInstallPhase::Preflight,
579 Some(json!({ "runtime_root": runtime_root.display().to_string() })),
580 )
581 .await?;
582 upsert_runner_runtime_record(RunnerRuntimeUpsert {
583 host,
584 runner_name: &runner_name,
585 payload,
586 github_runner_id: None,
587 registration_state: RunnerRegistrationState::Pending,
588 install_phase: RunnerInstallPhase::Preflight,
589 service_manager,
590 service_status: RunnerServiceStatus::Missing,
591 paths: RunnerRuntimePaths {
592 runtime_root,
593 work_dir: &work_dir,
594 config_dir: &config_dir,
595 log_path: &log_path,
596 service_path: Some(&service_path),
597 },
598 installed_version: None,
599 registration_error: None,
600 })
601 .await?;
602
603 let registration_token = request_runner_token(
604 "/runner-github/registration-token",
605 organization_login,
606 installation_id,
607 )
608 .await?;
609 let release = if payload.runtime == RunnerExecutionRuntime::Native {
610 Some(
611 resolve_runner_release(
612 host.platform,
613 host.arch.as_deref().unwrap_or("x64"),
614 payload.runner_version.as_deref(),
615 )
616 .await?,
617 )
618 } else {
619 None
620 };
621
622 if payload.runtime == RunnerExecutionRuntime::Native {
623 let release = release.clone().expect("native runner release");
624 write_job_phase(
625 job,
626 RunnerInstallPhase::Downloading,
627 Some(json!({
628 "version": release.version,
629 "download_url": release.download_url,
630 })),
631 )
632 .await?;
633 let archive_path = download_runner_release(runtime_root, &release).await?;
634 generated_files.push(archive_path.display().to_string());
635
636 write_job_phase(
637 job,
638 RunnerInstallPhase::Extracting,
639 Some(json!({
640 "archive": archive_path.display().to_string(),
641 })),
642 )
643 .await?;
644 extract_runner_archive(&archive_path, &runner_dir)?;
645
646 write_job_phase(job, RunnerInstallPhase::Cleanup, None).await?;
647 cleanup = cleanup_stale_runner_registration(RunnerCleanupRequest {
648 host,
649 payload,
650 runtime_root,
651 runner_dir: &runner_dir,
652 config_dir: &config_dir,
653 organization_login,
654 installation_id,
655 fallback_runner_name: Some(&runner_name),
656 })
657 .await?;
658
659 write_job_phase(job, RunnerInstallPhase::Registering, None).await?;
660 configure_native_runner(
661 host,
662 payload,
663 &runner_dir,
664 &work_dir,
665 organization_login,
666 ®istration_token.token,
667 &runner_name,
668 )?;
669
670 write_job_phase(job, RunnerInstallPhase::InstallingService, None).await?;
671 install_native_service(host.platform, &runner_dir)?;
672
673 write_job_phase(job, RunnerInstallPhase::StartingService, None).await?;
674 start_native_service(host.platform, &runner_dir)?;
675 generated_files.push(runner_dir.display().to_string());
676 } else {
677 write_job_phase(job, RunnerInstallPhase::Cleanup, None).await?;
678 cleanup = cleanup_stale_runner_registration(RunnerCleanupRequest {
679 host,
680 payload,
681 runtime_root,
682 runner_dir: &runner_dir,
683 config_dir: &config_dir,
684 organization_login,
685 installation_id,
686 fallback_runner_name: Some(&runner_name),
687 })
688 .await?;
689
690 let compose_path = runtime_root.join("compose.yaml");
691 fs::write(
692 &compose_path,
693 docker_compose_contents(
694 host,
695 payload,
696 runtime_root,
697 &runner_name,
698 ®istration_token.token,
699 organization_login,
700 ),
701 )
702 .map_err(|error| format!("Failed to write {}: {}", compose_path.display(), error))?;
703 generated_files.push(compose_path.display().to_string());
704
705 let dockerfile_path = runtime_root.join("Dockerfile");
706 fs::write(&dockerfile_path, dockerfile_contents())
707 .map_err(|error| format!("Failed to write {}: {}", dockerfile_path.display(), error))?;
708 generated_files.push(dockerfile_path.display().to_string());
709
710 write_job_phase(job, RunnerInstallPhase::StartingService, None).await?;
711 run_command(
712 "docker",
713 &[
714 "compose",
715 "-f",
716 &compose_path.display().to_string(),
717 "up",
718 "-d",
719 "--build",
720 ],
721 Some(runtime_root),
722 )?;
723 }
724
725 let github_runner =
726 list_remote_github_runners(organization_login, installation_id, Some(&runner_name))
727 .await?
728 .into_iter()
729 .next();
730 let runtime_metadata = LocalRunnerRuntimeMetadata {
731 runner_host_id: Some(host.id.clone()),
732 hostname: Some(host.hostname.clone()),
733 runner_name_prefix: Some(host.runner_name_prefix.clone()),
734 runner_name: Some(runner_name.clone()),
735 runner_group: payload.runner_group.clone(),
736 organization_login: Some(organization_login.to_string()),
737 github_installation_id: Some(installation_id.to_string()),
738 github_runner_id: github_runner.as_ref().map(|runner| runner.id),
739 removed_runner_names: cleanup.removed_runner_names.clone(),
740 removed_runner_ids: cleanup.removed_runner_ids.clone(),
741 };
742 let runtime_state = RunnerRuntimeState {
743 runtime: payload.runtime,
744 service_manager,
745 service_status: RunnerServiceStatus::Running,
746 root_directory: Some(runtime_root.display().to_string()),
747 work_directory: Some(work_dir.display().to_string()),
748 config_directory: Some(config_dir.display().to_string()),
749 log_path: Some(log_path.display().to_string()),
750 service_file_path: Some(service_path.display().to_string()),
751 last_cleanup_at: None,
752 metadata: json!({
753 "runner_name": runner_name,
754 "runner_group": payload.runner_group,
755 "labels": payload.labels,
756 "cleanup_removed_runner_names": cleanup.removed_runner_names,
757 "cleanup_removed_runner_ids": cleanup.removed_runner_ids,
758 }),
759 };
760
761 let config_path = runtime_root.join("runner-config.json");
762 fs::write(
763 &config_path,
764 serde_json::to_string_pretty(&payload)
765 .map_err(|error| format!("Failed to serialize runner config: {}", error))?,
766 )
767 .map_err(|error| format!("Failed to write {}: {}", config_path.display(), error))?;
768 generated_files.push(config_path.display().to_string());
769
770 let state_path = runtime_root.join("runtime-state.json");
771 fs::write(
772 &state_path,
773 serde_json::to_string_pretty(&runtime_state)
774 .map_err(|error| format!("Failed to serialize runtime state: {}", error))?,
775 )
776 .map_err(|error| format!("Failed to write {}: {}", state_path.display(), error))?;
777 generated_files.push(state_path.display().to_string());
778
779 let metadata_path = write_runtime_metadata(runtime_root, &runtime_metadata)?;
780 generated_files.push(metadata_path.display().to_string());
781
782 let startup_path = runtime_root.join("startup-summary.txt");
783 fs::write(
784 &startup_path,
785 format_startup_summary(host, payload, &runtime_state, &runner_name),
786 )
787 .map_err(|error| format!("Failed to write {}: {}", startup_path.display(), error))?;
788 generated_files.push(startup_path.display().to_string());
789
790 let installed_version = release.as_ref().map(|value| value.version.clone());
791 write_job_phase(
792 job,
793 RunnerInstallPhase::Running,
794 Some(json!({
795 "runtime_root": runtime_root.display().to_string(),
796 "runner_name": runner_name,
797 "installed_version": installed_version,
798 })),
799 )
800 .await?;
801 upsert_runner_runtime_record(RunnerRuntimeUpsert {
802 host,
803 runner_name: &runner_name,
804 payload,
805 github_runner_id: github_runner.as_ref().map(|runner| runner.id),
806 registration_state: RunnerRegistrationState::Registered,
807 install_phase: RunnerInstallPhase::Running,
808 service_manager,
809 service_status: RunnerServiceStatus::Running,
810 paths: RunnerRuntimePaths {
811 runtime_root,
812 work_dir: &work_dir,
813 config_dir: &config_dir,
814 log_path: &log_path,
815 service_path: Some(&service_path),
816 },
817 installed_version: installed_version.clone(),
818 registration_error: None,
819 })
820 .await?;
821
822 Ok(LocalRunnerJobResult {
823 runtime_root: runtime_root.display().to_string(),
824 service_manager,
825 service_status: RunnerServiceStatus::Running,
826 install_phase: RunnerInstallPhase::Running,
827 registration_state: RunnerRegistrationState::Registered,
828 installed_version,
829 generated_files,
830 })
831}
832
833async fn remove_runner_runtime(
834 host: &RunnerHostRecord,
835 job: &RunnerJobRecord,
836 payload: &LocalRunnerJobPayload,
837 runtime_root: &Path,
838) -> Result<LocalRunnerJobResult, String> {
839 let runner_name = resolve_runner_name(host, payload);
840 let service_manager = detect_service_manager(host.platform);
841 let organization_login = payload
842 .organization_login
843 .as_deref()
844 .filter(|value| !value.trim().is_empty())
845 .ok_or_else(|| "Runner payload is missing `organization_login`.".to_string())?;
846 let installation_id = payload
847 .github_installation_id
848 .as_deref()
849 .filter(|value| !value.trim().is_empty())
850 .ok_or_else(|| "Runner payload is missing `github_installation_id`.".to_string())?;
851 let runner_dir = runtime_root.join("runner");
852 let config_dir = runtime_root.join("config");
853
854 write_job_phase(job, RunnerInstallPhase::Removing, None).await?;
855 let cleanup = cleanup_stale_runner_registration(RunnerCleanupRequest {
856 host,
857 payload,
858 runtime_root,
859 runner_dir: &runner_dir,
860 config_dir: &config_dir,
861 organization_login,
862 installation_id,
863 fallback_runner_name: Some(&runner_name),
864 })
865 .await?;
866
867 if payload.runtime == RunnerExecutionRuntime::Docker {
868 let compose_path = runtime_root.join("compose.yaml");
869 if compose_path.exists() {
870 let _ = run_command(
871 "docker",
872 &[
873 "compose",
874 "-f",
875 &compose_path.display().to_string(),
876 "down",
877 "--remove-orphans",
878 ],
879 Some(runtime_root),
880 );
881 }
882 } else if runner_dir.exists() {
883 let _ = stop_native_service(host.platform, &runner_dir);
884 let _ = uninstall_native_service(host.platform, &runner_dir);
885 }
886
887 if runtime_root.exists() {
888 fs::remove_dir_all(runtime_root).map_err(|error| {
889 format!(
890 "Failed to remove runner runtime directory {}: {}",
891 runtime_root.display(),
892 error
893 )
894 })?;
895 }
896 Ok(LocalRunnerJobResult {
897 runtime_root: runtime_root.display().to_string(),
898 service_manager,
899 service_status: RunnerServiceStatus::Missing,
900 install_phase: RunnerInstallPhase::Removing,
901 registration_state: RunnerRegistrationState::Unregistered,
902 installed_version: None,
903 generated_files: cleanup
904 .removed_runner_names
905 .iter()
906 .zip(
907 cleanup
908 .removed_runner_ids
909 .iter()
910 .map(Some)
911 .chain(std::iter::repeat(None)),
912 )
913 .map(|(name, id)| match id {
914 Some(id) => format!("{name} ({id})"),
915 None => name.clone(),
916 })
917 .collect(),
918 })
919}
920
921fn build_local_preflight(host: &RunnerHostRecord) -> Result<LocalPreflight, String> {
922 let runtime_root = runner_runtime_root(
923 host,
924 &LocalRunnerJobPayload {
925 runtime: RunnerExecutionRuntime::Native,
926 organization_login: None,
927 github_installation_id: None,
928 runner_group: None,
929 labels: Vec::new(),
930 runner_name: None,
931 runner_version: None,
932 ephemeral: false,
933 },
934 )?;
935 fs::create_dir_all(&runtime_root).map_err(|error| error.to_string())?;
936 let docker_available =
937 command_available("docker", &["version", "--format", "{{.Server.Version}}"]);
938 let service_manager = detect_service_manager(host.platform);
939 let writable = runtime_root.exists();
940 let checks = json!({
941 "runtime_root": runtime_root.display().to_string(),
942 "runtime_root_writable": writable,
943 "docker_available": docker_available,
944 "service_manager": service_manager.as_str(),
945 "platform": host.platform.as_str(),
946 });
947 Ok(LocalPreflight {
948 platform: host.platform,
949 status: if writable {
950 RunnerSyncState::Synced
951 } else {
952 RunnerSyncState::Error
953 },
954 docker_available: Some(docker_available),
955 service_manager: Some(service_manager),
956 checks,
957 checked_at: Some(Utc::now().to_rfc3339()),
958 })
959}
960
961#[derive(Debug)]
962struct LocalPreflight {
963 platform: RunnerPlatform,
964 status: RunnerSyncState,
965 docker_available: Option<bool>,
966 service_manager: Option<RunnerServiceManager>,
967 checks: Value,
968 checked_at: Option<String>,
969}
970
971struct RunnerRuntimePaths<'a> {
972 runtime_root: &'a Path,
973 work_dir: &'a Path,
974 config_dir: &'a Path,
975 log_path: &'a Path,
976 service_path: Option<&'a Path>,
977}
978
979struct RunnerRuntimeUpsert<'a> {
980 host: &'a RunnerHostRecord,
981 runner_name: &'a str,
982 payload: &'a LocalRunnerJobPayload,
983 github_runner_id: Option<i64>,
984 registration_state: RunnerRegistrationState,
985 install_phase: RunnerInstallPhase,
986 service_manager: RunnerServiceManager,
987 service_status: RunnerServiceStatus,
988 paths: RunnerRuntimePaths<'a>,
989 installed_version: Option<String>,
990 registration_error: Option<String>,
991}
992
993struct RunnerCleanupRequest<'a> {
994 host: &'a RunnerHostRecord,
995 payload: &'a LocalRunnerJobPayload,
996 runtime_root: &'a Path,
997 runner_dir: &'a Path,
998 config_dir: &'a Path,
999 organization_login: &'a str,
1000 installation_id: &'a str,
1001 fallback_runner_name: Option<&'a str>,
1002}
1003
1004fn print_preflight(host: &RunnerHostRecord, preflight: &LocalPreflight) {
1005 println!("{}", "Runner Host Preflight".bold());
1006 println!(" host: {} ({})", host.hostname, host.id);
1007 println!(" platform: {}", host.platform);
1008 println!(" status: {}", color_status(preflight.status.as_str()));
1009 println!(
1010 " service manager: {}",
1011 preflight
1012 .service_manager
1013 .map(|value| value.to_string())
1014 .unwrap_or_else(|| "unknown".to_string())
1015 );
1016 println!(
1017 " docker available: {}",
1018 preflight
1019 .docker_available
1020 .map(|value| if value { "yes" } else { "no" })
1021 .unwrap_or("unknown")
1022 );
1023}
1024
1025async fn fetch_runner_host_by_id(
1026 runner_host_id: &str,
1027 target: &ApiTargetOptions,
1028) -> Result<RunnerHostRecord, String> {
1029 let response: RunnerHostListResponse =
1030 api_json(Method::GET, "/runner-hosts", target, None).await?;
1031 response
1032 .runner_hosts
1033 .into_iter()
1034 .find(|host| host.id == runner_host_id)
1035 .ok_or_else(|| format!("Runner host `{runner_host_id}` was not found."))
1036}
1037
1038async fn api_json<T: for<'de> Deserialize<'de>>(
1039 method: Method,
1040 path: &str,
1041 target: &ApiTargetOptions,
1042 body: Option<Value>,
1043) -> Result<T, String> {
1044 let (_, value) = api_json_status(method, path, target, body).await?;
1045 serde_json::from_value(value)
1046 .map_err(|error| format!("Failed to decode API response for `{}`: {}", path, error))
1047}
1048
1049async fn api_json_status(
1050 method: Method,
1051 path: &str,
1052 target: &ApiTargetOptions,
1053 body: Option<Value>,
1054) -> Result<(StatusCode, Value), String> {
1055 let url = resolve_request_url(path, target)?;
1056 let client = Client::builder()
1057 .timeout(Duration::from_secs(60))
1058 .build()
1059 .map_err(|error| format!("Failed to create HTTP client: {}", error))?;
1060 let mut request = client.request(method, url);
1061 if !target.no_auth {
1062 if let Some(token) = resolve_xbp_api_token() {
1063 request = request.bearer_auth(token);
1064 }
1065 if let Some(github_token) = resolve_github_oauth2_key() {
1066 request = request.header("X-XBP-GitHub-Token", github_token);
1067 }
1068 }
1069 if let Some(body) = body {
1070 request = request.json(&body);
1071 }
1072 let response = request
1073 .send()
1074 .await
1075 .map_err(|error| format!("Runner API request failed: {}", error))?;
1076 let status = response.status();
1077 if status == StatusCode::NO_CONTENT {
1078 return Ok((status, Value::Null));
1079 }
1080 let value = response
1081 .json::<Value>()
1082 .await
1083 .map_err(|error| format!("Failed to decode runner API response: {}", error))?;
1084 if !status.is_success() {
1085 return Err(format!(
1086 "Runner API request failed with status {}: {}",
1087 status,
1088 value
1089 .get("error")
1090 .and_then(Value::as_str)
1091 .unwrap_or("unknown error")
1092 ));
1093 }
1094 Ok((status, value))
1095}
1096
1097fn load_project_runner_defaults() -> Result<Option<GitHubRunnersProjectConfig>, String> {
1098 let current_dir =
1099 env::current_dir().map_err(|error| format!("Failed to read cwd: {}", error))?;
1100 let Some(found) = find_xbp_config_upwards(¤t_dir) else {
1101 return Ok(None);
1102 };
1103 let content = fs::read_to_string(&found.config_path)
1104 .map_err(|error| format!("Failed to read {}: {}", found.config_path.display(), error))?;
1105 let (config, _) = parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
1106 .map_err(|error| format!("Failed to parse {}: {}", found.config_path.display(), error))?;
1107 Ok(config.github.and_then(|github| github.runners))
1108}
1109
1110fn runner_runtime_root(
1111 host: &RunnerHostRecord,
1112 payload: &LocalRunnerJobPayload,
1113) -> Result<PathBuf, String> {
1114 let base = global_runner_root_dir()?;
1115 let organization = payload
1116 .organization_login
1117 .clone()
1118 .unwrap_or_else(|| host.organization_id.clone());
1119 Ok(base
1120 .join(slugify(&organization))
1121 .join(slugify(&host.hostname)))
1122}
1123
1124fn runtime_metadata_path(runtime_root: &Path) -> PathBuf {
1125 runtime_root.join("runtime-metadata.json")
1126}
1127
1128fn read_runtime_metadata(runtime_root: &Path) -> Option<LocalRunnerRuntimeMetadata> {
1129 let path = runtime_metadata_path(runtime_root);
1130 let content = fs::read_to_string(path).ok()?;
1131 serde_json::from_str(&content).ok()
1132}
1133
1134fn write_runtime_metadata(
1135 runtime_root: &Path,
1136 metadata: &LocalRunnerRuntimeMetadata,
1137) -> Result<PathBuf, String> {
1138 let path = runtime_metadata_path(runtime_root);
1139 fs::write(
1140 &path,
1141 serde_json::to_string_pretty(metadata)
1142 .map_err(|error| format!("Failed to serialize runtime metadata: {}", error))?,
1143 )
1144 .map_err(|error| format!("Failed to write {}: {}", path.display(), error))?;
1145 Ok(path)
1146}
1147
1148fn slugify(value: &str) -> String {
1149 value
1150 .chars()
1151 .map(|character| {
1152 if character.is_ascii_alphanumeric() {
1153 character.to_ascii_lowercase()
1154 } else {
1155 '-'
1156 }
1157 })
1158 .collect::<String>()
1159 .trim_matches('-')
1160 .to_string()
1161}
1162
1163fn with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
1164 let mut url = reqwest::Url::parse(&format!("http://localhost{path}"))
1165 .expect("static runner path should always parse");
1166 {
1167 let mut pairs = url.query_pairs_mut();
1168 for (key, value) in params {
1169 if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
1170 pairs.append_pair(key, value);
1171 }
1172 }
1173 }
1174 match url.query() {
1175 Some(query) if !query.is_empty() => format!("{}?{}", url.path(), query),
1176 _ => url.path().to_string(),
1177 }
1178}
1179
1180fn parse_job_payload(payload: Value) -> Result<LocalRunnerJobPayload, String> {
1181 let mut payload = if payload.is_null() {
1182 json!({})
1183 } else {
1184 payload
1185 };
1186 if !payload.is_object() {
1187 return Err("Runner job payload must be an object.".to_string());
1188 }
1189 if payload.get("runtime").is_none() {
1190 payload["runtime"] = Value::String("native".to_string());
1191 }
1192 serde_json::from_value(payload)
1193 .map_err(|error| format!("Invalid runner job payload: {}", error))
1194}
1195
1196async fn write_job_phase(
1197 job: &RunnerJobRecord,
1198 phase: RunnerInstallPhase,
1199 details: Option<Value>,
1200) -> Result<(), String> {
1201 let detail_value = details.unwrap_or_else(|| json!({}));
1202 let _: RunnerJobRecord = api_json(
1203 Method::PATCH,
1204 &format!("/runner-jobs/{}", job.id),
1205 &ApiTargetOptions::default(),
1206 Some(json!({
1207 "status": "running",
1208 "phase": phase,
1209 "details": detail_value,
1210 })),
1211 )
1212 .await?;
1213 Ok(())
1214}
1215
1216async fn upsert_runner_runtime_record(request: RunnerRuntimeUpsert<'_>) -> Result<(), String> {
1217 let runtime_state = json!({
1218 "runtime": request.payload.runtime,
1219 "service_manager": request.service_manager,
1220 "service_status": request.service_status,
1221 "root_directory": request.paths.runtime_root.display().to_string(),
1222 "work_directory": request.paths.work_dir.display().to_string(),
1223 "config_directory": request.paths.config_dir.display().to_string(),
1224 "log_path": request.paths.log_path.display().to_string(),
1225 "service_file_path": request.paths.service_path.map(|path| path.display().to_string()),
1226 "metadata": {
1227 "runner_name": request.runner_name,
1228 }
1229 });
1230 let desired_config = json!({
1231 "organization_login": request.payload.organization_login.clone(),
1232 "runner_group": request.payload.runner_group.clone(),
1233 "labels": request.payload.labels.clone(),
1234 "runtime": request.payload.runtime,
1235 "work_directory": request.paths.work_dir.display().to_string(),
1236 "config_directory": request.paths.config_dir.display().to_string(),
1237 "ephemeral": request.payload.ephemeral,
1238 });
1239 let _: Value = api_json(
1240 Method::POST,
1241 &format!("/organizations/{}/runners", request.host.organization_id),
1242 &ApiTargetOptions::default(),
1243 Some(json!({
1244 "organization_id": request.host.organization_id.clone(),
1245 "runner_host_id": request.host.id.clone(),
1246 "github_runner_id": request.github_runner_id,
1247 "name": request.runner_name,
1248 "platform": request.host.platform,
1249 "os": request.host.platform.as_str(),
1250 "architecture": request.host.arch.clone(),
1251 "status": if request.service_status == RunnerServiceStatus::Running {
1252 "synced"
1253 } else {
1254 "pending"
1255 },
1256 "busy": false,
1257 "labels": request.payload.labels.clone(),
1258 "desired_config": desired_config,
1259 "runtime_state": runtime_state,
1260 "registration_state": request.registration_state,
1261 "install_phase": request.install_phase,
1262 "installed_version": request.installed_version,
1263 "registration_error": request.registration_error,
1264 "metadata": {
1265 "organization_login": request.payload.organization_login.clone(),
1266 "github_installation_id": request.payload.github_installation_id.clone(),
1267 },
1268 "last_registration_attempt_at": Utc::now().to_rfc3339(),
1269 "last_seen_at": Utc::now().to_rfc3339(),
1270 })),
1271 )
1272 .await?;
1273 Ok(())
1274}
1275
1276async fn request_runner_token(
1277 endpoint: &str,
1278 organization_login: &str,
1279 installation_id: &str,
1280) -> Result<RunnerApiTokenResponse, String> {
1281 api_json(
1282 Method::POST,
1283 endpoint,
1284 &ApiTargetOptions::default(),
1285 Some(json!({
1286 "organization_login": organization_login,
1287 "github_installation_id": installation_id,
1288 })),
1289 )
1290 .await
1291}
1292
1293async fn resolve_runner_release(
1294 platform: RunnerPlatform,
1295 arch: &str,
1296 version: Option<&str>,
1297) -> Result<RunnerApiReleaseResponse, String> {
1298 let mut path = format!(
1299 "/runner-github/releases/latest?platform={}&arch={}",
1300 platform.as_str(),
1301 arch
1302 );
1303 if let Some(version) = version.map(str::trim).filter(|value| !value.is_empty()) {
1304 path.push_str("&version=");
1305 path.push_str(version);
1306 }
1307 api_json(Method::GET, &path, &ApiTargetOptions::default(), None).await
1308}
1309
1310async fn download_runner_release(
1311 runtime_root: &Path,
1312 release: &RunnerApiReleaseResponse,
1313) -> Result<PathBuf, String> {
1314 let downloads_dir = runtime_root.join("downloads");
1315 fs::create_dir_all(&downloads_dir).map_err(|error| error.to_string())?;
1316 let archive_path = downloads_dir.join(&release.file_name);
1317 let client = Client::builder()
1318 .timeout(Duration::from_secs(300))
1319 .build()
1320 .map_err(|error| format!("Failed to create download client: {}", error))?;
1321 let bytes = client
1322 .get(&release.download_url)
1323 .send()
1324 .await
1325 .map_err(|error| format!("Failed to download runner release: {}", error))?
1326 .error_for_status()
1327 .map_err(|error| format!("Runner release download failed: {}", error))?
1328 .bytes()
1329 .await
1330 .map_err(|error| format!("Failed to read runner release bytes: {}", error))?;
1331 fs::write(&archive_path, &bytes)
1332 .map_err(|error| format!("Failed to write {}: {}", archive_path.display(), error))?;
1333 Ok(archive_path)
1334}
1335
1336fn extract_runner_archive(archive_path: &Path, runner_dir: &Path) -> Result<(), String> {
1337 if runner_dir.exists() {
1338 fs::remove_dir_all(runner_dir)
1339 .map_err(|error| format!("Failed to clear {}: {}", runner_dir.display(), error))?;
1340 }
1341 fs::create_dir_all(runner_dir).map_err(|error| error.to_string())?;
1342
1343 let file_name = archive_path
1344 .file_name()
1345 .and_then(|value| value.to_str())
1346 .unwrap_or_default()
1347 .to_ascii_lowercase();
1348 if file_name.ends_with(".tar.gz") {
1349 let archive_file = fs::File::open(archive_path)
1350 .map_err(|error| format!("Failed to open archive: {}", error))?;
1351 let mut archive = Archive::new(GzDecoder::new(archive_file));
1352 archive
1353 .unpack(runner_dir)
1354 .map_err(|error| format!("Failed to unpack tar.gz archive: {}", error))?;
1355 } else if file_name.ends_with(".zip") {
1356 let bytes =
1357 fs::read(archive_path).map_err(|error| format!("Failed to read archive: {}", error))?;
1358 let reader = Cursor::new(bytes);
1359 let mut archive = ZipArchive::new(reader)
1360 .map_err(|error| format!("Failed to open zip archive: {}", error))?;
1361 for index in 0..archive.len() {
1362 let mut entry = archive
1363 .by_index(index)
1364 .map_err(|error| format!("Failed to read zip entry: {}", error))?;
1365 let output_path = runner_dir.join(entry.mangled_name());
1366 if entry.name().ends_with('/') {
1367 fs::create_dir_all(&output_path).map_err(|error| error.to_string())?;
1368 continue;
1369 }
1370 if let Some(parent) = output_path.parent() {
1371 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1372 }
1373 let mut output = fs::File::create(&output_path)
1374 .map_err(|error| format!("Failed to create file: {}", error))?;
1375 io::copy(&mut entry, &mut output)
1376 .map_err(|error| format!("Failed to extract zip entry: {}", error))?;
1377 }
1378 } else {
1379 return Err(format!(
1380 "Unsupported runner archive format for {}",
1381 archive_path.display()
1382 ));
1383 }
1384 ensure_executable(runner_dir.join("config.sh"))?;
1385 ensure_executable(runner_dir.join("svc.sh"))?;
1386 ensure_executable(runner_dir.join("run.sh"))?;
1387 Ok(())
1388}
1389
1390async fn cleanup_stale_runner_registration(
1391 request: RunnerCleanupRequest<'_>,
1392) -> Result<RunnerCleanupSummary, String> {
1393 let host = request.host;
1394 let payload = request.payload;
1395 let runtime_root = request.runtime_root;
1396 let runner_dir = request.runner_dir;
1397 let config_dir = request.config_dir;
1398 let organization_login = request.organization_login;
1399 let installation_id = request.installation_id;
1400
1401 if payload.runtime == RunnerExecutionRuntime::Docker {
1402 let compose_path = runtime_root.join("compose.yaml");
1403 if compose_path.exists() {
1404 let _ = run_command(
1405 "docker",
1406 &[
1407 "compose",
1408 "-f",
1409 &compose_path.display().to_string(),
1410 "down",
1411 "--remove-orphans",
1412 ],
1413 Some(runtime_root),
1414 );
1415 }
1416 } else if runner_dir.exists() {
1417 let _ = stop_native_service(host.platform, runner_dir);
1418 }
1419
1420 let runner_name = read_configured_runner_name(config_dir)
1421 .or_else(|| read_configured_runner_name(runner_dir))
1422 .or_else(|| read_runtime_metadata(runtime_root).and_then(|metadata| metadata.runner_name))
1423 .or_else(|| request.fallback_runner_name.map(ToString::to_string));
1424 let prior_metadata = read_runtime_metadata(runtime_root);
1425 let mut summary = RunnerCleanupSummary::default();
1426 let same_machine_runners =
1427 list_remote_github_runners(organization_login, installation_id, None)
1428 .await?
1429 .into_iter()
1430 .filter(|runner| {
1431 runner_matches_same_machine(host, payload, prior_metadata.as_ref(), runner)
1432 })
1433 .collect::<Vec<_>>();
1434
1435 if let Some(name) = runner_name.as_deref() {
1436 if payload.runtime == RunnerExecutionRuntime::Native {
1437 if let Ok(remove_token) = request_runner_token(
1438 "/runner-github/remove-token",
1439 organization_login,
1440 installation_id,
1441 )
1442 .await
1443 {
1444 let remove_script = if cfg!(windows) {
1445 runner_dir.join("config.cmd")
1446 } else {
1447 runner_dir.join("config.sh")
1448 };
1449 if remove_script.exists() {
1450 let remove_args = if cfg!(windows) {
1451 vec![
1452 "/c".to_string(),
1453 remove_script.display().to_string(),
1454 "remove".to_string(),
1455 "--token".to_string(),
1456 remove_token.token,
1457 ]
1458 } else {
1459 vec![
1460 remove_script.display().to_string(),
1461 "remove".to_string(),
1462 "--token".to_string(),
1463 remove_token.token,
1464 ]
1465 };
1466 let executable = if cfg!(windows) { "cmd" } else { "bash" };
1467 let _ = run_command(
1468 executable,
1469 &remove_args.iter().map(String::as_str).collect::<Vec<_>>(),
1470 Some(runner_dir),
1471 );
1472 }
1473 }
1474 }
1475
1476 if let Some(remote_runner) =
1477 list_remote_github_runners(organization_login, installation_id, Some(name))
1478 .await?
1479 .into_iter()
1480 .next()
1481 {
1482 delete_remote_runner(organization_login, installation_id, remote_runner.id).await?;
1483 summary.removed_runner_names.push(name.to_string());
1484 summary.removed_runner_ids.push(remote_runner.id);
1485 }
1486 }
1487
1488 for remote_runner in same_machine_runners {
1489 if summary.removed_runner_ids.contains(&remote_runner.id) {
1490 continue;
1491 }
1492 delete_remote_runner(organization_login, installation_id, remote_runner.id).await?;
1493 summary.removed_runner_names.push(remote_runner.name);
1494 summary.removed_runner_ids.push(remote_runner.id);
1495 }
1496
1497 if config_dir.exists() {
1498 let _ = fs::remove_dir_all(config_dir);
1499 let _ = fs::create_dir_all(config_dir);
1500 }
1501 let local_runner_file = runner_dir.join(".runner");
1502 if local_runner_file.exists() {
1503 let _ = fs::remove_file(local_runner_file);
1504 }
1505 Ok(summary)
1506}
1507
1508fn runner_matches_same_machine(
1509 host: &RunnerHostRecord,
1510 payload: &LocalRunnerJobPayload,
1511 metadata: Option<&LocalRunnerRuntimeMetadata>,
1512 runner: &RunnerGitHubRunner,
1513) -> bool {
1514 let runner_name = runner.name.trim().to_ascii_lowercase();
1515 let host_prefix = slugify(&host.runner_name_prefix);
1516 let host_name = slugify(&host.hostname);
1517 let expected_name = resolve_runner_name(host, payload).to_ascii_lowercase();
1518
1519 if runner_name == expected_name {
1520 return true;
1521 }
1522 if runner_name.starts_with(&format!("{host_prefix}-")) && runner_name.contains(&host_name) {
1523 return true;
1524 }
1525 metadata
1526 .and_then(|value| value.runner_name_prefix.as_deref().map(slugify))
1527 .is_some_and(|prefix| runner_name.starts_with(&format!("{prefix}-")))
1528}
1529
1530fn configure_native_runner(
1531 host: &RunnerHostRecord,
1532 payload: &LocalRunnerJobPayload,
1533 runner_dir: &Path,
1534 work_dir: &Path,
1535 organization_login: &str,
1536 registration_token: &str,
1537 runner_name: &str,
1538) -> Result<(), String> {
1539 let github_url = format!("https://github.com/{}", organization_login);
1540 if cfg!(windows) {
1541 let config_cmd = runner_dir.join("config.cmd");
1542 let mut args = vec![
1543 "/c".to_string(),
1544 config_cmd.display().to_string(),
1545 "--unattended".to_string(),
1546 "--url".to_string(),
1547 github_url,
1548 "--token".to_string(),
1549 registration_token.to_string(),
1550 "--name".to_string(),
1551 runner_name.to_string(),
1552 "--work".to_string(),
1553 work_dir.display().to_string(),
1554 "--replace".to_string(),
1555 "--runasservice".to_string(),
1556 ];
1557 if let Some(group) = payload
1558 .runner_group
1559 .as_deref()
1560 .filter(|value| !value.trim().is_empty())
1561 {
1562 args.push("--runnergroup".to_string());
1563 args.push(group.to_string());
1564 }
1565 if !payload.labels.is_empty() {
1566 args.push("--labels".to_string());
1567 args.push(payload.labels.join(","));
1568 }
1569 if payload.ephemeral {
1570 args.push("--ephemeral".to_string());
1571 }
1572 run_command(
1573 "cmd",
1574 &args.iter().map(String::as_str).collect::<Vec<_>>(),
1575 Some(runner_dir),
1576 )
1577 } else {
1578 let config_sh = runner_dir.join("config.sh");
1579 let mut args = vec![
1580 config_sh.display().to_string(),
1581 "--unattended".to_string(),
1582 "--url".to_string(),
1583 github_url,
1584 "--token".to_string(),
1585 registration_token.to_string(),
1586 "--name".to_string(),
1587 runner_name.to_string(),
1588 "--work".to_string(),
1589 work_dir.display().to_string(),
1590 "--replace".to_string(),
1591 ];
1592 if let Some(group) = payload
1593 .runner_group
1594 .as_deref()
1595 .filter(|value| !value.trim().is_empty())
1596 {
1597 args.push("--runnergroup".to_string());
1598 args.push(group.to_string());
1599 }
1600 if !payload.labels.is_empty() {
1601 args.push("--labels".to_string());
1602 args.push(payload.labels.join(","));
1603 }
1604 if payload.ephemeral {
1605 args.push("--ephemeral".to_string());
1606 }
1607 run_command(
1608 "bash",
1609 &args.iter().map(String::as_str).collect::<Vec<_>>(),
1610 Some(runner_dir),
1611 )
1612 }?;
1613
1614 let _ = host;
1615 Ok(())
1616}
1617
1618fn install_native_service(platform: RunnerPlatform, runner_dir: &Path) -> Result<(), String> {
1619 if cfg!(windows) {
1620 return Ok(());
1621 }
1622 let svc = runner_dir.join("svc.sh");
1623 if !svc.exists() {
1624 return Err(format!("Missing {}", svc.display()));
1625 }
1626 if matches!(platform, RunnerPlatform::Linux | RunnerPlatform::Macos) {
1627 run_command(
1628 "bash",
1629 &[&svc.display().to_string(), "install"],
1630 Some(runner_dir),
1631 )
1632 } else {
1633 Ok(())
1634 }
1635}
1636
1637fn start_native_service(platform: RunnerPlatform, runner_dir: &Path) -> Result<(), String> {
1638 if cfg!(windows) {
1639 return Ok(());
1640 }
1641 let svc = runner_dir.join("svc.sh");
1642 if matches!(platform, RunnerPlatform::Linux | RunnerPlatform::Macos) {
1643 run_command(
1644 "bash",
1645 &[&svc.display().to_string(), "start"],
1646 Some(runner_dir),
1647 )
1648 } else {
1649 Ok(())
1650 }
1651}
1652
1653fn stop_native_service(platform: RunnerPlatform, runner_dir: &Path) -> Result<(), String> {
1654 if cfg!(windows) {
1655 return Ok(());
1656 }
1657 let svc = runner_dir.join("svc.sh");
1658 if svc.exists() && matches!(platform, RunnerPlatform::Linux | RunnerPlatform::Macos) {
1659 run_command(
1660 "bash",
1661 &[&svc.display().to_string(), "stop"],
1662 Some(runner_dir),
1663 )
1664 } else {
1665 Ok(())
1666 }
1667}
1668
1669fn uninstall_native_service(platform: RunnerPlatform, runner_dir: &Path) -> Result<(), String> {
1670 if cfg!(windows) {
1671 return Ok(());
1672 }
1673 let svc = runner_dir.join("svc.sh");
1674 if svc.exists() && matches!(platform, RunnerPlatform::Linux | RunnerPlatform::Macos) {
1675 run_command(
1676 "bash",
1677 &[&svc.display().to_string(), "uninstall"],
1678 Some(runner_dir),
1679 )
1680 } else {
1681 Ok(())
1682 }
1683}
1684
1685async fn list_remote_github_runners(
1686 organization_login: &str,
1687 installation_id: &str,
1688 name: Option<&str>,
1689) -> Result<Vec<RunnerGitHubRunner>, String> {
1690 let mut path = format!(
1691 "/runner-github/orgs/{}/runners?github_installation_id={}",
1692 organization_login, installation_id
1693 );
1694 if let Some(name) = name.map(str::trim).filter(|value| !value.is_empty()) {
1695 path.push_str("&name=");
1696 path.push_str(name);
1697 }
1698 let response: RunnerGitHubListResponse =
1699 api_json(Method::GET, &path, &ApiTargetOptions::default(), None).await?;
1700 Ok(response.runners)
1701}
1702
1703async fn delete_remote_runner(
1704 organization_login: &str,
1705 installation_id: &str,
1706 runner_id: i64,
1707) -> Result<(), String> {
1708 let _ = api_json_status(
1709 Method::DELETE,
1710 &format!(
1711 "/runner-github/orgs/{}/runners/{}?github_installation_id={}",
1712 organization_login, runner_id, installation_id
1713 ),
1714 &ApiTargetOptions::default(),
1715 None,
1716 )
1717 .await?;
1718 Ok(())
1719}
1720
1721fn run_command(executable: &str, args: &[&str], cwd: Option<&Path>) -> Result<(), String> {
1722 let mut command = Command::new(executable);
1723 command.args(args);
1724 if let Some(cwd) = cwd {
1725 command.current_dir(cwd);
1726 }
1727 command.stdout(Stdio::piped()).stderr(Stdio::piped());
1728 let output = command
1729 .output()
1730 .map_err(|error| format!("Failed to start `{}`: {}", executable, error))?;
1731 if output.status.success() {
1732 return Ok(());
1733 }
1734 Err(format!(
1735 "`{}` failed with status {}: {}",
1736 executable,
1737 output
1738 .status
1739 .code()
1740 .map(|value| value.to_string())
1741 .unwrap_or_else(|| "unknown".to_string()),
1742 String::from_utf8_lossy(&output.stderr).trim()
1743 ))
1744}
1745
1746fn ensure_executable(path: PathBuf) -> Result<(), String> {
1747 if !path.exists() || cfg!(windows) {
1748 return Ok(());
1749 }
1750 #[cfg(unix)]
1751 {
1752 use std::os::unix::fs::PermissionsExt;
1753 let mut permissions = fs::metadata(&path)
1754 .map_err(|error| format!("Failed to read metadata for {}: {}", path.display(), error))?
1755 .permissions();
1756 permissions.set_mode(0o755);
1757 fs::set_permissions(&path, permissions).map_err(|error| {
1758 format!(
1759 "Failed to set executable permissions on {}: {}",
1760 path.display(),
1761 error
1762 )
1763 })?;
1764 }
1765 Ok(())
1766}
1767
1768fn read_configured_runner_name(root: &Path) -> Option<String> {
1769 let runner_file = root.join(".runner");
1770 let content = fs::read_to_string(runner_file).ok()?;
1771 let payload: Value = serde_json::from_str(&content).ok()?;
1772 payload
1773 .get("agentName")
1774 .and_then(Value::as_str)
1775 .map(ToString::to_string)
1776}
1777
1778fn resolve_runner_name(host: &RunnerHostRecord, payload: &LocalRunnerJobPayload) -> String {
1779 payload
1780 .runner_name
1781 .clone()
1782 .filter(|value| !value.trim().is_empty())
1783 .unwrap_or_else(|| {
1784 format!(
1785 "{}-{}",
1786 slugify(&host.runner_name_prefix),
1787 slugify(&host.hostname)
1788 )
1789 })
1790}
1791
1792fn command_available(executable: &str, args: &[&str]) -> bool {
1793 Command::new(executable)
1794 .args(args)
1795 .output()
1796 .map(|output| output.status.success())
1797 .unwrap_or(false)
1798}
1799
1800fn detect_service_manager(platform: RunnerPlatform) -> RunnerServiceManager {
1801 match platform {
1802 RunnerPlatform::Linux => {
1803 if command_available("systemctl", &["--version"]) {
1804 RunnerServiceManager::Systemd
1805 } else {
1806 RunnerServiceManager::Unknown
1807 }
1808 }
1809 RunnerPlatform::Macos => {
1810 if command_available("launchctl", &["list"]) {
1811 RunnerServiceManager::Launchd
1812 } else {
1813 RunnerServiceManager::Unknown
1814 }
1815 }
1816 RunnerPlatform::Windows => RunnerServiceManager::WindowsService,
1817 }
1818}
1819
1820fn service_file_path(root: &Path, service_manager: RunnerServiceManager) -> PathBuf {
1821 match service_manager {
1822 RunnerServiceManager::Systemd => root.join("systemd").join("xbp-github-runner.service"),
1823 RunnerServiceManager::Launchd => root.join("launchd").join("com.xbp.github-runner.plist"),
1824 RunnerServiceManager::WindowsService => {
1825 root.join("windows").join("install-runner-service.ps1")
1826 }
1827 RunnerServiceManager::DockerCompose => root.join("compose.yaml"),
1828 RunnerServiceManager::Unknown => root.join("runner-service.txt"),
1829 }
1830}
1831
1832fn dockerfile_contents() -> &'static str {
1833 "FROM myoung34/github-runner:latest\n\n# Generated by xbp runners agent.\n"
1834}
1835
1836fn docker_compose_contents(
1837 _host: &RunnerHostRecord,
1838 payload: &LocalRunnerJobPayload,
1839 runtime_root: &Path,
1840 runner_name: &str,
1841 registration_token: &str,
1842 organization_login: &str,
1843) -> String {
1844 format!(
1845 "services:\n github-runner:\n build: .\n image: xbp-github-runner:latest\n restart: unless-stopped\n environment:\n ORG_NAME: \"{org}\"\n RUNNER_NAME: \"{runner_name}\"\n RUNNER_SCOPE: \"org\"\n RUNNER_GROUP: \"{group}\"\n LABELS: \"{labels}\"\n ACCESS_TOKEN: \"{token}\"\n EPHEMERAL: \"{ephemeral}\"\n volumes:\n - \"{root}/work:/tmp/github-runner\"\n - \"{root}/config:/runner/config\"\n",
1846 org = organization_login,
1847 runner_name = runner_name,
1848 group = payload.runner_group.clone().unwrap_or_else(|| "Default".to_string()),
1849 labels = payload.labels.join(","),
1850 token = registration_token,
1851 ephemeral = if payload.ephemeral { "true" } else { "false" },
1852 root = runtime_root.display(),
1853 )
1854}
1855
1856#[allow(dead_code)]
1857fn native_service_contents(
1858 host: &RunnerHostRecord,
1859 payload: &LocalRunnerJobPayload,
1860 runtime_root: &Path,
1861 service_manager: RunnerServiceManager,
1862) -> String {
1863 match service_manager {
1864 RunnerServiceManager::Systemd => format!(
1865 "[Unit]\nDescription=XBP GitHub Runner ({host})\n\n[Service]\nWorkingDirectory={root}\nExecStart=/bin/echo xbp native runner placeholder for {group}\nRestart=always\n\n[Install]\nWantedBy=multi-user.target\n",
1866 host = host.hostname,
1867 root = runtime_root.display(),
1868 group = payload.runner_group.clone().unwrap_or_else(|| "Default".to_string()),
1869 ),
1870 RunnerServiceManager::Launchd => format!(
1871 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>Label</key><string>com.xbp.github-runner</string><key>ProgramArguments</key><array><string>/bin/echo</string><string>xbp native runner placeholder</string></array><key>WorkingDirectory</key><string>{}</string></dict></plist>\n",
1872 runtime_root.display()
1873 ),
1874 RunnerServiceManager::WindowsService => format!(
1875 "Write-Host \"Install GitHub runner service for {} in {}\"\n",
1876 host.hostname,
1877 runtime_root.display()
1878 ),
1879 RunnerServiceManager::DockerCompose => docker_compose_contents(
1880 host,
1881 payload,
1882 runtime_root,
1883 "github-runner",
1884 "registration-token",
1885 payload
1886 .organization_login
1887 .as_deref()
1888 .unwrap_or(&host.organization_id),
1889 ),
1890 RunnerServiceManager::Unknown => "XBP runner service manager could not be detected on this host.\n".to_string(),
1891 }
1892}
1893
1894fn format_startup_summary(
1895 host: &RunnerHostRecord,
1896 payload: &LocalRunnerJobPayload,
1897 runtime_state: &RunnerRuntimeState,
1898 runner_name: &str,
1899) -> String {
1900 format!(
1901 "Runner host: {host}\nRunner name: {runner_name}\nRuntime: {runtime}\nService manager: {manager}\nService status: {status}\nRunner group: {group}\nLabels: {labels}\n",
1902 host = host.hostname,
1903 runner_name = runner_name,
1904 runtime = payload.runtime,
1905 manager = runtime_state.service_manager,
1906 status = runtime_state.service_status,
1907 group = payload.runner_group.clone().unwrap_or_else(|| "Default".to_string()),
1908 labels = if payload.labels.is_empty() {
1909 "none".to_string()
1910 } else {
1911 payload.labels.join(", ")
1912 }
1913 )
1914}
1915
1916fn color_status(status: &str) -> String {
1917 match status.trim().to_ascii_lowercase().as_str() {
1918 "online" | "running" | "succeeded" | "synced" | "allowed" => status.green().to_string(),
1919 "queued" | "claimed" | "enrolled" | "installed" => status.yellow().to_string(),
1920 "offline" | "failed" | "error" | "cancelled" => status.red().to_string(),
1921 _ => status.normal().to_string(),
1922 }
1923}
1924
1925fn print_table(headers: &[&str], rows: &[Vec<String>]) {
1926 let mut widths = headers
1927 .iter()
1928 .map(|header| header.len())
1929 .collect::<Vec<_>>();
1930 for row in rows {
1931 for (index, cell) in row.iter().enumerate() {
1932 widths[index] = widths[index].max(strip_ansi(cell).len());
1933 }
1934 }
1935
1936 let header_line = headers
1937 .iter()
1938 .enumerate()
1939 .map(|(index, header)| format!("{:width$}", header.bold(), width = widths[index]))
1940 .collect::<Vec<_>>()
1941 .join(" | ");
1942 println!("{}", header_line);
1943 println!(
1944 "{}",
1945 widths
1946 .iter()
1947 .map(|width| "-".repeat(*width))
1948 .collect::<Vec<_>>()
1949 .join("-+-")
1950 );
1951 for row in rows {
1952 let rendered = row
1953 .iter()
1954 .enumerate()
1955 .map(|(index, cell)| pad_ansi(cell, widths[index]))
1956 .collect::<Vec<_>>()
1957 .join(" | ");
1958 println!("{}", rendered);
1959 }
1960}
1961
1962fn strip_ansi(value: &str) -> String {
1963 let mut out = String::with_capacity(value.len());
1964 let mut chars = value.chars().peekable();
1965 while let Some(ch) = chars.next() {
1966 if ch == '\u{1b}' && chars.peek() == Some(&'[') {
1967 let _ = chars.next();
1968 for next in chars.by_ref() {
1969 if ('@'..='~').contains(&next) {
1970 break;
1971 }
1972 }
1973 continue;
1974 }
1975 out.push(ch);
1976 }
1977 out
1978}
1979
1980fn pad_ansi(value: &str, width: usize) -> String {
1981 let visible = strip_ansi(value).len();
1982 if visible >= width {
1983 value.to_string()
1984 } else {
1985 format!("{value}{:pad$}", "", pad = width - visible)
1986 }
1987}
1988
1989fn read_json_string(value: &Value, key: &str) -> String {
1990 value
1991 .get(key)
1992 .and_then(Value::as_str)
1993 .unwrap_or("-")
1994 .to_string()
1995}