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 {name} -f {charset} {locale} || true"
144 ));
145 lines.push(String::new());
146 }
147 DistroFamily::FedoraLike | DistroFamily::SuseLike => {
148 }
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 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 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}