1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
//! HTTP client + provisioning orchestrator. Polls the cirun api for runners
//! to provision/delete, dispatches via the executor registry, tracks retries
//! and per-runner executor binding.
use crate::api::{AgentInfo, ApiResponse, RunnerToProvision};
use crate::provision::{provision_single_runner, ProvisionResult};
use log::{debug, error, info, warn};
use reqwest::{Client, Error};
use serde_json::json;
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use uuid::Uuid;
// Client for interacting with the CiRun API
pub struct CirunClient {
client: Client,
base_url: String,
api_token: String,
agent: AgentInfo,
/// Per-runner attempt counter. Mutexed for the same reason `in_flight`
/// is: the `ProvisionReporter` impl takes `&self`, so internal state
/// it mutates needs interior mutability. Lock contention is minimal —
/// only one task touches the map at a time in practice (sequential
/// `try_join_next` drain).
pub retry_tracker: std::sync::Mutex<HashMap<String, u32>>,
/// None means no limit, Some(n) means max n concurrent VMs
max_runners: Option<u32>,
/// Per-runner executor binding, learned at provision time. Cleanup/delete
/// paths consult this map instead of guessing from env vars.
/// Mutex (not just inner map) because lookups happen behind `&self` and
/// inserts behind `&mut self` from the main loop.
pub runner_executors: std::sync::Mutex<HashMap<String, crate::executor::ExecutorKind>>,
/// Names of runners currently being provisioned by this agent. Used to
/// suppress racing delete requests: if the api sends a
/// `runners_to_delete` for a name still in `in_flight`, the agent ignores
/// it instead of killing the half-built VM mid-spawn.
pub in_flight: std::sync::Mutex<std::collections::HashSet<String>>,
/// Executors available on this host, probed once at startup. `Arc` so it
/// can be cheaply cloned into per-runner provision tasks.
pub registry: Arc<crate::executor::registry::Registry>,
}
impl CirunClient {
pub fn new(
base_url: &str,
api_token: &str,
agent: AgentInfo,
max_runners: Option<u32>,
executor_filter: &crate::executor::ExecutorFilter,
) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(15))
.connect_timeout(Duration::from_secs(10))
.build()
.expect("Failed to build HTTP client");
CirunClient {
client,
base_url: base_url.to_string(),
api_token: api_token.to_string(),
agent,
retry_tracker: std::sync::Mutex::new(HashMap::new()),
max_runners,
runner_executors: std::sync::Mutex::new(HashMap::new()),
in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
registry: Arc::new(crate::executor::registry::Registry::probe_filtered(
executor_filter,
)),
}
}
/// Whether this agent can take a given runner. Three conditions must
/// hold: the runner's executor must resolve, the agent must actually
/// have that executor registered, and the executor must produce the
/// requested `runner.os`.
///
/// The old gate was `runner.os == agent.os`, which silently dropped
/// linux jobs from macOS agents running Docker Desktop (issue #14).
/// The new gate ignores the host OS and asks the executor what OS it
/// can serve — Docker on macOS can serve linux runners, meda is
/// linux-only, lume is macOS-only.
///
/// Why the gate still matters: when the api fans the same runner out
/// by executor capability alone, a mismatched dispatch would attempt
/// provision, fail (image arch mismatch, CPU bounds, etc.), and the
/// racing failure notification would orphan-delete the agent that did
/// accept the work.
fn is_runner_dispatchable(&self, r: &RunnerToProvision) -> bool {
let kind = match crate::executor::resolve_executor_kind(
r.executor.as_deref(),
r.extra_config.as_ref(),
&r.os,
) {
Ok(k) => k,
// Let the provision flow surface the misconfig with full context.
Err(_) => return true,
};
if self.registry.get(kind).is_err() {
debug!(
"Skipping runner '{}' — executor {:?} not available on this host",
r.name, kind
);
return false;
}
if !crate::executor::executor_serves_os(kind, &r.os) {
debug!(
"Skipping runner '{}' — executor {:?} cannot serve runner.os={}",
r.name, kind, r.os
);
return false;
}
true
}
/// Best-effort lookup of which executor owns a runner. Falls back to the
/// OS-default when the map has no entry (e.g. agent restarted between
/// provision and cleanup, or runner was created by an older agent build).
/// `seed_runner_executors_from_registry` should be called at startup to
/// reduce the chance of the fallback firing on a fresh process.
fn executor_for_runner(&self, runner_name: &str) -> crate::executor::ExecutorKind {
if let Ok(map) = self.runner_executors.lock() {
if let Some(k) = map.get(runner_name) {
return *k;
}
}
// Log the fallback so an operator can spot it in journald — silent
// mis-routing was a real risk per the round-1 review.
warn!(
"runner '{}' has no executor binding; falling back to OS default",
runner_name
);
match env::consts::OS {
"macos" => crate::executor::ExecutorKind::Lume,
_ => crate::executor::ExecutorKind::Meda,
}
}
/// Populate `runner_executors` from the registry's view of the world.
/// Call at startup so an agent restart with in-flight runners doesn't
/// silently mis-route deletes to the wrong executor. Best-effort: per-
/// executor list failures are logged and skipped.
pub async fn seed_runner_executors_from_registry(&mut self) {
let runners = self.registry.list_all().await;
let mut count = 0usize;
if let Ok(mut map) = self.runner_executors.lock() {
for (kind, runner) in runners {
if runner.name.starts_with("cirun-") {
map.insert(runner.name, kind);
count += 1;
}
}
}
info!(
"Seeded runner_executors map with {} entries from live executors",
count
);
}
// Helper method to create a request builder with common headers
fn create_request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
let request_id = Uuid::new_v4().to_string();
info!("Creating request with ID: {}", request_id);
self.client
.request(method, url)
.header("Authorization", format!("Bearer {}", self.api_token))
.header("X-Request-ID", request_id)
.header("X-Agent-ID", &self.agent.id)
}
async fn handle_orphaned_runners(&self, response: reqwest::Response) {
// Parse response for runners_to_delete (orphaned VMs)
match response.json::<ApiResponse>().await {
Ok(api_response) => {
if !api_response.runners_to_delete.is_empty() {
info!(
"API returned {} orphaned runners to delete from POST",
api_response.runners_to_delete.len()
);
let in_flight = self.in_flight_snapshot();
for runner in &api_response.runners_to_delete {
// Same race-protection as the GET-path delete loop:
// skip orphan-cleanup for runners we are still
// building. cirun api may class a runner as orphan
// before its provision flow has finished here, and
// killing the half-built VM mid-spawn produces the
// "disappeared during settle" error.
if in_flight.contains(&runner.name) {
warn!(
"ignoring SaaS orphan-delete for '{}' — provision in flight on this agent",
runner.name
);
continue;
}
match self.delete_runner(&runner.name).await {
Ok(_) => {
info!("[OK] Successfully deleted orphaned runner: {}", runner.name);
}
Err(e) => {
error!("✘ Failed to delete orphaned runner {}: {}", runner.name, e)
}
}
}
}
}
Err(e) => {
info!(
"No runners_to_delete in POST response or parse error: {}",
e
);
}
}
}
pub async fn report_running_vms(&self) {
use crate::executor::ExecutorKind;
info!("Reporting running VMs to API");
let all = self.registry.list_all().await;
let vms: Vec<_> = all
.into_iter()
.filter(|(_, r)| r.name.starts_with("cirun-"))
.map(|(k, r)| {
let os = match k {
ExecutorKind::Docker | ExecutorKind::Meda => "linux",
ExecutorKind::Lume => "macos",
};
json!({
"name": r.name,
"os": os,
"cpu": 0,
"memory": 0,
"disk_size": 0,
})
})
.collect();
let url = format!("{}/agent", self.base_url);
match self
.create_request(reqwest::Method::POST, &url)
.json(&json!({ "agent": self.agent, "vms": vms }))
.send()
.await
{
Ok(response) => {
info!("API response status: {}", response.status());
self.handle_orphaned_runners(response).await;
}
Err(e) => error!("Failed to send running VMs: {}", e),
}
}
async fn delete_runner(&self, runner_name: &str) -> Result<(), Box<dyn std::error::Error>> {
let kind = self.executor_for_runner(runner_name);
info!(
"Attempting to delete runner '{}' via {:?} executor",
runner_name, kind
);
// Binding is intentionally NOT removed on success. The cirun api may
// re-request a delete for the same runner across consecutive polling
// cycles (POST orphan-list then GET runners_to_delete); dropping the
// binding would force the second request through the OS-default
// fallback (Lume on macOS / Meda on Linux) which then fails with
// "not found" and burns the retry budget. Every executor's `kill` is
// idempotent on "already gone", so keeping the binding is safe.
self.registry
.get(kind)
.map_err(|e| -> Box<dyn std::error::Error> {
Box::new(std::io::Error::other(e.to_string()))
})?
.kill(runner_name)
.await
.map_err(|e| -> Box<dyn std::error::Error> {
Box::new(std::io::Error::other(e.to_string()))
})
}
/// Get the current retry count for a runner. Returns 0 on a poisoned
/// mutex — better to under-report and let the runner be re-tried
/// than to crash the agent.
fn get_retry_count(&self, runner_name: &str) -> u32 {
self.retry_tracker
.lock()
.map(|m| m.get(runner_name).copied().unwrap_or(0))
.unwrap_or(0)
}
/// Increment the retry count for a runner and return the new count.
/// Called from the `ProvisionReporter::report` impl when a runner
/// hits a real (non-admission) failure.
pub(crate) fn increment_retry(&self, runner_name: &str) -> u32 {
match self.retry_tracker.lock() {
Ok(mut m) => {
let count = m.entry(runner_name.to_string()).or_insert(0);
*count += 1;
*count
}
Err(_) => 1, // mutex poisoned; treat as "first attempt"
}
}
/// Clear the retry count for a runner (success path).
pub(crate) fn clear_retry(&self, runner_name: &str) {
if let Ok(mut m) = self.retry_tracker.lock() {
m.remove(runner_name);
}
}
/// Check if a runner should be retried based on max_retries
fn should_retry(&self, runner_name: &str, max_retries: u32) -> bool {
self.get_retry_count(runner_name) < max_retries
}
/// POST a generic `AgentEvent` to the cirun-go backend. ONE entry
/// point for all agent → backend observability — cirun-go reads
/// `event.kind` and dispatches per its own action table (update
/// check run, bump DB retry counter, log, …). Adding a new event
/// kind on the agent side means: add an `EventKind` variant in
/// `src/reporting.rs`, add a `to_agent_event` arm, mirror the kind
/// string on cirun-go. No new HTTP route. No new payload schema.
///
/// `pub(crate)` because the public entry point is the
/// `ProvisionReporter::report` trait method; direct callers would
/// bypass the retry-counter policy that's coupled to certain
/// event kinds.
pub(crate) async fn notify_event(&self, event: &crate::reporting::AgentEvent) {
let url = format!("{}/agent", self.base_url);
info!(
"Emitting agent event runner={} kind={:?} severity={:?}: {}",
event.runner_name, event.kind, event.severity, event.title
);
let request_data = json!({
"agent": self.agent,
"event": event,
});
match self
.create_request(reqwest::Method::POST, &url)
.json(&request_data)
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
debug!("Successfully posted agent event");
} else {
warn!(
"API returned non-success status for agent event: {}",
response.status()
);
}
}
Err(e) => {
warn!("Failed to post agent event: {}", e);
}
}
}
/// Snapshot of the in-flight provision name set. Held briefly under the
/// mutex; callers that need to hold the lock longer should grab the guard
/// directly. Returns an empty set if the mutex is poisoned (best-effort).
fn in_flight_snapshot(&self) -> std::collections::HashSet<String> {
self.in_flight.lock().map(|s| s.clone()).unwrap_or_default()
}
pub async fn manage_runner_lifecycle(
&mut self,
provision_set: &mut JoinSet<ProvisionResult>,
in_flight: &mut std::collections::HashSet<String>,
) -> Result<ApiResponse, Error> {
let url = format!("{}/agent", self.base_url);
info!("Fetching runner provision/deletion data from: {}", url);
// `executors` advertises which runtimes this agent can serve, so SaaS
// can route by capability instead of host OS. Lets a single Mac host
// pick up both linux/docker and macos/lume jobs.
let request_data = json!({
"agent": self.agent,
"executors": self.registry.kind_names(),
});
let response = self
.create_request(reqwest::Method::GET, &url)
.json(&request_data)
.send()
.await?;
info!("Response status: {}", response.status());
let json: ApiResponse = response.json().await?;
// Handle any runners that need deletion
if !json.runners_to_delete.is_empty() {
info!(
"Received {} runners to delete",
json.runners_to_delete.len()
);
for runner in &json.runners_to_delete {
// Skip delete if this runner is currently being provisioned
// by THIS agent. The cirun api occasionally orphan-cleans a
// runner before the agent has finished its create flow
// (observed 2026-05-15 with meda: VM was created, deleted
// mid-spawn, then `inspect` returned 500 → "disappeared
// during settle"). The provision flow itself emits
// `notify_provision_failure` on real errors, so SaaS will
// re-evaluate; ignoring the racing delete keeps the
// half-built VM alive long enough to either finish or fail
// for a real reason.
if in_flight.contains(&runner.name) {
warn!(
"ignoring SaaS delete request for '{}' — provision in flight on this agent",
runner.name
);
continue;
}
match self.delete_runner(&runner.name).await {
Ok(_) => {
info!("[OK] Successfully deleted runner: {}", runner.name);
self.report_running_vms().await;
}
Err(e) => error!("✘ Failed to delete runner {}: {}", runner.name, e),
}
}
}
// Handle runners that need provisioning
if !json.runners_to_provision.is_empty() {
info!(
"Received {} runners to provision",
json.runners_to_provision.len()
);
// First, handle retry-exhausted runners (notify API, skip them).
// Same agent-event funnel used elsewhere: emit a
// ProvisionEvent::Failed and let the reporter impl build the
// wire payload so cirun-go gets the consistent shape.
for runner in &json.runners_to_provision {
let current_attempts = self.get_retry_count(&runner.name);
if !self.should_retry(&runner.name, runner.max_retries) {
warn!(
"Runner '{}' has exceeded max retries ({}/{}). Skipping provisioning.",
runner.name, current_attempts, runner.max_retries
);
use crate::reporting::ProvisionReporter;
self.report(crate::reporting::ProvisionEvent::Failed {
runner_name: runner.name.clone(),
error: format!("Exceeded max retries ({})", runner.max_retries),
diagnostics: serde_json::Map::new(),
})
.await;
}
}
// Collect eligible runners (not retry-exhausted, not already
// in-flight, AND served by an executor we actually have).
// The capability filter is the important one: when the api
// fans the same runner out to multiple agents, an agent that
// can't serve it must drop it silently. Returning a
// ProvisionFailure for a capability mismatch causes the api
// to mark the runner failed and then issue an orphan-delete
// — which can race with the WINNING agent's still-running VM
// and tear it down mid-job. Filtering here means the api only
// ever hears from agents that can actually do the work.
let eligible_runners: Vec<RunnerToProvision> = json
.runners_to_provision
.iter()
.filter(|r| self.should_retry(&r.name, r.max_retries))
.filter(|r| {
if in_flight.contains(&r.name) {
info!("Skipping runner '{}' — already in-flight", r.name);
false
} else {
true
}
})
.filter(|r| self.is_runner_dispatchable(r))
.cloned()
.collect();
if !eligible_runners.is_empty() {
// Calculate available slots based on runner capacity
let available_slots = if let Some(max_runners) = self.max_runners {
let running_count = self.registry.total_count_running().await;
let slots = (max_runners as usize).saturating_sub(running_count);
info!(
"Runner capacity: {}/{} running, {} slots available, {} requested",
running_count,
max_runners,
slots,
eligible_runners.len()
);
if slots == 0 {
info!("No runner slots available. Runners will be picked up on next poll.");
}
slots
} else {
eligible_runners.len()
};
if available_slots > 0 {
// Cap runners to available slots
let runners_to_spawn: Vec<RunnerToProvision> =
eligible_runners.into_iter().take(available_slots).collect();
info!(
"Spawning {} runners in parallel (max concurrency: {})",
runners_to_spawn.len(),
available_slots
);
let semaphore = Arc::new(Semaphore::new(available_slots));
for runner in runners_to_spawn {
in_flight.insert(runner.name.clone());
// Mirror into self.in_flight so the orphan-delete
// path (handle_orphaned_runners, called from the
// POST report-running-vms response) can also see
// it without taking the local arg.
if let Ok(mut s) = self.in_flight.lock() {
s.insert(runner.name.clone());
}
let sem = semaphore.clone();
let reg = Arc::clone(&self.registry);
provision_set.spawn(provision_single_runner(runner, sem, reg));
}
info!(
"Spawned provisioning tasks. Total in-flight: {}",
provision_set.len()
);
}
}
}
Ok(json)
}
}
/// `ProvisionReporter` is the agent's single funnel for provision-outcome
/// observability (see `src/reporting.rs`). Every per-event policy
/// decision (which HTTP payload to send, whether to touch the retry
/// counter) lives in this impl — main.rs just calls `report(event)`.
///
/// Adding a new event type means: add a variant in `ProvisionEvent`,
/// add a match arm here. main.rs does not change.
#[async_trait::async_trait]
impl crate::reporting::ProvisionReporter for CirunClient {
async fn report(&self, event: crate::reporting::ProvisionEvent) {
use crate::reporting::{to_agent_event, ProvisionEvent};
// Per-event POLICY: retry-counter mutation. AtCapacity does NOT
// increment because the runner was never spawned (admission
// denial); the runner stays in the SaaS's `requested` pool to
// be re-fanned on the next poll. Succeeded clears any prior
// retry state. Failed bumps the counter for max_retries
// accounting and the attempt number rides on the wire event.
let attempt = match &event {
ProvisionEvent::Succeeded { runner_name } => {
self.clear_retry(runner_name);
0
}
ProvisionEvent::Failed { runner_name, .. } => self.increment_retry(runner_name),
ProvisionEvent::AtCapacity { .. } => 0,
};
// WIRE: build the generic AgentEvent and post it through the
// single observability funnel. Succeeded returns None today —
// the running-VMs heartbeat already tells SaaS the runner is up,
// so we skip the wire emit to avoid duplicate check-run noise.
if let Some(agent_event) = to_agent_event(&event, attempt) {
self.notify_event(&agent_event).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::executor::{
registry::Registry, Executor, ExecutorKind, OwnedRunner, ProvisionError, RunnerSpec,
RunnerState,
};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
/// Fake executor that records every kill() invocation and always reports
/// success. Used to drive `delete_runner` without touching docker/lume.
struct RecordingExecutor {
killed: StdMutex<Vec<String>>,
}
impl RecordingExecutor {
fn new() -> Self {
Self {
killed: StdMutex::new(Vec::new()),
}
}
}
#[async_trait]
impl Executor for RecordingExecutor {
fn settle_timeout(&self) -> Duration {
Duration::from_secs(1)
}
fn validate(&self, _spec: &RunnerSpec) -> Result<(), ProvisionError> {
Ok(())
}
async fn inspect(&self, _name: &str) -> Result<RunnerState, ProvisionError> {
Ok(RunnerState::Absent)
}
async fn spawn(&self, _spec: &RunnerSpec) -> Result<(), ProvisionError> {
Ok(())
}
async fn kill(&self, name: &str) -> Result<(), ProvisionError> {
self.killed.lock().unwrap().push(name.to_string());
Ok(())
}
async fn list_owned(&self) -> Result<Vec<OwnedRunner>, ProvisionError> {
Ok(Vec::new())
}
}
fn test_client(registry: Arc<Registry>) -> CirunClient {
CirunClient {
client: Client::builder().build().unwrap(),
base_url: "https://example.invalid".to_string(),
api_token: "tok".to_string(),
agent: AgentInfo {
id: "agent-x".into(),
hostname: "h".into(),
os: "linux".into(),
arch: "x86_64".into(),
},
retry_tracker: std::sync::Mutex::new(HashMap::new()),
max_runners: None,
runner_executors: std::sync::Mutex::new(HashMap::new()),
in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
registry,
}
}
/// Regression test for the binding-loss bug observed in prod 2026-05-15:
/// a POST-orphan-delete succeeded via Docker, but the binding was removed
/// from `runner_executors`. The very next GET cycle re-requested the same
/// delete; with no binding the routing fell back to the OS-default (Lume
/// on macOS), which then hit "VM not found" and burned the retry budget.
///
/// Successful deletes must preserve the binding so that idempotent re-
/// requests from the api route to the same executor — where `kill()` is
/// already a no-op when the runner is already gone.
#[tokio::test]
async fn delete_runner_preserves_binding_for_idempotent_retries() {
let mut execs: HashMap<ExecutorKind, Arc<dyn Executor>> = HashMap::new();
execs.insert(ExecutorKind::Docker, Arc::new(RecordingExecutor::new()));
let registry = Arc::new(Registry::from_executors(execs));
let client = test_client(registry);
client
.runner_executors
.lock()
.unwrap()
.insert("cirun-r1".into(), ExecutorKind::Docker);
client.delete_runner("cirun-r1").await.expect("delete ok");
let map = client.runner_executors.lock().unwrap();
assert_eq!(
map.get("cirun-r1"),
Some(&ExecutorKind::Docker),
"binding must persist after a successful delete so SaaS retries do not fall back to the OS-default executor"
);
}
fn make_runner(name: &str, runner_os: &str, executor: Option<&str>) -> RunnerToProvision {
RunnerToProvision {
name: name.into(),
provision_script: String::new(),
image: "ubuntu:24.04".into(),
os: runner_os.into(),
cpu: 2,
memory: 4,
disk: 20,
login: crate::api::RunnerLogin {
username: "runner".into(),
password: "p".into(),
},
max_retries: 3,
executor: executor.map(|s| s.to_string()),
gpu: None,
extra_config: None,
}
}
fn macos_test_client(registry: Arc<Registry>) -> CirunClient {
let mut c = test_client(registry);
c.agent.os = "macos".into();
c
}
/// Regression for issue #14 — a macOS agent that has Docker registered
/// must accept a `runner.os=linux` job dispatched with `executor=docker`.
/// Old gate compared runner.os to agent.os and dropped these silently.
#[tokio::test]
async fn macos_agent_with_docker_dispatches_linux_runner() {
let mut execs: HashMap<ExecutorKind, Arc<dyn Executor>> = HashMap::new();
execs.insert(ExecutorKind::Docker, Arc::new(RecordingExecutor::new()));
let registry = Arc::new(Registry::from_executors(execs));
let client = macos_test_client(registry);
let runner = make_runner("cirun-r1", "linux", Some("docker"));
assert!(
client.is_runner_dispatchable(&runner),
"Docker on macOS must serve linux runners (issue #14)"
);
}
/// Lume on macOS cannot run linux containers; an explicit lume+linux
/// dispatch must be rejected.
#[tokio::test]
async fn macos_agent_with_only_lume_drops_linux_runner() {
let mut execs: HashMap<ExecutorKind, Arc<dyn Executor>> = HashMap::new();
execs.insert(ExecutorKind::Lume, Arc::new(RecordingExecutor::new()));
let registry = Arc::new(Registry::from_executors(execs));
let client = macos_test_client(registry);
let runner = make_runner("cirun-r1", "linux", Some("lume"));
assert!(
!client.is_runner_dispatchable(&runner),
"lume cannot serve linux runners regardless of host"
);
}
/// Capability gate: a linux runner asking for docker on an agent that
/// has no docker registered must be dropped.
#[tokio::test]
async fn agent_without_requested_executor_drops_runner() {
let mut execs: HashMap<ExecutorKind, Arc<dyn Executor>> = HashMap::new();
execs.insert(ExecutorKind::Meda, Arc::new(RecordingExecutor::new()));
let registry = Arc::new(Registry::from_executors(execs));
let client = test_client(registry); // agent.os = linux
let runner = make_runner("cirun-r1", "linux", Some("docker"));
assert!(
!client.is_runner_dispatchable(&runner),
"agent without docker must not accept a docker-tagged runner"
);
}
/// Sanity: looking up a runner that was never bound falls back to the
/// OS-default and emits the fallback warning. Locks in the historical
/// behaviour the regression test above depends on.
#[test]
fn unbound_runner_routes_to_os_default() {
let registry = Arc::new(Registry::from_executors(HashMap::new()));
let client = test_client(registry);
let kind = client.executor_for_runner("nope");
let expected = match env::consts::OS {
"macos" => ExecutorKind::Lume,
_ => ExecutorKind::Meda,
};
assert_eq!(kind, expected);
}
}