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 Reseed {
103 profile: String,
105 reply: oneshot::Sender<Result<(), ControlError>>,
107 },
108 CleanCache {
110 name: String,
112 reply: oneshot::Sender<Result<(), ControlError>>,
114 },
115 ReplaceConfig {
117 config: Box<Config>,
119 memory_max: Option<u64>,
121 commit: Commit,
123 reply: oneshot::Sender<Result<Option<String>, ControlError>>,
125 },
126}
127
128pub struct Commit(Box<dyn FnOnce() -> Result<Option<String>, String> + Send>);
131
132impl Commit {
133 pub fn new(write: impl FnOnce() -> Result<Option<String>, String> + Send + 'static) -> Commit {
135 Commit(Box::new(write))
136 }
137
138 pub async fn run(self) -> Result<Option<String>, String> {
140 tokio::task::spawn_blocking(self.0)
141 .await
142 .unwrap_or_else(|join_error| Err(join_error.to_string()))
143 }
144}
145
146impl std::fmt::Debug for Commit {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.write_str("Commit")
149 }
150}
151
152#[derive(Debug)]
154pub struct Commands(mpsc::UnboundedReceiver<Command>);
155
156impl Commands {
157 pub async fn recv(&mut self) -> Option<Command> {
159 self.0.recv().await
160 }
161}
162
163#[derive(Debug)]
165pub struct Control {
166 repositories: Mutex<BTreeSet<String>>,
169 paused: Mutex<BTreeSet<String>>,
170 store: Store,
172 shutdown: Mutex<Shutdown>,
173 pub(super) wake: Notify,
174 commands: mpsc::UnboundedSender<Command>,
175}
176
177impl Control {
178 pub fn new(
182 repositories: impl IntoIterator<Item = String>,
183 paused: impl IntoIterator<Item = String>,
184 store: Store,
185 ) -> (Control, Commands) {
186 let repositories: BTreeSet<String> = repositories.into_iter().collect();
187 let paused: BTreeSet<String> = paused
188 .into_iter()
189 .filter(|repository| repositories.contains(repository))
190 .collect();
191 drop(store.paused(paused.iter().cloned().collect()));
193 let (commands, receiver) = mpsc::unbounded_channel();
194 let control = Control {
195 repositories: Mutex::new(repositories),
196 paused: Mutex::new(paused),
197 store,
198 shutdown: Mutex::new(Shutdown::No),
199 wake: Notify::new(),
200 commands,
201 };
202 (control, Commands(receiver))
203 }
204
205 pub fn mode(&self) -> Shutdown {
207 *lock(&self.shutdown)
208 }
209
210 pub async fn set_paused(&self, target: &Target, paused: bool) -> Result<(), ControlError> {
214 let recorded = {
215 let repositories = lock(&self.repositories);
216 let names: Vec<&String> = match target {
217 Target::Repository(repository) => {
218 let Some(name) = repositories.get(repository) else {
219 return Err(not_configured(repository));
220 };
221 vec![name]
222 }
223 Target::All => repositories.iter().collect(),
224 };
225 let mut set = lock(&self.paused);
226 for name in names {
227 if paused {
228 set.insert(name.clone());
229 } else {
230 set.remove(name);
231 }
232 }
233 self.store.paused(set.iter().cloned().collect())
235 };
236 self.wake.notify_one();
237 if recorded.await {
238 return Ok(());
239 }
240 let name = match target {
241 Target::Repository(repository) => repository.as_str(),
242 Target::All => "every repository",
243 };
244 let (done, undone) = if paused {
245 ("paused", "resumes it")
246 } else {
247 ("resumed", "pauses it again")
248 };
249 let message = format!(
250 "{name} is {done} until runnerd restarts, but the job history cannot record it, so a restart {undone}"
251 );
252 warn!("{message}");
253 Err(ControlError::NotRecorded(message))
254 }
255
256 fn set_repositories(&self, repositories: BTreeSet<String>) {
258 let mut configured = lock(&self.repositories);
259 let mut paused = lock(&self.paused);
260 let before = paused.len();
261 paused.retain(|repository| repositories.contains(repository));
262 if paused.len() != before {
263 drop(self.store.paused(paused.iter().cloned().collect()));
265 }
266 *configured = repositories;
267 }
268
269 pub fn shutdown(&self, jobs: RunningJobs) {
271 let mode = match jobs {
272 RunningJobs::Keep => Shutdown::Graceful,
273 RunningJobs::Kill => Shutdown::Force,
274 };
275 let mut shutdown = lock(&self.shutdown);
276 *shutdown = (*shutdown).max(mode);
277 drop(shutdown);
278 self.wake.notify_one();
279 }
280
281 pub fn is_paused(&self, repository: &str) -> bool {
283 lock(&self.paused).contains(repository)
284 }
285
286 async fn send<T>(
287 &self,
288 command: impl FnOnce(oneshot::Sender<T>) -> Command,
289 ) -> Result<T, ControlError> {
290 let (reply, answer) = oneshot::channel();
291 self.commands
292 .send(command(reply))
293 .map_err(|_| ControlError::Stopped)?;
294 answer.await.map_err(|_| ControlError::Stopped)
295 }
296
297 pub async fn pause(
301 &self,
302 target: Target,
303 jobs: RunningJobs,
304 ) -> Result<api::Paused, ControlError> {
305 self.send(|reply| Command::Pause {
306 target,
307 jobs,
308 reply,
309 })
310 .await?
311 }
312
313 pub async fn reseed(&self, profile: &str) -> Result<(), ControlError> {
315 self.send(|reply| Command::Reseed {
316 profile: profile.to_owned(),
317 reply,
318 })
319 .await?
320 }
321
322 pub async fn clean_cache(&self, name: &str) -> Result<(), ControlError> {
324 self.send(|reply| Command::CleanCache {
325 name: name.to_owned(),
326 reply,
327 })
328 .await?
329 }
330
331 pub async fn replace_config(
335 &self,
336 config: Config,
337 memory_max: Option<u64>,
338 commit: Commit,
339 ) -> Result<Option<String>, ControlError> {
340 self.send(|reply| Command::ReplaceConfig {
341 config: Box::new(config),
342 memory_max,
343 commit,
344 reply,
345 })
346 .await?
347 }
348}
349
350fn not_configured(repository: &str) -> ControlError {
351 ControlError::NotFound(format!("{repository} is not a configured repository"))
352}
353
354fn list(jobs: &[api::RunningJob]) -> String {
356 jobs.iter()
357 .map(ToString::to_string)
358 .collect::<Vec<_>>()
359 .join(", ")
360}
361
362impl<G: GithubApi, D: DockerApi + 'static> Engine<G, D> {
363 pub(super) async fn command(&mut self, command: Command) {
365 match command {
366 Command::Pause {
367 target,
368 jobs,
369 reply,
370 } => {
371 let _ = reply.send(self.pause(&target, jobs).await);
372 }
373 Command::Reseed { profile, reply } => {
374 let _ = reply.send(self.request_reseed(&profile));
375 }
376 Command::CleanCache { name, reply } => {
377 let _ = reply.send(self.clean_cache(&name).await);
378 }
379 Command::ReplaceConfig {
380 config,
381 memory_max,
382 commit,
383 reply,
384 } => {
385 let answer = match commit.run().await {
386 Ok(warning) => {
387 self.reload(*config, memory_max);
388 Ok(warning)
389 }
390 Err(message) => Err(ControlError::Failed(message)),
391 };
392 let _ = reply.send(answer);
393 }
394 }
395 }
396
397 pub(super) async fn pause(
401 &mut self,
402 target: &Target,
403 running: RunningJobs,
404 ) -> Result<api::Paused, ControlError> {
405 let repositories: BTreeSet<String> = match target {
406 Target::Repository(repository) if !self.repositories.contains_key(repository) => {
407 return Err(not_configured(repository));
408 }
409 Target::Repository(repository) => BTreeSet::from([repository.clone()]),
410 Target::All => self.repositories.keys().cloned().collect(),
411 };
412 self.take_found();
413 let runners: Vec<(SlotRef, Occupant)> = self
414 .occupied()
415 .filter(|(_, occupant)| repositories.contains(&occupant.repository))
416 .map(|(slot, occupant)| (slot, occupant.clone()))
417 .collect();
418 let jobs: Vec<api::RunningJob> = runners
419 .iter()
420 .filter(|(_, occupant)| occupant.busy || occupant.job.is_some())
421 .map(|(_, occupant)| occupant.running_job())
422 .collect();
423 if running == RunningJobs::Keep && !jobs.is_empty() {
424 let mut by_repository: BTreeMap<&str, Vec<api::RunningJob>> = BTreeMap::new();
425 for job in &jobs {
426 by_repository
427 .entry(&job.repository)
428 .or_default()
429 .push(job.clone());
430 }
431 let runs: Vec<String> = by_repository
432 .iter()
433 .map(|(repository, jobs)| format!("{repository} runs {}", list(jobs)))
434 .collect();
435 return Err(ControlError::Busy {
436 message: format!(
437 "{}; pause with force to cancel {}",
438 runs.join("; "),
439 if jobs.len() == 1 { "it" } else { "them" }
440 ),
441 jobs,
442 });
443 }
444 let recorded = self.control.set_paused(target, true).await;
446 match target {
447 Target::Repository(repository) => info!("{repository}: paused"),
448 Target::All => info!("paused every repository"),
449 }
450 let mut stopped = Vec::new();
451 if running == RunningJobs::Keep {
452 return recorded.map(|()| api::Paused { stopped });
453 }
454 for (slot, occupant) in runners {
455 let repository = &occupant.repository;
456 let cancelled = if let Some(job) = occupant.job {
457 self.cancel(repository, job).await
458 } else {
459 if occupant.busy {
460 warn!(
461 "{repository}: {} runs a job its log has not named yet; GitHub fails it once the runner is gone",
462 occupant.runner_name
463 );
464 }
465 false
466 };
467 if cancelled {
468 self.cancelling.insert(
470 occupant.runner_name.clone(),
471 tokio::time::Instant::now() + CANCEL_GRACE,
472 );
473 } else {
474 self.stop(&slot, RunningJobs::Kill).await;
475 if self
476 .occupied()
477 .any(|(_, candidate)| candidate.runner_name == occupant.runner_name)
478 {
479 continue;
480 }
481 }
482 stopped.push(occupant.running_job());
483 }
484 match recorded {
485 Ok(()) => Ok(api::Paused { stopped }),
486 Err(ControlError::NotRecorded(message)) if !stopped.is_empty() => Err(
487 ControlError::NotRecorded(format!("{message}; stopped {}", list(&stopped))),
488 ),
489 Err(error) => Err(error),
490 }
491 }
492
493 pub(super) async fn kill_cancelled(&mut self) {
495 let now = tokio::time::Instant::now();
496 let running: BTreeSet<String> = self
497 .occupied()
498 .map(|(_, occupant)| occupant.runner_name.clone())
499 .collect();
500 self.cancelling.retain(|name, _| running.contains(name));
501 let overdue: Vec<(SlotRef, String)> = self
502 .occupied()
503 .filter(|(_, occupant)| {
504 self.cancelling
505 .get(&occupant.runner_name)
506 .is_some_and(|deadline| *deadline <= now)
507 })
508 .map(|(slot, occupant)| (slot, occupant.runner_name.clone()))
509 .collect();
510 for (slot, name) in overdue {
511 warn!(
512 "{name} did not exit {}s after its run was cancelled",
513 CANCEL_GRACE.as_secs()
514 );
515 self.stop(&slot, RunningJobs::Kill).await;
517 }
518 }
519
520 async fn cancel(&self, repository: &str, job: u64) -> bool {
522 let run = match self.github.job(repository, job).await {
523 Ok(details) => details.run_id,
524 Err(error) => {
525 warn!("{repository}: cannot read job {job} to cancel its run: {error}");
526 return false;
527 }
528 };
529 match self.github.cancel_run(repository, run).await {
530 Ok(()) => {
531 info!("{repository}: cancelled run {run} of job {job}");
532 true
533 }
534 Err(error) => {
535 warn!("{repository}: cannot cancel run {run}: {error}");
536 false
537 }
538 }
539 }
540
541 pub(super) fn request_reseed(&mut self, profile: &str) -> Result<(), ControlError> {
543 if !self.config.profiles.contains_key(profile) {
544 return Err(ControlError::NotFound(format!(
545 "{profile} is not a configured profile"
546 )));
547 }
548 if self.is_reseeding(profile) {
549 return Err(ControlError::Conflict(format!(
550 "{profile} is reseeding already"
551 )));
552 }
553 info!("{profile}: reseeding once its slots are free");
554 self.reseed_requested.insert(profile.to_owned());
555 Ok(())
556 }
557
558 pub(super) async fn clean_cache(&mut self, name: &str) -> Result<(), ControlError> {
561 let Some(volume) = cache::volumes(&self.names, &self.config)
562 .into_iter()
563 .find(|volume| volume.name == name)
564 else {
565 return Err(ControlError::NotFound(format!(
566 "{name} is not a cache volume of this config"
567 )));
568 };
569 let users: Vec<api::RunningJob> = match &volume.kind {
570 api::CacheKind::Snapshot { profile } => {
571 return Err(ControlError::Invalid(format!(
572 "{name} is {profile}'s snapshot, which every slot's layers sit on; reseed {profile} to rebuild it"
573 )));
574 }
575 api::CacheKind::Slot { profile, slot } => {
576 if self.reseeds.contains_key(profile) {
577 return Err(ControlError::Conflict(format!(
578 "{profile} is reseeding, which writes {name}"
579 )));
580 }
581 self.occupied()
582 .filter(|(at, _)| at.profile == *profile && at.index + 1 == *slot)
583 .map(|(_, occupant)| occupant.running_job())
584 .collect()
585 }
586 api::CacheKind::Pnpm | api::CacheKind::Maven | api::CacheKind::Mirror => self
587 .occupied()
588 .map(|(_, occupant)| occupant.running_job())
589 .collect(),
590 };
591 if !users.is_empty() {
592 return Err(ControlError::Busy {
593 message: format!("{name} is in use by {}", list(&users)),
594 jobs: users,
595 });
596 }
597 if volume.kind == api::CacheKind::Mirror && self.mirror_up {
598 cache::mirror_down(&*self.docker, &self.names)
599 .await
600 .map_err(|error| {
601 ControlError::Failed(format!("cannot stop the mirror: {error}"))
602 })?;
603 self.mirror_up = false;
604 }
605 self.docker
606 .remove_volume(name)
607 .await
608 .map_err(|error| ControlError::Failed(format!("cannot remove {name}: {error}")))?;
609 info!("removed {name}");
610 self.trim_due = true;
612 Ok(())
613 }
614
615 pub(super) fn reload(&mut self, config: Config, memory_max: Option<u64>) {
619 let mut old = std::mem::replace(&mut self.config, config);
620 for (name, profile) in &self.config.profiles {
621 let slots = self.slots.entry(name.clone()).or_default();
622 if slots.len() < profile.slots as usize {
623 slots.resize(profile.slots as usize, None);
624 }
625 self.retired.remove(name);
626 }
627 for (name, profile) in std::mem::take(&mut old.profiles) {
628 if !self.config.profiles.contains_key(&name) {
629 self.retired.insert(name, profile);
630 }
631 }
632 self.drop_closed_slots();
633 let names: BTreeSet<String> = self
634 .config
635 .repositories
636 .iter()
637 .map(|repository| repository.name.clone())
638 .collect();
639 let removed: Vec<String> = self
640 .repositories
641 .keys()
642 .filter(|name| !names.contains(*name))
643 .cloned()
644 .collect();
645 self.draining.extend(removed);
646 self.draining.retain(|name| !names.contains(name));
647 self.repositories.retain(|name, _| names.contains(name));
648 for name in &names {
649 self.repositories.entry(name.clone()).or_default();
650 }
651 self.control.set_repositories(names);
652 let profiles = &self.config.profiles;
653 self.reseed_requested
654 .retain(|name| profiles.contains_key(name));
655 self.isolation = isolation::backend(self.config.host.isolation);
656 self.memory_max = memory_max;
657 self.caches
658 .send_replace(cache::volumes(&self.names, &self.config));
659 info!("reloaded the config");
660 }
661}