Skip to main content

podbox/
build.rs

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
22/// SHA-256 hex digest of a string, used for lock-file invalidation.
23pub fn checksum(content: &str) -> String {
24    let mut hasher = Sha256::new();
25    hasher.update(content.as_bytes());
26    hex::encode(hasher.finalize())
27}
28
29/// Build context directory: ~/.local/share/podbox/<name>/
30pub 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
37/// Full build log for a container: ~/.local/state/podbox/<name>/build.log
38pub 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
46/// Last `n` non-empty lines of `text`, joined with newlines.
47/// Shown after a failed build so the user sees the error without opening
48/// the full log.
49fn 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
55/// Open (truncate) the build log and record the command being run.
56pub(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
68/// Run one timed phase with a progress step line.
69fn 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
81/// Run podman via the log-teeing runner; on failure emit a tail + rich
82/// BuildFailed error pointing at the log file.
83pub(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
109/// Run the full build orchestration.
110pub 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        // Custom builds bake the embedded guest into the image. Builds from
121        // the published crate have no guest (PODBOX_GUEST is None); reject
122        // up front so the user never gets partway through codegen first.
123        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
130// --- Custom build path ------------------------------------------------------
131
132fn 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    // Acquire exclusive build lock (auto-releases on panic/crash via kernel flock)
145    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    // Guarded by `run()` for custom builds; prebuilt builds never reach here.
154    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}