1use std::collections::BTreeSet;
7use std::sync::Mutex;
8use std::time::Duration;
9
10use tokio::sync::{Notify, mpsc, oneshot};
11use tracing::{info, warn};
12
13use super::{Engine, Occupant};
14use crate::api;
15use crate::cache;
16use crate::config::Config;
17use crate::docker::DockerApi;
18use crate::github::GithubApi;
19use crate::isolation;
20use crate::scheduler::SlotRef;
21use crate::sync::lock;
22
23pub(super) const CANCEL_GRACE: Duration = Duration::from_secs(90);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29pub enum Shutdown {
30 No,
32 Graceful,
34 Force,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum RunningJobs {
41 Keep,
43 Kill,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49pub enum ControlError {
50 #[error("{0}")]
52 NotFound(String),
53 #[error("{message}")]
55 Busy {
56 message: String,
58 jobs: Vec<api::RunningJob>,
60 },
61 #[error("{0}")]
63 Conflict(String),
64 #[error("{0}")]
66 Invalid(String),
67 #[error("{0}")]
69 Failed(String),
70 #[error("runnerd is stopping")]
72 Stopped,
73}
74
75#[derive(Debug)]
77pub enum Command {
78 Pause {
80 repository: String,
82 jobs: RunningJobs,
84 reply: oneshot::Sender<Result<api::Paused, ControlError>>,
86 },
87 Reseed {
89 profile: String,
91 reply: oneshot::Sender<Result<(), ControlError>>,
93 },
94 CleanCache {
96 name: String,
98 reply: oneshot::Sender<Result<(), ControlError>>,
100 },
101 ReplaceConfig {
103 config: Box<Config>,
105 memory_max: Option<u64>,
107 commit: Commit,
109 reply: oneshot::Sender<Result<Option<String>, ControlError>>,
111 },
112}
113
114pub struct Commit(Box<dyn FnOnce() -> Result<Option<String>, String> + Send>);
117
118impl Commit {
119 pub fn new(write: impl FnOnce() -> Result<Option<String>, String> + Send + 'static) -> Commit {
121 Commit(Box::new(write))
122 }
123
124 pub async fn run(self) -> Result<Option<String>, String> {
126 tokio::task::spawn_blocking(self.0)
127 .await
128 .unwrap_or_else(|join_error| Err(join_error.to_string()))
129 }
130}
131
132impl std::fmt::Debug for Commit {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.write_str("Commit")
135 }
136}
137
138#[derive(Debug)]
140pub struct Commands(mpsc::UnboundedReceiver<Command>);
141
142impl Commands {
143 pub async fn recv(&mut self) -> Option<Command> {
145 self.0.recv().await
146 }
147}
148
149#[derive(Debug)]
151pub struct Control {
152 repositories: Mutex<BTreeSet<String>>,
155 paused: Mutex<BTreeSet<String>>,
156 shutdown: Mutex<Shutdown>,
157 pub(super) wake: Notify,
158 commands: mpsc::UnboundedSender<Command>,
159}
160
161impl Control {
162 pub fn new(repositories: impl IntoIterator<Item = String>) -> (Control, Commands) {
164 let (commands, receiver) = mpsc::unbounded_channel();
165 let control = Control {
166 repositories: Mutex::new(repositories.into_iter().collect()),
167 paused: Mutex::new(BTreeSet::new()),
168 shutdown: Mutex::new(Shutdown::No),
169 wake: Notify::new(),
170 commands,
171 };
172 (control, Commands(receiver))
173 }
174
175 pub fn mode(&self) -> Shutdown {
177 *lock(&self.shutdown)
178 }
179
180 pub fn set_paused(&self, repository: &str, paused: bool) -> bool {
182 if !lock(&self.repositories).contains(repository) {
183 return false;
184 }
185 let mut set = lock(&self.paused);
186 if paused {
187 set.insert(repository.to_owned());
188 } else {
189 set.remove(repository);
190 }
191 drop(set);
192 self.wake.notify_one();
193 true
194 }
195
196 fn set_repositories(&self, repositories: BTreeSet<String>) {
198 lock(&self.paused).retain(|repository| repositories.contains(repository));
199 *lock(&self.repositories) = repositories;
200 }
201
202 pub fn shutdown(&self, jobs: RunningJobs) {
204 let mode = match jobs {
205 RunningJobs::Keep => Shutdown::Graceful,
206 RunningJobs::Kill => Shutdown::Force,
207 };
208 let mut shutdown = lock(&self.shutdown);
209 *shutdown = (*shutdown).max(mode);
210 drop(shutdown);
211 self.wake.notify_one();
212 }
213
214 pub fn is_paused(&self, repository: &str) -> bool {
216 lock(&self.paused).contains(repository)
217 }
218
219 async fn send<T>(
220 &self,
221 command: impl FnOnce(oneshot::Sender<T>) -> Command,
222 ) -> Result<T, ControlError> {
223 let (reply, answer) = oneshot::channel();
224 self.commands
225 .send(command(reply))
226 .map_err(|_| ControlError::Stopped)?;
227 answer.await.map_err(|_| ControlError::Stopped)
228 }
229
230 pub async fn pause(
234 &self,
235 repository: &str,
236 jobs: RunningJobs,
237 ) -> Result<api::Paused, ControlError> {
238 self.send(|reply| Command::Pause {
239 repository: repository.to_owned(),
240 jobs,
241 reply,
242 })
243 .await?
244 }
245
246 pub async fn reseed(&self, profile: &str) -> Result<(), ControlError> {
248 self.send(|reply| Command::Reseed {
249 profile: profile.to_owned(),
250 reply,
251 })
252 .await?
253 }
254
255 pub async fn clean_cache(&self, name: &str) -> Result<(), ControlError> {
257 self.send(|reply| Command::CleanCache {
258 name: name.to_owned(),
259 reply,
260 })
261 .await?
262 }
263
264 pub async fn replace_config(
268 &self,
269 config: Config,
270 memory_max: Option<u64>,
271 commit: Commit,
272 ) -> Result<Option<String>, ControlError> {
273 self.send(|reply| Command::ReplaceConfig {
274 config: Box::new(config),
275 memory_max,
276 commit,
277 reply,
278 })
279 .await?
280 }
281}
282
283fn list(jobs: &[api::RunningJob]) -> String {
285 jobs.iter()
286 .map(ToString::to_string)
287 .collect::<Vec<_>>()
288 .join(", ")
289}
290
291impl<G: GithubApi, D: DockerApi + 'static> Engine<G, D> {
292 pub(super) async fn command(&mut self, command: Command) {
294 match command {
295 Command::Pause {
296 repository,
297 jobs,
298 reply,
299 } => {
300 let _ = reply.send(self.pause(&repository, jobs).await);
301 }
302 Command::Reseed { profile, reply } => {
303 let _ = reply.send(self.request_reseed(&profile));
304 }
305 Command::CleanCache { name, reply } => {
306 let _ = reply.send(self.clean_cache(&name).await);
307 }
308 Command::ReplaceConfig {
309 config,
310 memory_max,
311 commit,
312 reply,
313 } => {
314 let answer = match commit.run().await {
315 Ok(warning) => {
316 self.reload(*config, memory_max);
317 Ok(warning)
318 }
319 Err(message) => Err(ControlError::Failed(message)),
320 };
321 let _ = reply.send(answer);
322 }
323 }
324 }
325
326 pub(super) async fn pause(
330 &mut self,
331 repository: &str,
332 running: RunningJobs,
333 ) -> Result<api::Paused, ControlError> {
334 if !self.repositories.contains_key(repository) {
335 return Err(ControlError::NotFound(format!(
336 "{repository} is not a configured repository"
337 )));
338 }
339 self.take_found();
340 let runners: Vec<(SlotRef, Occupant)> = self
341 .occupied()
342 .filter(|(_, occupant)| occupant.repository == repository)
343 .map(|(slot, occupant)| (slot, occupant.clone()))
344 .collect();
345 let jobs: Vec<api::RunningJob> = runners
346 .iter()
347 .filter(|(_, occupant)| occupant.busy || occupant.job.is_some())
348 .map(|(_, occupant)| occupant.running_job())
349 .collect();
350 if running == RunningJobs::Keep && !jobs.is_empty() {
351 return Err(ControlError::Busy {
352 message: format!(
353 "{repository} runs {}; pause with force to cancel it",
354 list(&jobs)
355 ),
356 jobs,
357 });
358 }
359 self.control.set_paused(repository, true);
360 info!("{repository}: paused");
361 let mut stopped = Vec::new();
362 if running == RunningJobs::Keep {
363 return Ok(api::Paused { stopped });
364 }
365 for (slot, occupant) in runners {
366 let cancelled = if let Some(job) = occupant.job {
367 self.cancel(repository, job).await
368 } else {
369 if occupant.busy {
370 warn!(
371 "{repository}: {} runs a job its log has not named yet; GitHub fails it once the runner is gone",
372 occupant.runner_name
373 );
374 }
375 false
376 };
377 if cancelled {
378 self.cancelling.insert(
380 occupant.runner_name.clone(),
381 tokio::time::Instant::now() + CANCEL_GRACE,
382 );
383 } else {
384 self.stop(&slot, RunningJobs::Kill).await;
385 if self
386 .occupied()
387 .any(|(_, candidate)| candidate.runner_name == occupant.runner_name)
388 {
389 continue;
390 }
391 }
392 stopped.push(occupant.running_job());
393 }
394 Ok(api::Paused { stopped })
395 }
396
397 pub(super) async fn kill_cancelled(&mut self) {
399 let now = tokio::time::Instant::now();
400 let running: BTreeSet<String> = self
401 .occupied()
402 .map(|(_, occupant)| occupant.runner_name.clone())
403 .collect();
404 self.cancelling.retain(|name, _| running.contains(name));
405 let overdue: Vec<(SlotRef, String)> = self
406 .occupied()
407 .filter(|(_, occupant)| {
408 self.cancelling
409 .get(&occupant.runner_name)
410 .is_some_and(|deadline| *deadline <= now)
411 })
412 .map(|(slot, occupant)| (slot, occupant.runner_name.clone()))
413 .collect();
414 for (slot, name) in overdue {
415 warn!(
416 "{name} did not exit {}s after its run was cancelled",
417 CANCEL_GRACE.as_secs()
418 );
419 self.stop(&slot, RunningJobs::Kill).await;
421 }
422 }
423
424 async fn cancel(&self, repository: &str, job: u64) -> bool {
426 let run = match self.github.job(repository, job).await {
427 Ok(details) => details.run_id,
428 Err(error) => {
429 warn!("{repository}: cannot read job {job} to cancel its run: {error}");
430 return false;
431 }
432 };
433 match self.github.cancel_run(repository, run).await {
434 Ok(()) => {
435 info!("{repository}: cancelled run {run} of job {job}");
436 true
437 }
438 Err(error) => {
439 warn!("{repository}: cannot cancel run {run}: {error}");
440 false
441 }
442 }
443 }
444
445 pub(super) fn request_reseed(&mut self, profile: &str) -> Result<(), ControlError> {
447 if !self.config.profiles.contains_key(profile) {
448 return Err(ControlError::NotFound(format!(
449 "{profile} is not a configured profile"
450 )));
451 }
452 if self.is_reseeding(profile) {
453 return Err(ControlError::Conflict(format!(
454 "{profile} is reseeding already"
455 )));
456 }
457 info!("{profile}: reseeding once its slots are free");
458 self.reseed_requested.insert(profile.to_owned());
459 Ok(())
460 }
461
462 pub(super) async fn clean_cache(&mut self, name: &str) -> Result<(), ControlError> {
465 let Some(volume) = cache::volumes(&self.names, &self.config)
466 .into_iter()
467 .find(|volume| volume.name == name)
468 else {
469 return Err(ControlError::NotFound(format!(
470 "{name} is not a cache volume of this config"
471 )));
472 };
473 let users: Vec<api::RunningJob> = match &volume.kind {
474 api::CacheKind::Snapshot { profile } => {
475 return Err(ControlError::Invalid(format!(
476 "{name} is {profile}'s snapshot, which every slot's layers sit on; reseed {profile} to rebuild it"
477 )));
478 }
479 api::CacheKind::Slot { profile, slot } => {
480 if self.reseeds.contains_key(profile) {
481 return Err(ControlError::Conflict(format!(
482 "{profile} is reseeding, which writes {name}"
483 )));
484 }
485 self.occupied()
486 .filter(|(at, _)| at.profile == *profile && at.index + 1 == *slot)
487 .map(|(_, occupant)| occupant.running_job())
488 .collect()
489 }
490 api::CacheKind::Pnpm | api::CacheKind::Maven | api::CacheKind::Mirror => self
491 .occupied()
492 .map(|(_, occupant)| occupant.running_job())
493 .collect(),
494 };
495 if !users.is_empty() {
496 return Err(ControlError::Busy {
497 message: format!("{name} is in use by {}", list(&users)),
498 jobs: users,
499 });
500 }
501 if volume.kind == api::CacheKind::Mirror && self.mirror_up {
502 cache::mirror_down(&*self.docker, &self.names)
503 .await
504 .map_err(|error| {
505 ControlError::Failed(format!("cannot stop the mirror: {error}"))
506 })?;
507 self.mirror_up = false;
508 }
509 self.docker
510 .remove_volume(name)
511 .await
512 .map_err(|error| ControlError::Failed(format!("cannot remove {name}: {error}")))?;
513 info!("removed {name}");
514 self.trim_due = true;
516 Ok(())
517 }
518
519 pub(super) fn reload(&mut self, config: Config, memory_max: Option<u64>) {
523 let mut old = std::mem::replace(&mut self.config, config);
524 for (name, profile) in &self.config.profiles {
525 let slots = self.slots.entry(name.clone()).or_default();
526 if slots.len() < profile.slots as usize {
527 slots.resize(profile.slots as usize, None);
528 }
529 self.retired.remove(name);
530 }
531 for (name, profile) in std::mem::take(&mut old.profiles) {
532 if !self.config.profiles.contains_key(&name) {
533 self.retired.insert(name, profile);
534 }
535 }
536 self.drop_closed_slots();
537 let names: BTreeSet<String> = self
538 .config
539 .repositories
540 .iter()
541 .map(|repository| repository.name.clone())
542 .collect();
543 let removed: Vec<String> = self
544 .repositories
545 .keys()
546 .filter(|name| !names.contains(*name))
547 .cloned()
548 .collect();
549 self.draining.extend(removed);
550 self.draining.retain(|name| !names.contains(name));
551 self.repositories.retain(|name, _| names.contains(name));
552 for name in &names {
553 self.repositories.entry(name.clone()).or_default();
554 }
555 self.control.set_repositories(names);
556 let profiles = &self.config.profiles;
557 self.reseed_requested
558 .retain(|name| profiles.contains_key(name));
559 self.isolation = isolation::backend(self.config.host.isolation);
560 self.memory_max = memory_max;
561 self.caches
562 .send_replace(cache::volumes(&self.names, &self.config));
563 info!("reloaded the config");
564 }
565}