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));
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 }
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 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 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}