use async_process::Command;
use beet_core::prelude::*;
use std::process::Stdio;
#[derive(Debug, Default, Clone)]
pub struct AwsCli {
pub profile: Option<String>,
pub region: Option<String>,
pub no_sign_request: bool,
pub extra_global_args: Vec<String>,
}
impl AwsCli {
pub fn new() -> Self { Self::default() }
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
pub fn with_no_sign_request(mut self, enabled: bool) -> Self {
self.no_sign_request = enabled;
self
}
pub fn with_global_arg(mut self, arg: impl Into<String>) -> Self {
self.extra_global_args.push(arg.into());
self
}
pub fn build_s3_sync_args(
&self,
src: &str,
dst: &str,
opts: &S3Sync,
) -> Vec<String> {
let mut argv = vec!["aws".to_string()];
if let Some(profile) = &self.profile {
argv.push("--profile".into());
argv.push(profile.clone());
}
if let Some(region) = &self.region {
argv.push("--region".into());
argv.push(region.clone());
}
if self.no_sign_request {
argv.push("--no-sign-request".into());
}
argv.extend(self.extra_global_args.iter().cloned());
argv.push("s3".into());
argv.push("sync".into());
argv.push(src.into());
argv.push(dst.into());
argv.extend(opts.to_args());
argv
}
async fn run_argv(&self, argv: Vec<String>) -> Result {
let (prog, rest) =
argv.split_first().ok_or_else(|| bevyhow!("empty argv"))?;
let status = Command::new(prog)
.args(rest)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await?;
if !status.success() {
bevybail!("aws cli exited with non-zero status: {:?}", status);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum S3Filter {
Exclude(String),
Include(String),
}
impl S3Filter {
fn to_args(&self) -> [String; 2] {
match self {
S3Filter::Exclude(p) => ["--exclude".into(), p.clone()],
S3Filter::Include(p) => ["--include".into(), p.clone()],
}
}
}
#[derive(Debug, Clone)]
pub struct S3Sync {
pub cli: AwsCli,
pub src: String,
pub dst: String,
pub delete: bool,
pub size_only: bool,
pub dry_run: bool,
pub no_progress: bool,
pub exact_timestamps: bool,
pub follow_symlinks: bool,
pub acl: Option<String>,
pub filters: Vec<S3Filter>,
pub additional_args: Vec<String>,
}
impl Default for S3Sync {
fn default() -> Self {
Self {
cli: default(),
src: String::new(),
dst: String::new(),
delete: false,
size_only: false,
dry_run: false,
no_progress: true, exact_timestamps: false,
follow_symlinks: false,
acl: None,
filters: Vec::new(),
additional_args: Vec::new(),
}
}
}
impl S3Sync {
pub fn push(local_dir: AbsPathBuf, s3_uri: impl AsRef<str>) -> Self {
let s3_uri = s3_uri.as_ref();
if !is_s3_uri(&s3_uri) {
panic!("expected S3 URI (s3://...), got: {}", &s3_uri);
}
Self {
src: local_dir.to_string_lossy().to_string(),
dst: s3_uri.to_string(),
..Default::default()
}
}
pub fn pull(s3_uri: impl AsRef<str>, local_dir: AbsPathBuf) -> Self {
let s3_uri = s3_uri.as_ref();
if !is_s3_uri(&s3_uri) {
panic!("expected S3 URI (s3://...), got: {}", &s3_uri);
}
Self {
src: s3_uri.to_string(),
dst: local_dir.to_string_lossy().to_string(),
..Default::default()
}
}
pub async fn send(&self) -> Result {
let argv = self.cli.build_s3_sync_args(&self.src, &self.dst, self);
self.cli.run_argv(argv).await
}
pub fn delete(mut self, value: bool) -> Self {
self.delete = value;
self
}
pub fn size_only(mut self, value: bool) -> Self {
self.size_only = value;
self
}
pub fn dry_run(mut self, value: bool) -> Self {
self.dry_run = value;
self
}
pub fn no_progress(mut self, value: bool) -> Self {
self.no_progress = value;
self
}
pub fn exact_timestamps(mut self, value: bool) -> Self {
self.exact_timestamps = value;
self
}
pub fn follow_symlinks(mut self, value: bool) -> Self {
self.follow_symlinks = value;
self
}
pub fn acl_public_read(mut self) -> Self {
self.acl = Some("public-read".to_string());
self
}
pub fn acl(mut self, value: impl Into<String>) -> Self {
self.acl = Some(value.into());
self
}
pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
self.filters.push(S3Filter::Exclude(pattern.into()));
self
}
pub fn include(mut self, pattern: impl Into<String>) -> Self {
self.filters.push(S3Filter::Include(pattern.into()));
self
}
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.additional_args.push(arg.into());
self
}
fn to_args(&self) -> Vec<String> {
let mut out = Vec::new();
if self.delete {
out.push("--delete".into());
}
if self.size_only {
out.push("--size-only".into());
}
if self.dry_run {
out.push("--dryrun".into());
}
if self.no_progress {
out.push("--no-progress".into());
}
if self.exact_timestamps {
out.push("--exact-timestamps".into());
}
if self.follow_symlinks {
out.push("--follow-symlinks".into());
}
if let Some(acl) = &self.acl {
out.push("--acl".into());
out.push(acl.clone());
}
for f in &self.filters {
let [flag, val] = f.to_args();
out.push(flag);
out.push(val);
}
out.extend(self.additional_args.iter().cloned());
out
}
}
fn is_s3_uri(s: &str) -> bool { s.starts_with("s3://") }
#[cfg(test)]
mod test {
use super::*;
#[beet_core::test]
fn builds_basic_sync_args() {
let aws = AwsCli::new()
.with_profile("dev")
.with_region("us-west-2")
.with_no_sign_request(true);
let local = AbsPathBuf::new("foo/bar").unwrap();
let opts = S3Sync::default()
.delete(true)
.size_only(true)
.dry_run(true)
.no_progress(true)
.exact_timestamps(true)
.follow_symlinks(true)
.acl_public_read()
.include("public/**")
.exclude("node_modules/**")
.arg("--storage-class")
.arg("STANDARD_IA");
let argv = aws.build_s3_sync_args(
&local.to_string_lossy(),
"s3://my-bucket/site",
&opts,
);
argv[0].xpect_eq("aws");
argv.contains(&"--profile".to_string()).xpect_true();
argv.contains(&"dev".to_string()).xpect_true();
argv.contains(&"--region".to_string()).xpect_true();
argv.contains(&"us-west-2".to_string()).xpect_true();
argv.contains(&"--no-sign-request".to_string()).xpect_true();
let flags = [
"--delete",
"--size-only",
"--dryrun",
"--no-progress",
"--exact-timestamps",
"--follow-symlinks",
"--acl",
];
for f in flags {
argv.contains(&f.to_string()).xpect_true();
}
argv.contains(&"public-read".to_string()).xpect_true();
argv.contains(&"--storage-class".to_string()).xpect_true();
argv.contains(&"STANDARD_IA".to_string()).xpect_true();
}
#[beet_core::test]
fn preserves_filter_order() {
let aws = AwsCli::new();
let local = AbsPathBuf::new("some/dir").unwrap();
let opts = S3Sync::default()
.exclude("node_modules/**")
.include("public/**")
.exclude("**/*.map")
.include("assets/**");
let argv = aws.build_s3_sync_args(
&local.to_string_lossy(),
"s3://bucket/prefix",
&opts,
);
let mut filter_pairs = Vec::<(String, String)>::new();
let mut i = 0usize;
while i + 1 < argv.len() {
match argv[i].as_str() {
"--exclude" | "--include" => {
filter_pairs.push((argv[i].clone(), argv[i + 1].clone()));
i += 2;
}
_ => i += 1,
}
}
filter_pairs.xpect_eq(vec![
("--exclude".into(), "node_modules/**".into()),
("--include".into(), "public/**".into()),
("--exclude".into(), "**/*.map".into()),
("--include".into(), "assets/**".into()),
]);
}
#[beet_core::test]
#[should_panic]
async fn rejects_non_s3_uri() {
S3Sync::push(AbsPathBuf::new("out").unwrap(), "not-an-s3-uri")
.send()
.await
.unwrap_err()
.to_string()
.contains("expected S3 URI")
.xpect_true();
}
}