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