Skip to main content

zoi_package/
docker.rs

1//! Containerized package builds using Docker.
2//!
3//! This module allows building Zoi packages inside a Docker container.
4//! This is useful for cross-compilation, ensuring a clean and consistent
5//! build environment, and for building packages for different Linux
6//! distributions from a single host.
7
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use anyhow::{Result, anyhow};
12use colored::Colorize;
13use zoi_core::utils;
14
15/// Runs the package build process inside a Docker container.
16/// # Errors
17///
18/// Returns an error if the Docker image cannot be built or the container fails
19/// to run.
20pub fn run(
21    package_file: &Path,
22    build_type: Option<&str>,
23    platforms: &[String],
24    sign_key: Option<String>,
25    output_dir: Option<&Path>,
26    version_override: Option<&str>,
27    sub_packages: Option<Vec<String>>,
28    image: &str,
29    fakeroot: bool,
30    install_deps: bool,
31    test: bool
32) -> Result<()> {
33    println!("{} Building package using Docker...", "::".bold().blue());
34    println!("Image: {}", image.cyan());
35
36    if !utils::command_exists("docker") {
37        return Err(anyhow!(
38            "Docker is not installed or not in PATH. Please install Docker to \
39             use this method."
40        ));
41    }
42
43    let abs_package_file = package_file.canonicalize()?;
44    let package_dir = abs_package_file.parent().ok_or_else(|| {
45        anyhow!("Could not get parent directory of package file")
46    })?;
47
48    let abs_output_dir = if let Some(dir) = output_dir {
49        if !dir.exists() {
50            std::fs::create_dir_all(dir)?;
51        }
52        dir.canonicalize()?
53    } else {
54        package_dir.to_path_buf()
55    };
56
57    let container_workdir = "/work";
58    let container_output_dir = "/output";
59
60    let mut docker_args = vec![
61        "run".to_string(),
62        "--rm".to_string(),
63        "-v".to_string(),
64        format!("{}:{}", package_dir.display(), container_workdir),
65        "-v".to_string(),
66        format!("{}:{}", abs_output_dir.display(), container_output_dir),
67        "-w".to_string(),
68        container_workdir.to_string(),
69    ];
70
71    if let Ok(user_id) = Command::new("id").arg("-u").output() {
72        let uid = String::from_utf8_lossy(&user_id.stdout).trim().to_string();
73        if let Ok(group_id) = Command::new("id").arg("-g").output() {
74            let gid =
75                String::from_utf8_lossy(&group_id.stdout).trim().to_string();
76            docker_args.push("--user".to_string());
77            docker_args.push(format!("{uid}:{gid}"));
78        }
79    }
80
81    if sign_key.is_some() {
82        let host_gpg_home = std::env::var("GNUPGHOME").map_or_else(
83            |_| {
84                utils::get_user_home()
85                    .map(|h| h.join(".gnupg"))
86                    .unwrap_or_default()
87            },
88            PathBuf::from
89        );
90
91        if host_gpg_home.exists() {
92            let container_gpg_home = "/gpg_home";
93            docker_args.push("-v".to_string());
94            docker_args.push(format!(
95                "{}:{}",
96                host_gpg_home.display(),
97                container_gpg_home
98            ));
99            docker_args.push("-e".to_string());
100            docker_args.push(format!("GNUPGHOME={container_gpg_home}"));
101        }
102    }
103
104    if let Ok(password) = std::env::var("GPG_PASSWORD") {
105        docker_args.push("-e".to_string());
106        docker_args.push(format!("GPG_PASSWORD={password}"));
107    }
108
109    docker_args.push(image.to_string());
110
111    let package_filename = abs_package_file
112        .file_name()
113        .ok_or_else(|| anyhow!("Invalid package file name"))?
114        .to_string_lossy()
115        .into_owned();
116    let build_args = build_command_args(
117        package_filename,
118        build_type,
119        platforms,
120        sign_key,
121        container_output_dir,
122        version_override,
123        sub_packages,
124        fakeroot,
125        install_deps,
126        test
127    );
128
129    let inner_script = "if ! command -v sudo >/dev/null 2>&1 && [ \"$(id -u)\" -eq 0 ]; then \
130            if command -v pacman >/dev/null 2>&1; then pacman -Sy --noconfirm sudo gnupg; \
131            elif command -v apt-get >/dev/null 2>&1; then apt-get update && apt-get install -y sudo gnupg; \
132            elif command -v dnf >/dev/null 2>&1; then dnf install -y sudo gnupg; \
133            elif command -v apk >/dev/null 2>&1; then apk add --update sudo gnupg; fi; \
134         fi && \
135         if command -v pacman >/dev/null 2>&1; then pacman -Sy --noconfirm base-devel git; \
136         elif command -v apt-get >/dev/null 2>&1; then apt-get update && apt-get install -y build-essential git; \
137         elif command -v dnf >/dev/null 2>&1; then dnf install -y @development-tools git; \
138         elif command -v apk >/dev/null 2>&1; then apk add --update build-base git; fi && \
139         curl -fsSL https://zillowe.pages.dev/scripts/zoi/install.sh | bash && \
140         export PATH=\"$HOME/.local/bin:$PATH\" && \
141         zoi sync && \
142         exec zoi package build \"$@\"";
143
144    docker_args.push("bash".to_string());
145    docker_args.push("-c".to_string());
146    docker_args.push(inner_script.to_string());
147    docker_args.push("zoi-package-build".to_string());
148    docker_args.extend(build_args);
149
150    println!("Running docker command: {}", "docker".cyan());
151    let status = Command::new("docker").args(&docker_args).status()?;
152
153    if !status.success() {
154        return Err(anyhow!(
155            "Docker build failed with exit code {:?}",
156            status.code()
157        ));
158    }
159
160    println!("{}", "Docker build successful!".green());
161
162    Ok(())
163}
164
165#[allow(clippy::too_many_arguments)]
166/// Builds the argument vector forwarded by the Docker bootstrap shell.
167fn build_command_args(
168    package_filename: String,
169    build_type: Option<&str>,
170    platforms: &[String],
171    sign_key: Option<String>,
172    output_dir: &str,
173    version_override: Option<&str>,
174    sub_packages: Option<Vec<String>>,
175    fakeroot: bool,
176    install_deps: bool,
177    test: bool
178) -> Vec<String> {
179    let mut args = vec![
180        package_filename,
181        "--output-dir".to_string(),
182        output_dir.to_string(),
183    ];
184
185    if let Some(build_type) = build_type {
186        args.extend(["--type".to_string(), build_type.to_string()]);
187    }
188    for platform in platforms {
189        args.extend(["--platform".to_string(), platform.clone()]);
190    }
191    if let Some(sign_key) = sign_key {
192        args.extend(["--sign".to_string(), sign_key]);
193    }
194    if let Some(version_override) = version_override {
195        args.extend([
196            "--version-override".to_string(),
197            version_override.to_string()
198        ]);
199    }
200    if let Some(sub_packages) = sub_packages {
201        for sub_package in sub_packages {
202            args.extend(["--sub".to_string(), sub_package]);
203        }
204    }
205    if fakeroot {
206        args.push("--fakeroot".to_string());
207    }
208    if install_deps {
209        args.push("--install-deps".to_string());
210    }
211    if test {
212        args.push("--test".to_string());
213    }
214
215    args
216}
217
218#[cfg(test)]
219mod tests {
220    use super::build_command_args;
221
222    #[test]
223    fn build_arguments_keep_shell_syntax_as_data() {
224        let args = build_command_args(
225            "package; touch /tmp/pwned.pkg.lua".to_string(),
226            Some("source; id"),
227            &["linux-amd64; id".to_string()],
228            Some("key; id".to_string()),
229            "/output",
230            Some("1.0.0; id"),
231            Some(vec!["sub; id".to_string()]),
232            true,
233            true,
234            true
235        );
236
237        assert!(
238            args.contains(&"package; touch /tmp/pwned.pkg.lua".to_string())
239        );
240        assert!(args.contains(&"source; id".to_string()));
241        assert!(args.contains(&"linux-amd64; id".to_string()));
242        assert!(args.contains(&"key; id".to_string()));
243        assert!(args.contains(&"1.0.0; id".to_string()));
244        assert!(args.contains(&"sub; id".to_string()));
245    }
246}