1use std::ffi::OsString;
2use std::io::Write;
3use std::os::unix::fs::PermissionsExt;
4use std::path::PathBuf;
5use std::time::Instant;
6
7use anyhow::{Context, Result};
8use nix::fcntl::{Flock, FlockArg};
9use sha2::{Digest, Sha256};
10
11use crate::codegen::containerfile;
12use crate::config::Config;
13use crate::env::HostEnv;
14use crate::error::PodboxError;
15use crate::ui;
16use crate::xdg::ResolvedXdgDirs;
17
18mod prebuilt;
19
20pub(crate) use prebuilt::run_prebuilt;
21
22pub fn checksum(content: &str) -> String {
24 let mut hasher = Sha256::new();
25 hasher.update(content.as_bytes());
26 hex::encode(hasher.finalize())
27}
28
29pub fn build_context_dir(name: &str) -> PathBuf {
31 dirs::data_dir()
32 .unwrap_or_else(|| PathBuf::from("~/.local/share"))
33 .join("podbox")
34 .join(name)
35}
36
37pub fn build_log_path(name: &str) -> PathBuf {
39 dirs::state_dir()
40 .unwrap_or_else(|| PathBuf::from("~/.local/state"))
41 .join("podbox")
42 .join(name)
43 .join("build.log")
44}
45
46fn tail_lines(text: &str, n: usize) -> String {
50 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
51 let start = lines.len().saturating_sub(n);
52 lines[start..].join("\n")
53}
54
55pub(crate) fn open_log(name: &str, cmd: &str) -> Result<std::fs::File> {
57 let path = build_log_path(name);
58 if let Some(parent) = path.parent() {
59 std::fs::create_dir_all(parent)
60 .with_context(|| format!("failed to create log dir '{}'", parent.display()))?;
61 }
62 let mut f = std::fs::File::create(&path)
63 .with_context(|| format!("failed to create build log '{}'", path.display()))?;
64 writeln!(f, "$ {cmd}")?;
65 Ok(f)
66}
67
68fn phase<T>(label: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
70 ui::step(label);
71 let start = Instant::now();
72 match f() {
73 Ok(v) => {
74 ui::ok(&format!("{label} ({:.1}s)", start.elapsed().as_secs_f32()));
75 Ok(v)
76 }
77 Err(e) => Err(e),
78 }
79}
80
81pub(crate) fn run_podman_logged(
84 args: &[OsString],
85 name: &str,
86 what: &str,
87 log: &mut std::fs::File,
88) -> Result<()> {
89 let mirror = ui::is_verbose();
90 let status = crate::process::run_with_log("podman", args, log, mirror)?;
91 if status.success() {
92 return Ok(());
93 }
94 let log_path = build_log_path(name);
95 let text = std::fs::read_to_string(&log_path).unwrap_or_default();
96 let tail = tail_lines(&text, 15);
97 if !tail.is_empty() {
98 eprintln!("\n{tail}");
99 }
100 Err(PodboxError::BuildFailed(format!(
101 "{what} failed ({status}).\n\n\
102 Hint: Full output: podman's complete log is at\n {}\n\
103 Re-run with --verbose to stream build output live.",
104 log_path.display()
105 ))
106 .into())
107}
108
109pub fn run(
111 config: &Config,
112 env: &HostEnv,
113 xdg: &ResolvedXdgDirs,
114 dry_run: bool,
115 rebuild: bool,
116) -> Result<()> {
117 if config.image.source().is_prebuilt() {
118 run_prebuilt(config, dry_run, rebuild)
119 } else {
120 if crate::guest::PODBOX_GUEST.is_none() {
124 return Err(PodboxError::GuestBinaryUnavailable.into());
125 }
126 run_build(config, env, xdg, dry_run, rebuild)
127 }
128}
129
130fn run_build(
133 config: &Config,
134 _env: &HostEnv,
135 _xdg: &ResolvedXdgDirs,
136 dry_run: bool,
137 rebuild: bool,
138) -> Result<()> {
139 let name = &config.container.name;
140 let context_dir = build_context_dir(name);
141 let containerfile_path = context_dir.join("Containerfile");
142 let lock_path = context_dir.join(".podbox.lock");
143
144 let _build_lock = if !dry_run {
146 std::fs::create_dir_all(&context_dir)?;
147 let file = std::fs::File::create(context_dir.join(".build.lock"))?;
148 Some(Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?)
149 } else {
150 None
151 };
152
153 let guest_bin = crate::guest::PODBOX_GUEST.expect("custom build without embedded guest");
155
156 let definition_toml = toml::to_string(config)
157 .with_context(|| "failed to serialize definition config".to_string())?;
158 let config_checksum = checksum(&definition_toml);
159
160 if !rebuild {
161 if let Some(lock) = crate::lock::read(&lock_path)? {
162 if lock.config_checksum == config_checksum {
163 println!("Definition unchanged and image already built. Skipping.");
164 println!("Use --rebuild to force.");
165 return Ok(());
166 }
167 }
168 }
169
170 let containerfile = containerfile::generate(config, "podbox-guest")?;
171
172 if dry_run {
173 println!("=== Build context: {} ===", context_dir.display());
174 println!("=== Containerfile ===");
175 println!("{containerfile}");
176 println!();
177 println!("=== Embedded podbox-guest ===");
178 println!("{} bytes (embedded in podbox binary)", guest_bin.len());
179 println!(
180 "podman build -t localhost/podbox-{}:latest {}",
181 config.image.name,
182 context_dir.display()
183 );
184 return Ok(());
185 }
186
187 phase("Writing build context", || {
188 std::fs::create_dir_all(&context_dir).map_err(|e| PodboxError::HomeCreateFailed {
189 path: context_dir.clone(),
190 source: e,
191 })?;
192 let _ = std::fs::set_permissions(&context_dir, std::fs::Permissions::from_mode(0o700));
193
194 std::fs::write(&containerfile_path, containerfile).with_context(|| {
195 format!(
196 "failed to write Containerfile to '{}'",
197 containerfile_path.display()
198 )
199 })?;
200
201 let guest_dest = context_dir.join("podbox-guest");
202 std::fs::write(&guest_dest, guest_bin).with_context(|| {
203 format!("failed to write guest binary to '{}'", guest_dest.display())
204 })?;
205
206 std::fs::create_dir_all(&config.container.home).with_context(|| {
207 format!(
208 "failed to create home dir '{}'",
209 config.container.home.display()
210 )
211 })?;
212 Ok(())
213 })?;
214
215 let tag = format!("localhost/podbox-{}:latest", config.image.name);
216 let args: Vec<OsString> = vec![
217 "build".into(),
218 "-t".into(),
219 tag.clone().into(),
220 context_dir.clone().into(),
221 ];
222
223 let mut log = open_log(
224 name,
225 &format!("podman build -t {tag} {}", context_dir.display()),
226 )?;
227 ui::step(&format!(
228 "Building image {tag} (log: {})",
229 build_log_path(name).display()
230 ));
231 let start = Instant::now();
232 run_podman_logged(&args, name, "podman build", &mut log)?;
233 ui::ok(&format!(
234 "Image {tag} built ({:.1}s)",
235 start.elapsed().as_secs_f32()
236 ));
237
238 phase("Writing lock file", || {
239 let digest = crate::podman::image_digest(&tag)?;
240 let lock = crate::lock::LockFile {
241 config_checksum,
242 image_digest: digest,
243 };
244 crate::lock::write(&lock_path, &lock)?;
245 Ok(())
246 })?;
247
248 Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn tail_lines_keeps_last_n_non_empty() {
257 let text = "a\n\nb\nc\nd";
258 assert_eq!(tail_lines(text, 2), "c\nd");
259 assert_eq!(tail_lines(text, 10), "a\nb\nc\nd");
260 assert_eq!(tail_lines("", 3), "");
261 }
262
263 #[test]
264 fn build_log_path_uses_state_dir() {
265 let p = build_log_path("myenv");
266 assert!(p.ends_with("podbox/myenv/build.log"));
267 }
268}