Skip to main content

qcode/ui/workspace/
keep.rs

1//! What a person installs as the administrator in a workspace stays in that workspace.
2//!
3//! A workspace's container is made again whenever its plan changes or its profile's image is
4//! rebuilt, and everything outside the home and the workspace's folders goes with it. So before a
5//! container with changes to the system is removed, it is committed to an image of the workspace's
6//! own ([`image`]), and the next container of the same profile is made from that image while the
7//! profile's image under it is the same one. Nothing of it ever goes back to the profile or to
8//! another workspace: the image is named after both, and only this workspace's container is made
9//! from it.
10//!
11//! When the profile's image is rebuilt the old layer cannot be carried onto the new image. The
12//! administrator's commands, recorded in the workspace's home ([`HISTORY`]), are run again on the
13//! new image in a short-lived container and that one is committed instead. A command that fails
14//! leaves the workspace on its old image, and the person is told which one it was.
15//!
16//! Everything here runs engine commands and waits for them, on a background thread.
17
18use std::path::Path;
19
20use crate::base::paths::{HOME_DIR, KEEP_ALIVE, USER};
21use crate::engine::run::{EngineError, capture};
22use crate::engine::{Access, ContainerCreate, Engine, EngineCommand, Exec, HostUser, Mount, MountSource, names};
23use crate::profile::changes::Changes;
24
25use super::plan::ContainerPlan;
26
27/// Where the administrator's commands in a workspace are recorded: in the workspace's own home,
28/// so they last as long as the workspace, go with a copy of it, and are nobody else's.
29pub const HISTORY: &str = "/home/qcode/.qcode-admin-history";
30
31/// The label a workspace's image carries the identity of the profile's image it was made on in.
32pub const BASE_LABEL: &str = "qcode.workspace.base";
33
34/// The image a workspace keeps what its administrator installed for one of its profiles in.
35///
36/// Both names are in it as folders of their own, so two pairs whose names joined the same way
37/// (`a-b` with `c`, and `a` with `b-c`) never share an image.
38#[must_use]
39pub fn image(workspace: &str, profile: &str) -> String {
40    format!("qcode/workspace/{workspace}/{profile}")
41}
42
43/// The command an "As administrator" tab runs: `bash` as root in the container of `plan`,
44/// recording each command in the workspace's home as it is run.
45#[must_use]
46pub fn enter_admin(engine: &Engine, plan: &ContainerPlan) -> EngineCommand {
47    let env = [("HISTFILE", HISTORY), ("PROMPT_COMMAND", "history -a")];
48    engine.exec_as_root(&Exec { container: &plan.name, command: &["bash"] }, &env)
49}
50
51/// Whether a plan is one a workspace keeps an image of its own for: a profile's command-line
52/// container. The shell's container is the base image's and a window's is made afresh each time.
53fn kept(plan: &ContainerPlan) -> Option<(&str, &str)> {
54    if plan.home.is_none() || plan.window.is_some() {
55        return None;
56    }
57    let home = plan.home.as_ref()?;
58    Some((home.workspace().as_str(), home.profile().as_str()))
59}
60
61/// What the container of `plan` is to be made from, and why.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Made {
64    /// The profile's image: the workspace keeps nothing of its own for this profile.
65    Profile,
66    /// The workspace's own image, made on the profile's image as it is now.
67    Own(String),
68    /// The workspace's own image, made on an earlier build of the profile's image, because running
69    /// the recorded commands again on the new one failed at this command.
70    Behind {
71        /// The workspace's image.
72        image: String,
73        /// The command that failed.
74        failed: String,
75    },
76}
77
78/// Keeps what the stopped container of `plan` changed in the system before it is removed: when
79/// its `diff` shows such a change, the container is committed onto the workspace's image, which
80/// is labelled with the profile's image it descends from. A container without such changes, or a
81/// plan the workspace keeps nothing for, is left alone.
82///
83/// # Errors
84///
85/// When the engine refuses to list or commit.
86pub fn keep(engine: &Engine, plan: &ContainerPlan) -> Result<(), EngineError> {
87    let Some((workspace, profile)) = kept(plan) else { return Ok(()) };
88    let changes = Changes::parse(&capture(&engine.container_diff(&plan.name))?);
89    if changes.system.is_empty() {
90        return Ok(());
91    }
92    let own = image(workspace, profile);
93    // Made from the workspace's image, the container descends from what that one did; made from
94    // the profile's, from that one.
95    let made_from = capture(&engine.container_image(&plan.name))?;
96    let own_id = capture(&engine.image_exists(&own)).ok();
97    let base = if own_id.as_deref().map(str::trim) == Some(made_from.trim()) {
98        label(engine, &own).unwrap_or_default()
99    } else {
100        made_from.trim().to_owned()
101    };
102    capture(&engine.commit_container(&plan.name, &own, USER, &[(BASE_LABEL, &base)]))?;
103    Ok(())
104}
105
106/// Removes the workspace image `own` had before a commit, once nothing is made from it.
107pub fn let_go(engine: &Engine, old: Option<&str>) {
108    if let Some(old) = old {
109        let _ = capture(&engine.remove_unused_image(old));
110    }
111}
112
113/// The identity of the workspace's image of `plan`, when it has one.
114#[must_use]
115pub fn current(engine: &Engine, plan: &ContainerPlan) -> Option<String> {
116    let (workspace, profile) = kept(plan)?;
117    capture(&engine.image_exists(&image(workspace, profile))).ok().map(|id| id.trim().to_owned())
118}
119
120/// The workspace's image of `plan` when there is one, and whether it was made on the profile's
121/// image as it is now; asked without running anything, to tell whether a stopped container is
122/// still the one to start.
123#[must_use]
124pub fn standing(engine: &Engine, plan: &ContainerPlan) -> Option<(String, bool)> {
125    let (workspace, profile) = kept(plan)?;
126    let own = image(workspace, profile);
127    capture(&engine.image_exists(&own)).ok()?;
128    let now = capture(&engine.image_exists(&plan.image)).ok();
129    let fresh = now.is_some_and(|now| label(engine, &own).as_deref() == Some(now.trim()));
130    Some((own, fresh))
131}
132
133/// What the next container of `plan` is made from: the workspace's image when there is one made
134/// on the profile's image as it is; when the profile's image was rebuilt since, the recorded
135/// commands run again on it first, and the workspace stays on the image it had when one fails.
136#[must_use]
137pub fn choose(engine: &Engine, plan: &ContainerPlan, user: HostUser) -> Made {
138    let Some((workspace, profile)) = kept(plan) else { return Made::Profile };
139    let own = image(workspace, profile);
140    if capture(&engine.image_exists(&own)).is_err() {
141        return Made::Profile;
142    }
143    let Ok(now) = capture(&engine.image_exists(&plan.image)) else { return Made::Own(own) };
144    if label(engine, &own).as_deref() == Some(now.trim()) {
145        return Made::Own(own);
146    }
147    match replay(engine, plan, &own, now.trim(), user) {
148        Ok(()) => Made::Own(own),
149        Err(failed) => Made::Behind { image: own, failed },
150    }
151}
152
153/// The value of [`BASE_LABEL`] on `image`.
154fn label(engine: &Engine, image: &str) -> Option<String> {
155    let said = capture(&engine.image_label(image, BASE_LABEL)).ok()?;
156    let said = said.trim();
157    (!said.is_empty() && said != "<no value>").then(|| said.to_owned())
158}
159
160/// Runs the workspace's recorded administrator commands again on the profile's rebuilt image in a
161/// container of its own, and commits it onto the workspace's image. Answers the command that
162/// failed, or the engine's words when the engine itself refused; the workspace's image is then
163/// as it was.
164fn replay(engine: &Engine, plan: &ContainerPlan, own: &str, base: &str, user: HostUser) -> Result<(), String> {
165    let Some(home) = plan.home.as_ref() else { return Ok(()) };
166    let name = format!("{}.replay", plan.name);
167    let volume = home.volume();
168    let mounts =
169        [Mount { source: MountSource::Volume(&volume), target: Path::new(HOME_DIR), access: Access::ReadOnly }];
170    let request = ContainerCreate {
171        name: &name,
172        hostname: names::HOSTNAME,
173        labels: &[],
174        image: &plan.image,
175        mounts: &mounts,
176        network: plan.network,
177        user,
178        workdir: None,
179        command: KEEP_ALIVE,
180    };
181    let _ = capture(&engine.remove_container(&name));
182    let said = |error: EngineError| match error {
183        EngineError::Failed(failure) => failure.output,
184        other => format!("{other:?}"),
185    };
186    let result = capture(&engine.create_container(&request))
187        .and_then(|_| capture(&engine.start_container(&name)))
188        .map_err(said)
189        .and_then(|_| match capture(&replay_command(engine, &name)) {
190            Ok(_) => Ok(()),
191            Err(EngineError::Failed(failure)) => Err(failure.output.trim().to_owned()),
192            Err(other) => Err(format!("{other:?}")),
193        })
194        .and_then(|()| {
195            let old = capture(&engine.image_exists(own)).ok();
196            let old_base = label(engine, own);
197            capture(&engine.commit_container(&name, own, USER, &[(BASE_LABEL, base)])).map_err(said)?;
198            let _ = capture(&engine.remove_container(&name));
199            // The workspace's old image, and then the profile's image from before the rebuild,
200            // which only that one still held.
201            let_go(engine, old.as_deref().map(str::trim));
202            let_go(engine, old_base.as_deref());
203            Ok(())
204        });
205    let _ = capture(&engine.remove_container(&name));
206    result
207}
208
209/// The loop that runs each command of the record at `history` again, one at a time, and on the
210/// first that fails prints it and stops.
211///
212/// The commands were typed into `bash` by a person who answered what they asked, so each runs in
213/// `bash` with every question answered yes: `apt install jq` asks before it installs, and with
214/// nothing to read from it would give up. `exit` only ended the shell it was typed in.
215/// The command that runs the recorded administrator commands again in the container `name`.
216/// It is waited for however long it takes: each command may install packages, and may take up to
217/// the quarter of an hour [`replay_loop`] gives it.
218fn replay_command(engine: &Engine, name: &str) -> EngineCommand {
219    let script = format!("{}; rm -rf /var/lib/apt/lists/*", replay_loop(HISTORY));
220    engine.exec_as_root_without_terminal(&Exec { container: name, command: &["sh", "-c", &script] }).unbounded()
221}
222
223fn replay_loop(history: &str) -> String {
224    format!(
225        "while IFS= read -r line; do case \"$line\" in ''|exit|logout|'#'*) continue ;; esac; \
226         yes 2>/dev/null | DEBIAN_FRONTEND=noninteractive timeout 900 bash -c \"$line\" >/dev/null 2>&1 \
227         || {{ printf '%s' \"$line\"; exit 1; }}; done < {history}"
228    )
229}
230
231#[cfg(test)]
232mod tests {
233    #[test]
234    fn installing_again_on_a_rebuilt_image_is_waited_for_however_long_it_takes() {
235        let engine = Engine::new(crate::engine::EngineKind::Podman, "/usr/bin/podman");
236        let command = super::replay_command(&engine, "qcode-a-b.replay");
237        assert_eq!(command.deadline, None, "apt can take minutes; a question's limit would cut it off");
238    }
239
240    use super::*;
241
242    #[test]
243    fn a_workspaces_image_names_the_workspace_and_the_profile_apart() {
244        assert_eq!(image("site", "claude"), "qcode/workspace/site/claude");
245        assert_ne!(image("a-b", "c"), image("a", "b-c"));
246    }
247
248    /// Runs the loop over `record` on this machine, which has `sh`, `bash`, `yes` and `timeout`
249    /// like every image; answers what it printed and whether it went through.
250    fn replayed(record: &str) -> (String, bool) {
251        let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
252        let history = std::env::temp_dir().join(format!("qcode-replay-{stamp}"));
253        std::fs::write(&history, record).expect("the record");
254        let output = std::process::Command::new("sh")
255            .args(["-c", &replay_loop(&history.display().to_string())])
256            .output()
257            .expect("sh runs");
258        let _ = std::fs::remove_file(&history);
259        (String::from_utf8_lossy(&output.stdout).into_owned(), output.status.success())
260    }
261
262    #[test]
263    fn a_command_that_asks_before_it_goes_on_is_answered_yes_when_it_runs_again() {
264        let (said, through) = replayed("true\nread answer; [ \"$answer\" = y ]\nexit\n");
265        assert!(through, "{said}");
266    }
267
268    #[test]
269    fn the_first_command_that_fails_is_named_and_nothing_after_it_runs() {
270        let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
271        let after = std::env::temp_dir().join(format!("qcode-replay-after-{stamp}"));
272        let record = format!("[[ 1 == 1 ]]\nfalse && echo never\ntouch {}\n", after.display());
273        let (said, through) = replayed(&record);
274        assert!(!through);
275        assert_eq!(said, "false && echo never");
276        assert!(!after.exists(), "a command after the failed one ran");
277    }
278}