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
16pub fn checksum(content: &str) -> String {
18 let mut hasher = Sha256::new();
19 hasher.update(content.as_bytes());
20 hex::encode(hasher.finalize())
21}
22
23pub 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
31pub 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 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
52fn 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 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 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 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 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
205fn 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
218fn 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 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 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}