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(
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            &registration_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                &registration_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(&current_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}