use std::{
collections::HashMap,
env,
fs::{self, DirEntry},
io,
path::{absolute, Path, PathBuf},
};
use crate::{
api::{Api, Deployment, DeploymentFramework, DeploymentGit, DeploymentOverride},
config::{Config, Destination},
manifest::Entrypoint,
rust::build,
};
use cargo_metadata::{CargoOpt, MetadataCommand};
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use reqwest::{
multipart::{Form, Part},
Client,
};
use serde::Deserialize;
use crate::bular_json::BularJson;
#[derive(clap::Args)]
#[command(about = "Deploy your application!")]
pub struct Command {
}
impl Command {
pub async fn run(&self, cwd: PathBuf, server: String) {
let bular_json = BularJson::load();
let Ok(config) = std::fs::read_to_string(cwd.join("bular.yaml")) else {
eprintln!("No `bular.yaml` file found in the current directory."); std::process::exit(1);
};
let config: Config = serde_yaml::from_str(&config).unwrap();
let mut deployment = Deployment {
version: 1,
framework: Some(DeploymentFramework {
name: "bular-cli".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
href: Some("https://github.com/specta-rs/bular.git".into()),
}),
git: git_info(),
routes: None,
assets: None,
overrides: None,
};
let mut form = Form::new();
if let Some(_static) = config._static {
if let Some(build_cmd) = _static.build {
println!("Running build command: {build_cmd:?}");
command_from_string(&build_cmd)
.current_dir(&cwd)
.status()
.expect("Failed to build static files");
}
if let Some(static_dir) = _static.dist {
let static_dir = absolute(cwd.join(static_dir)).unwrap();
if !static_dir.exists() {
eprintln!("Static directory does not exist: {static_dir:?}");
std::process::exit(1);
}
let mut asset_manifest = HashMap::new(); let mut overrides = HashMap::new(); let mut assets = HashMap::new();
visit_dirs(&static_dir, &mut |entry| {
if !entry.file_type().unwrap().is_file() {
return;
}
let key = entry
.path()
.strip_prefix(&static_dir)
.unwrap()
.to_str()
.unwrap()
.to_string();
let content = std::fs::read(entry.path()).unwrap();
let hash = sha256::digest(content.as_slice()).to_string();
asset_manifest.insert(key.clone(), hash.clone());
overrides.insert(
key.clone(),
DeploymentOverride {
content_type: mime_infer::from_path(entry.path())
.first()
.map(|m| m.to_string()),
..Default::default()
},
);
let a = assets.get(&hash);
if a.is_some() && a != Some(&content) {
eprintln!(
"Hash collision detected at ${key} with hash ${hash}: ${a:?} !== ${content:?}"
);
std::process::exit(1);
}
assets.insert(hash, content);
})
.unwrap();
deployment.assets = Some(asset_manifest);
deployment.overrides = Some(overrides);
for (hash, content) in assets {
form = form.part(hash, Part::bytes(content));
}
}
}
let mut clients = None;
for path in config.paths {
match path.destination {
Some(Destination::Rewrite { to }) => todo!(),
Some(Destination::Redirect { redirect }) => todo!(),
Some(Destination::Rust { _crate, bin }) => {
let (lambda, iam) = loop {
let Some(value) = &clients else {
let config = aws_config::from_env().region("us-east-1").load().await;
let lambda = aws_sdk_lambda::Client::new(&config);
let iam = aws_sdk_iam::Client::new(&config);
clients = Some((lambda, iam));
continue;
};
break value;
};
let metadata = MetadataCommand::new()
.current_dir(&cwd)
.features(CargoOpt::AllFeatures)
.exec()
.unwrap();
let target_dir = metadata.target_directory.into_std_path_buf(); let bin = _crate.clone();
let entrypoint = Entrypoint {
_crate: _crate.clone(),
environment: Default::default(), resources: Default::default(), lambda: None, };
let binary_path = crate::rust::build(&cwd, &target_dir, &bin, &entrypoint);
let name = format!(
"{}-{}",
bin,
thread_rng()
.sample_iter(&Alphanumeric)
.take(4)
.map(char::from)
.collect::<String>()
);
let result = crate::lambda::deploy(
&name,
&entrypoint,
binary_path,
&iam,
&lambda,
Default::default(),
)
.await;
println!("{:?}", result); }
Some(Destination::Final { r#final: bool }) => todo!(),
None => todo!(),
}
}
if let Some(bular_app_id) = config.bular_id {
let client = reqwest::Client::builder()
.user_agent(concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
))
.build()
.unwrap();
let Some(bular_bearer_token) = get_access_token(&client, &bular_json).await else {
eprintln!("No Bular configuration found. Please run `bular login` first.");
std::process::exit(1);
};
let api = Api::new(client, server, bular_bearer_token).unwrap();
let result = api.deployment(bular_app_id, deployment, form).await;
println!("{result:?}");
}
}
}
fn command_from_string(cmd: &str) -> std::process::Command {
if cfg!(target_os = "windows") {
let mut c = std::process::Command::new("cmd");
c.args(["/C", cmd]);
c
} else {
let mut c = std::process::Command::new("sh");
c.args(["-c", cmd]);
c
}
}
fn git_info() -> Option<DeploymentGit> {
let commit = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?
.stdout;
let message = std::process::Command::new("git")
.args(["log", "-1", "--pretty=%B"])
.output()
.ok()
.and_then(|v| {
String::from_utf8(v.stdout)
.ok()
.map(|v| v.trim().to_string())
});
let branch = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.and_then(|v| {
String::from_utf8(v.stdout)
.ok()
.map(|v| v.trim().to_string())
});
let dirty = std::process::Command::new("git")
.args(["diff", "--quiet"])
.status()
.ok()
.map(|v| !v.success())
.unwrap_or(false);
Some(DeploymentGit {
commit: String::from_utf8(commit).unwrap().trim().to_string(),
message,
branch,
dirty: Some(dirty),
})
}
fn visit_dirs(dir: &Path, cb: &mut dyn FnMut(&DirEntry)) -> io::Result<()> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit_dirs(&path, cb)?;
} else {
cb(&entry);
}
}
}
Ok(())
}
#[derive(Deserialize)]
pub struct GitHubActionsOIDCResponse {
value: String,
}
pub async fn get_access_token(client: &Client, bular_json: &Option<BularJson>) -> Option<String> {
if let Some(token) = bular_json.as_ref().and_then(|c| c.token.as_ref()) {
return Some(token.to_string());
}
let actions_id_token_request_token = env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
let actions_id_token_request_url = env::var("ACTIONS_ID_TOKEN_REQUEST_URL");
if let (Ok(token), Ok(url)) = (actions_id_token_request_token, actions_id_token_request_url) {
println!("Authenticating via GitHub Actions...");
let result: GitHubActionsOIDCResponse = client
.get(format!("{url}&audience=https://bular.cloud"))
.bearer_auth(token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
return Some(result.value);
}
None
}