1use std::collections::{BTreeMap, BTreeSet};
8use std::sync::Mutex;
9use std::time::Duration;
10
11use tokio::sync::{Notify, mpsc, oneshot};
12use tracing::{info, warn};
13
14use super::{Engine, Occupant};
15use crate::api;
16use crate::cache;
17use crate::config::Config;
18use crate::docker::DockerApi;
19use crate::github::GithubApi;
20use crate::isolation;
21use crate::scheduler::SlotRef;
22use crate::store::Store;
23use crate::sync::lock;
24
25pub(super) const CANCEL_GRACE: Duration = Duration::from_secs(90);
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
31pub enum Shutdown {
32 No,
34 Graceful,
36 Force,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum RunningJobs {
43 Keep,
45 Kill,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum Target {
52 Repository(String),
54 All,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
60pub enum ControlError {
61 #[error("{0}")]
63 NotFound(String),
64 #[error("{message}")]
66 Busy {
67 message: String,
69 jobs: Vec<api::RunningJob>,
71 },
72 #[error("{0}")]
74 Conflict(String),
75 #[error("{0}")]
77 Invalid(String),
78 #[error("{0}")]
80 Failed(String),
81 #[error("{0}")]
83 NotRecorded(String),
84 #[error("runnerd is stopping")]
86 Stopped,
87}
88
89#[derive(Debug)]
91pub enum Command {
92 Pause {
94 target: Target,
96 jobs: RunningJobs,
98 reply: oneshot::Sender<Result<api::Paused, ControlError>>,
100 },
101 Resume {
103 target: Target,
105 reply: oneshot::Sender<Result<(), ControlError>>,
107 },
108 Reseed {
110 profile: String,
112 reply: oneshot::Sender<Result<(), ControlError>>,
114 },
115 CleanCache {
117 name: String,
119 reply: oneshot::Sender<Result<(), ControlError>>,
121 },
122 ReplaceConfig {
124 config: Box<Config>,
126 memory_max: Option<u64>,
128 commit: Commit,
130 reply: oneshot::Sender<Result<Option<String>, ControlError>>,
132 },
133}
134
135pub struct Commit(Box<dyn FnOnce() -> Result<Option<String>, String> + Send>);
138
139impl Commit {
140 pub fn new(write: impl FnOnce() -> Result<Option<String>, String> + Send + 'static) -> Commit {
142 Commit(Box::new(write))
143 }
144
145 pub async fn run(self) -> Result<Option<String>, String> {
147 tokio::task::spawn_blocking(self.0)
148 .await
149 .unwrap_or_else(|join_error| Err(join_error.to_string()))
150 }
151}
152
153impl std::fmt::Debug for Commit {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 f.write_str("Commit")
156 }
157}
158
159#[derive(Debug)]
161pub struct Commands(mpsc::UnboundedReceiver<Command>);
162
163impl Commands {
164 pub async fn recv(&mut self) -> Option<Command> {
166 self.0.recv().await
167 }
168}
169
170#[derive(Debug)]
172pub struct Control {
173 repositories: Mutex<BTreeSet<String>>,
176 paused: Mutex<BTreeSet<String>>,
177 store: Store,
179 shutdown: Mutex<Shutdown>,
180 pub(super) wake: Notify,
181 commands: mpsc::UnboundedSender<Command>,
182}
183
184impl Control {
185 pub fn new(
189 repositories: impl IntoIterator<Item = String>,
190 paused: impl IntoIterator<Item = String>,
191 store: Store,
192 ) -> (Control, Commands) {
193 let repositories: BTreeSet<String> = repositories.into_iter().collect();
194 let paused: BTreeSet<String> = paused
195 .into_iter()
196 .filter(|repository| repositories.contains(repository))
197 .collect();
198 drop(store.paused(paused.iter().cloned().collect()));
200 let (commands, receiver) = mpsc::unbounded_channel();
201 let control = Control {
202 repositories: Mutex::new(repositories),
203 paused: Mutex::new(paused),
204 store,
205 shutdown: Mutex::new(Shutdown::No),
206 wake: Notify::new(),
207 commands,
208 };
209 (control, Commands(receiver))
210 }
211
212 pub fn mode(&self) -> Shutdown {
214 *lock(&self.shutdown)
215 }
216
217 pub async fn set_paused(&self, target: &Target, paused: bool) -> Result<(), ControlError> {
223 let recorded = {
224 let repositories = lock(&self.repositories);
225 let names: Vec<&String> = match target {
226 Target::Repository(repository) => {
227 let Some(name) = repositories.get(repository) else {
228 return Err(not_configured(repository, repositories.iter()));
229 };
230 vec![name]
231 }
232 Target::All => repositories.iter().collect(),
233 };
234 let mut set = lock(&self.paused);
235 for name in names {
236 if paused {
237 set.insert(name.clone());
238 } else {
239 set.remove(name);
240 }
241 }
242 self.store.paused(set.iter().cloned().collect())
244 };
245 self.wake.notify_one();
246 if recorded.await {
247 return Ok(());
248 }
249 let name = match target {
250 Target::Repository(repository) => repository.as_str(),
251 Target::All => "every repository",
252 };
253 let (done, undone) = if paused {
254 ("paused", "resumes it")
255 } else {
256 ("resumed", "pauses it again")
257 };
258 let message = format!(
259 "{name} is {done} until runnerd restarts, but the job history cannot record it, so a restart {undone}"
260 );
261 warn!("{message}");
262 Err(ControlError::NotRecorded(message))
263 }
264
265 fn set_repositories(&self, repositories: BTreeSet<String>) {
267 let mut configured = lock(&self.repositories);
268 let mut paused = lock(&self.paused);
269 let before = paused.len();
270 paused.retain(|repository| repositories.contains(repository));
271 if paused.len() != before {
272 drop(self.store.paused(paused.iter().cloned().collect()));
274 }
275 *configured = repositories;
276 }
277
278 pub fn shutdown(&self, jobs: RunningJobs) {
280 let mode = match jobs {
281 RunningJobs::Keep => Shutdown::Graceful,
282 RunningJobs::Kill => Shutdown::Force,
283 };
284 let mut shutdown = lock(&self.shutdown);
285 *shutdown = (*shutdown).max(mode);
286 drop(shutdown);
287 self.wake.notify_one();
288 }
289
290 pub fn is_paused(&self, repository: &str) -> bool {
292 lock(&self.paused).contains(repository)
293 }
294
295 async fn send<T>(
296 &self,
297 command: impl FnOnce(oneshot::Sender<T>) -> Command,
298 ) -> Result<T, ControlError> {
299 let (reply, answer) = oneshot::channel();
300 self.commands
301 .send(command(reply))
302 .map_err(|_| ControlError::Stopped)?;
303 answer.await.map_err(|_| ControlError::Stopped)
304 }
305
306 pub async fn pause(
310 &self,
311 target: Target,
312 jobs: RunningJobs,
313 ) -> Result<api::Paused, ControlError> {
314 self.send(|reply| Command::Pause {
315 target,
316 jobs,
317 reply,
318 })
319 .await?
320 }
321
322 pub async fn resume(&self, target: Target) -> Result<(), ControlError> {
325 self.send(|reply| Command::Resume { target, reply }).await?
326 }
327
328 pub async fn reseed(&self, profile: &str) -> Result<(), ControlError> {
330 self.send(|reply| Command::Reseed {
331 profile: profile.to_owned(),
332 reply,
333 })
334 .await?
335 }
336
337 pub async fn clean_cache(&self, name: &str) -> Result<(), ControlError> {
339 self.send(|reply| Command::CleanCache {
340 name: name.to_owned(),
341 reply,
342 })
343 .await?
344 }
345
346 pub async fn replace_config(
350 &self,
351 config: Config,
352 memory_max: Option<u64>,
353 commit: Commit,
354 ) -> Result<Option<String>, ControlError> {
355 self.send(|reply| Command::ReplaceConfig {
356 config: Box::new(config),
357 memory_max,
358 commit,
359 reply,
360 })
361 .await?
362 }
363}
364
365fn not_configured<'name>(
367 repository: &str,
368 configured: impl Iterator<Item = &'name String>,
369) -> ControlError {
370 let configured: Vec<&str> = configured.map(String::as_str).collect();
371 let configured = if configured.is_empty() {
372 "none is configured".to_owned()
373 } else {
374 format!("configured: {}", configured.join(", "))
375 };
376 ControlError::NotFound(format!(
377 "{repository} is not a configured repository; {configured}"
378 ))
379}
380
381fn list(jobs: &[api::RunningJob]) -> String {
383 jobs.iter()
384 .map(ToString::to_string)
385 .collect::<Vec<_>>()
386 .join(", ")
387}
388
389impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
390 pub(super) async fn command(&mut self, command: Command) {
392 match command {
393 Command::Pause {
394 target,
395 jobs,
396 reply,
397 } => {
398 let _ = reply.send(self.pause(&target, jobs).await);
399 }
400 Command::Resume { target, reply } => {
401 let _ = reply.send(self.control.set_paused(&target, false).await);
402 }
403 Command::Reseed { profile, reply } => {
404 let _ = reply.send(self.request_reseed(&profile));
405 }
406 Command::CleanCache { name, reply } => {
407 let _ = reply.send(self.clean_cache(&name).await);
408 }
409 Command::ReplaceConfig {
410 config,
411 memory_max,
412 commit,
413 reply,
414 } => {
415 let answer = match commit.run().await {
416 Ok(warning) => {
417 self.reload(*config, memory_max);
418 Ok(warning)
419 }
420 Err(message) => Err(ControlError::Failed(message)),
421 };
422 let _ = reply.send(answer);
423 }
424 }
425 }
426
427 pub(super) async fn pause(
431 &mut self,
432 target: &Target,
433 running: RunningJobs,
434 ) -> Result<api::Paused, ControlError> {
435 let repositories: BTreeSet<String> = match target {
436 Target::Repository(repository) if !self.repositories.contains_key(repository) => {
437 return Err(not_configured(repository, self.repositories.keys()));
438 }
439 Target::Repository(repository) => BTreeSet::from([repository.clone()]),
440 Target::All => self.repositories.keys().cloned().collect(),
441 };
442 self.take_found();
443 let runners: Vec<(SlotRef, Occupant)> = self
444 .occupied()
445 .filter(|(_, occupant)| repositories.contains(&occupant.repository))
446 .map(|(slot, occupant)| (slot, occupant.clone()))
447 .collect();
448 let jobs: Vec<api::RunningJob> = runners
449 .iter()
450 .filter(|(_, occupant)| occupant.busy || occupant.job.is_some())
451 .map(|(_, occupant)| occupant.running_job())
452 .collect();
453 if running == RunningJobs::Keep && !jobs.is_empty() {
454 let mut by_repository: BTreeMap<&str, Vec<api::RunningJob>> = BTreeMap::new();
455 for job in &jobs {
456 by_repository
457 .entry(&job.repository)
458 .or_default()
459 .push(job.clone());
460 }
461 let runs: Vec<String> = by_repository
462 .iter()
463 .map(|(repository, jobs)| format!("{repository} runs {}", list(jobs)))
464 .collect();
465 return Err(ControlError::Busy {
467 message: runs.join("; "),
468 jobs,
469 });
470 }
471 let recorded = self.control.set_paused(target, true).await;
473 match target {
474 Target::Repository(repository) => info!("{repository}: paused"),
475 Target::All => info!("paused every repository"),
476 }
477 let mut stopped = Vec::new();
478 if running == RunningJobs::Keep {
479 return recorded.map(|()| api::Paused { stopped });
480 }
481 for (slot, occupant) in runners {
482 let repository = &occupant.repository;
483 let cancelled = if let Some(job) = occupant.job {
484 self.cancel(repository, job).await
485 } else {
486 if occupant.busy {
487 warn!(
488 "{repository}: {} runs a job its log has not named yet; GitHub fails it once the runner is gone",
489 occupant.runner_name
490 );
491 }
492 false
493 };
494 if cancelled {
495 self.cancelling.insert(
497 occupant.runner_name.clone(),
498 tokio::time::Instant::now() + CANCEL_GRACE,
499 );
500 } else {
501 self.stop(&slot, RunningJobs::Kill).await;
502 if self
503 .occupied()
504 .any(|(_, candidate)| candidate.runner_name == occupant.runner_name)
505 {
506 continue;
507 }
508 }
509 stopped.push(occupant.running_job());
510 }
511 match recorded {
512 Ok(()) => Ok(api::Paused { stopped }),
513 Err(ControlError::NotRecorded(message)) if !stopped.is_empty() => Err(
514 ControlError::NotRecorded(format!("{message}; stopped {}", list(&stopped))),
515 ),
516 Err(error) => Err(error),
517 }
518 }
519
520 pub(super) async fn kill_cancelled(&mut self) {
522 let now = tokio::time::Instant::now();
523 let running: BTreeSet<String> = self
524 .occupied()
525 .map(|(_, occupant)| occupant.runner_name.clone())
526 .collect();
527 self.cancelling.retain(|name, _| running.contains(name));
528 let overdue: Vec<(SlotRef, String)> = self
529 .occupied()
530 .filter(|(_, occupant)| {
531 self.cancelling
532 .get(&occupant.runner_name)
533 .is_some_and(|deadline| *deadline <= now)
534 })
535 .map(|(slot, occupant)| (slot, occupant.runner_name.clone()))
536 .collect();
537 for (slot, name) in overdue {
538 warn!(
539 "{name} did not exit {}s after its run was cancelled",
540 CANCEL_GRACE.as_secs()
541 );
542 self.stop(&slot, RunningJobs::Kill).await;
544 }
545 }
546
547 async fn cancel(&self, repository: &str, job: u64) -> bool {
549 let run = match self.github.job(repository, job).await {
550 Ok(details) => details.run_id,
551 Err(error) => {
552 warn!("{repository}: cannot read job {job} to cancel its run: {error}");
553 return false;
554 }
555 };
556 match self.github.cancel_run(repository, run).await {
557 Ok(()) => {
558 info!("{repository}: cancelled run {run} of job {job}");
559 true
560 }
561 Err(error) => {
562 warn!("{repository}: cannot cancel run {run}: {error}");
563 false
564 }
565 }
566 }
567
568 pub(super) fn request_reseed(&mut self, profile: &str) -> Result<(), ControlError> {
570 if !self.config.profiles.contains_key(profile) {
571 return Err(ControlError::NotFound(format!(
572 "{profile} is not a configured profile"
573 )));
574 }
575 if self.is_reseeding(profile) {
576 return Err(ControlError::Conflict(format!(
577 "{profile} is reseeding already"
578 )));
579 }
580 info!("{profile}: reseeding once its slots are free");
581 self.reseed_requested.insert(profile.to_owned());
582 Ok(())
583 }
584
585 pub(super) async fn clean_cache(&mut self, name: &str) -> Result<(), ControlError> {
588 let Some(volume) = cache::volumes(&self.names, &self.config)
589 .into_iter()
590 .find(|volume| volume.name == name)
591 else {
592 return Err(ControlError::NotFound(format!(
593 "{name} is not a cache volume of this config; `runnerctl caches` lists them"
594 )));
595 };
596 let users: Vec<api::RunningJob> = match &volume.kind {
597 api::CacheKind::Snapshot { profile } => {
598 return Err(ControlError::Invalid(format!(
599 "{name} is {profile}'s snapshot, which every slot's layers sit on; reseed {profile} to rebuild it"
600 )));
601 }
602 api::CacheKind::Slot { profile, slot } => {
603 if self.reseeds.contains_key(profile) {
604 return Err(ControlError::Conflict(format!(
605 "{profile} is reseeding, which writes {name}"
606 )));
607 }
608 self.occupied()
609 .filter(|(at, _)| at.profile == *profile && at.index + 1 == *slot)
610 .map(|(_, occupant)| occupant.running_job())
611 .collect()
612 }
613 api::CacheKind::Pnpm | api::CacheKind::Maven | api::CacheKind::Mirror => self
614 .occupied()
615 .map(|(_, occupant)| occupant.running_job())
616 .collect(),
617 };
618 if !users.is_empty() {
619 return Err(ControlError::Busy {
620 message: format!(
621 "{name} is in use by {}; clean it once they finish",
622 list(&users)
623 ),
624 jobs: users,
625 });
626 }
627 if volume.kind == api::CacheKind::Mirror && self.mirror_up {
628 cache::mirror_down(&*self.docker, &self.names)
629 .await
630 .map_err(|error| {
631 ControlError::Failed(format!("cannot stop the mirror: {error}"))
632 })?;
633 self.mirror_up = false;
634 }
635 self.docker
636 .remove_volume(name)
637 .await
638 .map_err(|error| ControlError::Failed(format!("cannot remove {name}: {error}")))?;
639 info!("removed {name}");
640 self.trim_due = true;
642 Ok(())
643 }
644
645 pub(super) fn reload(&mut self, config: Config, memory_max: Option<u64>) {
649 let mut old = std::mem::replace(&mut self.config, config);
650 for (name, profile) in &self.config.profiles {
651 let slots = self.slots.entry(name.clone()).or_default();
652 if slots.len() < profile.slots as usize {
653 slots.resize(profile.slots as usize, None);
654 }
655 self.retired.remove(name);
656 }
657 for (name, profile) in std::mem::take(&mut old.profiles) {
658 if !self.config.profiles.contains_key(&name) {
659 self.retired.insert(name, profile);
660 }
661 }
662 self.drop_closed_slots();
663 let names: BTreeSet<String> = self
664 .config
665 .repositories
666 .iter()
667 .map(|repository| repository.name.clone())
668 .collect();
669 let removed: Vec<String> = self
670 .repositories
671 .keys()
672 .filter(|name| !names.contains(*name))
673 .cloned()
674 .collect();
675 self.draining.extend(removed);
676 self.draining.retain(|name| !names.contains(name));
677 self.repositories.retain(|name, _| names.contains(name));
678 for name in &names {
679 self.repositories.entry(name.clone()).or_default();
680 }
681 self.control.set_repositories(names);
682 let profiles = &self.config.profiles;
683 self.reseed_requested
684 .retain(|name| profiles.contains_key(name));
685 self.isolation = isolation::backend(self.config.host.isolation);
686 self.memory_max = memory_max;
687 self.caches
688 .send_replace(cache::volumes(&self.names, &self.config));
689 info!("reloaded the config");
690 }
691}