podbox/codegen/
containerfile.rs1use 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));
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 }
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 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 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}