1use std::collections::{BTreeMap, BTreeSet};
9use std::sync::Mutex;
10use std::time::Duration;
11
12use tokio::sync::{Notify, mpsc, oneshot};
13use tracing::{info, warn};
14
15use super::records::RecordSelection;
16use super::{Engine, Occupant};
17use crate::api;
18use crate::cache;
19use crate::config::{ByteSize, Config};
20use crate::docker::{DockerApi, Tty, TtySize};
21use crate::github::GithubApi;
22use crate::isolation;
23use crate::scheduler::SlotRef;
24use crate::store::Store;
25use crate::sync::lock;
26
27pub(super) const CANCEL_GRACE: Duration = Duration::from_secs(90);
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub enum Shutdown {
34 No,
36 Graceful,
38 Force,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum RunningJobs {
45 Keep,
47 Kill,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum RunningSlots {
54 Drain,
56 Keep,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum Target {
63 Repository(String),
65 All,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
71pub enum ControlError {
72 #[error("{0}")]
74 NotFound(String),
75 #[error("{message}")]
77 Busy {
78 message: String,
80 jobs: Vec<api::RunningJob>,
82 },
83 #[error("{0}")]
85 Conflict(String),
86 #[error("{0}")]
88 Invalid(String),
89 #[error("{0}")]
91 Failed(String),
92 #[error("{0}")]
94 NotRecorded(String),
95 #[error("runnerd is stopping")]
97 Stopped,
98}
99
100#[derive(Debug)]
102pub enum Command {
103 Pause {
105 target: Target,
107 jobs: RunningJobs,
109 reply: oneshot::Sender<Result<api::Paused, ControlError>>,
111 },
112 Resume {
114 target: Target,
116 reply: oneshot::Sender<Result<(), ControlError>>,
118 },
119 Reseed {
121 profile: String,
123 reply: oneshot::Sender<Result<(), ControlError>>,
125 },
126 CleanCache {
128 name: String,
130 reply: oneshot::Sender<Result<(), ControlError>>,
132 },
133 ReplaceConfig {
135 config: Box<Config>,
137 memory_max: Option<u64>,
139 cpus: Option<u64>,
141 commit: Commit,
143 running: RunningSlots,
145 reply: oneshot::Sender<Result<Option<String>, ControlError>>,
147 },
148 RemoveRecords {
150 selection: RecordSelection,
152 reply: oneshot::Sender<Result<api::RemovedRecords, ControlError>>,
154 },
155 OpenShell {
157 job: u64,
159 size: TtySize,
161 reply: oneshot::Sender<Result<Tty, ControlError>>,
163 },
164 CloseShell {
166 container: String,
168 },
169}
170
171pub struct Commit(Box<dyn FnOnce() -> Result<Option<String>, String> + Send>);
174
175impl Commit {
176 pub fn new(write: impl FnOnce() -> Result<Option<String>, String> + Send + 'static) -> Commit {
178 Commit(Box::new(write))
179 }
180
181 pub async fn run(self) -> Result<Option<String>, String> {
183 tokio::task::spawn_blocking(self.0)
184 .await
185 .unwrap_or_else(|join_error| Err(join_error.to_string()))
186 }
187}
188
189impl std::fmt::Debug for Commit {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 f.write_str("Commit")
192 }
193}
194
195#[derive(Debug)]
197pub struct Commands(mpsc::UnboundedReceiver<Command>);
198
199impl Commands {
200 pub async fn recv(&mut self) -> Option<Command> {
202 self.0.recv().await
203 }
204}
205
206#[derive(Debug)]
208pub struct Control {
209 repositories: Mutex<BTreeSet<String>>,
212 paused: Mutex<BTreeSet<String>>,
213 store: Store,
215 shutdown: Mutex<Shutdown>,
216 pub(super) wake: Notify,
217 commands: mpsc::UnboundedSender<Command>,
218}
219
220impl Control {
221 pub fn new(
225 repositories: impl IntoIterator<Item = String>,
226 paused: impl IntoIterator<Item = String>,
227 store: Store,
228 ) -> (Control, Commands) {
229 let repositories: BTreeSet<String> = repositories.into_iter().collect();
230 let paused: BTreeSet<String> = paused
231 .into_iter()
232 .filter(|repository| repositories.contains(repository))
233 .collect();
234 drop(store.paused(paused.iter().cloned().collect()));
236 let (commands, receiver) = mpsc::unbounded_channel();
237 let control = Control {
238 repositories: Mutex::new(repositories),
239 paused: Mutex::new(paused),
240 store,
241 shutdown: Mutex::new(Shutdown::No),
242 wake: Notify::new(),
243 commands,
244 };
245 (control, Commands(receiver))
246 }
247
248 pub fn mode(&self) -> Shutdown {
250 *lock(&self.shutdown)
251 }
252
253 pub async fn set_paused(&self, target: &Target, paused: bool) -> Result<(), ControlError> {
259 let recorded = {
260 let repositories = lock(&self.repositories);
261 let names: Vec<&String> = match target {
262 Target::Repository(repository) => {
263 let Some(name) = repositories.get(repository) else {
264 return Err(not_configured(repository, repositories.iter()));
265 };
266 vec![name]
267 }
268 Target::All => repositories.iter().collect(),
269 };
270 let mut set = lock(&self.paused);
271 for name in names {
272 if paused {
273 set.insert(name.clone());
274 } else {
275 set.remove(name);
276 }
277 }
278 self.store.paused(set.iter().cloned().collect())
280 };
281 self.wake.notify_one();
282 if recorded.await {
283 return Ok(());
284 }
285 let name = match target {
286 Target::Repository(repository) => repository.as_str(),
287 Target::All => "every repository",
288 };
289 let (done, undone) = if paused {
290 ("paused", "resumes it")
291 } else {
292 ("resumed", "pauses it again")
293 };
294 let message = format!(
295 "{name} is {done} until runnerd restarts, but the job history cannot record it, so a restart {undone}"
296 );
297 warn!("{message}");
298 Err(ControlError::NotRecorded(message))
299 }
300
301 fn set_repositories(&self, repositories: BTreeSet<String>) {
303 let mut configured = lock(&self.repositories);
304 let mut paused = lock(&self.paused);
305 let before = paused.len();
306 paused.retain(|repository| repositories.contains(repository));
307 if paused.len() != before {
308 drop(self.store.paused(paused.iter().cloned().collect()));
310 }
311 *configured = repositories;
312 }
313
314 pub fn shutdown(&self, jobs: RunningJobs) {
316 let mode = match jobs {
317 RunningJobs::Keep => Shutdown::Graceful,
318 RunningJobs::Kill => Shutdown::Force,
319 };
320 let mut shutdown = lock(&self.shutdown);
321 *shutdown = (*shutdown).max(mode);
322 drop(shutdown);
323 self.wake.notify_one();
324 }
325
326 pub fn is_paused(&self, repository: &str) -> bool {
328 lock(&self.paused).contains(repository)
329 }
330
331 async fn send<T>(
332 &self,
333 command: impl FnOnce(oneshot::Sender<T>) -> Command,
334 ) -> Result<T, ControlError> {
335 let (reply, answer) = oneshot::channel();
336 self.commands
337 .send(command(reply))
338 .map_err(|_| ControlError::Stopped)?;
339 answer.await.map_err(|_| ControlError::Stopped)
340 }
341
342 pub async fn pause(
346 &self,
347 target: Target,
348 jobs: RunningJobs,
349 ) -> Result<api::Paused, ControlError> {
350 self.send(|reply| Command::Pause {
351 target,
352 jobs,
353 reply,
354 })
355 .await?
356 }
357
358 pub async fn resume(&self, target: Target) -> Result<(), ControlError> {
361 self.send(|reply| Command::Resume { target, reply }).await?
362 }
363
364 pub async fn reseed(&self, profile: &str) -> Result<(), ControlError> {
366 self.send(|reply| Command::Reseed {
367 profile: profile.to_owned(),
368 reply,
369 })
370 .await?
371 }
372
373 pub async fn clean_cache(&self, name: &str) -> Result<(), ControlError> {
375 self.send(|reply| Command::CleanCache {
376 name: name.to_owned(),
377 reply,
378 })
379 .await?
380 }
381
382 pub async fn remove_records(
384 &self,
385 selection: RecordSelection,
386 ) -> Result<api::RemovedRecords, ControlError> {
387 self.send(|reply| Command::RemoveRecords { selection, reply })
388 .await?
389 }
390
391 pub async fn open_shell(&self, job: u64, size: TtySize) -> Result<Tty, ControlError> {
394 self.send(|reply| Command::OpenShell { job, size, reply })
395 .await?
396 }
397
398 pub fn close_shell(&self, container: String) {
400 let _ = self.commands.send(Command::CloseShell { container });
401 }
402
403 pub async fn replace_config(
408 &self,
409 config: Config,
410 memory_max: Option<u64>,
411 cpus: Option<u64>,
412 commit: Commit,
413 running: RunningSlots,
414 ) -> Result<Option<String>, ControlError> {
415 self.send(|reply| Command::ReplaceConfig {
416 config: Box::new(config),
417 memory_max,
418 cpus,
419 commit,
420 running,
421 reply,
422 })
423 .await?
424 }
425}
426
427fn not_configured<'name>(
429 repository: &str,
430 configured: impl Iterator<Item = &'name String>,
431) -> ControlError {
432 let configured: Vec<&str> = configured.map(String::as_str).collect();
433 let configured = if configured.is_empty() {
434 "none is configured".to_owned()
435 } else {
436 format!("configured: {}", configured.join(", "))
437 };
438 ControlError::NotFound(format!(
439 "{repository} is not a configured repository; {configured}"
440 ))
441}
442
443fn list(jobs: &[api::RunningJob]) -> String {
445 jobs.iter()
446 .map(ToString::to_string)
447 .collect::<Vec<_>>()
448 .join(", ")
449}
450
451impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
452 pub(super) async fn command(&mut self, command: Command) {
454 match command {
455 Command::Pause {
456 target,
457 jobs,
458 reply,
459 } => {
460 let _ = reply.send(self.pause(&target, jobs).await);
461 }
462 Command::Resume { target, reply } => {
463 let _ = reply.send(self.control.set_paused(&target, false).await);
464 }
465 Command::Reseed { profile, reply } => {
466 let _ = reply.send(self.request_reseed(&profile));
467 }
468 Command::CleanCache { name, reply } => {
469 let _ = reply.send(self.clean_cache(&name).await);
470 }
471 Command::ReplaceConfig {
472 config,
473 memory_max,
474 cpus,
475 commit,
476 running,
477 reply,
478 } => {
479 if running == RunningSlots::Keep
480 && let Some(occupied) = self.occupied_past(&config)
481 {
482 let _ = reply.send(Err(ControlError::Conflict(occupied)));
483 return;
484 }
485 let answer = match commit.run().await {
486 Ok(warning) => {
487 self.reload(*config, memory_max, cpus);
488 Ok(warning)
489 }
490 Err(message) => Err(ControlError::Failed(message)),
491 };
492 let _ = reply.send(answer);
493 }
494 Command::RemoveRecords { selection, reply } => {
495 let _ = reply.send(self.remove_records(&selection).await);
496 }
497 Command::OpenShell { job, size, reply } => {
498 let opened = self.open_shell(job, size).await;
499 if let Err(Ok(tty)) = reply.send(opened) {
501 self.close_shell(&tty.container).await;
502 }
503 }
504 Command::CloseShell { container } => self.close_shell(&container).await,
505 }
506 }
507
508 pub(super) async fn pause(
512 &mut self,
513 target: &Target,
514 running: RunningJobs,
515 ) -> Result<api::Paused, ControlError> {
516 let repositories: BTreeSet<String> = match target {
517 Target::Repository(repository) if !self.repositories.contains_key(repository) => {
518 return Err(not_configured(repository, self.repositories.keys()));
519 }
520 Target::Repository(repository) => BTreeSet::from([repository.clone()]),
521 Target::All => self.repositories.keys().cloned().collect(),
522 };
523 self.take_found();
524 let runners: Vec<(SlotRef, Occupant)> = self
525 .occupied()
526 .filter(|(_, occupant)| repositories.contains(&occupant.repository))
527 .map(|(slot, occupant)| (slot, occupant.clone()))
528 .collect();
529 let jobs: Vec<api::RunningJob> = runners
530 .iter()
531 .filter(|(_, occupant)| occupant.busy || occupant.job.is_some())
532 .map(|(_, occupant)| occupant.running_job())
533 .collect();
534 if running == RunningJobs::Keep && !jobs.is_empty() {
535 let mut by_repository: BTreeMap<&str, Vec<api::RunningJob>> = BTreeMap::new();
536 for job in &jobs {
537 by_repository
538 .entry(&job.repository)
539 .or_default()
540 .push(job.clone());
541 }
542 let runs: Vec<String> = by_repository
543 .iter()
544 .map(|(repository, jobs)| format!("{repository} runs {}", list(jobs)))
545 .collect();
546 return Err(ControlError::Busy {
548 message: runs.join("; "),
549 jobs,
550 });
551 }
552 let recorded = self.control.set_paused(target, true).await;
554 match target {
555 Target::Repository(repository) => info!("{repository}: paused"),
556 Target::All => info!("paused every repository"),
557 }
558 let mut stopped = Vec::new();
559 if running == RunningJobs::Keep {
560 return recorded.map(|()| api::Paused { stopped });
561 }
562 for (slot, occupant) in runners {
563 let repository = &occupant.repository;
564 let cancelled = if let Some(job) = occupant.job {
565 self.cancel(repository, job).await
566 } else {
567 if occupant.busy {
568 warn!(
569 "{repository}: {} runs a job its log has not named yet; GitHub fails it once the runner is gone",
570 occupant.runner_name
571 );
572 }
573 false
574 };
575 if cancelled {
576 self.cancelling.insert(
578 occupant.runner_name.clone(),
579 tokio::time::Instant::now() + CANCEL_GRACE,
580 );
581 } else {
582 self.stop(&slot, RunningJobs::Kill).await;
583 if self
584 .occupied()
585 .any(|(_, candidate)| candidate.runner_name == occupant.runner_name)
586 {
587 continue;
588 }
589 }
590 stopped.push(occupant.running_job());
591 }
592 match recorded {
593 Ok(()) => Ok(api::Paused { stopped }),
594 Err(ControlError::NotRecorded(message)) if !stopped.is_empty() => Err(
595 ControlError::NotRecorded(format!("{message}; stopped {}", list(&stopped))),
596 ),
597 Err(error) => Err(error),
598 }
599 }
600
601 pub(super) async fn kill_cancelled(&mut self) {
603 let now = tokio::time::Instant::now();
604 let running: BTreeSet<String> = self
605 .occupied()
606 .map(|(_, occupant)| occupant.runner_name.clone())
607 .collect();
608 self.cancelling.retain(|name, _| running.contains(name));
609 let overdue: Vec<(SlotRef, String)> = self
610 .occupied()
611 .filter(|(_, occupant)| {
612 self.cancelling
613 .get(&occupant.runner_name)
614 .is_some_and(|deadline| *deadline <= now)
615 })
616 .map(|(slot, occupant)| (slot, occupant.runner_name.clone()))
617 .collect();
618 for (slot, name) in overdue {
619 warn!(
620 "{name} did not exit {}s after its run was cancelled",
621 CANCEL_GRACE.as_secs()
622 );
623 self.stop(&slot, RunningJobs::Kill).await;
625 }
626 }
627
628 async fn cancel(&self, repository: &str, job: u64) -> bool {
630 let run = match self.github.job(repository, job).await {
631 Ok(details) => details.run_id,
632 Err(error) => {
633 warn!("{repository}: cannot read job {job} to cancel its run: {error}");
634 return false;
635 }
636 };
637 match self.github.cancel_run(repository, run).await {
638 Ok(()) => {
639 info!("{repository}: cancelled run {run} of job {job}");
640 true
641 }
642 Err(error) => {
643 warn!("{repository}: cannot cancel run {run}: {error}");
644 false
645 }
646 }
647 }
648
649 pub(super) fn request_reseed(&mut self, profile: &str) -> Result<(), ControlError> {
651 if !self.config.profiles.contains_key(profile) {
652 return Err(ControlError::NotFound(format!(
653 "{profile} is not a configured profile"
654 )));
655 }
656 if self.is_reseeding(profile) {
657 return Err(ControlError::Conflict(format!(
658 "{profile} is reseeding already"
659 )));
660 }
661 if self.shell_profiles().contains(profile) {
663 return Err(ControlError::Conflict(format!(
664 "a shell is open in a record of {profile}, which mounts its snapshot; reseed it once the shell exits"
665 )));
666 }
667 info!("{profile}: reseeding once its slots are free");
668 self.reseed_requested.insert(profile.to_owned());
669 Ok(())
670 }
671
672 pub(super) async fn clean_cache(&mut self, name: &str) -> Result<(), ControlError> {
675 let Some(volume) = cache::volumes(&self.names, &self.config)
676 .into_iter()
677 .find(|volume| volume.name == name)
678 else {
679 return Err(ControlError::NotFound(format!(
680 "{name} is not a cache volume of this config; `runnerctl caches` lists them"
681 )));
682 };
683 let users: Vec<api::RunningJob> = match &volume.kind {
684 api::CacheKind::Snapshot { profile } => {
685 return Err(ControlError::Invalid(format!(
686 "{name} is {profile}'s snapshot, which every slot's layers sit on; reseed {profile} to rebuild it"
687 )));
688 }
689 api::CacheKind::Slot { profile, slot } => {
690 if self.reseeds.contains_key(profile) {
691 return Err(ControlError::Conflict(format!(
692 "{profile} is reseeding, which writes {name}"
693 )));
694 }
695 self.occupied()
696 .filter(|(at, _)| at.profile == *profile && at.index + 1 == *slot)
697 .map(|(_, occupant)| occupant.running_job())
698 .collect()
699 }
700 api::CacheKind::Pnpm
701 | api::CacheKind::Maven
702 | api::CacheKind::Mirror
703 | api::CacheKind::Build => self
704 .occupied()
705 .map(|(_, occupant)| occupant.running_job())
706 .collect(),
707 };
708 let shared = matches!(volume.kind, api::CacheKind::Pnpm | api::CacheKind::Maven);
709 if shared && !self.records.shells.is_empty() {
710 return Err(ControlError::Conflict(format!(
711 "a shell open in a record mounts {name}; clean it once the shell exits"
712 )));
713 }
714 if !users.is_empty() {
715 return Err(ControlError::Busy {
716 message: format!(
717 "{name} is in use by {}; clean it once they finish",
718 list(&users)
719 ),
720 jobs: users,
721 });
722 }
723 if volume.kind == api::CacheKind::Build {
724 let Some(cache) = self.build_cache.clone() else {
725 return Err(ControlError::Failed(
726 "the build cache did not open when runnerd started; its log says why"
727 .to_owned(),
728 ));
729 };
730 let trimmed = tokio::task::spawn_blocking(move || cache.clear())
731 .await
732 .map_err(|error| ControlError::Failed(format!("cannot empty {name}: {error}")))?;
733 info!(
734 "emptied {name}: {} entries, {}",
735 trimmed.before.entries,
736 ByteSize(trimmed.before.bytes)
737 );
738 self.trim_due = true;
739 return Ok(());
740 }
741 if volume.kind == api::CacheKind::Mirror && self.mirror_up {
742 cache::mirror_down(&*self.docker, &self.names)
743 .await
744 .map_err(|error| {
745 ControlError::Failed(format!("cannot stop the mirror: {error}"))
746 })?;
747 self.mirror_up = false;
748 }
749 self.docker
750 .remove_volume(name)
751 .await
752 .map_err(|error| ControlError::Failed(format!("cannot remove {name}: {error}")))?;
753 info!("removed {name}");
754 self.trim_due = true;
756 Ok(())
757 }
758
759 fn occupied_past(&self, config: &Config) -> Option<String> {
761 self.slots.iter().find_map(|(name, slots)| {
762 let kept = config
763 .profiles
764 .get(name)
765 .map_or(0, |profile| profile.slots as usize);
766 slots
767 .iter()
768 .enumerate()
769 .skip(kept)
770 .find(|(_, slot)| slot.is_some())
771 .map(|(index, _)| {
772 format!(
773 "{name}'s slot {} has a runner, and the new config keeps {kept} slots",
774 index + 1
775 )
776 })
777 })
778 }
779
780 pub(super) fn reload(&mut self, config: Config, memory_max: Option<u64>, cpus: Option<u64>) {
785 let mut old = std::mem::replace(&mut self.config, config);
786 if let Some(cache) = &self.build_cache {
787 cache.set_max_size(self.config.caches.build.max_size.0);
788 }
789 for (name, profile) in &self.config.profiles {
790 let slots = self.slots.entry(name.clone()).or_default();
791 if slots.len() < profile.slots as usize {
792 slots.resize(profile.slots as usize, None);
793 }
794 self.retired.remove(name);
795 }
796 for (name, profile) in std::mem::take(&mut old.profiles) {
797 if !self.config.profiles.contains_key(&name) {
798 self.retired.insert(name, profile);
799 }
800 }
801 self.drop_closed_slots();
802 self.images.stale_due = true;
803 let names: BTreeSet<String> = self
804 .config
805 .repositories
806 .iter()
807 .map(|repository| repository.name.clone())
808 .collect();
809 let removed: Vec<String> = self
810 .repositories
811 .keys()
812 .filter(|name| !names.contains(*name))
813 .cloned()
814 .collect();
815 self.draining.extend(removed);
816 self.draining.retain(|name| !names.contains(name));
817 self.repositories.retain(|name, _| names.contains(name));
818 for name in &names {
819 self.repositories.entry(name.clone()).or_default();
820 }
821 self.control.set_repositories(names);
822 let profiles = &self.config.profiles;
823 self.reseed_requested
824 .retain(|name| profiles.contains_key(name));
825 self.oversized.retain(|name| profiles.contains_key(name));
826 self.isolation = isolation::backend(self.config.host.isolation);
827 self.memory_max = memory_max;
828 self.cpus = cpus;
829 self.caches
830 .send_replace(cache::volumes(&self.names, &self.config));
831 info!("reloaded the config");
832 }
833}