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!("Prebuilt image already present as {local_tag}. Skipping pull.");
83                println!("Use --rebuild to re-pull.");
84                return Ok(());
85            }
86        }
87    }
88
89    if dry_run {
90        println!("Would pull: {image_ref}");
91        if has_packages {
92            println!(
93                "Would install packages on top: {}",
94                config.image.packages.install.join(", ")
95            );
96        }
97        println!("Would tag as: {local_tag}");
98        println!("Would write lock file at: {}", lock_path.display());
99        return Ok(());
100    }
101
102    // Warn on version mismatch from labels (best-effort, image may not exist yet)
103    if let Ok(labels) = crate::podman::image_labels(&image_ref) {
104        if let Some(guest_ver) = labels
105            .get("podbox.guest_version")
106            .or_else(|| labels.get("podmgr.guest_version"))
107        {
108            let guest_clean = guest_ver.trim_start_matches('v');
109            let host_clean = crate::VERSION.trim_start_matches('v');
110            if guest_clean != host_clean {
111                eprintln!(
112                    "Warning: image guest version (v{guest_clean}) differs from host (v{host_clean}). \
113                     Protocol compatibility will be validated at runtime."
114                );
115            }
116        }
117    }
118
119    println!("Pulling {image_ref}...");
120    let status = std::process::Command::new("podman")
121        .args(["pull", &image_ref])
122        .status()?;
123    if !status.success() {
124        return Err(PodboxError::PullFailed {
125            image: image_ref.clone(),
126        }
127        .into());
128    }
129
130    if has_packages {
131        // Layer the config's packages on top of the prebuilt image.
132        let distro = resolve_prebuilt_distro(config);
133        let install_cmd = distro.install_cmd();
134        let clean_cmd = distro.clean_cmd();
135
136        let packages = config.image.packages.install.join(" ");
137        let run_line = if clean_cmd.is_empty() {
138            format!("RUN {install_cmd} {packages}")
139        } else {
140            format!("RUN {install_cmd} {packages} && {clean_cmd}")
141        };
142
143        let containerfile = format!("FROM {image_ref}\n{run_line}\n");
144
145        std::fs::create_dir_all(&context_dir)
146            .with_context(|| format!("failed to create context dir '{}'", context_dir.display()))?;
147
148        let containerfile_path = context_dir.join("Containerfile");
149        std::fs::write(&containerfile_path, &containerfile).with_context(|| {
150            format!(
151                "failed to write Containerfile to '{}'",
152                containerfile_path.display()
153            )
154        })?;
155
156        println!("Installing packages on top of prebuilt image...");
157        let args: Vec<std::ffi::OsString> = vec![
158            "build".into(),
159            "-t".into(),
160            local_tag.clone().into(),
161            "-f".into(),
162            containerfile_path.clone().into(),
163            context_dir.clone().into(),
164        ];
165        let status = crate::process::spawn_interactive("podman", &args)
166            .with_context(|| format!("failed to build prebuilt overlay for '{image_ref}'"))?;
167        if !status.success() {
168            return Err(PodboxError::BuildFailed("overlay build failed".into()).into());
169        }
170        println!("Image {local_tag} ready with packages installed.");
171    } else {
172        println!("Tagging as {local_tag}...");
173        let status = std::process::Command::new("podman")
174            .args(["tag", &image_ref, &local_tag])
175            .status()?;
176        if !status.success() {
177            return Err(PodboxError::TagFailed {
178                image: local_tag.clone(),
179            }
180            .into());
181        }
182        println!("Image {local_tag} ready.");
183    }
184
185    std::fs::create_dir_all(&config.container.home).with_context(|| {
186        format!(
187            "failed to create home dir '{}'",
188            config.container.home.display()
189        )
190    })?;
191    let digest = crate::podman::image_digest(&local_tag)?;
192    let lock = crate::lock::LockFile {
193        config_checksum,
194        image_digest: digest,
195    };
196    crate::lock::write(&lock_path, &lock)?;
197
198    Ok(())
199}
200
201/// Resolve the distro family for package installation on a prebuilt image.
202/// Respects the explicit `manager` field in the config, falling back to
203/// name-based detection via `DistroFamily`.
204fn resolve_prebuilt_distro(config: &Config) -> DistroFamily {
205    match config.image.packages.manager {
206        crate::config::PackageManager::Apt => DistroFamily::DebianLike,
207        crate::config::PackageManager::Dnf => DistroFamily::FedoraLike,
208        crate::config::PackageManager::Pacman => DistroFamily::ArchLike,
209        crate::config::PackageManager::Apk => DistroFamily::AlpineLike,
210        crate::config::PackageManager::Zypper => DistroFamily::SuseLike,
211    }
212}
213
214// --- Custom build path ------------------------------------------------------
215
216fn run_build(
217    config: &Config,
218    _env: &HostEnv,
219    _xdg: &ResolvedXdgDirs,
220    dry_run: bool,
221    rebuild: bool,
222) -> Result<()> {
223    let name = &config.container.name;
224    let context_dir = build_context_dir(name);
225    let containerfile_path = context_dir.join("Containerfile");
226    let lock_path = context_dir.join(".podbox.lock");
227
228    // Acquire exclusive build lock (auto-releases on panic/crash via kernel flock)
229    let _build_lock = if !dry_run {
230        std::fs::create_dir_all(&context_dir)?;
231        let file = std::fs::File::create(context_dir.join(".build.lock"))?;
232        Some(Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?)
233    } else {
234        None
235    };
236
237    // Guarded by `run()` for custom builds; prebuilt builds never reach here.
238    let guest_bin = crate::guest::PODBOX_GUEST.expect("custom build without embedded guest");
239
240    let definition_toml = toml::to_string(config)
241        .with_context(|| "failed to serialize definition config".to_string())?;
242    let config_checksum = checksum(&definition_toml);
243
244    if !rebuild {
245        if let Some(lock) = crate::lock::read(&lock_path)? {
246            if lock.config_checksum == config_checksum {
247                println!("Definition unchanged and image already built. Skipping.");
248                println!("Use --rebuild to force.");
249                return Ok(());
250            }
251        }
252    }
253
254    let containerfile = containerfile::generate(config, "podbox-guest")?;
255
256    if dry_run {
257        println!("=== Build context: {} ===", context_dir.display());
258        println!("=== Containerfile ===");
259        println!("{containerfile}");
260        println!();
261        println!("=== Embedded podbox-guest ===");
262        println!("{} bytes (embedded in podbox binary)", guest_bin.len());
263        println!(
264            "podman build -t localhost/podbox-{}:latest {}",
265            config.image.name,
266            context_dir.display()
267        );
268        return Ok(());
269    }
270
271    std::fs::create_dir_all(&context_dir).map_err(|e| PodboxError::HomeCreateFailed {
272        path: context_dir.clone(),
273        source: e,
274    })?;
275    let _ = std::fs::set_permissions(&context_dir, std::fs::Permissions::from_mode(0o700));
276
277    std::fs::write(&containerfile_path, containerfile).with_context(|| {
278        format!(
279            "failed to write Containerfile to '{}'",
280            containerfile_path.display()
281        )
282    })?;
283
284    let guest_dest = context_dir.join("podbox-guest");
285    std::fs::write(&guest_dest, guest_bin)
286        .with_context(|| format!("failed to write guest binary to '{}'", guest_dest.display()))?;
287
288    std::fs::create_dir_all(&config.container.home).with_context(|| {
289        format!(
290            "failed to create home dir '{}'",
291            config.container.home.display()
292        )
293    })?;
294
295    let tag = format!("localhost/podbox-{}:latest", config.image.name);
296    let args: Vec<OsString> = vec![
297        "build".into(),
298        "-t".into(),
299        tag.clone().into(),
300        context_dir.clone().into(),
301    ];
302
303    println!("Building image {tag}...");
304    let status = crate::process::spawn_interactive("podman", &args)
305        .with_context(|| format!("failed to execute podman build for image '{tag}'"))?;
306    if !status.success() {
307        return Err(PodboxError::BuildFailed("build failed".into()).into());
308    }
309    println!("Image {tag} built successfully.");
310
311    let digest = crate::podman::image_digest(&tag)?;
312    let lock = crate::lock::LockFile {
313        config_checksum,
314        image_digest: digest,
315    };
316    crate::lock::write(&lock_path, &lock)?;
317
318    Ok(())
319}