1#![deny(missing_docs)]
2
3pub 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
22pub static DEFAULT_RUST_VERSION: &str = "stable";
24
25#[throws]
27fn ensure_dir_exists(path: &Path) {
28 let _ = fs::create_dir(path);
30 if !path.is_dir() {
31 throw!(anyhow!("failed to create directory {}", path.display()));
32 }
33}
34
35#[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
69fn 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 hash
94 )
95}
96
97#[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#[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 #[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 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 let registry_dir = self
177 .output_dir
178 .join(format!("{}-cargo-registry", mode_name));
179 ensure_dir_exists(®istry_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 set_podman_permissions(&UserAndGroup::current(), self.output_dir)?;
188
189 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 Volume {
218 src: self.code_root.into(),
219 dst: Path::new("/code").into(),
220 read_write: false,
221 options: mount_options.clone(),
222 },
223 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 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 resetter.reset_permissions()?;
254 }
255
256 self.output_dir
258 .join(mode_name)
259 .join("release")
260 .join(self.bin)
261 }
262}
263
264#[derive(Debug, Clone, Copy, Eq, PartialEq)]
266pub enum BuildMode {
267 AmazonLinux2,
271
272 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
303pub enum Relabel {
304 Shared,
306
307 Unshared,
309}
310
311pub struct BuilderOutput {
313 pub real: PathBuf,
315
316 pub symlink: PathBuf,
318}
319
320#[must_use]
322#[derive(Debug, Clone, Eq, PartialEq)]
323pub struct Builder {
324 pub rust_version: String,
327
328 pub mode: BuildMode,
330
331 pub bin: Option<String>,
334
335 pub strip: bool,
337
338 pub launcher: Launcher,
340
341 pub code_root: PathBuf,
344
345 pub project_path: PathBuf,
348
349 pub packages: Vec<String>,
351
352 pub relabel: Option<Relabel>,
359}
360
361impl Builder {
362 #[throws]
372 pub fn run(&self) -> BuilderOutput {
373 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 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 let binaries = get_package_binaries(&project_path)?;
394
395 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 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 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 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 let zip_name = base_unique_name + ".zip";
447 let zip_path =
448 output_dir.join(self.mode.name()).join(&zip_name);
449
450 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 let symlink_path =
470 target_dir.join(format!("latest-{}", self.mode.name()));
471 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 #[throws]
485 fn build_container(&self, relative_project_path: &Path) -> String {
486 let from = match self.mode {
487 BuildMode::AmazonLinux2 => {
488 "docker.io/amazonlinux:2"
490 }
491 BuildMode::Lambda => {
492 "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}