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 {} -f {} {} || true",
144                        name, charset, locale
145                    ));
146                    lines.push(String::new());
147                }
148                DistroFamily::FedoraLike | DistroFamily::SuseLike => {
149                    // glibc-all-langpacks includes pre-generated locales, no localedef needed
150                }
151                DistroFamily::AlpineLike | DistroFamily::Unknown => {}
152            }
153        }
154
155        if self.has_guest_binary {
156            lines.push("COPY podbox-guest /usr/local/bin/podbox-guest".into());
157            lines.push("RUN chmod +x /usr/local/bin/podbox-guest".into());
158            lines.push(String::new());
159        }
160
161        for (key, value) in &self.env_vars {
162            lines.push(format!("ENV {}={}", key, value));
163        }
164
165        lines.push(format!("ENV PODBOX_CONTAINER={}", self.container_name));
166        lines.push(format!("ENV PODBOX_HOST_VERSION={}", crate::VERSION));
167        lines.push(String::new());
168
169        lines.push("ENTRYPOINT [\"/usr/local/bin/podbox-guest\", \"--entry\"]".into());
170        lines.push(format!("CMD [\"{}\"]", self.default_shell()));
171        lines.push(String::new());
172
173        lines.join("\n")
174    }
175
176    fn default_shell(&self) -> &str {
177        if let Some(ref shell) = self.forced_shell {
178            return shell;
179        }
180        self.packages
181            .iter()
182            .find_map(|p| match p.as_str() {
183                "fish" => Some("fish"),
184                "zsh" => Some("zsh"),
185                "bash" => Some("bash"),
186                _ => None,
187            })
188            .unwrap_or("fish")
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::codegen::distros::DistroFamily;
196
197    #[test]
198    fn test_builder_debian() {
199        let builder = ContainerfileBuilder::new("debian:12", "test").add_base_packages(
200            DistroFamily::DebianLike,
201            Some("/usr/bin/fish"),
202            Some("en_US.UTF-8"),
203        );
204        let cf = builder.build();
205        assert!(cf.contains("apt-get update"));
206        assert!(cf.contains("sudo"));
207        assert!(cf.contains("fish"));
208        assert!(cf.contains("locales"));
209        assert!(cf.contains("ENV LANG=en_US.UTF-8"));
210        assert!(cf.contains("localedef -i en_US -f UTF-8 en_US.UTF-8"));
211        assert!(cf.contains("ENV PODBOX_CONTAINER=test"));
212    }
213
214    #[test]
215    fn test_builder_fedora() {
216        let builder = ContainerfileBuilder::new("fedora:41", "test").add_base_packages(
217            DistroFamily::FedoraLike,
218            Some("/usr/bin/zsh"),
219            None,
220        );
221        let cf = builder.build();
222        assert!(cf.contains("dnf install -y"));
223        assert!(cf.contains("sudo"));
224        assert!(cf.contains("zsh"));
225        assert!(cf.contains("ENV PODBOX_CONTAINER=test"));
226        // Fedora uses glibc-all-langpacks (no localedef needed)
227        assert!(!cf.contains("localedef"));
228    }
229
230    #[test]
231    fn test_builder_arch() {
232        let builder = ContainerfileBuilder::new("archlinux:latest", "test").add_base_packages(
233            DistroFamily::ArchLike,
234            Some("/bin/bash"),
235            None,
236        );
237        let cf = builder.build();
238        assert!(cf.contains("pacman -Syu --noconfirm"));
239        assert!(cf.contains("bash"));
240        assert!(cf.contains("bash-completion"));
241        assert!(cf.contains("ENV PODBOX_CONTAINER=test"));
242        // No locale requested, so no localedef
243        assert!(!cf.contains("localedef"));
244    }
245
246    #[test]
247    fn test_builder_arch_with_locale() {
248        let builder = ContainerfileBuilder::new("archlinux:latest", "test").add_base_packages(
249            DistroFamily::ArchLike,
250            Some("/bin/bash"),
251            Some("en_US.UTF-8"),
252        );
253        let cf = builder.build();
254        assert!(cf.contains("localedef -i en_US -f UTF-8 en_US.UTF-8"));
255    }
256
257    #[test]
258    fn test_builder_alpine() {
259        let builder = ContainerfileBuilder::new("alpine:3.20", "test").add_base_packages(
260            DistroFamily::AlpineLike,
261            None,
262            None,
263        );
264        let cf = builder.build();
265        assert!(cf.contains("apk add --no-cache"));
266        assert!(cf.contains("sudo"));
267        assert!(cf.contains("ENV PODBOX_CONTAINER=test"));
268    }
269}