1use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::time::Duration;
8
9use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType};
10use tokio::sync::{mpsc, watch};
11use tokio::task::JoinHandle;
12use tokio::time::Instant;
13use tracing::{info, warn};
14
15use super::control::ControlError;
16use super::{Engine, Occupant};
17use crate::admission::HIGH_WATER;
18use crate::api;
19use crate::config::ByteSize;
20use crate::docker::{DockerApi, Tty, TtySize, is_not_found};
21use crate::github::GithubApi;
22use crate::record;
23use crate::scheduler::SlotRef;
24use crate::time;
25
26#[derive(Debug)]
29pub(super) struct Recording {
30 record: api::Record,
31 container: String,
32 estimate: u64,
35 stage: Stage,
36}
37
38#[derive(Debug)]
39enum Stage {
40 Checking(JoinHandle<Result<Checked, String>>),
42 Committing(JoinHandle<Result<u64, String>>),
44}
45
46impl Stage {
47 fn is_finished(&self) -> bool {
48 match self {
49 Stage::Checking(task) => task.is_finished(),
50 Stage::Committing(task) => task.is_finished(),
51 }
52 }
53}
54
55#[derive(Debug)]
58struct Checked {
59 incoming: u64,
60 free: Option<u64>,
61}
62
63#[derive(Debug)]
65pub(super) struct RecordState {
66 list: Vec<api::Record>,
68 pub(super) listed: bool,
70 pub(super) recordings: BTreeMap<u64, Recording>,
72 pub(super) shells: BTreeMap<String, Shell>,
74 opened: u64,
76 status: watch::Sender<api::Records>,
78 done: mpsc::UnboundedSender<()>,
80 pub(super) finished: mpsc::UnboundedReceiver<()>,
81}
82
83impl RecordState {
84 pub(super) fn new() -> (RecordState, watch::Receiver<api::Records>) {
86 let (status, receiver) = watch::channel(api::Records::default());
87 let (done, finished) = mpsc::unbounded_channel();
88 let state = RecordState {
89 list: Vec::new(),
90 listed: false,
91 recordings: BTreeMap::new(),
92 shells: BTreeMap::new(),
93 opened: 0,
94 status,
95 done,
96 finished,
97 };
98 (state, receiver)
99 }
100}
101
102#[derive(Debug, Clone)]
104pub(super) struct Shell {
105 job: u64,
106 pub(super) profile: String,
108 pub(super) memory: u64,
110 opened: Instant,
111}
112
113const MAX_SHELL: Duration = Duration::from_hours(8);
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum RecordSelection {
119 Job(u64),
121 All,
123 OlderThan(Duration),
125}
126
127const SHELL_SCRIPT: &str = r#"
130. /usr/local/lib/runnerd-layers.sh && mount_layers > /dev/null 2>&1
131install -d -o runner -g runner -m 700 "$XDG_RUNTIME_DIR" 2> /dev/null
132cd "$1" 2> /dev/null || cd /home/runner
133exec setpriv --reuid=runner --regid=runner --init-groups -- env HOME=/home/runner USER=runner LOGNAME=runner bash -l
134"#;
135
136const COMMIT_FACTOR: u64 = 2;
139
140impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
141 fn records_failures(&self, profile: &str) -> bool {
143 self.config
144 .profiles
145 .get(profile)
146 .or_else(|| self.retired.get(profile))
147 .is_some_and(|profile| profile.record_failures)
148 }
149
150 pub(super) async fn begin_record(&mut self, slot: &SlotRef, occupant: &Occupant) -> bool {
154 let job = self
156 .occupied()
157 .find(|(_, candidate)| candidate.runner_name == occupant.runner_name)
158 .and_then(|(_, candidate)| candidate.job)
159 .or(occupant.job);
160 let Some(job) = job else {
161 return false;
162 };
163 if !self.records_failures(&slot.profile) {
164 return false;
165 }
166 if !self.records.listed {
167 self.list_records().await;
168 if !self.records.listed {
169 let why = "Docker did not list the records kept".to_owned();
170 self.skip_record(job, &occupant.repository, why);
171 return false;
172 }
173 }
174 let summary = self
175 .store
176 .job(job)
177 .await
178 .ok()
179 .flatten()
180 .map(|job| job.summary);
181 let record = api::Record {
182 job,
183 repository: occupant.repository.clone(),
184 profile: slot.profile.clone(),
185 workflow: summary
186 .as_ref()
187 .and_then(|summary| summary.workflow.clone()),
188 name: summary.map(|summary| summary.name),
189 created_at: time::now_ms() / 1000,
190 bytes: 0,
191 image: record::image(job),
192 };
193 info!(
194 "{}: keeping job {job}'s runner as {}",
195 occupant.repository, record.image
196 );
197 let docker = self.docker.clone();
198 let container = occupant.container.clone();
199 let done = self.records.done.clone();
200 let check = tokio::spawn(async move {
201 let checked = check(&*docker, &container).await;
202 let _ = done.send(());
203 checked
204 });
205 self.records.recordings.insert(
206 job,
207 Recording {
208 record,
209 container: occupant.container.clone(),
210 estimate: 0,
211 stage: Stage::Checking(check),
212 },
213 );
214 true
215 }
216
217 pub(super) async fn note_lost_record(&self, profile: &str, job: Option<u64>, container: &str) {
221 let Some(job) = job else {
222 return;
223 };
224 if !self.records_failures(profile)
225 || self.records.list.iter().any(|record| record.job == job)
226 {
227 return;
228 }
229 if let Ok(log) = self.docker.logs(container).await
230 && record::failed(&log)
231 {
232 self.store.record_skipped(
233 job,
234 "runnerd was not running when the runner stopped, or stopped while keeping it"
235 .to_owned(),
236 );
237 }
238 }
239
240 fn skip_record(&self, job: u64, repository: &str, why: String) {
242 warn!("{repository}: no record of job {job}: {why}");
243 self.store.record_skipped(job, why);
244 }
245
246 async fn make_room_and_commit(
249 &mut self,
250 record: api::Record,
251 container: String,
252 checked: Checked,
253 ) {
254 let job = record.job;
255 let pending: u64 = self
257 .records
258 .recordings
259 .values()
260 .map(|other| other.estimate)
261 .sum();
262 let room = record::make_room(
263 &self.records.list,
264 &self.shell_jobs(),
265 &self.config.records,
266 checked.incoming,
267 checked.free.map(|free| free.saturating_sub(pending)),
268 time::now_ms() / 1000,
269 );
270 let removed = match room {
271 Ok(removals) => {
272 let mut removed = Ok(());
273 for (old, why) in removals {
274 if let Err(error) = self.remove_record(old, why).await {
275 removed = Err(error.to_string());
276 break;
277 }
278 }
279 removed
280 }
281 Err(why) => Err(why),
282 };
283 if let Err(why) = removed {
284 self.skip_record(job, &record.repository, why);
285 self.remove_container(&container).await;
286 return;
287 }
288 let labels = labels(&record);
289 let docker = self.docker.clone();
290 let image = record.image.clone();
291 let done = self.records.done.clone();
292 let committed_container = container.clone();
293 let commit = tokio::spawn(async move {
294 let container = committed_container;
295 let committed = docker.commit(&container, &image, labels).await;
296 if let Err(error) = docker.remove(&container).await
297 && !is_not_found(&error)
298 {
299 warn!("cannot remove {container}: {error}");
300 }
301 let _ = done.send(());
302 committed.map_err(|error| error.to_string())
303 });
304 self.records.recordings.insert(
305 job,
306 Recording {
307 record,
308 container,
309 estimate: checked.incoming,
310 stage: Stage::Committing(commit),
311 },
312 );
313 }
314
315 async fn remove_container(&self, container: &str) {
316 if let Err(error) = self.docker.remove(container).await
317 && !is_not_found(&error)
318 {
319 warn!("cannot remove {container}: {error}");
320 }
321 }
322
323 pub(super) async fn collect_recordings(&mut self) {
326 while self.records.finished.try_recv().is_ok() {}
327 let done: Vec<u64> = self
328 .records
329 .recordings
330 .iter()
331 .filter(|(_, recording)| recording.stage.is_finished())
332 .map(|(job, _)| *job)
333 .collect();
334 for job in done {
335 let Some(recording) = self.records.recordings.remove(&job) else {
336 continue;
337 };
338 let Recording {
339 record,
340 container,
341 stage,
342 ..
343 } = recording;
344 let task = match stage {
345 Stage::Checking(task) => {
346 let checked = task
347 .await
348 .map_err(|error| error.to_string())
349 .and_then(|checked| checked);
350 match checked {
351 Ok(checked) => self.make_room_and_commit(record, container, checked).await,
352 Err(why) => {
353 self.skip_record(job, &record.repository, why);
354 self.remove_container(&container).await;
355 }
356 }
357 continue;
358 }
359 Stage::Committing(task) => task,
360 };
361 match task
362 .await
363 .map_err(|error| error.to_string())
364 .and_then(|committed| committed)
365 {
366 Ok(bytes) => self.keep_record(record, bytes).await,
367 Err(error) => self.skip_record(
368 job,
369 &record.repository,
370 format!("Docker did not commit it: {error}"),
371 ),
372 }
373 }
374 }
375
376 async fn keep_record(&mut self, mut record: api::Record, bytes: u64) {
378 let job = record.job;
379 record.bytes = bytes;
380 self.name_from_history(&mut record).await;
381 info!(
382 "{}: kept job {job}'s runner as {} ({})",
383 record.repository,
384 record.image,
385 ByteSize(bytes)
386 );
387 let removals = record::make_room(
388 &self.records.list,
389 &self.shell_jobs(),
390 &self.config.records,
391 bytes,
392 None,
393 record.created_at,
394 );
395 self.store.record_made(record.clone());
396 self.records.list.push(record);
397 let removals = removals.unwrap_or_else(|_| vec![(job, api::RecordRemoval::Size)]);
398 for (old, why) in removals {
399 if let Err(error) = self.remove_record(old, why).await {
400 warn!("{error}");
401 }
402 }
403 }
404
405 async fn name_from_history(&self, record: &mut api::Record) {
408 if record.workflow.is_some() && record.name.is_some() {
409 return;
410 }
411 if let Ok(Some(job)) = self.store.job(record.job).await {
412 record.workflow = record.workflow.take().or(job.summary.workflow);
413 record.name.get_or_insert(job.summary.name);
414 }
415 }
416
417 pub(super) async fn list_records(&mut self) {
419 match self.docker.images(record::LABEL).await {
420 Ok(images) => {
421 let mut records: Vec<api::Record> = images
422 .into_iter()
423 .filter_map(|image| {
424 let label = |name: &str| image.labels.get(name).cloned();
425 Some(api::Record {
426 job: label(record::LABEL)?.parse().ok()?,
427 repository: label("runnerd.repository").unwrap_or_default(),
428 profile: label("runnerd.profile").unwrap_or_default(),
429 workflow: label("runnerd.workflow"),
430 name: label("runnerd.name"),
431 created_at: label("runnerd.created-at")
432 .and_then(|at| at.parse().ok())
433 .unwrap_or(0),
434 bytes: image.bytes,
435 image: image.reference,
436 })
437 })
438 .collect();
439 records.sort_by_key(|record| (record.created_at, record.job));
440 for record in &mut records {
441 self.name_from_history(record).await;
442 let noted = self.store.job(record.job).await.ok().flatten();
444 if noted
445 .is_some_and(|job| job.record.is_none_or(|note| note.created_at.is_none()))
446 {
447 self.store.record_made(record.clone());
448 }
449 }
450 self.records.list = records;
451 self.records.listed = true;
452 }
453 Err(error) => warn!("cannot list the records: {error}"),
454 }
455 }
456
457 pub(super) async fn expire_records(&mut self) {
459 let expired = record::expired(
460 &self.records.list,
461 &self.shell_jobs(),
462 &self.config.records,
463 time::now_ms() / 1000,
464 );
465 for (job, why) in expired {
466 if let Err(error) = self.remove_record(job, why).await {
467 warn!("{error}");
468 }
469 }
470 }
471
472 async fn remove_record(
474 &mut self,
475 job: u64,
476 why: api::RecordRemoval,
477 ) -> Result<u64, ControlError> {
478 let Some(index) = self
479 .records
480 .list
481 .iter()
482 .position(|record| record.job == job)
483 else {
484 return Err(ControlError::NotFound(format!(
485 "job {job} has no record; `runnerctl records` lists them"
486 )));
487 };
488 let image = self.records.list[index].image.clone();
489 self.docker.remove_image(&image).await.map_err(|error| {
490 ControlError::Failed(format!("cannot remove the record of job {job}: {error}"))
491 })?;
492 let removed = self.records.list.remove(index);
493 info!("removed the record of job {job} ({why})");
494 self.store.record_removed(job, time::now_ms(), why);
495 Ok(removed.bytes)
496 }
497
498 pub(super) async fn remove_records(
500 &mut self,
501 selection: &RecordSelection,
502 ) -> Result<api::RemovedRecords, ControlError> {
503 let open = self.shell_jobs();
504 let now = time::now_ms() / 1000;
505 let jobs: Vec<u64> = match selection {
506 RecordSelection::Job(job) => {
507 if open.contains(job) {
508 return Err(ControlError::Conflict(format!(
509 "a shell is open in the record of job {job}; remove it once the shell exits"
510 )));
511 }
512 vec![*job]
513 }
514 RecordSelection::All => self.records.list.iter().map(|record| record.job).collect(),
515 RecordSelection::OlderThan(age) => self
516 .records
517 .list
518 .iter()
519 .filter(|record| now.saturating_sub(record.created_at) > age.as_secs())
520 .map(|record| record.job)
521 .collect(),
522 };
523 let mut removed = api::RemovedRecords::default();
524 for job in jobs.into_iter().filter(|job| !open.contains(job)) {
525 removed.bytes += self.remove_record(job, api::RecordRemoval::User).await?;
526 removed.jobs.push(job);
527 }
528 Ok(removed)
529 }
530
531 fn shell_jobs(&self) -> BTreeSet<u64> {
533 self.records
534 .shells
535 .values()
536 .map(|shell| shell.job)
537 .collect()
538 }
539
540 pub(super) fn shell_profiles(&self) -> BTreeSet<&str> {
542 self.records
543 .shells
544 .values()
545 .map(|shell| shell.profile.as_str())
546 .collect()
547 }
548
549 pub(super) fn shell_memory(&self) -> u64 {
551 self.records.shells.values().map(|shell| shell.memory).sum()
552 }
553
554 pub(super) fn shell_budget(&self) -> Vec<api::ShellMemory> {
556 self.records
557 .shells
558 .values()
559 .map(|shell| api::ShellMemory {
560 job: shell.job,
561 memory: shell.memory,
562 })
563 .collect()
564 }
565
566 pub(super) fn publish_records(&self) {
568 let records = self.records_status();
569 self.records.status.send_if_modified(|current| {
570 let changed = *current != records;
571 *current = records;
572 changed
573 });
574 }
575
576 fn records_status(&self) -> api::Records {
578 let limits = &self.config.records;
579 api::Records {
580 records: self.records.list.clone(),
581 limits: api::RecordLimits {
582 max_total: limits.max_total.0,
583 max_count: limits.max_count,
584 max_age: limits.max_age.as_secs(),
585 min_free: limits.min_free.0,
586 },
587 shells: self.shell_jobs().into_iter().collect(),
588 }
589 }
590
591 pub(super) async fn open_shell(
594 &mut self,
595 job: u64,
596 size: TtySize,
597 ) -> Result<Tty, ControlError> {
598 let Some(record) = self
599 .records
600 .list
601 .iter()
602 .find(|record| record.job == job)
603 .cloned()
604 else {
605 return Err(ControlError::NotFound(format!(
606 "job {job} has no record; `runnerctl records` lists them"
607 )));
608 };
609 let profile = self.config.profiles.get(&record.profile);
610 let Some(profile) = profile.or_else(|| self.retired.get(&record.profile)) else {
611 return Err(ControlError::Invalid(format!(
612 "the record of job {job} ran in profile {}, which the config no longer has",
613 record.profile
614 )));
615 };
616 let (memory, cpus) = (profile.memory.0, profile.cpus);
617 if self.reseeds.contains_key(&record.profile) {
619 return Err(ControlError::Conflict(format!(
620 "{} is reseeding its snapshot, which a shell mounts; open it once the reseed is done",
621 record.profile
622 )));
623 }
624 if self.trim.as_ref().is_some_and(|trim| !trim.is_finished()) {
626 return Err(ControlError::Conflict(
627 "the shared caches, which a shell mounts, are being trimmed; open it once the trim is done"
628 .to_owned(),
629 ));
630 }
631 if let Some(max) = self.memory_max {
632 let in_use = self.memory_in_use();
633 if in_use.saturating_add(memory) > max {
634 return Err(ControlError::Conflict(format!(
635 "the budget is full: {} of the slice's {} is held, and a shell takes {} as a slot of {} \
636 does; open it once a job finishes",
637 ByteSize(in_use),
638 ByteSize(max),
639 ByteSize(memory),
640 record.profile
641 )));
642 }
643 }
644 if self.admitting(&mut [], None).held {
646 return Err(ControlError::Conflict(format!(
647 "the slice is at {HIGH_WATER}% of its MemoryMax, where measured admission starts nothing; open it \
648 once it falls under"
649 )));
650 }
651 let body = self.shell_body(&record, memory, cpus);
652 self.records.opened += 1;
653 let name = format!(
654 "runnerd-shell-{job}-{}-{}",
655 time::now_ms() / 1000,
656 self.records.opened
657 );
658 let tty = self
659 .docker
660 .shell(&name, body, size)
661 .await
662 .map_err(|error| ControlError::Failed(format!("cannot start a shell: {error}")))?;
663 info!("opened a shell in the record of job {job}");
664 self.records.shells.insert(
665 tty.container.clone(),
666 Shell {
667 job,
668 profile: record.profile,
669 memory,
670 opened: Instant::now(),
671 },
672 );
673 Ok(tty)
674 }
675
676 fn shell_body(&self, record: &api::Record, memory: u64, cpus: u32) -> ContainerCreateBody {
679 let workspace = record::workspace(&record.repository);
680 let limit = i64::try_from(memory).unwrap_or(i64::MAX);
681 let mut mounts = vec![Mount {
682 target: Some("/state".to_owned()),
683 typ: Some(MountType::VOLUME),
684 ..Default::default()
685 }];
686 if self.config.profiles.contains_key(&record.profile) {
687 mounts.extend(
689 crate::cache::runner_mounts(&self.names, &record.profile, 1)
690 .into_iter()
691 .filter(|mount| mount.target.as_deref() != Some("/state"))
692 .map(|mount| Mount {
693 read_only: Some(true),
694 ..mount
695 }),
696 );
697 }
698 let mut host_config = HostConfig {
699 cgroup_parent: Some(self.config.host.slice.clone()),
700 memory: Some(limit),
701 memory_swap: Some(limit),
702 nano_cpus: Some(i64::from(cpus) * 1_000_000_000),
703 init: Some(true),
704 mounts: Some(mounts),
705 network_mode: Some(self.names.network()),
706 ..Default::default()
707 };
708 self.isolation.configure(&mut host_config);
709 ContainerCreateBody {
710 image: Some(record.image.clone()),
711 entrypoint: Some(vec![
712 "bash".to_owned(),
713 "-c".to_owned(),
714 SHELL_SCRIPT.to_owned(),
715 ]),
716 cmd: Some(vec!["shell".to_owned(), workspace]),
717 env: Some(vec!["TERM=xterm-256color".to_owned()]),
718 tty: Some(true),
719 open_stdin: Some(true),
720 stdin_once: Some(true),
721 attach_stdin: Some(true),
722 attach_stdout: Some(true),
723 attach_stderr: Some(true),
724 labels: Some(HashMap::from([(
725 record::SHELL_LABEL.to_owned(),
726 record.job.to_string(),
727 )])),
728 host_config: Some(host_config),
729 ..Default::default()
730 }
731 }
732
733 pub(super) async fn close_shell(&mut self, container: &str) {
735 let Some(shell) = self.records.shells.get(container) else {
736 return;
737 };
738 let job = shell.job;
739 match self.docker.remove(container).await {
740 Err(error) if !is_not_found(&error) => {
741 warn!("cannot remove the shell in the record of job {job}: {error}");
742 }
743 _ => {
744 info!("closed the shell in the record of job {job}");
745 self.records.shells.remove(container);
746 }
747 }
748 }
749
750 pub(super) async fn collect_shells(&mut self) {
753 let shells: Vec<(String, Instant)> = self
754 .records
755 .shells
756 .iter()
757 .map(|(container, shell)| (container.clone(), shell.opened))
758 .collect();
759 for (container, opened) in shells {
760 if opened.elapsed() > MAX_SHELL {
761 info!("closing {container}: a shell is open for 8 hours at most");
762 self.close_shell(&container).await;
763 continue;
764 }
765 let ended = match self.docker.inspect(&container).await {
766 Ok(state) => !state.running,
767 Err(error) => is_not_found(&error),
768 };
769 if ended {
770 self.close_shell(&container).await;
771 }
772 }
773 }
774
775 pub(super) async fn close_shells(&mut self) {
777 let containers: Vec<String> = self.records.shells.keys().cloned().collect();
778 for container in containers {
779 self.close_shell(&container).await;
780 }
781 }
782}
783
784fn labels(record: &api::Record) -> BTreeMap<String, String> {
786 let mut labels = BTreeMap::from([
787 (record::LABEL.to_owned(), record.job.to_string()),
788 ("runnerd.repository".to_owned(), record.repository.clone()),
789 ("runnerd.profile".to_owned(), record.profile.clone()),
790 (
791 "runnerd.created-at".to_owned(),
792 record.created_at.to_string(),
793 ),
794 ]);
795 if let Some(workflow) = &record.workflow {
796 labels.insert("runnerd.workflow".to_owned(), workflow.clone());
797 }
798 if let Some(name) = &record.name {
799 labels.insert("runnerd.name".to_owned(), name.clone());
800 }
801 labels
802}
803
804async fn check(docker: &impl DockerApi, container: &str) -> Result<Checked, String> {
808 let changed = docker
809 .changes(container)
810 .await
811 .map_err(|error| format!("Docker did not list its files: {error}"))?;
812 let secrets = record::secrets_left(changed.iter().map(String::as_str));
813 if !secrets.is_empty() {
814 return Err(format!(
815 "its runner left files a record must not keep ({}): runner images before runnerd 0.0.7 do not delete \
816 them; use the image of runnerd {}, or rebuild image_stage from its image/",
817 record::listed(&secrets),
818 env!("CARGO_PKG_VERSION")
819 ));
820 }
821 let incoming = docker
822 .written(container)
823 .await
824 .map_err(|error| format!("Docker did not measure it: {error}"))?
825 .saturating_mul(COMMIT_FACTOR);
826 let free = match docker.free_space().await {
827 Ok(free) => Some(free),
828 Err(error) => {
829 warn!("cannot measure Docker's free disk: {error}");
830 None
831 }
832 };
833 Ok(Checked { incoming, free })
834}