1use std::path::{Path, PathBuf};
8
9use qframe::widgets::TerminalSession;
10
11use crate::base;
12use crate::engine::names;
13use crate::engine::run::{EngineError, build_image, capture, stream};
14use crate::engine::scratch::Scratch;
15use crate::engine::{Access, ContainerCreate, CopyIn, Engine, Exec, HostUser, ImageBuild, Mount, MountSource, Network};
16use crate::profile::identity::{self, STORE_DIR};
17use crate::profile::own::{self, Own};
18use crate::profile::{Profile, SafeName, account};
19
20use super::recipe;
21use super::shell;
22use super::status::{Readiness, Revision, Status};
23
24const IDLE: &str = "86400";
27
28#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Problem {
33 Refused(String),
35 Unreachable(String),
37 Cancelled,
39 Machine(String),
42 NoLogin,
44}
45
46impl From<EngineError> for Problem {
47 fn from(error: EngineError) -> Self {
48 match error {
49 EngineError::NotRunnable { error, .. } => Self::Unreachable(error.to_string()),
50 EngineError::Failed(failure) => Self::Refused(failure.output),
51 EngineError::Cancelled { .. } => Self::Cancelled,
52 EngineError::TimedOut { command, after } => {
53 Self::Unreachable(crate::engine::run::timed_out(&command, after))
54 }
55 }
56 }
57}
58
59impl From<base::Failure> for Problem {
60 fn from(failure: base::Failure) -> Self {
61 match failure {
62 base::Failure::Host(error) => Self::Machine(error.to_string()),
63 base::Failure::Engine(error) => error.into(),
64 }
65 }
66}
67
68impl Problem {
69 #[must_use]
71 pub fn output(&self) -> Option<&str> {
72 match self {
73 Self::Refused(text) | Self::Unreachable(text) | Self::Machine(text) => Some(text),
74 Self::Cancelled | Self::NoLogin => None,
75 }
76 }
77}
78
79#[must_use]
82pub fn probe(engine: &Engine, profiles: &[Profile]) -> Vec<Status> {
83 let volumes = capture(&engine.list_volumes()).ok();
84 profiles
85 .iter()
86 .map(|profile| {
87 let asked = capture(&engine.image_label(&profile.image(), recipe::REVISION_LABEL));
91 let (image, revision) = match asked {
92 Ok(label) if label.trim() == recipe::image(profile).revision() => {
93 (Readiness::Present, Revision::Current)
94 }
95 Ok(_) => (Readiness::Present, Revision::Earlier),
96 Err(_) => (Readiness::Missing, Revision::Unknown),
97 };
98 let identity = match &volumes {
99 Some(listing) => Readiness::of(identity::is_stored(listing, &profile.name)),
100 None => Readiness::Unknown,
101 };
102 Status { name: profile.name.clone(), image, revision, identity }
103 })
104 .collect()
105}
106
107pub fn build(
114 engine: &Engine,
115 profile: &Profile,
116 cancel: &dyn Fn() -> bool,
117 line: &mut dyn FnMut(&str),
118) -> Result<(), Problem> {
119 build_from(engine, &profile.image(), &recipe::image(profile), None, Fresh::No, cancel, line)
120}
121
122#[derive(Debug, Clone, Default)]
124pub struct Additions {
125 pub own: Own,
127 pub archive: Option<PathBuf>,
129}
130
131impl Additions {
132 #[must_use]
136 pub fn of(profiles: &Path, profile: &SafeName) -> Self {
137 Self { own: Own::load(profiles, profile).0, archive: shell::archive_of(profiles, profile) }
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143enum Fresh {
144 No,
146 Rebuild,
148}
149
150fn build_from(
152 engine: &Engine,
153 image: &str,
154 recipe: &recipe::Recipe,
155 additions: Option<&Additions>,
156 fresh: Fresh,
157 cancel: &dyn Fn() -> bool,
158 line: &mut dyn FnMut(&str),
159) -> Result<(), Problem> {
160 let folder = Scratch::new(&format!("build-{}", image.replace('/', "-")))
162 .map_err(|error| Problem::Machine(error.to_string()))?;
163 let context = folder.path();
164 let containerfile = context.join("Containerfile");
165 let tail = additions.map(|added| added.own.containerfile_tail(added.archive.is_some())).unwrap_or_default();
168 let written = std::fs::write(&containerfile, recipe.labelled() + &tail).and_then(|()| {
169 if let Some(archive) = additions.and_then(|added| added.archive.as_ref()) {
170 std::fs::copy(archive, context.join(own::HOME_ARCHIVE))?;
171 }
172 for (path, contents) in &recipe.files {
173 let target = context.join(path);
174 if let Some(parent) = target.parent() {
175 std::fs::create_dir_all(parent)?;
176 }
177 std::fs::write(&target, contents)?;
178 }
179 Ok(())
180 });
181 let result = match written {
182 Ok(()) => {
183 let request = ImageBuild { image, containerfile: &containerfile, context };
184 match fresh {
185 Fresh::No => build_image(engine, &request, cancel, line).map_err(Problem::from),
186 Fresh::Rebuild => stream(&engine.rebuild_image(&request), cancel, line).map_err(Problem::from),
192 }
193 }
194 Err(error) => Err(Problem::Machine(error.to_string())),
195 };
196 drop(folder);
197 result
198}
199
200pub fn build_whole(
212 engine: &Engine,
213 profile: &Profile,
214 cancel: &dyn Fn() -> bool,
215 base_started: &mut dyn FnMut(),
216 line: &mut dyn FnMut(&str),
217) -> Result<(), Problem> {
218 ensure_base(engine, profile, cancel, base_started, line)?;
219 build(engine, profile, cancel, line)
220}
221
222pub fn build_whole_in(
230 engine: &Engine,
231 profile: &Profile,
232 profiles: &Path,
233 cancel: &dyn Fn() -> bool,
234 base_started: &mut dyn FnMut(),
235 line: &mut dyn FnMut(&str),
236) -> Result<(), Problem> {
237 ensure_base(engine, profile, cancel, base_started, line)?;
238 let additions = Additions::of(profiles, &profile.name);
239 build_from(engine, &profile.image(), &recipe::image(profile), Some(&additions), Fresh::No, cancel, line)
240}
241
242pub fn rebuild(
260 engine: &Engine,
261 profile: &Profile,
262 profiles: &Path,
263 cancel: &dyn Fn() -> bool,
264 base_started: &mut dyn FnMut(),
265 line: &mut dyn FnMut(&str),
266) -> Result<(), Problem> {
267 ensure_base(engine, profile, cancel, base_started, line)?;
268 let additions = Additions::of(profiles, &profile.name);
269 rebuild_from(engine, &profile.image(), &recipe::image(profile), Some(&additions), cancel, line)
270}
271
272pub(crate) fn rebuild_from(
280 engine: &Engine,
281 image: &str,
282 recipe: &recipe::Recipe,
283 additions: Option<&Additions>,
284 cancel: &dyn Fn() -> bool,
285 line: &mut dyn FnMut(&str),
286) -> Result<(), Problem> {
287 let before = capture(&engine.image_exists(image)).ok();
288 build_from(engine, image, recipe, additions, Fresh::Rebuild, cancel, line)?;
289 let after = capture(&engine.image_exists(image)).ok();
293 if let Some(before) = before.as_deref().map(str::trim)
294 && after.as_deref().map(str::trim) != Some(before)
295 {
296 let _ = capture(&engine.remove_unused_image(before));
297 }
298 let _ = clear_leftovers(engine);
303 Ok(())
304}
305
306#[must_use]
316pub fn clear_leftovers(engine: &Engine) -> usize {
317 let Ok(listed) = capture(&engine.our_leftover_images()) else { return 0 };
318 listed
319 .lines()
320 .map(str::trim)
321 .filter(|id| !id.is_empty())
322 .filter(|id| capture(&engine.remove_unused_image(id)).is_ok())
323 .count()
324}
325
326fn ensure_base(
329 engine: &Engine,
330 profile: &Profile,
331 cancel: &dyn Fn() -> bool,
332 base_started: &mut dyn FnMut(),
333 line: &mut dyn FnMut(&str),
334) -> Result<(), Problem> {
335 let mut announced = false;
336 base::ensure_os(engine, profile.os, cancel, &mut |text| {
337 if !announced {
338 announced = true;
339 base_started();
340 }
341 line(text);
342 })?;
343 Ok(())
344}
345
346#[derive(Debug)]
348pub struct LoginContainer {
349 pub name: String,
351 pub capture: PathBuf,
353 folder: Option<Scratch>,
356}
357
358impl LoginContainer {
359 #[must_use]
362 pub fn into_folder(name: String, capture: PathBuf) -> Self {
363 Self { name, capture, folder: None }
364 }
365}
366
367pub fn open_login(engine: &Engine, profile: &Profile) -> Result<LoginContainer, Problem> {
377 let name = login_container(&profile.name);
378 let _ = capture(&engine.remove_container(&name));
380 let folder =
381 Scratch::new(&format!("login-{}", profile.name)).map_err(|error| Problem::Machine(error.to_string()))?;
382 let user = HostUser::current().map_err(|error| Problem::Machine(error.to_string()))?;
383 let mounts = [Mount {
384 source: MountSource::Path(folder.path()),
385 target: Path::new(recipe::CAPTURE_DIR),
386 access: Access::ReadWrite,
387 }];
388 let request = ContainerCreate {
389 name: &name,
390 hostname: names::HOSTNAME,
391 labels: &[],
392 image: &profile.image(),
393 mounts: &mounts,
394 network: Network::Full,
395 user,
396 workdir: None,
397 command: &["sleep", IDLE],
398 };
399 let started = capture(&engine.create_container(&request)).and_then(|_| capture(&engine.start_container(&name)));
400 match started {
401 Ok(_) => Ok(LoginContainer { name, capture: folder.path().to_owned(), folder: Some(folder) }),
402 Err(error) => Err(error.into()),
403 }
404}
405
406pub fn start_harness(engine: &Engine, profile: &Profile, container: &str) -> Result<TerminalSession, Problem> {
416 let command = [profile.harness.record().command];
417 let request = engine.exec(&Exec { container, command: &command });
418 TerminalSession::spawn(request.program.as_os_str(), &request.args, &std::env::temp_dir())
419 .map_err(|error| Problem::Machine(error.to_string()))
420}
421
422pub fn store_login(engine: &Engine, profile: &Profile, container: &LoginContainer) -> Result<usize, Problem> {
434 let script = recipe::capture_script(profile);
435 let command = ["sh", "-c", script.as_str()];
436 match capture(&engine.exec_without_terminal(&Exec { container: &container.name, command: &command })) {
439 Ok(_) => {}
440 Err(EngineError::Failed(_)) => return Err(Problem::NoLogin),
441 Err(error) => return Err(error.into()),
442 }
443 if let Some(take) = account::take(profile.harness, recipe::CAPTURE_DIR) {
446 let words: Vec<&str> = take.iter().map(String::as_str).collect();
447 capture(&engine.exec_without_terminal(&Exec { container: &container.name, command: &words }))?;
448 }
449 let stored = count_files(&container.capture);
450 if stored == 0 {
451 return Err(Problem::NoLogin);
452 }
453
454 let holder = credentials_container(&profile.name);
455 let _ = capture(&engine.remove_container(&holder));
456 let volume = names::credential_volume(profile.name.as_str());
457 let user = HostUser::current().map_err(|error| Problem::Machine(error.to_string()))?;
458 let mounts =
459 [Mount { source: MountSource::Volume(&volume), target: Path::new(STORE_DIR), access: Access::ReadWrite }];
460 let request = ContainerCreate {
461 name: &holder,
462 hostname: names::HOSTNAME,
463 labels: &[],
464 image: &profile.image(),
465 mounts: &mounts,
466 network: Network::None,
467 user,
468 workdir: None,
469 command: &["sleep", IDLE],
470 };
471 let result = capture(&engine.create_container(&request))
472 .and_then(|_| capture(&engine.start_container(&holder)))
473 .and_then(|_| {
474 capture(&engine.copy_in(&CopyIn {
475 host: &container.capture.join("."),
476 container: &holder,
477 target: Path::new(STORE_DIR),
478 }))
479 });
480 let _ = capture(&engine.remove_container(&holder));
481 match result {
482 Ok(_) => Ok(stored),
483 Err(error) => {
484 let _ = capture(&engine.remove_volume(&volume));
486 Err(error.into())
487 }
488 }
489}
490
491pub fn close_login(engine: &Engine, container: &LoginContainer) {
495 let _ = capture(&engine.remove_container(&container.name));
496 if let Some(folder) = &container.folder {
498 let _ = std::fs::remove_dir_all(folder.path());
499 }
500}
501
502pub fn sign_out(engine: &Engine, profile: &SafeName) -> Result<(), Problem> {
508 let volume = identity::sign_out(profile);
509 match capture(&engine.remove_volume(&volume)) {
510 Ok(_) => Ok(()),
511 Err(error) => Err(error.into()),
512 }
513}
514
515fn count_files(folder: &Path) -> usize {
517 let Ok(entries) = std::fs::read_dir(folder) else { return 0 };
518 entries
519 .flatten()
520 .map(|entry| {
521 let path = entry.path();
522 if path.is_dir() { count_files(&path) } else { 1 }
523 })
524 .sum()
525}
526
527fn login_container(profile: &SafeName) -> String {
529 format!("qcode-login-{profile}")
530}
531
532fn credentials_container(profile: &SafeName) -> String {
534 format!("qcode-store-{profile}")
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::engine::names::BASE_IMAGE;
541
542 #[test]
543 fn a_problem_carries_the_engines_own_words() {
544 let refused = Problem::Refused("Error: no such image qcode/base".to_owned());
545 assert_eq!(refused.output(), Some("Error: no such image qcode/base"));
546 assert_eq!(Problem::NoLogin.output(), None);
547 assert_eq!(Problem::Cancelled.output(), None);
548 }
549
550 #[test]
551 fn the_base_image_is_what_a_profile_image_is_built_on() {
552 assert_eq!(BASE_IMAGE, "qcode/base");
554 }
555
556 fn recording(name: &str, listed: &str, refused: &[&str]) -> (Engine, PathBuf, PathBuf) {
562 let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
563 let folder = std::env::temp_dir().join(format!("qcode-work-{name}-{stamp}"));
564 std::fs::create_dir_all(&folder).expect("a folder");
565 let (binary, calls) = (folder.join("engine"), folder.join("calls"));
566 let refusing = refused
567 .iter()
568 .map(|call| format!(" '{call}'*) printf 'Error: the image is in use by a container\\n' >&2; exit 125 ;;"))
569 .collect::<Vec<String>>()
570 .join("\n");
571 let script = format!(
572 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{calls}'\ncase \"$1 $2 $3\" in\n{refusing}\nesac\n\
573 case \"$1 $2\" in\n 'images --quiet') printf '{listed}';;\nesac\nexit 0\n",
574 calls = calls.display(),
575 );
576 std::fs::write(&binary, script).expect("the stand-in engine");
577 std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("runnable");
578 (Engine::new(crate::engine::EngineKind::Podman, &binary), calls, folder)
579 }
580
581 fn asked(calls: &Path) -> Vec<String> {
583 std::fs::read_to_string(calls).unwrap_or_default().lines().map(str::to_owned).collect()
584 }
585
586 #[test]
589 fn an_image_of_ours_goes_only_while_no_container_is_made_from_it() {
590 let (engine, calls, folder) = recording("leftovers", "sha-one\\nsha-two\\n", &["image rm sha-two"]);
591 assert_eq!(clear_leftovers(&engine), 1, "one of the two went");
592 assert_eq!(
593 asked(&calls),
594 [
595 "images --quiet --no-trunc --filter dangling=true --filter label=qcode.profile",
596 "image rm sha-one",
597 "image rm sha-two",
598 ],
599 "the listing and one plain removal each: {:#?}",
600 asked(&calls)
601 );
602 let _ = std::fs::remove_dir_all(&folder);
603 }
604
605 #[test]
608 fn an_engine_that_cannot_list_the_leftovers_is_asked_to_take_nothing_away() {
609 let (engine, calls, folder) = recording("no-leftovers", "", &["images --quiet"]);
610 assert_eq!(clear_leftovers(&engine), 0);
611 assert_eq!(
612 asked(&calls),
613 ["images --quiet --no-trunc --filter dangling=true --filter label=qcode.profile"],
614 "{:#?}",
615 asked(&calls)
616 );
617 let _ = std::fs::remove_dir_all(&folder);
618 }
619}