Skip to main content

xbp_cli/commands/
runners_runtime.rs

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", &params)
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            &registration_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                &registration_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(&current_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}