aws_build_lib/
lib.rs

1#![deny(missing_docs)]
2
3//! Build a Rust project in a container for deployment to either
4//! Amazon Linux 2 or AWS Lambda.
5
6pub use docker_command;
7
8use anyhow::{anyhow, Context, Error};
9use cargo_metadata::MetadataCommand;
10use docker_command::command_run::{Command, LogTo};
11use docker_command::{BuildOpt, Launcher, RunOpt, UserAndGroup, Volume};
12use fehler::{throw, throws};
13use fs_err as fs;
14use log::{error, info};
15use sha2::Digest;
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use tempfile::TempDir;
19use time::{Date, OffsetDateTime};
20use zip::ZipWriter;
21
22/// Default rust version to install.
23pub static DEFAULT_RUST_VERSION: &str = "stable";
24
25/// Create directory if it doesn't already exist.
26#[throws]
27fn ensure_dir_exists(path: &Path) {
28    // Ignore the return value since the directory might already exist
29    let _ = fs::create_dir(path);
30    if !path.is_dir() {
31        throw!(anyhow!("failed to create directory {}", path.display()));
32    }
33}
34
35/// Get the names of all the binaries targets in a project.
36#[throws]
37fn get_package_binaries(path: &Path) -> Vec<String> {
38    let metadata = MetadataCommand::new().current_dir(path).no_deps().exec()?;
39    let mut names = Vec::new();
40    for package in metadata.packages {
41        for target in package.targets {
42            if target.kind.contains(&"bin".to_string()) {
43                names.push(target.name);
44            }
45        }
46    }
47    names
48}
49
50#[throws]
51fn write_container_files() -> TempDir {
52    let tmp_dir = TempDir::new()?;
53
54    let dockerfile = include_str!("container/Dockerfile");
55    fs::write(tmp_dir.path().join("Dockerfile"), dockerfile)?;
56
57    let build_script = include_str!("container/build.sh");
58    fs::write(tmp_dir.path().join("build.sh"), build_script)?;
59
60    tmp_dir
61}
62
63fn set_up_command(cmd: &mut Command) {
64    cmd.log_to = LogTo::Log;
65    cmd.combine_output = true;
66    cmd.log_output_on_error = true;
67}
68
69/// Create a unique output file name.
70///
71/// The file name is intended to be identifiable, sortable by time,
72/// unique, and reasonably short. To make this it includes:
73/// - build-mode prefix (al2 or lambda)
74/// - executable name
75/// - year, month, and day
76/// - first 16 digits of the sha256 hex hash
77fn make_unique_name(
78    mode: BuildMode,
79    name: &str,
80    contents: &[u8],
81    when: Date,
82) -> String {
83    let hash = sha2::Sha256::digest(contents);
84    format!(
85        "{}-{}-{}{:02}{:02}-{:.16x}",
86        mode.name(),
87        name,
88        when.year(),
89        u8::from(when.month()),
90        when.day(),
91        // The hash is truncated to 16 characters so that the file
92        // name isn't unnecessarily long
93        hash
94    )
95}
96
97/// Run the strip command to remove symbols and decrease the size.
98#[throws]
99fn strip(path: &Path) {
100    let mut cmd = Command::new("strip");
101    cmd.add_arg(path);
102    set_up_command(&mut cmd);
103    cmd.run()?;
104}
105
106/// Recursively set the owner of `dir` using the `podman unshare`
107/// command. The input `user` is treated as a user (and group)
108/// inside the container. This means that an input of "root" is
109/// really the current user (from outside the chroot).
110#[throws]
111fn set_podman_permissions(user: &UserAndGroup, dir: &Path) {
112    Command::with_args(
113        "podman",
114        &["unshare", "chown", "--recursive", &user.arg()],
115    )
116    .add_arg(dir)
117    .run()?;
118}
119
120struct ResetPodmanPermissions<'a> {
121    user: UserAndGroup,
122    dir: &'a Path,
123    done: bool,
124}
125
126impl<'a> ResetPodmanPermissions<'a> {
127    fn new(user: UserAndGroup, dir: &'a Path) -> Self {
128        Self {
129            dir,
130            user,
131            done: false,
132        }
133    }
134
135    /// Reset the permissions if not already done. Calling this is
136    /// preferred to waiting for the drop, because the error can be
137    /// propagated.
138    #[throws]
139    fn reset_permissions(&mut self) {
140        if !self.done {
141            set_podman_permissions(&self.user, self.dir)?;
142            self.done = true;
143        }
144    }
145}
146
147impl<'a> Drop for ResetPodmanPermissions<'a> {
148    fn drop(&mut self) {
149        if let Err(err) = self.reset_permissions() {
150            error!("failed to reset permissions: {}", err);
151        }
152    }
153}
154
155struct Container<'a> {
156    mode: BuildMode,
157    bin: &'a String,
158    launcher: &'a Launcher,
159    output_dir: &'a Path,
160    image_tag: &'a str,
161    relabel: Option<Relabel>,
162
163    /// The root of the code that gets mounted in the container. All the
164    /// source must live beneath this directory.
165    code_root: &'a Path,
166}
167
168impl<'a> Container<'a> {
169    #[throws]
170    fn run(&self) -> PathBuf {
171        let mode_name = self.mode.name();
172
173        // Create two cache directories to speed up rebuilds. These are
174        // host mounts rather than volumes so that the permissions aren't
175        // set to root only.
176        let registry_dir = self
177            .output_dir
178            .join(format!("{}-cargo-registry", mode_name));
179        ensure_dir_exists(&registry_dir)?;
180        let git_dir = self.output_dir.join(format!("{}-cargo-git", mode_name));
181        ensure_dir_exists(&git_dir)?;
182
183        let mut reset_podman_permissions = None;
184        if self.launcher.is_podman() {
185            // Recursively set the output directory's permissions such
186            // that the non-root user in the container owns it.
187            set_podman_permissions(&UserAndGroup::current(), self.output_dir)?;
188
189            // Prepare an object to reset the permissions back to the
190            // current user. The current user is "root" inside the
191            // container, hence the odd-looking input.
192            reset_podman_permissions = Some(ResetPodmanPermissions::new(
193                UserAndGroup::root(),
194                self.output_dir,
195            ));
196        }
197
198        let mount_options = match self.relabel {
199            Some(Relabel::Shared) => vec!["z".to_string()],
200            Some(Relabel::Unshared) => vec!["Z".to_string()],
201            None => vec![],
202        };
203
204        let mut cmd = self.launcher.run(RunOpt {
205            remove: true,
206            env: vec![
207                (
208                    "TARGET_DIR".into(),
209                    Path::new("/target").join(mode_name).into(),
210                ),
211                ("BIN_TARGET".into(), self.bin.into()),
212            ],
213            init: true,
214            user: Some(UserAndGroup::current()),
215            volumes: vec![
216                // Mount the code root
217                Volume {
218                    src: self.code_root.into(),
219                    dst: Path::new("/code").into(),
220                    read_write: false,
221                    options: mount_options.clone(),
222                },
223                // Mount two cargo directories to make rebuilds faster
224                Volume {
225                    src: registry_dir,
226                    dst: Path::new("/cargo/registry").into(),
227                    read_write: true,
228                    options: mount_options.clone(),
229                },
230                Volume {
231                    src: git_dir,
232                    dst: Path::new("/cargo/git").into(),
233                    read_write: true,
234                    options: mount_options.clone(),
235                },
236                // Mount the output target directory
237                Volume {
238                    src: self.output_dir.into(),
239                    dst: Path::new("/target").into(),
240                    read_write: true,
241                    options: mount_options,
242                },
243            ],
244            image: self.image_tag.into(),
245            ..Default::default()
246        });
247        set_up_command(&mut cmd);
248        cmd.run()?;
249
250        if let Some(mut resetter) = reset_podman_permissions {
251            // Recursively set the output directory's permissions back
252            // to the current user.
253            resetter.reset_permissions()?;
254        }
255
256        // Return the path of the binary that was built
257        self.output_dir
258            .join(mode_name)
259            .join("release")
260            .join(self.bin)
261    }
262}
263
264/// Whether to build for Amazon Linux 2 or AWS Lambda.
265#[derive(Debug, Clone, Copy, Eq, PartialEq)]
266pub enum BuildMode {
267    /// Build for Amazon Linux 2. The result is a standalone binary
268    /// that can be copied to (e.g) an EC2 instance running Amazon
269    /// Linux 2.
270    AmazonLinux2,
271
272    /// Build for AWS Lambda running Amazon Linux 2. The result is a
273    /// zip file containing a single "bootstrap" executable.
274    Lambda,
275}
276
277impl BuildMode {
278    fn name(&self) -> &'static str {
279        match self {
280            BuildMode::AmazonLinux2 => "al2",
281            BuildMode::Lambda => "lambda",
282        }
283    }
284}
285
286impl std::str::FromStr for BuildMode {
287    type Err = Error;
288
289    #[throws]
290    fn from_str(s: &str) -> Self {
291        if s == "al2" {
292            Self::AmazonLinux2
293        } else if s == "lambda" {
294            Self::Lambda
295        } else {
296            throw!(anyhow!("invalid mode {}", s));
297        }
298    }
299}
300
301/// Relabel files before bind-mounting.
302#[derive(Clone, Copy, Debug, Eq, PartialEq)]
303pub enum Relabel {
304    /// Mount volumes with the `z` option.
305    Shared,
306
307    /// Mount volumes with the `Z` option.
308    Unshared,
309}
310
311/// Output returned from [`Builder::run`] on success.
312pub struct BuilderOutput {
313    /// Path of the generated file.
314    pub real: PathBuf,
315
316    /// Path of the `latest-*` symlink.
317    pub symlink: PathBuf,
318}
319
320/// Options for running the build.
321#[must_use]
322#[derive(Debug, Clone, Eq, PartialEq)]
323pub struct Builder {
324    /// Rust version to install. Can be anything rustup understands as
325    /// a valid version, e.g. "stable" or "1.45.2".
326    pub rust_version: String,
327
328    /// Whether to build for Amazon Linux 2 or AWS Lambda.
329    pub mode: BuildMode,
330
331    /// Name of the binary target to build. Can be None if the project
332    /// only has one binary target.
333    pub bin: Option<String>,
334
335    /// Strip the binary.
336    pub strip: bool,
337
338    /// Container launcher.
339    pub launcher: Launcher,
340
341    /// The root of the code that gets mounted in the container. All the
342    /// source must live beneath this directory.
343    pub code_root: PathBuf,
344
345    /// The project path is the path of the crate to build. It must be
346    /// somewhere within the `code_root` directory (or the same path).
347    pub project_path: PathBuf,
348
349    /// dev packages to install in container for build
350    pub packages: Vec<String>,
351
352    /// Relabel files before bind-mounting (`z` or `Z` volume
353    /// option). Warning: this overwrites the current label on files on
354    /// the host. Doing this to a system directory like `/usr` could
355    /// [break your system].
356    ///
357    /// [break your system]: https://docs.docker.com/storage/bind-mounts/#configure-the-selinux-label
358    pub relabel: Option<Relabel>,
359}
360
361impl Builder {
362    /// Run the build in a container.
363    ///
364    /// This will produce either a standalone executable (for Amazon
365    /// Linux 2) or a zip file (for AWS Lambda). The file is given a
366    /// unique name for convenient uploading to S3, and a short
367    /// symlink to the file is also created (target/latest-al2 or
368    /// target/latest-lambda).
369    ///
370    /// The paths of the files are returned.
371    #[throws]
372    pub fn run(&self) -> BuilderOutput {
373        // Canonicalize the input paths. This is necessary for when it's
374        // passed as a Docker volume arg.
375        let code_root = fs::canonicalize(&self.code_root)?;
376        let project_path = fs::canonicalize(&self.project_path)?;
377        let relative_project_path = project_path
378            .strip_prefix(&code_root)
379            .context("project path must be within the code root")?;
380
381        // Ensure that the target directory exists
382        let target_dir = project_path.join("target");
383        ensure_dir_exists(&target_dir)?;
384
385        let output_dir = target_dir.join("aws-build");
386        ensure_dir_exists(&output_dir)?;
387
388        let image_tag = self
389            .build_container(relative_project_path)
390            .context("container build failed")?;
391
392        // Get the binary target names
393        let binaries = get_package_binaries(&project_path)?;
394
395        // Get the name of the binary target to build
396        let bin: String = if let Some(bin) = &self.bin {
397            bin.clone()
398        } else if binaries.len() == 1 {
399            binaries[0].clone()
400        } else {
401            throw!(anyhow!(
402                "must specify bin target when package has more than one"
403            ));
404        };
405
406        // Build the project in a container
407        let container = Container {
408            mode: self.mode,
409            launcher: &self.launcher,
410            output_dir: &output_dir,
411            image_tag: &image_tag,
412            bin: &bin,
413            relabel: self.relabel,
414            code_root: &code_root,
415        };
416        let bin_path = container.run().context("container run failed")?;
417
418        // Optionally strip symbols
419        if self.strip {
420            strip(&bin_path)?;
421        }
422
423        let bin_contents = fs::read(&bin_path)?;
424        let base_unique_name = make_unique_name(
425            self.mode,
426            &bin,
427            &bin_contents,
428            OffsetDateTime::now_utc().date(),
429        );
430
431        let out_path = match self.mode {
432            BuildMode::AmazonLinux2 => {
433                // Give the binary a unique name so that multiple
434                // versions can be uploaded to S3 without overwriting
435                // each other.
436                let out_path =
437                    output_dir.join(self.mode.name()).join(base_unique_name);
438                fs::copy(bin_path, &out_path)?;
439                info!("writing {}", out_path.display());
440                out_path
441            }
442            BuildMode::Lambda => {
443                // Zip the binary and give the zip a unique name so
444                // that multiple versions can be uploaded to S3
445                // without overwriting each other.
446                let zip_name = base_unique_name + ".zip";
447                let zip_path =
448                    output_dir.join(self.mode.name()).join(&zip_name);
449
450                // Create the zip file containing just a bootstrap
451                // file (the executable)
452                info!("writing {}", zip_path.display());
453                let file = fs::File::create(&zip_path)?;
454                let mut zip = ZipWriter::new(file);
455                let options = zip::write::FileOptions::default()
456                    .unix_permissions(0o755)
457                    .compression_method(zip::CompressionMethod::Deflated);
458                zip.start_file("bootstrap", options)?;
459                zip.write_all(&bin_contents)?;
460
461                zip.finish()?;
462
463                zip_path
464            }
465        };
466
467        // Create a symlink pointing to the output file. Either
468        // "target/latest-al2" or "target/latest-lambda"
469        let symlink_path =
470            target_dir.join(format!("latest-{}", self.mode.name()));
471        // Remove the symlink if it already exists, but ignore an
472        // error in case it doesn't exist.
473        let _ = fs::remove_file(&symlink_path);
474        std::os::unix::fs::symlink(&out_path, &symlink_path)?;
475        info!("symlink: {}", symlink_path.display());
476
477        BuilderOutput {
478            real: out_path,
479            symlink: symlink_path,
480        }
481    }
482
483    /// Build the container image and return its hash.
484    #[throws]
485    fn build_container(&self, relative_project_path: &Path) -> String {
486        let from = match self.mode {
487            BuildMode::AmazonLinux2 => {
488                // https://hub.docker.com/_/amazonlinux
489                "docker.io/amazonlinux:2"
490            }
491            BuildMode::Lambda => {
492                // https://github.com/lambci/docker-lambda#documentation
493                "docker.io/lambci/lambda:build-provided.al2"
494            }
495        };
496        let tmp_dir = write_container_files()?;
497        let iid_path = tmp_dir.path().join("iidfile");
498        let mut cmd = self.launcher.build(BuildOpt {
499            build_args: vec![
500                ("FROM_IMAGE".into(), from.into()),
501                ("RUST_VERSION".into(), self.rust_version.clone()),
502                ("DEV_PKGS".into(), self.packages.join(" ")),
503                (
504                    "PROJECT_PATH".into(),
505                    relative_project_path
506                        .to_str()
507                        .ok_or_else(|| anyhow!("project path is not utf-8"))?
508                        .into(),
509                ),
510            ],
511            context: tmp_dir.path().into(),
512            iidfile: Some(iid_path.clone()),
513            ..Default::default()
514        });
515        set_up_command(&mut cmd);
516        cmd.run()?;
517        fs::read_to_string(&iid_path)?
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use time::Month;
525
526    #[test]
527    fn test_unique_name() {
528        let when = Date::from_calendar_date(2020, Month::August, 31).unwrap();
529        assert_eq!(
530            make_unique_name(
531                BuildMode::Lambda,
532                "testexecutable",
533                "testcontents".as_bytes(),
534                when
535            ),
536            "lambda-testexecutable-20200831-7097a82a108e78da"
537        );
538    }
539}