Skip to main content

runnerd_core/scheduler/engine/
records.rs

1//! Records of failed jobs (`crate::record`): keeps a failed job's stopped runner container as an image before it is
2//! removed, keeps the records within `[records]`, removes them on request, and runs the shells opened in them, whose
3//! memory counts against the budget like a slot's. It must not serve a shell's terminal: the daemon does, with the
4//! `Tty` `open_shell` returns, and closes it with `Control::close_shell`.
5
6use 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/// A record being made: its container checked and measured, then committed and removed, each in a task of its own
27/// so the engine's loop does not wait for Docker's walk of the container's files.
28#[derive(Debug)]
29pub(super) struct Recording {
30    record: api::Record,
31    container: String,
32    /// The disk its commit is expected to take, once measured: what the records made after it count against
33    /// `min_free` until it is done.
34    estimate: u64,
35    stage: Stage,
36}
37
38#[derive(Debug)]
39enum Stage {
40    /// Its changes checked for secrets, and its size and Docker's free disk measured.
41    Checking(JoinHandle<Result<Checked, String>>),
42    /// The commit, then the container's removal; the record's size.
43    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/// What checking a container found: it may be kept, at about `incoming` bytes, with `free` bytes on Docker's disk
56/// when known.
57#[derive(Debug)]
58struct Checked {
59    incoming: u64,
60    free: Option<u64>,
61}
62
63/// The records of failed jobs and the shells open in them.
64#[derive(Debug)]
65pub(super) struct RecordState {
66    /// Oldest first.
67    list: Vec<api::Record>,
68    /// Whether `list` is what Docker has: until Docker lists them, no record is made.
69    pub(super) listed: bool,
70    /// Records being made, by job.
71    pub(super) recordings: BTreeMap<u64, Recording>,
72    /// Shells open in records, by container ID.
73    pub(super) shells: BTreeMap<String, Shell>,
74    /// Shells opened by this engine, which tells their containers' names apart.
75    opened: u64,
76    /// What `Handle::records` shows.
77    status: watch::Sender<api::Records>,
78    /// A recording's tasks say here that they are done, so the loop moves them on without waiting for a tick.
79    done: mpsc::UnboundedSender<()>,
80    pub(super) finished: mpsc::UnboundedReceiver<()>,
81}
82
83impl RecordState {
84    /// No record known yet, and the receiver of what `publish_records` shows.
85    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/// A shell open in a record.
103#[derive(Debug, Clone)]
104pub(super) struct Shell {
105    job: u64,
106    /// The profile the record's job ran in, whose snapshot and shared caches the shell mounts.
107    pub(super) profile: String,
108    /// Its container's memory cap, which counts against the budget.
109    pub(super) memory: u64,
110    opened: Instant,
111}
112
113/// The longest a shell stays open: a forgotten one holds a slot's memory and keeps its record from going.
114const MAX_SHELL: Duration = Duration::from_hours(8);
115
116/// Which records a removal asks for.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum RecordSelection {
119    /// The record of one job.
120    Job(u64),
121    /// Every record without a shell open.
122    All,
123    /// The records older than this, without a shell open.
124    OlderThan(Duration),
125}
126
127/// What a shell container runs, as root: the profile's snapshot under the cache layers as a runner sees them, with
128/// the shell's own changes in its anonymous /state, then a login shell as `runner` in the directory `$1`.
129const 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
136/// The disk Docker takes per byte a container's layer holds once committed: the containerd image store keeps the
137/// layer's archive besides its unpacked copy.
138const COMMIT_FACTOR: u64 = 2;
139
140impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
141    /// Whether the profile of a slot keeps its failed jobs' runners.
142    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    /// Starts keeping a stopped runner container whose job failed as the job's record: checks and measures it, makes
151    /// room within the limits, then commits and removes it, in the background. Returns whether it took the container
152    /// over; if not, the caller removes it.
153    pub(super) async fn begin_record(&mut self, slot: &SlotRef, occupant: &Occupant) -> bool {
154        // The capture's last report may have named the job after `occupant` was taken.
155        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    /// Notes that a stopped runner container an earlier daemon left was not kept, if its profile keeps failed jobs'
218    /// runners, its job failed and no record of it was made: no daemon ran when it stopped, or one stopped while it
219    /// made the record.
220    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    /// Notes in the history why a failed job has no record.
241    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    /// Makes room for a checked container within the limits, removing the records in its way, then commits it in
247    /// the background; or notes why it is not kept and removes it.
248    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        // Records being committed take their disk too.
256        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    /// Moves the recordings whose task finished on: a checked container to its commit, a committed one into the
324    /// records, kept within their limits with its real size.
325    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    /// Adds a committed record, and removes the records past the limits with its real size.
377    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    /// Fills in a record's workflow and job name from the history, which may have learnt them from GitHub only after
406    /// the commit started.
407    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    /// Lists the record images Docker has; until it answers, no record is made.
418    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                    // Committed before an earlier daemon stopped, which did not get to note it.
443                    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    /// Removes the records past `max_age`.
458    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    /// Removes one record's image, and its layers with it.
473    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    /// Removes the selected records, but those with a shell open: one asked for by its job is refused instead.
499    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    /// The jobs whose record has a shell open.
532    fn shell_jobs(&self) -> BTreeSet<u64> {
533        self.records
534            .shells
535            .values()
536            .map(|shell| shell.job)
537            .collect()
538    }
539
540    /// The profiles whose snapshot and shared caches a shell mounts.
541    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    /// The shells' part of the memory in use.
550    pub(super) fn shell_memory(&self) -> u64 {
551        self.records.shells.values().map(|shell| shell.memory).sum()
552    }
553
554    /// The shells' memory by job, for the published budget.
555    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    /// Shows the records as they are now, if they changed.
567    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    /// Every record and the limits, as `GET /v1/records` shows them.
577    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    /// Starts a shell in a job's record, with the record's profile's limits, if the budget has room for them as for
592    /// a slot of the profile.
593    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        // A reseed replaces the snapshot the shell's layers sit on.
618        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        // A trim removes the shared caches' volumes, which a shell mounts.
625        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        // Under measured admission nothing starts at the high-water mark, a shell no more than a runner.
645        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    /// The container of a shell in `record`: the record's image under the profile's caps, its snapshot and shared
677    /// caches mounted read-only, and a terminal.
678    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            // The snapshot and the shared caches as the job saw them, read-only: running jobs share them.
688            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    /// Removes a shell's container and frees its memory. One Docker does not remove now goes at a later tick.
734    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    /// Removes the shells whose process exited, whatever became of their terminal, and those open longer than
751    /// `MAX_SHELL`.
752    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    /// Removes every shell: on shutdown, and those an earlier daemon left, which no terminal is attached to.
776    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
784/// The labels a record's image carries, which `list_records` reads back.
785fn 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
804/// Checks that a stopped runner container may be kept, and measures it: refuses one whose changes still hold a
805/// secret, and estimates the disk its commit takes. Docker walks the container's files for both, so it runs in a
806/// task.
807async 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 ({}): its image did not delete them; use the image of \
816             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}