Skip to main content

podbox/codegen/
containerfile.rs

1use crate::codegen::distros::{DistroFamily, detect_host_locale, detect_host_shell};
2use crate::config::Config;
3use crate::error::PodboxError;
4
5pub fn generate(config: &Config, _guest_binary_name: &str) -> Result<String, PodboxError> {
6    if config.image.source().is_prebuilt() {
7        // Prebuilt registry images already embed a guest binary; the local
8        // overlay path (`build::prebuilt`) layers the host guest on top when
9        // one is embedded in this binary. Keep this minimal so pulls stay
10        // cache-friendly.
11        return Ok(generate_prebuilt(config));
12    }
13    generate_custom(config)
14}
15
16fn generate_prebuilt(config: &Config) -> String {
17    let builder = ContainerfileBuilder::new(&config.image.base, &config.container.name);
18    builder
19        .add_user_packages(config.image.packages.install.clone())
20        .add_run_commands(config.image.run.commands.clone())
21        .set_shell(&config.container.shell)
22        .build()
23}
24
25fn generate_custom(config: &Config) -> Result<String, PodboxError> {
26    let distro = DistroFamily::from_base_image(&config.image.base);
27    let host_shell = detect_host_shell();
28    let host_locale = detect_host_locale();
29
30    let builder = ContainerfileBuilder::new(&config.image.base, &config.container.name);
31    let builder = builder
32        .add_base_packages(distro, host_shell.as_deref(), host_locale.as_deref())
33        .add_user_packages(config.image.packages.install.clone())
34        .add_run_commands(config.image.run.commands.clone())
35        .add_guest_binary()?;
36    Ok(builder.build())
37}
38
39struct ContainerfileBuilder {
40    base_image: String,
41    container_name: String,
42    packages: Vec<String>,
43    run_commands: Vec<String>,
44    has_guest_binary: bool,
45    env_vars: Vec<(String, String)>,
46    forced_shell: Option<String>,
47}
48
49impl ContainerfileBuilder {
50    fn new(base_image: &str, container_name: &str) -> Self {
51        Self {
52            base_image: base_image.to_string(),
53            container_name: container_name.to_string(),
54            packages: Vec::new(),
55            run_commands: Vec::new(),
56            has_guest_binary: false,
57            env_vars: Vec::new(),
58            forced_shell: None,
59        }
60    }
61
62    fn add_base_packages(
63        mut self,
64        distro: DistroFamily,
65        host_shell: Option<&str>,
66        host_locale: Option<&str>,
67    ) -> Self {
68        let mut pkgs = distro.base_packages(host_shell);
69        let locale_pkgs = distro.locale_packages();
70        for pkg in locale_pkgs {
71            if !pkgs.contains(&pkg) {
72                pkgs.push(pkg);
73            }
74        }
75        self.packages = pkgs;
76        if let Some(locale) = host_locale {
77            self.env_vars.push(("LANG".into(), locale.to_string()));
78            self.env_vars.push(("LC_ALL".into(), locale.to_string()));
79            self.env_vars.push(("LC_CTYPE".into(), locale.to_string()));
80        }
81        self
82    }
83
84    fn add_user_packages(mut self, pkgs: Vec<String>) -> Self {
85        for pkg in pkgs {
86            if !self.packages.contains(&pkg) {
87                self.packages.push(pkg);
88            }
89        }
90        self
91    }
92
93    fn add_run_commands(mut self, cmds: Vec<String>) -> Self {
94        self.run_commands = cmds;
95        self
96    }
97
98    fn add_guest_binary(mut self) -> Result<Self, PodboxError> {
99        if crate::guest::PODBOX_GUEST.is_none() {
100            return Err(PodboxError::GuestBinaryUnavailable);
101        }
102        self.has_guest_binary = true;
103        Ok(self)
104    }
105
106    fn set_shell(mut self, shell: &str) -> Self {
107        self.forced_shell = Some(shell.to_string());
108        self
109    }
110
111    fn build(self) -> String {
112        let distro = DistroFamily::from_base_image(&self.base_image);
113        let mut lines = Vec::new();
114
115        lines.push(format!("FROM {}", self.base_image));
116        lines.push(String::new());
117
118        if !self.packages.is_empty() {
119            let pkgs = self.packages.join(" ");
120            let clean = distro.clean_cmd();
121            let cmd = if clean.is_empty() {
122                format!("{} {}", distro.install_cmd(), pkgs)
123            } else {
124                format!("{} {} && {}", distro.install_cmd(), pkgs, clean)
125            };
126            lines.push(format!("RUN {cmd}"));
127            lines.push(String::new());
128        }
129
130        for cmd in &self.run_commands {
131            lines.push(format!("RUN {cmd}"));
132        }
133        if !self.run_commands.is_empty() {
134            lines.push(String::new());
135        }
136
137        if let Some(locale) = self
138            .env_vars
139            .iter()
140            .find(|(k, _)| k == "LANG")
141            .map(|(_, v)| v.as_str())
142        {
143            match distro {
144                DistroFamily::DebianLike | DistroFamily::ArchLike => {
145                    let (name, charset) = locale.split_once('.').unwrap_or((locale, "UTF-8"));
146                    lines.push(format!(
147                        "RUN localedef -i {name} -f {charset} {locale} || true"
148                    ));
149                    lines.push(String::new());
150                }
151                DistroFamily::FedoraLike | DistroFamily::SuseLike => {
152                    // glibc-all-langpacks includes pre-generated locales, no localedef needed
153                }
154                DistroFamily::AlpineLike | DistroFamily::Unknown => {}
155            }
156        }
157
158        if self.has_guest_binary {
159            lines.push("COPY podbox-guest /usr/local/bin/podbox-guest".into());
160            lines.push("RUN chmod +x /usr/local/bin/podbox-guest".into());
161            lines.push(String::new());
162        }
163
164        for (key, value) in &self.env_vars {
165            lines.push(format!("ENV {key}={value}"));
166        }
167
168        lines.push(format!("ENV PODBOX_CONTAINER={}", self.container_name));
169        lines.push(format!("ENV PODBOX_HOST_VERSION={}", crate::VERSION));
170        lines.push(String::new());
171
172        lines.push("ENTRYPOINT [\"/usr/local/bin/podbox-guest\", \"--entry\"]".into());
173        lines.push(format!("CMD [\"{}\"]", self.default_shell()));
174        lines.push(String::new());
175
176        lines.join("\n")
177    }
178
179    fn default_shell(&self) -> &str {
180        if let Some(ref shell) = self.forced_shell {
181            return shell;
182        }
183        self.packages
184            .iter()
185            .find_map(|p| match p.as_str() {
186                "fish" => Some("fish"),
187                "zsh" => Some("zsh"),
188                "bash" => Some("bash"),
189                _ => None,
190            })
191            .unwrap_or("fish")
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::codegen::distros::DistroFamily;
199
200    #[test]
201    fn test_builder_debian() {
202        let builder = ContainerfileBuilder::new("debian:12", "test").add_base_packages(
203            DistroFamily::DebianLike,
204            Some("/usr/bin/fish"),
205            Some("en_US.UTF-8"),
206        );
207        let cf = builder.build();
208        assert!(cf.contains("apt-get update"));
209        assert!(cf.contains("sudo"));
210        assert!(cf.contains("fish"));
211        assert!(cf.contains("locales"));
212        assert!(cf.contains("ENV LANG=en_US.UTF-8"));
213        assert!(cf.contains("localedef -i en_US -f UTF-8 en_US.UTF-8"));
214        assert!(cf.contains("ENV PODBOX_CONTAINER=test"));
215    }
216
217    #[test]
218    fn test_builder_fedora() {
219        let builder = ContainerfileBuilder::new("fedora:41", "test").add_base_packages(
220            DistroFamily::FedoraLike,
221            Some("/usr/bin/zsh"),
222            None,
223        );
224        let cf = builder.build();
225        assert!(cf.contains("dnf install -y"));
226        assert!(cf.contains("sudo"));
227        assert!(cf.contains("zsh"));
228        assert!(cf.contains("ENV PODBOX_CONTAINER=test"));
229        // Fedora uses glibc-all-langpacks (no localedef needed)
230        assert!(!cf.contains("localedef"));
231    }
232
233    #[test]
234    fn test_builder_arch() {
235        let builder = ContainerfileBuilder::new("archlinux:latest", "test").add_base_packages(
236            DistroFamily::ArchLike,
237            Some("/bin/bash"),
238            None,
239        );
240        let cf = builder.build();
241        assert!(cf.contains("pacman -Syu --noconfirm"));
242        assert!(cf.contains("bash"));
243        assert!(cf.contains("bash-completion"));
244        assert!(cf.contains("ENV PODBOX_CONTAINER=test"));
245        // No locale requested, so no localedef
246        assert!(!cf.contains("localedef"));
247    }
248
249    #[test]
250    fn test_builder_arch_with_locale() {
251        let builder = ContainerfileBuilder::new("archlinux:latest", "test").add_base_packages(
252            DistroFamily::ArchLike,
253            Some("/bin/bash"),
254            Some("en_US.UTF-8"),
255        );
256        let cf = builder.build();
257        assert!(cf.contains("localedef -i en_US -f UTF-8 en_US.UTF-8"));
258    }
259
260    #[test]
261    fn test_builder_alpine() {
262        let builder = ContainerfileBuilder::new("alpine:3.20", "test").add_base_packages(
263            DistroFamily::AlpineLike,
264            None,
265            None,
266        );
267        let cf = builder.build();
268        assert!(cf.contains("apk add --no-cache"));
269        assert!(cf.contains("sudo"));
270        assert!(cf.contains("ENV PODBOX_CONTAINER=test"));
271    }
272}