Skip to main content

podbox/
build.rs

1use std::ffi::OsString;
2use std::os::unix::fs::PermissionsExt;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use nix::fcntl::{Flock, FlockArg};
7use sha2::{Digest, Sha256};
8
9use crate::codegen::containerfile;
10use crate::codegen::distros::DistroFamily;
11use crate::config::Config;
12use crate::env::HostEnv;
13use crate::error::PodboxError;
14use crate::xdg::ResolvedXdgDirs;
15
16/// SHA-256 hex digest of a string, used for lock-file invalidation.
17pub fn checksum(content: &str) -> String {
18    let mut hasher = Sha256::new();
19    hasher.update(content.as_bytes());
20    hex::encode(hasher.finalize())
21}
22
23/// Build context directory: ~/.local/share/podbox/<name>/
24pub fn build_context_dir(name: &str) -> PathBuf {
25    dirs::data_dir()
26        .unwrap_or_else(|| PathBuf::from("~/.local/share"))
27        .join("podbox")
28        .join(name)
29}
30
31/// Run the full build orchestration.
32pub fn run(
33    config: &Config,
34    env: &HostEnv,
35    xdg: &ResolvedXdgDirs,
36    dry_run: bool,
37    rebuild: bool,
38) -> Result<()> {
39    if config.image.source().is_prebuilt() {
40        run_prebuilt(config, dry_run, rebuild)
41    } else {
42        // Custom builds bake the embedded guest into the image. Builds from
43        // the published crate have no guest (PODBOX_GUEST is None); reject
44        // up front so the user never gets partway through codegen first.
45        if crate::guest::PODBOX_GUEST.is_none() {
46            return Err(PodboxError::GuestBinaryUnavailable.into());
47        }
48        run_build(config, env, xdg, dry_run, rebuild)
49    }
50}
51
52// --- Prebuilt image path ----------------------------------------------------
53
54fn run_prebuilt(config: &Config, dry_run: bool, rebuild: bool) -> Result<()> {
55    let image_ref = match config.image.source() {
56        crate::config::ImageSource::Prebuilt { ref_str } => ref_str,
57        _ => config.image.base.clone(),
58    };
59    let local_tag = format!("localhost/podbox-{}:latest", config.image.name);
60    let context_dir = build_context_dir(&config.container.name);
61    let lock_path = context_dir.join(".podbox.lock");
62    let has_packages = !config.image.packages.install.is_empty();
63
64    // Acquire exclusive build lock (auto-releases on panic/crash via kernel flock)
65    let _build_lock = if !dry_run {
66        std::fs::create_dir_all(&context_dir)?;
67        let file = std::fs::File::create(context_dir.join(".build.lock"))?;
68        Some(Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?)
69    } else {
70        None
71    };
72
73    // Checksum covers both the image ref and the install list so that
74    // changing either triggers a rebuild.
75    let definition_toml = toml::to_string(config)
76        .with_context(|| "failed to serialize definition config".to_string())?;
77    let config_checksum = checksum(&definition_toml);
78
79    if !rebuild {
80        if let Some(lock) = crate::lock::read(&lock_path)? {
81            if lock.config_checksum == config_checksum && crate::podman::image_exists(&local_tag)? {
82                println!(
83                    "Prebuilt image already present as {}. Skipping pull.",
84                    local_tag
85                );
86                println!("Use --rebuild to re-pull.");
87                return Ok(());
88            }
89        }
90    }
91
92    if dry_run {
93        println!("Would pull: {}", image_ref);
94        if has_packages {
95            println!(
96                "Would install packages on top: {}",
97                config.image.packages.install.join(", ")
98            );
99        }
100        println!("Would tag as: {}", local_tag);
101        println!("Would write lock file at: {}", lock_path.display());
102        return Ok(());
103    }
104
105    // Warn on version mismatch from labels (best-effort, image may not exist yet)
106    if let Ok(labels) = crate::podman::image_labels(&image_ref) {
107        if let Some(guest_ver) = labels
108            .get("podbox.guest_version")
109            .or_else(|| labels.get("podmgr.guest_version"))
110        {
111            let guest_clean = guest_ver.trim_start_matches('v');
112            let host_clean = crate::VERSION.trim_start_matches('v');
113            if guest_clean != host_clean {
114                eprintln!(
115                    "Warning: image guest version (v{}) differs from host (v{}). \
116                     Protocol compatibility will be validated at runtime.",
117                    guest_clean, host_clean
118                );
119            }
120        }
121    }
122
123    println!("Pulling {}...", image_ref);
124    let status = std::process::Command::new("podman")
125        .args(["pull", &image_ref])
126        .status()?;
127    if !status.success() {
128        return Err(PodboxError::PullFailed {
129            image: image_ref.clone(),
130        }
131        .into());
132    }
133
134    if has_packages {
135        // Layer the config's packages on top of the prebuilt image.
136        let distro = resolve_prebuilt_distro(config);
137        let install_cmd = distro.install_cmd();
138        let clean_cmd = distro.clean_cmd();
139
140        let packages = config.image.packages.install.join(" ");
141        let run_line = if clean_cmd.is_empty() {
142            format!("RUN {} {}", install_cmd, packages)
143        } else {
144            format!("RUN {} {} && {}", install_cmd, packages, clean_cmd)
145        };
146
147        let containerfile = format!("FROM {}\n{}\n", image_ref, run_line);
148
149        std::fs::create_dir_all(&context_dir)
150            .with_context(|| format!("failed to create context dir '{}'", context_dir.display()))?;
151
152        let containerfile_path = context_dir.join("Containerfile");
153        std::fs::write(&containerfile_path, &containerfile).with_context(|| {
154            format!(
155                "failed to write Containerfile to '{}'",
156                containerfile_path.display()
157            )
158        })?;
159
160        println!("Installing packages on top of prebuilt image...");
161        let args: Vec<std::ffi::OsString> = vec![
162            "build".into(),
163            "-t".into(),
164            local_tag.clone().into(),
165            "-f".into(),
166            containerfile_path.clone().into(),
167            context_dir.clone().into(),
168        ];
169        let status = crate::process::spawn_interactive("podman", &args)
170            .with_context(|| format!("failed to build prebuilt overlay for '{}'", image_ref))?;
171        if !status.success() {
172            return Err(PodboxError::BuildFailed("overlay build failed".into()).into());
173        }
174        println!("Image {} ready with packages installed.", local_tag);
175    } else {
176        println!("Tagging as {}...", local_tag);
177        let status = std::process::Command::new("podman")
178            .args(["tag", &image_ref, &local_tag])
179            .status()?;
180        if !status.success() {
181            return Err(PodboxError::TagFailed {
182                image: local_tag.clone(),
183            }
184            .into());
185        }
186        println!("Image {} ready.", local_tag);
187    }
188
189    std::fs::create_dir_all(&config.container.home).with_context(|| {
190        format!(
191            "failed to create home dir '{}'",
192            config.container.home.display()
193        )
194    })?;
195    let digest = crate::podman::image_digest(&local_tag)?;
196    let lock = crate::lock::LockFile {
197        config_checksum,
198        image_digest: digest,
199    };
200    crate::lock::write(&lock_path, &lock)?;
201
202    Ok(())
203}
204
205/// Resolve the distro family for package installation on a prebuilt image.
206/// Respects the explicit `manager` field in the config, falling back to
207/// name-based detection via `DistroFamily`.
208fn resolve_prebuilt_distro(config: &Config) -> DistroFamily {
209    match config.image.packages.manager {
210        crate::config::PackageManager::Apt => DistroFamily::DebianLike,
211        crate::config::PackageManager::Dnf => DistroFamily::FedoraLike,
212        crate::config::PackageManager::Pacman => DistroFamily::ArchLike,
213        crate::config::PackageManager::Apk => DistroFamily::AlpineLike,
214        crate::config::PackageManager::Zypper => DistroFamily::SuseLike,
215    }
216}
217
218// --- Custom build path ------------------------------------------------------
219
220fn run_build(
221    config: &Config,
222    _env: &HostEnv,
223    _xdg: &ResolvedXdgDirs,
224    dry_run: bool,
225    rebuild: bool,
226) -> Result<()> {
227    let name = &config.container.name;
228    let context_dir = build_context_dir(name);
229    let containerfile_path = context_dir.join("Containerfile");
230    let lock_path = context_dir.join(".podbox.lock");
231
232    // Acquire exclusive build lock (auto-releases on panic/crash via kernel flock)
233    let _build_lock = if !dry_run {
234        std::fs::create_dir_all(&context_dir)?;
235        let file = std::fs::File::create(context_dir.join(".build.lock"))?;
236        Some(Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?)
237    } else {
238        None
239    };
240
241    // Guarded by `run()` for custom builds; prebuilt builds never reach here.
242    let guest_bin = crate::guest::PODBOX_GUEST.expect("custom build without embedded guest");
243
244    let definition_toml = toml::to_string(config)
245        .with_context(|| "failed to serialize definition config".to_string())?;
246    let config_checksum = checksum(&definition_toml);
247
248    if !rebuild {
249        if let Some(lock) = crate::lock::read(&lock_path)? {
250            if lock.config_checksum == config_checksum {
251                println!("Definition unchanged and image already built. Skipping.");
252                println!("Use --rebuild to force.");
253                return Ok(());
254            }
255        }
256    }
257
258    let containerfile = containerfile::generate(config, "podbox-guest")?;
259
260    if dry_run {
261        println!("=== Build context: {} ===", context_dir.display());
262        println!("=== Containerfile ===");
263        println!("{}", containerfile);
264        println!();
265        println!("=== Embedded podbox-guest ===");
266        println!("{} bytes (embedded in podbox binary)", guest_bin.len());
267        println!(
268            "podman build -t localhost/podbox-{}:latest {}",
269            config.image.name,
270            context_dir.display()
271        );
272        return Ok(());
273    }
274
275    std::fs::create_dir_all(&context_dir).map_err(|e| PodboxError::HomeCreateFailed {
276        path: context_dir.clone(),
277        source: e,
278    })?;
279    let _ = std::fs::set_permissions(&context_dir, std::fs::Permissions::from_mode(0o700));
280
281    std::fs::write(&containerfile_path, containerfile).with_context(|| {
282        format!(
283            "failed to write Containerfile to '{}'",
284            containerfile_path.display()
285        )
286    })?;
287
288    let guest_dest = context_dir.join("podbox-guest");
289    std::fs::write(&guest_dest, guest_bin)
290        .with_context(|| format!("failed to write guest binary to '{}'", guest_dest.display()))?;
291
292    std::fs::create_dir_all(&config.container.home).with_context(|| {
293        format!(
294            "failed to create home dir '{}'",
295            config.container.home.display()
296        )
297    })?;
298
299    let tag = format!("localhost/podbox-{}:latest", config.image.name);
300    let args: Vec<OsString> = vec![
301        "build".into(),
302        "-t".into(),
303        tag.clone().into(),
304        context_dir.clone().into(),
305    ];
306
307    println!("Building image {}...", tag);
308    let status = crate::process::spawn_interactive("podman", &args)
309        .with_context(|| format!("failed to execute podman build for image '{}'", tag))?;
310    if !status.success() {
311        return Err(PodboxError::BuildFailed("build failed".into()).into());
312    }
313    println!("Image {} built successfully.", tag);
314
315    let digest = crate::podman::image_digest(&tag)?;
316    let lock = crate::lock::LockFile {
317        config_checksum,
318        image_digest: digest,
319    };
320    crate::lock::write(&lock_path, &lock)?;
321
322    Ok(())
323}