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(
583 host,
584 &runner_name,
585 payload,
586 None,
587 RunnerRegistrationState::Pending,
588 RunnerInstallPhase::Preflight,
589 service_manager,
590 RunnerServiceStatus::Missing,
591 runtime_root,
592 &work_dir,
593 &config_dir,
594 &log_path,
595 Some(&service_path),
596 None,
597 None,
598 )
599 .await?;
600
601 let registration_token = request_runner_token(
602 "/runner-github/registration-token",
603 organization_login,
604 installation_id,
605 )
606 .await?;
607 let release = if payload.runtime == RunnerExecutionRuntime::Native {
608 Some(
609 resolve_runner_release(
610 host.platform,
611 host.arch.as_deref().unwrap_or("x64"),
612 payload.runner_version.as_deref(),
613 )
614 .await?,
615 )
616 } else {
617 None
618 };
619
620 if payload.runtime == RunnerExecutionRuntime::Native {
621 let release = release.clone().expect("native runner release");
622 write_job_phase(
623 job,
624 RunnerInstallPhase::Downloading,
625 Some(json!({
626 "version": release.version,
627 "download_url": release.download_url,
628 })),
629 )
630 .await?;
631 let archive_path = download_runner_release(runtime_root, &release).await?;
632 generated_files.push(archive_path.display().to_string());
633
634 write_job_phase(
635 job,
636 RunnerInstallPhase::Extracting,
637 Some(json!({
638 "archive": archive_path.display().to_string(),
639 })),
640 )
641 .await?;
642 extract_runner_archive(&archive_path, &runner_dir)?;
643
644 write_job_phase(job, RunnerInstallPhase::Cleanup, None).await?;
645 cleanup = cleanup_stale_runner_registration(
646 host,
647 payload,
648 runtime_root,
649 &runner_dir,
650 &config_dir,
651 organization_login,
652 installation_id,
653 Some(&runner_name),
654 )
655 .await?;
656
657 write_job_phase(job, RunnerInstallPhase::Registering, None).await?;
658 configure_native_runner(
659 host,
660 payload,
661 &runner_dir,
662 &work_dir,
663 organization_login,
664 ®istration_token.token,
665 &runner_name,
666 )?;
667
668 write_job_phase(job, RunnerInstallPhase::InstallingService, None).await?;
669 install_native_service(host.platform, &runner_dir)?;
670
671 write_job_phase(job, RunnerInstallPhase::StartingService, None).await?;
672 start_native_service(host.platform, &runner_dir)?;
673 generated_files.push(runner_dir.display().to_string());
674 } else {
675 write_job_phase(job, RunnerInstallPhase::Cleanup, None).await?;
676 cleanup = cleanup_stale_runner_registration(
677 host,
678 payload,
679 runtime_root,
680 &runner_dir,
681 &config_dir,
682 organization_login,
683 installation_id,
684 Some(&runner_name),
685 )
686 .await?;
687
688 let compose_path = runtime_root.join("compose.yaml");
689 fs::write(
690 &compose_path,
691 docker_compose_contents(
692 host,
693 payload,
694 runtime_root,
695 &runner_name,
696 ®istration_token.token,
697 organization_login,
698 ),
699 )
700 .map_err(|error| format!("Failed to write {}: {}", compose_path.display(), error))?;
701 generated_files.push(compose_path.display().to_string());
702
703 let dockerfile_path = runtime_root.join("Dockerfile");
704 fs::write(&dockerfile_path, dockerfile_contents())
705 .map_err(|error| format!("Failed to write {}: {}", dockerfile_path.display(), error))?;
706 generated_files.push(dockerfile_path.display().to_string());
707
708 write_job_phase(job, RunnerInstallPhase::StartingService, None).await?;
709 run_command(
710 "docker",
711 &[
712 "compose",
713 "-f",
714 &compose_path.display().to_string(),
715 "up",
716 "-d",
717 "--build",
718 ],
719 Some(runtime_root),
720 )?;
721 }
722
723 let github_runner =
724 list_remote_github_runners(organization_login, installation_id, Some(&runner_name))
725 .await?
726 .into_iter()
727 .next();
728 let runtime_metadata = LocalRunnerRuntimeMetadata {
729 runner_host_id: Some(host.id.clone()),
730 hostname: Some(host.hostname.clone()),
731 runner_name_prefix: Some(host.runner_name_prefix.clone()),
732 runner_name: Some(runner_name.clone()),
733 runner_group: payload.runner_group.clone(),
734 organization_login: Some(organization_login.to_string()),
735 github_installation_id: Some(installation_id.to_string()),
736 github_runner_id: github_runner.as_ref().map(|runner| runner.id),
737 removed_runner_names: cleanup.removed_runner_names.clone(),
738 removed_runner_ids: cleanup.removed_runner_ids.clone(),
739 };
740 let runtime_state = RunnerRuntimeState {
741 runtime: payload.runtime,
742 service_manager,
743 service_status: RunnerServiceStatus::Running,
744 root_directory: Some(runtime_root.display().to_string()),
745 work_directory: Some(work_dir.display().to_string()),
746 config_directory: Some(config_dir.display().to_string()),
747 log_path: Some(log_path.display().to_string()),
748 service_file_path: Some(service_path.display().to_string()),
749 last_cleanup_at: None,
750 metadata: json!({
751 "runner_name": runner_name,
752 "runner_group": payload.runner_group,
753 "labels": payload.labels,
754 "cleanup_removed_runner_names": cleanup.removed_runner_names,
755 "cleanup_removed_runner_ids": cleanup.removed_runner_ids,
756 }),
757 };
758
759 let config_path = runtime_root.join("runner-config.json");
760 fs::write(
761 &config_path,
762 serde_json::to_string_pretty(&payload)
763 .map_err(|error| format!("Failed to serialize runner config: {}", error))?,
764 )
765 .map_err(|error| format!("Failed to write {}: {}", config_path.display(), error))?;
766 generated_files.push(config_path.display().to_string());
767
768 let state_path = runtime_root.join("runtime-state.json");
769 fs::write(
770 &state_path,
771 serde_json::to_string_pretty(&runtime_state)
772 .map_err(|error| format!("Failed to serialize runtime state: {}", error))?,
773 )
774 .map_err(|error| format!("Failed to write {}: {}", state_path.display(), error))?;
775 generated_files.push(state_path.display().to_string());
776
777 let metadata_path = write_runtime_metadata(runtime_root, &runtime_metadata)?;
778 generated_files.push(metadata_path.display().to_string());
779
780 let startup_path = runtime_root.join("startup-summary.txt");
781 fs::write(
782 &startup_path,
783 format_startup_summary(host, payload, &runtime_state, &runner_name),
784 )
785 .map_err(|error| format!("Failed to write {}: {}", startup_path.display(), error))?;
786 generated_files.push(startup_path.display().to_string());
787
788 let installed_version = release.as_ref().map(|value| value.version.clone());
789 write_job_phase(
790 job,
791 RunnerInstallPhase::Running,
792 Some(json!({
793 "runtime_root": runtime_root.display().to_string(),
794 "runner_name": runner_name,
795 "installed_version": installed_version,
796 })),
797 )
798 .await?;
799 upsert_runner_runtime_record(
800 host,
801 &runner_name,
802 payload,
803 github_runner.as_ref().map(|runner| runner.id),
804 RunnerRegistrationState::Registered,
805 RunnerInstallPhase::Running,
806 service_manager,
807 RunnerServiceStatus::Running,
808 runtime_root,
809 &work_dir,
810 &config_dir,
811 &log_path,
812 Some(&service_path),
813 installed_version.clone(),
814 None,
815 )
816 .await?;
817
818 Ok(LocalRunnerJobResult {
819 runtime_root: runtime_root.display().to_string(),
820 service_manager,
821 service_status: RunnerServiceStatus::Running,
822 install_phase: RunnerInstallPhase::Running,
823 registration_state: RunnerRegistrationState::Registered,
824 installed_version,
825 generated_files,
826 })
827}
828
829async fn remove_runner_runtime(
830 host: &RunnerHostRecord,
831 job: &RunnerJobRecord,
832 payload: &LocalRunnerJobPayload,
833 runtime_root: &Path,
834) -> Result<LocalRunnerJobResult, String> {
835 let runner_name = resolve_runner_name(host, payload);
836 let service_manager = detect_service_manager(host.platform);
837 let organization_login = payload
838 .organization_login
839 .as_deref()
840 .filter(|value| !value.trim().is_empty())
841 .ok_or_else(|| "Runner payload is missing `organization_login`.".to_string())?;
842 let installation_id = payload
843 .github_installation_id
844 .as_deref()
845 .filter(|value| !value.trim().is_empty())
846 .ok_or_else(|| "Runner payload is missing `github_installation_id`.".to_string())?;
847 let runner_dir = runtime_root.join("runner");
848 let config_dir = runtime_root.join("config");
849
850 write_job_phase(job, RunnerInstallPhase::Removing, None).await?;
851 let cleanup = cleanup_stale_runner_registration(
852 host,
853 payload,
854 runtime_root,
855 &runner_dir,
856 &config_dir,
857 organization_login,
858 installation_id,
859 Some(&runner_name),
860 )
861 .await?;
862
863 if payload.runtime == RunnerExecutionRuntime::Docker {
864 let compose_path = runtime_root.join("compose.yaml");
865 if compose_path.exists() {
866 let _ = run_command(
867 "docker",
868 &[
869 "compose",
870 "-f",
871 &compose_path.display().to_string(),
872 "down",
873 "--remove-orphans",
874 ],
875 Some(runtime_root),
876 );
877 }
878 } else if runner_dir.exists() {
879 let _ = stop_native_service(host.platform, &runner_dir);
880 let _ = uninstall_native_service(host.platform, &runner_dir);
881 }
882
883 if runtime_root.exists() {
884 fs::remove_dir_all(runtime_root).map_err(|error| {
885 format!(
886 "Failed to remove runner runtime directory {}: {}",
887 runtime_root.display(),
888 error
889 )
890 })?;
891 }
892 Ok(LocalRunnerJobResult {
893 runtime_root: runtime_root.display().to_string(),
894 service_manager,
895 service_status: RunnerServiceStatus::Missing,
896 install_phase: RunnerInstallPhase::Removing,
897 registration_state: RunnerRegistrationState::Unregistered,
898 installed_version: None,
899 generated_files: cleanup
900 .removed_runner_names
901 .iter()
902 .zip(
903 cleanup
904 .removed_runner_ids
905 .iter()
906 .map(Some)
907 .chain(std::iter::repeat(None)),
908 )
909 .map(|(name, id)| match id {
910 Some(id) => format!("{name} ({id})"),
911 None => name.clone(),
912 })
913 .collect(),
914 })
915}
916
917fn build_local_preflight(host: &RunnerHostRecord) -> Result<LocalPreflight, String> {
918 let runtime_root = runner_runtime_root(
919 host,
920 &LocalRunnerJobPayload {
921 runtime: RunnerExecutionRuntime::Native,
922 organization_login: None,
923 github_installation_id: None,
924 runner_group: None,
925 labels: Vec::new(),
926 runner_name: None,
927 runner_version: None,
928 ephemeral: false,
929 },
930 )?;
931 fs::create_dir_all(&runtime_root).map_err(|error| error.to_string())?;
932 let docker_available =
933 command_available("docker", &["version", "--format", "{{.Server.Version}}"]);
934 let service_manager = detect_service_manager(host.platform);
935 let writable = runtime_root.exists();
936 let checks = json!({
937 "runtime_root": runtime_root.display().to_string(),
938 "runtime_root_writable": writable,
939 "docker_available": docker_available,
940 "service_manager": service_manager.as_str(),
941 "platform": host.platform.as_str(),
942 });
943 Ok(LocalPreflight {
944 platform: host.platform,
945 status: if writable {
946 RunnerSyncState::Synced
947 } else {
948 RunnerSyncState::Error
949 },
950 docker_available: Some(docker_available),
951 service_manager: Some(service_manager),
952 checks,
953 checked_at: Some(Utc::now().to_rfc3339()),
954 })
955}
956
957#[derive(Debug)]
958struct LocalPreflight {
959 platform: RunnerPlatform,
960 status: RunnerSyncState,
961 docker_available: Option<bool>,
962 service_manager: Option<RunnerServiceManager>,
963 checks: Value,
964 checked_at: Option<String>,
965}
966
967fn print_preflight(host: &RunnerHostRecord, preflight: &LocalPreflight) {
968 println!("{}", "Runner Host Preflight".bold());
969 println!(" host: {} ({})", host.hostname, host.id);
970 println!(" platform: {}", host.platform);
971 println!(" status: {}", color_status(preflight.status.as_str()));
972 println!(
973 " service manager: {}",
974 preflight
975 .service_manager
976 .map(|value| value.to_string())
977 .unwrap_or_else(|| "unknown".to_string())
978 );
979 println!(
980 " docker available: {}",
981 preflight
982 .docker_available
983 .map(|value| if value { "yes" } else { "no" })
984 .unwrap_or("unknown")
985 );
986}
987
988async fn fetch_runner_host_by_id(
989 runner_host_id: &str,
990 target: &ApiTargetOptions,
991) -> Result<RunnerHostRecord, String> {
992 let response: RunnerHostListResponse =
993 api_json(Method::GET, "/runner-hosts", target, None).await?;
994 response
995 .runner_hosts
996 .into_iter()
997 .find(|host| host.id == runner_host_id)
998 .ok_or_else(|| format!("Runner host `{runner_host_id}` was not found."))
999}
1000
1001async fn api_json<T: for<'de> Deserialize<'de>>(
1002 method: Method,
1003 path: &str,
1004 target: &ApiTargetOptions,
1005 body: Option<Value>,
1006) -> Result<T, String> {
1007 let (_, value) = api_json_status(method, path, target, body).await?;
1008 serde_json::from_value(value)
1009 .map_err(|error| format!("Failed to decode API response for `{}`: {}", path, error))
1010}
1011
1012async fn api_json_status(
1013 method: Method,
1014 path: &str,
1015 target: &ApiTargetOptions,
1016 body: Option<Value>,
1017) -> Result<(StatusCode, Value), String> {
1018 let url = resolve_request_url(path, target)?;
1019 let client = Client::builder()
1020 .timeout(Duration::from_secs(60))
1021 .build()
1022 .map_err(|error| format!("Failed to create HTTP client: {}", error))?;
1023 let mut request = client.request(method, url);
1024 if !target.no_auth {
1025 if let Some(token) = resolve_xbp_api_token() {
1026 request = request.bearer_auth(token);
1027 }
1028 if let Some(github_token) = resolve_github_oauth2_key() {
1029 request = request.header("X-XBP-GitHub-Token", github_token);
1030 }
1031 }
1032 if let Some(body) = body {
1033 request = request.json(&body);
1034 }
1035 let response = request
1036 .send()
1037 .await
1038 .map_err(|error| format!("Runner API request failed: {}", error))?;
1039 let status = response.status();
1040 if status == StatusCode::NO_CONTENT {
1041 return Ok((status, Value::Null));
1042 }
1043 let value = response
1044 .json::<Value>()
1045 .await
1046 .map_err(|error| format!("Failed to decode runner API response: {}", error))?;
1047 if !status.is_success() {
1048 return Err(format!(
1049 "Runner API request failed with status {}: {}",
1050 status,
1051 value
1052 .get("error")
1053 .and_then(Value::as_str)
1054 .unwrap_or("unknown error")
1055 ));
1056 }
1057 Ok((status, value))
1058}
1059
1060fn load_project_runner_defaults() -> Result<Option<GitHubRunnersProjectConfig>, String> {
1061 let current_dir =
1062 env::current_dir().map_err(|error| format!("Failed to read cwd: {}", error))?;
1063 let Some(found) = find_xbp_config_upwards(¤t_dir) else {
1064 return Ok(None);
1065 };
1066 let content = fs::read_to_string(&found.config_path)
1067 .map_err(|error| format!("Failed to read {}: {}", found.config_path.display(), error))?;
1068 let (config, _) = parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
1069 .map_err(|error| format!("Failed to parse {}: {}", found.config_path.display(), error))?;
1070 Ok(config.github.and_then(|github| github.runners))
1071}
1072
1073fn runner_runtime_root(
1074 host: &RunnerHostRecord,
1075 payload: &LocalRunnerJobPayload,
1076) -> Result<PathBuf, String> {
1077 let base = global_runner_root_dir()?;
1078 let organization = payload
1079 .organization_login
1080 .clone()
1081 .unwrap_or_else(|| host.organization_id.clone());
1082 Ok(base
1083 .join(slugify(&organization))
1084 .join(slugify(&host.hostname)))
1085}
1086
1087fn runtime_metadata_path(runtime_root: &Path) -> PathBuf {
1088 runtime_root.join("runtime-metadata.json")
1089}
1090
1091fn read_runtime_metadata(runtime_root: &Path) -> Option<LocalRunnerRuntimeMetadata> {
1092 let path = runtime_metadata_path(runtime_root);
1093 let content = fs::read_to_string(path).ok()?;
1094 serde_json::from_str(&content).ok()
1095}
1096
1097fn write_runtime_metadata(
1098 runtime_root: &Path,
1099 metadata: &LocalRunnerRuntimeMetadata,
1100) -> Result<PathBuf, String> {
1101 let path = runtime_metadata_path(runtime_root);
1102 fs::write(
1103 &path,
1104 serde_json::to_string_pretty(metadata)
1105 .map_err(|error| format!("Failed to serialize runtime metadata: {}", error))?,
1106 )
1107 .map_err(|error| format!("Failed to write {}: {}", path.display(), error))?;
1108 Ok(path)
1109}
1110
1111fn slugify(value: &str) -> String {
1112 value
1113 .chars()
1114 .map(|character| {
1115 if character.is_ascii_alphanumeric() {
1116 character.to_ascii_lowercase()
1117 } else {
1118 '-'
1119 }
1120 })
1121 .collect::<String>()
1122 .trim_matches('-')
1123 .to_string()
1124}
1125
1126fn with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
1127 let mut url = reqwest::Url::parse(&format!("http://localhost{path}"))
1128 .expect("static runner path should always parse");
1129 {
1130 let mut pairs = url.query_pairs_mut();
1131 for (key, value) in params {
1132 if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
1133 pairs.append_pair(key, value);
1134 }
1135 }
1136 }
1137 match url.query() {
1138 Some(query) if !query.is_empty() => format!("{}?{}", url.path(), query),
1139 _ => url.path().to_string(),
1140 }
1141}
1142
1143fn parse_job_payload(payload: Value) -> Result<LocalRunnerJobPayload, String> {
1144 let mut payload = if payload.is_null() {
1145 json!({})
1146 } else {
1147 payload
1148 };
1149 if !payload.is_object() {
1150 return Err("Runner job payload must be an object.".to_string());
1151 }
1152 if payload.get("runtime").is_none() {
1153 payload["runtime"] = Value::String("native".to_string());
1154 }
1155 serde_json::from_value(payload)
1156 .map_err(|error| format!("Invalid runner job payload: {}", error))
1157}
1158
1159async fn write_job_phase(
1160 job: &RunnerJobRecord,
1161 phase: RunnerInstallPhase,
1162 details: Option<Value>,
1163) -> Result<(), String> {
1164 let detail_value = details.unwrap_or_else(|| json!({}));
1165 let _: RunnerJobRecord = api_json(
1166 Method::PATCH,
1167 &format!("/runner-jobs/{}", job.id),
1168 &ApiTargetOptions::default(),
1169 Some(json!({
1170 "status": "running",
1171 "phase": phase,
1172 "details": detail_value,
1173 })),
1174 )
1175 .await?;
1176 Ok(())
1177}
1178
1179async fn upsert_runner_runtime_record(
1180 host: &RunnerHostRecord,
1181 runner_name: &str,
1182 payload: &LocalRunnerJobPayload,
1183 github_runner_id: Option<i64>,
1184 registration_state: RunnerRegistrationState,
1185 install_phase: RunnerInstallPhase,
1186 service_manager: RunnerServiceManager,
1187 service_status: RunnerServiceStatus,
1188 runtime_root: &Path,
1189 work_dir: &Path,
1190 config_dir: &Path,
1191 log_path: &Path,
1192 service_path: Option<&Path>,
1193 installed_version: Option<String>,
1194 registration_error: Option<String>,
1195) -> Result<(), String> {
1196 let runtime_state = json!({
1197 "runtime": payload.runtime,
1198 "service_manager": service_manager,
1199 "service_status": service_status,
1200 "root_directory": runtime_root.display().to_string(),
1201 "work_directory": work_dir.display().to_string(),
1202 "config_directory": config_dir.display().to_string(),
1203 "log_path": log_path.display().to_string(),
1204 "service_file_path": service_path.map(|path| path.display().to_string()),
1205 "metadata": {
1206 "runner_name": runner_name,
1207 }
1208 });
1209 let desired_config = json!({
1210 "organization_login": payload.organization_login.clone(),
1211 "runner_group": payload.runner_group.clone(),
1212 "labels": payload.labels.clone(),
1213 "runtime": payload.runtime,
1214 "work_directory": work_dir.display().to_string(),
1215 "config_directory": config_dir.display().to_string(),
1216 "ephemeral": payload.ephemeral,
1217 });
1218 let _: Value = api_json(
1219 Method::POST,
1220 &format!("/organizations/{}/runners", host.organization_id),
1221 &ApiTargetOptions::default(),
1222 Some(json!({
1223 "organization_id": host.organization_id.clone(),
1224 "runner_host_id": host.id.clone(),
1225 "github_runner_id": github_runner_id,
1226 "name": runner_name,
1227 "platform": host.platform,
1228 "os": host.platform.as_str(),
1229 "architecture": host.arch.clone(),
1230 "status": if service_status == RunnerServiceStatus::Running {
1231 "synced"
1232 } else {
1233 "pending"
1234 },
1235 "busy": false,
1236 "labels": payload.labels.clone(),
1237 "desired_config": desired_config,
1238 "runtime_state": runtime_state,
1239 "registration_state": registration_state,
1240 "install_phase": install_phase,
1241 "installed_version": installed_version,
1242 "registration_error": registration_error,
1243 "metadata": {
1244 "organization_login": payload.organization_login.clone(),
1245 "github_installation_id": payload.github_installation_id.clone(),
1246 },
1247 "last_registration_attempt_at": Utc::now().to_rfc3339(),
1248 "last_seen_at": Utc::now().to_rfc3339(),
1249 })),
1250 )
1251 .await?;
1252 Ok(())
1253}
1254
1255async fn request_runner_token(
1256 endpoint: &str,
1257 organization_login: &str,
1258 installation_id: &str,
1259) -> Result<RunnerApiTokenResponse, String> {
1260 api_json(
1261 Method::POST,
1262 endpoint,
1263 &ApiTargetOptions::default(),
1264 Some(json!({
1265 "organization_login": organization_login,
1266 "github_installation_id": installation_id,
1267 })),
1268 )
1269 .await
1270}
1271
1272async fn resolve_runner_release(
1273 platform: RunnerPlatform,
1274 arch: &str,
1275 version: Option<&str>,
1276) -> Result<RunnerApiReleaseResponse, String> {
1277 let mut path = format!(
1278 "/runner-github/releases/latest?platform={}&arch={}",
1279 platform.as_str(),
1280 arch
1281 );
1282 if let Some(version) = version.map(str::trim).filter(|value| !value.is_empty()) {
1283 path.push_str("&version=");
1284 path.push_str(version);
1285 }
1286 api_json(Method::GET, &path, &ApiTargetOptions::default(), None).await
1287}
1288
1289async fn download_runner_release(
1290 runtime_root: &Path,
1291 release: &RunnerApiReleaseResponse,
1292) -> Result<PathBuf, String> {
1293 let downloads_dir = runtime_root.join("downloads");
1294 fs::create_dir_all(&downloads_dir).map_err(|error| error.to_string())?;
1295 let archive_path = downloads_dir.join(&release.file_name);
1296 let client = Client::builder()
1297 .timeout(Duration::from_secs(300))
1298 .build()
1299 .map_err(|error| format!("Failed to create download client: {}", error))?;
1300 let bytes = client
1301 .get(&release.download_url)
1302 .send()
1303 .await
1304 .map_err(|error| format!("Failed to download runner release: {}", error))?
1305 .error_for_status()
1306 .map_err(|error| format!("Runner release download failed: {}", error))?
1307 .bytes()
1308 .await
1309 .map_err(|error| format!("Failed to read runner release bytes: {}", error))?;
1310 fs::write(&archive_path, &bytes)
1311 .map_err(|error| format!("Failed to write {}: {}", archive_path.display(), error))?;
1312 Ok(archive_path)
1313}
1314
1315fn extract_runner_archive(archive_path: &Path, runner_dir: &Path) -> Result<(), String> {
1316 if runner_dir.exists() {
1317 fs::remove_dir_all(runner_dir)
1318 .map_err(|error| format!("Failed to clear {}: {}", runner_dir.display(), error))?;
1319 }
1320 fs::create_dir_all(runner_dir).map_err(|error| error.to_string())?;
1321
1322 let file_name = archive_path
1323 .file_name()
1324 .and_then(|value| value.to_str())
1325 .unwrap_or_default()
1326 .to_ascii_lowercase();
1327 if file_name.ends_with(".tar.gz") {
1328 let archive_file = fs::File::open(archive_path)
1329 .map_err(|error| format!("Failed to open archive: {}", error))?;
1330 let mut archive = Archive::new(GzDecoder::new(archive_file));
1331 archive
1332 .unpack(runner_dir)
1333 .map_err(|error| format!("Failed to unpack tar.gz archive: {}", error))?;
1334 } else if file_name.ends_with(".zip") {
1335 let bytes =
1336 fs::read(archive_path).map_err(|error| format!("Failed to read archive: {}", error))?;
1337 let reader = Cursor::new(bytes);
1338 let mut archive = ZipArchive::new(reader)
1339 .map_err(|error| format!("Failed to open zip archive: {}", error))?;
1340 for index in 0..archive.len() {
1341 let mut entry = archive
1342 .by_index(index)
1343 .map_err(|error| format!("Failed to read zip entry: {}", error))?;
1344 let output_path = runner_dir.join(entry.mangled_name());
1345 if entry.name().ends_with('/') {
1346 fs::create_dir_all(&output_path).map_err(|error| error.to_string())?;
1347 continue;
1348 }
1349 if let Some(parent) = output_path.parent() {
1350 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1351 }
1352 let mut output = fs::File::create(&output_path)
1353 .map_err(|error| format!("Failed to create file: {}", error))?;
1354 io::copy(&mut entry, &mut output)
1355 .map_err(|error| format!("Failed to extract zip entry: {}", error))?;
1356 }
1357 } else {
1358 return Err(format!(
1359 "Unsupported runner archive format for {}",
1360 archive_path.display()
1361 ));
1362 }
1363 ensure_executable(runner_dir.join("config.sh"))?;
1364 ensure_executable(runner_dir.join("svc.sh"))?;
1365 ensure_executable(runner_dir.join("run.sh"))?;
1366 Ok(())
1367}
1368
1369async fn cleanup_stale_runner_registration(
1370 host: &RunnerHostRecord,
1371 payload: &LocalRunnerJobPayload,
1372 runtime_root: &Path,
1373 runner_dir: &Path,
1374 config_dir: &Path,
1375 organization_login: &str,
1376 installation_id: &str,
1377 fallback_runner_name: Option<&str>,
1378) -> Result<RunnerCleanupSummary, String> {
1379 if payload.runtime == RunnerExecutionRuntime::Docker {
1380 let compose_path = runtime_root.join("compose.yaml");
1381 if compose_path.exists() {
1382 let _ = run_command(
1383 "docker",
1384 &[
1385 "compose",
1386 "-f",
1387 &compose_path.display().to_string(),
1388 "down",
1389 "--remove-orphans",
1390 ],
1391 Some(runtime_root),
1392 );
1393 }
1394 } else if runner_dir.exists() {
1395 let _ = stop_native_service(host.platform, runner_dir);
1396 }
1397
1398 let runner_name = read_configured_runner_name(config_dir)
1399 .or_else(|| read_configured_runner_name(runner_dir))
1400 .or_else(|| read_runtime_metadata(runtime_root).and_then(|metadata| metadata.runner_name))
1401 .or_else(|| fallback_runner_name.map(ToString::to_string));
1402 let prior_metadata = read_runtime_metadata(runtime_root);
1403 let mut summary = RunnerCleanupSummary::default();
1404 let same_machine_runners =
1405 list_remote_github_runners(organization_login, installation_id, None)
1406 .await?
1407 .into_iter()
1408 .filter(|runner| {
1409 runner_matches_same_machine(host, payload, prior_metadata.as_ref(), runner)
1410 })
1411 .collect::<Vec<_>>();
1412
1413 if let Some(name) = runner_name.as_deref() {
1414 if payload.runtime == RunnerExecutionRuntime::Native {
1415 if let Ok(remove_token) = request_runner_token(
1416 "/runner-github/remove-token",
1417 organization_login,
1418 installation_id,
1419 )
1420 .await
1421 {
1422 let remove_script = if cfg!(windows) {
1423 runner_dir.join("config.cmd")
1424 } else {
1425 runner_dir.join("config.sh")
1426 };
1427 if remove_script.exists() {
1428 let remove_args = if cfg!(windows) {
1429 vec![
1430 "/c".to_string(),
1431 remove_script.display().to_string(),
1432 "remove".to_string(),
1433 "--token".to_string(),
1434 remove_token.token,
1435 ]
1436 } else {
1437 vec![
1438 remove_script.display().to_string(),
1439 "remove".to_string(),
1440 "--token".to_string(),
1441 remove_token.token,
1442 ]
1443 };
1444 let executable = if cfg!(windows) { "cmd" } else { "bash" };
1445 let _ = run_command(
1446 executable,
1447 &remove_args.iter().map(String::as_str).collect::<Vec<_>>(),
1448 Some(runner_dir),
1449 );
1450 }
1451 }
1452 }
1453
1454 if let Some(remote_runner) =
1455 list_remote_github_runners(organization_login, installation_id, Some(name))
1456 .await?
1457 .into_iter()
1458 .next()
1459 {
1460 delete_remote_runner(organization_login, installation_id, remote_runner.id).await?;
1461 summary.removed_runner_names.push(name.to_string());
1462 summary.removed_runner_ids.push(remote_runner.id);
1463 }
1464 }
1465
1466 for remote_runner in same_machine_runners {
1467 if summary.removed_runner_ids.contains(&remote_runner.id) {
1468 continue;
1469 }
1470 delete_remote_runner(organization_login, installation_id, remote_runner.id).await?;
1471 summary.removed_runner_names.push(remote_runner.name);
1472 summary.removed_runner_ids.push(remote_runner.id);
1473 }
1474
1475 if config_dir.exists() {
1476 let _ = fs::remove_dir_all(config_dir);
1477 let _ = fs::create_dir_all(config_dir);
1478 }
1479 let local_runner_file = runner_dir.join(".runner");
1480 if local_runner_file.exists() {
1481 let _ = fs::remove_file(local_runner_file);
1482 }
1483 Ok(summary)
1484}
1485
1486fn runner_matches_same_machine(
1487 host: &RunnerHostRecord,
1488 payload: &LocalRunnerJobPayload,
1489 metadata: Option<&LocalRunnerRuntimeMetadata>,
1490 runner: &RunnerGitHubRunner,
1491) -> bool {
1492 let runner_name = runner.name.trim().to_ascii_lowercase();
1493 let host_prefix = slugify(&host.runner_name_prefix);
1494 let host_name = slugify(&host.hostname);
1495 let expected_name = resolve_runner_name(host, payload).to_ascii_lowercase();
1496
1497 if runner_name == expected_name {
1498 return true;
1499 }
1500 if runner_name.starts_with(&format!("{host_prefix}-")) && runner_name.contains(&host_name) {
1501 return true;
1502 }
1503 metadata
1504 .and_then(|value| value.runner_name_prefix.as_deref().map(slugify))
1505 .is_some_and(|prefix| runner_name.starts_with(&format!("{prefix}-")))
1506}
1507
1508fn configure_native_runner(
1509 host: &RunnerHostRecord,
1510 payload: &LocalRunnerJobPayload,
1511 runner_dir: &Path,
1512 work_dir: &Path,
1513 organization_login: &str,
1514 registration_token: &str,
1515 runner_name: &str,
1516) -> Result<(), String> {
1517 let github_url = format!("https://github.com/{}", organization_login);
1518 if cfg!(windows) {
1519 let config_cmd = runner_dir.join("config.cmd");
1520 let mut args = vec![
1521 "/c".to_string(),
1522 config_cmd.display().to_string(),
1523 "--unattended".to_string(),
1524 "--url".to_string(),
1525 github_url,
1526 "--token".to_string(),
1527 registration_token.to_string(),
1528 "--name".to_string(),
1529 runner_name.to_string(),
1530 "--work".to_string(),
1531 work_dir.display().to_string(),
1532 "--replace".to_string(),
1533 "--runasservice".to_string(),
1534 ];
1535 if let Some(group) = payload
1536 .runner_group
1537 .as_deref()
1538 .filter(|value| !value.trim().is_empty())
1539 {
1540 args.push("--runnergroup".to_string());
1541 args.push(group.to_string());
1542 }
1543 if !payload.labels.is_empty() {
1544 args.push("--labels".to_string());
1545 args.push(payload.labels.join(","));
1546 }
1547 if payload.ephemeral {
1548 args.push("--ephemeral".to_string());
1549 }
1550 run_command(
1551 "cmd",
1552 &args.iter().map(String::as_str).collect::<Vec<_>>(),
1553 Some(runner_dir),
1554 )
1555 } else {
1556 let config_sh = runner_dir.join("config.sh");
1557 let mut args = vec![
1558 config_sh.display().to_string(),
1559 "--unattended".to_string(),
1560 "--url".to_string(),
1561 github_url,
1562 "--token".to_string(),
1563 registration_token.to_string(),
1564 "--name".to_string(),
1565 runner_name.to_string(),
1566 "--work".to_string(),
1567 work_dir.display().to_string(),
1568 "--replace".to_string(),
1569 ];
1570 if let Some(group) = payload
1571 .runner_group
1572 .as_deref()
1573 .filter(|value| !value.trim().is_empty())
1574 {
1575 args.push("--runnergroup".to_string());
1576 args.push(group.to_string());
1577 }
1578 if !payload.labels.is_empty() {
1579 args.push("--labels".to_string());
1580 args.push(payload.labels.join(","));
1581 }
1582 if payload.ephemeral {
1583 args.push("--ephemeral".to_string());
1584 }
1585 run_command(
1586 "bash",
1587 &args.iter().map(String::as_str).collect::<Vec<_>>(),
1588 Some(runner_dir),
1589 )
1590 }?;
1591
1592 let _ = host;
1593 Ok(())
1594}
1595
1596fn install_native_service(platform: RunnerPlatform, runner_dir: &Path) -> Result<(), String> {
1597 if cfg!(windows) {
1598 return Ok(());
1599 }
1600 let svc = runner_dir.join("svc.sh");
1601 if !svc.exists() {
1602 return Err(format!("Missing {}", svc.display()));
1603 }
1604 if matches!(platform, RunnerPlatform::Linux | RunnerPlatform::Macos) {
1605 run_command(
1606 "bash",
1607 &[&svc.display().to_string(), "install"],
1608 Some(runner_dir),
1609 )
1610 } else {
1611 Ok(())
1612 }
1613}
1614
1615fn start_native_service(platform: RunnerPlatform, runner_dir: &Path) -> Result<(), String> {
1616 if cfg!(windows) {
1617 return Ok(());
1618 }
1619 let svc = runner_dir.join("svc.sh");
1620 if matches!(platform, RunnerPlatform::Linux | RunnerPlatform::Macos) {
1621 run_command(
1622 "bash",
1623 &[&svc.display().to_string(), "start"],
1624 Some(runner_dir),
1625 )
1626 } else {
1627 Ok(())
1628 }
1629}
1630
1631fn stop_native_service(platform: RunnerPlatform, runner_dir: &Path) -> Result<(), String> {
1632 if cfg!(windows) {
1633 return Ok(());
1634 }
1635 let svc = runner_dir.join("svc.sh");
1636 if svc.exists() && matches!(platform, RunnerPlatform::Linux | RunnerPlatform::Macos) {
1637 run_command(
1638 "bash",
1639 &[&svc.display().to_string(), "stop"],
1640 Some(runner_dir),
1641 )
1642 } else {
1643 Ok(())
1644 }
1645}
1646
1647fn uninstall_native_service(platform: RunnerPlatform, runner_dir: &Path) -> Result<(), String> {
1648 if cfg!(windows) {
1649 return Ok(());
1650 }
1651 let svc = runner_dir.join("svc.sh");
1652 if svc.exists() && matches!(platform, RunnerPlatform::Linux | RunnerPlatform::Macos) {
1653 run_command(
1654 "bash",
1655 &[&svc.display().to_string(), "uninstall"],
1656 Some(runner_dir),
1657 )
1658 } else {
1659 Ok(())
1660 }
1661}
1662
1663async fn list_remote_github_runners(
1664 organization_login: &str,
1665 installation_id: &str,
1666 name: Option<&str>,
1667) -> Result<Vec<RunnerGitHubRunner>, String> {
1668 let mut path = format!(
1669 "/runner-github/orgs/{}/runners?github_installation_id={}",
1670 organization_login, installation_id
1671 );
1672 if let Some(name) = name.map(str::trim).filter(|value| !value.is_empty()) {
1673 path.push_str("&name=");
1674 path.push_str(name);
1675 }
1676 let response: RunnerGitHubListResponse =
1677 api_json(Method::GET, &path, &ApiTargetOptions::default(), None).await?;
1678 Ok(response.runners)
1679}
1680
1681async fn delete_remote_runner(
1682 organization_login: &str,
1683 installation_id: &str,
1684 runner_id: i64,
1685) -> Result<(), String> {
1686 let _ = api_json_status(
1687 Method::DELETE,
1688 &format!(
1689 "/runner-github/orgs/{}/runners/{}?github_installation_id={}",
1690 organization_login, runner_id, installation_id
1691 ),
1692 &ApiTargetOptions::default(),
1693 None,
1694 )
1695 .await?;
1696 Ok(())
1697}
1698
1699fn run_command(executable: &str, args: &[&str], cwd: Option<&Path>) -> Result<(), String> {
1700 let mut command = Command::new(executable);
1701 command.args(args);
1702 if let Some(cwd) = cwd {
1703 command.current_dir(cwd);
1704 }
1705 command.stdout(Stdio::piped()).stderr(Stdio::piped());
1706 let output = command
1707 .output()
1708 .map_err(|error| format!("Failed to start `{}`: {}", executable, error))?;
1709 if output.status.success() {
1710 return Ok(());
1711 }
1712 Err(format!(
1713 "`{}` failed with status {}: {}",
1714 executable,
1715 output
1716 .status
1717 .code()
1718 .map(|value| value.to_string())
1719 .unwrap_or_else(|| "unknown".to_string()),
1720 String::from_utf8_lossy(&output.stderr).trim()
1721 ))
1722}
1723
1724fn ensure_executable(path: PathBuf) -> Result<(), String> {
1725 if !path.exists() || cfg!(windows) {
1726 return Ok(());
1727 }
1728 let mut permissions = fs::metadata(&path)
1729 .map_err(|error| format!("Failed to read metadata for {}: {}", path.display(), error))?
1730 .permissions();
1731 #[cfg(unix)]
1732 {
1733 use std::os::unix::fs::PermissionsExt;
1734 permissions.set_mode(0o755);
1735 fs::set_permissions(&path, permissions).map_err(|error| {
1736 format!(
1737 "Failed to set executable permissions on {}: {}",
1738 path.display(),
1739 error
1740 )
1741 })?;
1742 }
1743 Ok(())
1744}
1745
1746fn read_configured_runner_name(root: &Path) -> Option<String> {
1747 let runner_file = root.join(".runner");
1748 let content = fs::read_to_string(runner_file).ok()?;
1749 let payload: Value = serde_json::from_str(&content).ok()?;
1750 payload
1751 .get("agentName")
1752 .and_then(Value::as_str)
1753 .map(ToString::to_string)
1754}
1755
1756fn resolve_runner_name(host: &RunnerHostRecord, payload: &LocalRunnerJobPayload) -> String {
1757 payload
1758 .runner_name
1759 .clone()
1760 .filter(|value| !value.trim().is_empty())
1761 .unwrap_or_else(|| {
1762 format!(
1763 "{}-{}",
1764 slugify(&host.runner_name_prefix),
1765 slugify(&host.hostname)
1766 )
1767 })
1768}
1769
1770fn command_available(executable: &str, args: &[&str]) -> bool {
1771 Command::new(executable)
1772 .args(args)
1773 .output()
1774 .map(|output| output.status.success())
1775 .unwrap_or(false)
1776}
1777
1778fn detect_service_manager(platform: RunnerPlatform) -> RunnerServiceManager {
1779 match platform {
1780 RunnerPlatform::Linux => {
1781 if command_available("systemctl", &["--version"]) {
1782 RunnerServiceManager::Systemd
1783 } else {
1784 RunnerServiceManager::Unknown
1785 }
1786 }
1787 RunnerPlatform::Macos => {
1788 if command_available("launchctl", &["list"]) {
1789 RunnerServiceManager::Launchd
1790 } else {
1791 RunnerServiceManager::Unknown
1792 }
1793 }
1794 RunnerPlatform::Windows => RunnerServiceManager::WindowsService,
1795 }
1796}
1797
1798fn service_file_path(root: &Path, service_manager: RunnerServiceManager) -> PathBuf {
1799 match service_manager {
1800 RunnerServiceManager::Systemd => root.join("systemd").join("xbp-github-runner.service"),
1801 RunnerServiceManager::Launchd => root.join("launchd").join("com.xbp.github-runner.plist"),
1802 RunnerServiceManager::WindowsService => {
1803 root.join("windows").join("install-runner-service.ps1")
1804 }
1805 RunnerServiceManager::DockerCompose => root.join("compose.yaml"),
1806 RunnerServiceManager::Unknown => root.join("runner-service.txt"),
1807 }
1808}
1809
1810fn dockerfile_contents() -> &'static str {
1811 "FROM myoung34/github-runner:latest\n\n# Generated by xbp runners agent.\n"
1812}
1813
1814fn docker_compose_contents(
1815 _host: &RunnerHostRecord,
1816 payload: &LocalRunnerJobPayload,
1817 runtime_root: &Path,
1818 runner_name: &str,
1819 registration_token: &str,
1820 organization_login: &str,
1821) -> String {
1822 format!(
1823 "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",
1824 org = organization_login,
1825 runner_name = runner_name,
1826 group = payload.runner_group.clone().unwrap_or_else(|| "Default".to_string()),
1827 labels = payload.labels.join(","),
1828 token = registration_token,
1829 ephemeral = if payload.ephemeral { "true" } else { "false" },
1830 root = runtime_root.display(),
1831 )
1832}
1833
1834#[allow(dead_code)]
1835fn native_service_contents(
1836 host: &RunnerHostRecord,
1837 payload: &LocalRunnerJobPayload,
1838 runtime_root: &Path,
1839 service_manager: RunnerServiceManager,
1840) -> String {
1841 match service_manager {
1842 RunnerServiceManager::Systemd => format!(
1843 "[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",
1844 host = host.hostname,
1845 root = runtime_root.display(),
1846 group = payload.runner_group.clone().unwrap_or_else(|| "Default".to_string()),
1847 ),
1848 RunnerServiceManager::Launchd => format!(
1849 "<?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",
1850 runtime_root.display()
1851 ),
1852 RunnerServiceManager::WindowsService => format!(
1853 "Write-Host \"Install GitHub runner service for {} in {}\"\n",
1854 host.hostname,
1855 runtime_root.display()
1856 ),
1857 RunnerServiceManager::DockerCompose => docker_compose_contents(
1858 host,
1859 payload,
1860 runtime_root,
1861 "github-runner",
1862 "registration-token",
1863 payload
1864 .organization_login
1865 .as_deref()
1866 .unwrap_or(&host.organization_id),
1867 ),
1868 RunnerServiceManager::Unknown => "XBP runner service manager could not be detected on this host.\n".to_string(),
1869 }
1870}
1871
1872fn format_startup_summary(
1873 host: &RunnerHostRecord,
1874 payload: &LocalRunnerJobPayload,
1875 runtime_state: &RunnerRuntimeState,
1876 runner_name: &str,
1877) -> String {
1878 format!(
1879 "Runner host: {host}\nRunner name: {runner_name}\nRuntime: {runtime}\nService manager: {manager}\nService status: {status}\nRunner group: {group}\nLabels: {labels}\n",
1880 host = host.hostname,
1881 runner_name = runner_name,
1882 runtime = payload.runtime,
1883 manager = runtime_state.service_manager,
1884 status = runtime_state.service_status,
1885 group = payload.runner_group.clone().unwrap_or_else(|| "Default".to_string()),
1886 labels = if payload.labels.is_empty() {
1887 "none".to_string()
1888 } else {
1889 payload.labels.join(", ")
1890 }
1891 )
1892}
1893
1894fn color_status(status: &str) -> String {
1895 match status.trim().to_ascii_lowercase().as_str() {
1896 "online" | "running" | "succeeded" | "synced" | "allowed" => status.green().to_string(),
1897 "queued" | "claimed" | "enrolled" | "installed" => status.yellow().to_string(),
1898 "offline" | "failed" | "error" | "cancelled" => status.red().to_string(),
1899 _ => status.normal().to_string(),
1900 }
1901}
1902
1903fn print_table(headers: &[&str], rows: &[Vec<String>]) {
1904 let mut widths = headers
1905 .iter()
1906 .map(|header| header.len())
1907 .collect::<Vec<_>>();
1908 for row in rows {
1909 for (index, cell) in row.iter().enumerate() {
1910 widths[index] = widths[index].max(strip_ansi(cell).len());
1911 }
1912 }
1913
1914 let header_line = headers
1915 .iter()
1916 .enumerate()
1917 .map(|(index, header)| format!("{:width$}", header.bold(), width = widths[index]))
1918 .collect::<Vec<_>>()
1919 .join(" | ");
1920 println!("{}", header_line);
1921 println!(
1922 "{}",
1923 widths
1924 .iter()
1925 .map(|width| "-".repeat(*width))
1926 .collect::<Vec<_>>()
1927 .join("-+-")
1928 );
1929 for row in rows {
1930 let rendered = row
1931 .iter()
1932 .enumerate()
1933 .map(|(index, cell)| pad_ansi(cell, widths[index]))
1934 .collect::<Vec<_>>()
1935 .join(" | ");
1936 println!("{}", rendered);
1937 }
1938}
1939
1940fn strip_ansi(value: &str) -> String {
1941 let mut out = String::with_capacity(value.len());
1942 let mut chars = value.chars().peekable();
1943 while let Some(ch) = chars.next() {
1944 if ch == '\u{1b}' && chars.peek() == Some(&'[') {
1945 let _ = chars.next();
1946 for next in chars.by_ref() {
1947 if ('@'..='~').contains(&next) {
1948 break;
1949 }
1950 }
1951 continue;
1952 }
1953 out.push(ch);
1954 }
1955 out
1956}
1957
1958fn pad_ansi(value: &str, width: usize) -> String {
1959 let visible = strip_ansi(value).len();
1960 if visible >= width {
1961 value.to_string()
1962 } else {
1963 format!("{value}{:pad$}", "", pad = width - visible)
1964 }
1965}
1966
1967fn read_json_string(value: &Value, key: &str) -> String {
1968 value
1969 .get(key)
1970 .and_then(Value::as_str)
1971 .unwrap_or("-")
1972 .to_string()
1973}