1use 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
27pub const HISTORY: &str = "/home/qcode/.qcode-admin-history";
30
31pub const BASE_LABEL: &str = "qcode.workspace.base";
33
34#[must_use]
39pub fn image(workspace: &str, profile: &str) -> String {
40 format!("qcode/workspace/{workspace}/{profile}")
41}
42
43#[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
51fn 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#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Made {
64 Profile,
66 Own(String),
68 Behind {
71 image: String,
73 failed: String,
75 },
76}
77
78pub 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 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
106pub 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#[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#[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#[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
153fn 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
160fn 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 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
209fn 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 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}