lambda_build/
lib.rs

1mod command;
2mod docker;
3mod git;
4
5use anyhow::{anyhow, Context, Error};
6use cargo_metadata::MetadataCommand;
7use chrono::{Date, Datelike, Utc};
8use docker::{Docker, Volume};
9use fehler::{throw, throws};
10use git::Repo;
11use log::info;
12use sha2::Digest;
13use std::fs;
14use std::io::Write;
15use std::path::{Path, PathBuf};
16use zip::ZipWriter;
17
18/// Default lambda-rust repo URL.
19pub static DEFAULT_REPO: &str = "https://github.com/softprops/lambda-rust";
20/// Default revision of the lambda-rust repo to use. Can be a branch,
21/// tag, or commit hash.
22pub static DEFAULT_REV: &str = "master";
23/// Default container command used to run the build.
24pub static DEFAULT_CONTAINER_CMD: &str = "docker";
25
26/// Create directory if it doesn't already exist.
27#[throws]
28fn ensure_dir_exists(path: &Path) {
29    // Ignore the return value since the directory might already exist
30    let _ = fs::create_dir(path);
31    if !path.is_dir() {
32        throw!(anyhow!("failed to create directory {}", path.display()));
33    }
34}
35
36/// Get the names of all the binaries targets in a project.
37#[throws]
38fn get_package_binaries(path: &Path) -> Vec<String> {
39    let metadata = MetadataCommand::new().current_dir(path).no_deps().exec()?;
40    let mut names = Vec::new();
41    for package in metadata.packages {
42        for target in package.targets {
43            if target.kind.contains(&"bin".to_string()) {
44                names.push(target.name);
45            }
46        }
47    }
48    names
49}
50
51/// Create the unique zip file name.
52///
53/// The file name is intended to be identifiable, sortable by time,
54/// unique, and reasonably short. To make this it includes:
55/// - executable name
56/// - year, month, and day
57/// - first 16 digits of the sha256 hex hash
58fn make_zip_name(name: &str, contents: &[u8], when: Date<Utc>) -> String {
59    let hash = sha2::Sha256::digest(&contents);
60    format!(
61        "{}-{}{:02}{:02}-{:.16x}.zip",
62        name,
63        when.year(),
64        when.month(),
65        when.day(),
66        // The hash is truncated to 16 characters so that the file
67        // name isn't unnecessarily long
68        hash
69    )
70}
71
72/// Options for running the build.
73#[derive(Debug, Clone, Eq, PartialEq)]
74pub struct LambdaBuilder {
75    /// The lambda-rust repo URL.
76    pub repo: String,
77
78    /// Branch/tag/commit from which to build.
79    pub rev: String,
80
81    /// Container command. Defaults to "docker", but "podman" should
82    /// work as well.
83    pub container_cmd: String,
84
85    /// Path of the project to build.
86    pub project: PathBuf,
87}
88
89impl Default for LambdaBuilder {
90    fn default() -> Self {
91        LambdaBuilder {
92            repo: DEFAULT_REPO.into(),
93            rev: DEFAULT_REV.into(),
94            container_cmd: DEFAULT_CONTAINER_CMD.into(),
95            project: PathBuf::default(),
96        }
97    }
98}
99
100impl LambdaBuilder {
101    /// Run the build in a container.
102    ///
103    /// This will produce zip files ready for use with AWS Lambda in
104    /// the lambda-target subdirectory, one zip file per binary
105    /// target. The lambda-target/latest file will be updated with a
106    /// list of the latest zip names.
107    ///
108    /// Returns the full paths of each zip file.
109    #[throws]
110    pub fn run(&self) -> Vec<PathBuf> {
111        // Canonicalize the project path. This is necessary for when it's
112        // passed as a Docker volume arg.
113        let project_path = self.project.canonicalize().context(format!(
114            "failed to canonicalize {}",
115            self.project.display(),
116        ))?;
117
118        // Ensure that the target directory exists. The output directory
119        // ("lambda-target") is mounted to /code/target in the container,
120        // but we mount /code from the host read-only, so the target
121        // subdirectory needs to already exist. Usually the "target"
122        // directory will already exist on the host, but won't if "cargo
123        // test" or similar hasn't been run yet.
124        ensure_dir_exists(&project_path.join("target"))?;
125
126        // Create the output directory if it doesn't already exist.
127        let output_dir = project_path.join("lambda-target");
128        ensure_dir_exists(&output_dir)?;
129
130        let repo_url = &self.repo;
131        let repo = Repo::new(output_dir.join("lambda-rust"));
132        ensure_dir_exists(&repo.path)?;
133
134        if !repo.path.join(".git").exists() {
135            // Clone the repo if it doesn't exist
136            repo.clone(repo_url)?;
137        } else {
138            // Ensure the remote is set correctly
139            repo.remote_set_url(repo_url)?;
140            // Fetch updates
141            repo.fetch()?;
142        };
143
144        // Check out the specified revision.
145        repo.checkout(&self.rev)?;
146
147        // Build the container
148        let image_tag = format!("lambda-build-{:.16}", repo.rev_parse("HEAD")?);
149        let docker = Docker::new(self.container_cmd.clone());
150        docker.build(&repo.path, &image_tag)?;
151
152        // Create two cache directories to speed up rebuilds. These are
153        // host mounts rather than volumes so that the permissions aren't
154        // set to root only.
155        let registry_dir = output_dir.join("cargo-registry");
156        ensure_dir_exists(&registry_dir)?;
157        let git_dir = output_dir.join("cargo-git");
158        ensure_dir_exists(&git_dir)?;
159
160        // Run the container
161        docker.run(
162            &[
163                // Mount the project directory
164                Volume {
165                    src: project_path.clone(),
166                    dst: Path::new("/code").into(),
167                    read_only: true,
168                },
169                // Mount two cargo directories to make rebuilds faster
170                Volume {
171                    src: registry_dir,
172                    dst: Path::new("/cargo/registry").into(),
173                    read_only: false,
174                },
175                Volume {
176                    src: git_dir,
177                    dst: Path::new("/cargo/git").into(),
178                    read_only: false,
179                },
180                // Mount the output target directory
181                Volume {
182                    src: output_dir.clone(),
183                    dst: Path::new("/code/target").into(),
184                    read_only: false,
185                },
186            ],
187            &image_tag,
188        )?;
189
190        // Get the binary target names.
191        let binaries = get_package_binaries(&project_path)?;
192
193        // Zip each binary and give the zip a unique name. The lambda-rust
194        // build already zips the binaries, but the name is just the
195        // binary name. It's helpful to have a more specific name so that
196        // multiple versions can be uploaded to S3 without overwriting
197        // each other. The new name is
198        // "<exec-name>-<yyyymmdd>-<exec-hash>.zip".
199        let mut zip_names = Vec::new();
200        let mut zip_paths = Vec::new();
201        for name in binaries {
202            let src = output_dir.join("lambda/release").join(&name);
203            let contents = fs::read(&src)
204                .context(format!("failed to read {}", src.display()))?;
205            let dst_name = make_zip_name(&name, &contents, Utc::now().date());
206            let dst = output_dir.join(&dst_name);
207            zip_names.push(dst_name);
208            zip_paths.push(dst.clone());
209
210            // Create the zip file containing just a bootstrap file (the
211            // executable)
212            info!("writing {}", dst.display());
213            let file = fs::File::create(&dst)
214                .context(format!("failed to create {}", dst.display()))?;
215            let mut zip = ZipWriter::new(file);
216            let options = zip::write::FileOptions::default()
217                .compression_method(zip::CompressionMethod::Deflated);
218            zip.start_file("bootstrap", options)?;
219            zip.write_all(&contents)?;
220
221            zip.finish()?;
222        }
223
224        let latest_path = output_dir.join("latest");
225        info!("writing {}", latest_path.display());
226        fs::write(latest_path, zip_names.join("\n") + "\n")?;
227
228        zip_paths
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use chrono::TimeZone;
236
237    #[test]
238    fn test_zip_name() {
239        let when = Utc.ymd(2020, 8, 31);
240        assert_eq!(
241            make_zip_name("testexecutable", "testcontents".as_bytes(), when),
242            "testexecutable-20200831-7097a82a108e78da.zip"
243        );
244    }
245}