use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::progress::{self, Step, Transfer, human_bytes, human_duration, rate};
use crate::remote::{self, Target};
use crate::shell::Dialect;
use crate::ssh::Session;
const ARCHIVE_THRESHOLD: u64 = 8;
fn should_archive(files: u64, no_archive: bool) -> bool {
!no_archive && files >= ARCHIVE_THRESHOLD
}
pub fn run(app_name: Option<String>, overrides: remote::Overrides, no_build: bool, backup: bool, clear: bool, no_archive: bool) -> Result<()> {
let target = remote::resolve(app_name, overrides)?;
let (app, server) = (&target.app, &target.server);
let Some(dist) = &app.dist_dir else {
bail!("app '{0}' has no artifact directory - set it with `turnout app edit {0} --dist DIR`", app.name);
};
let project = crate::utils::project_dir(Path::new(&app.path))?;
if !no_build && let Some(build) = app.commands.get("build") {
eprintln!("[{}] {build}", app.name);
let status = crate::utils::run_in_dir(build, &project)?;
if !status.success() {
bail!("build failed with {status} - nothing uploaded");
}
}
let local = project.join(dist);
if !local.is_dir() {
bail!("artifact directory {} does not exist - did the build produce it?", local.display());
}
let plan = plan_upload(&local)?;
if plan.files == 0 {
bail!("artifact directory {} is empty - nothing to upload", local.display());
}
progress::intro(format!("Deploying {} → {}", app.name, server.name));
match deploy_over_ssh(&target, &plan, &Options { backup, clear, no_archive }) {
Ok(files) => {
crate::journal::record("deploy", Some(&app.name), Some(&server.name), Some(&format!("{files} files")));
progress::outro(format!("Deploy of '{}' to '{}' finished", app.name, server.name));
Ok(())
}
Err(err) => {
progress::outro_error(format!("Deploy of '{}' to '{}' failed", app.name, server.name));
Err(err)
}
}
}
struct Options {
backup: bool,
clear: bool,
no_archive: bool,
}
fn deploy_over_ssh(target: &Target, plan: &Plan, options: &Options) -> Result<u64> {
let (server, credential, path) = (&target.server, &target.credential, &target.path);
let where_to = target.connection_label();
let step = Step::start(format!("Connecting to {where_to} ..."));
let session = remote::connect(server, credential)?;
step.update("Detecting the server shell ...");
let dialect = remote::dialect(&session, server);
step.done(format!("Connected to {where_to}"));
remote::check_quotable(dialect, &[&path.dir])?;
if options.backup {
let step = Step::start(format!("Backing up {} ...", path.dir));
let name = remote::run_backup(&session, dialect, &path.dir)?;
step.done(format!("Backup {} created in {}", name.trim(), remote::backups_dir(&path.dir)));
}
if options.clear {
let step = Step::start(format!("Clearing {} ...", path.dir));
remote::exec(&session, &dialect.clear_dir(&path.dir))?;
step.done(format!("Cleared {}", path.dir));
}
let (files, _bytes) = match archive_upload(&session, dialect, &path.dir, plan, options.no_archive)? {
Some(counts) => counts,
None => upload(&session, &path.dir, plan)?,
};
if let Some(restart) = &path.restart {
let step = Step::start(format!("Running: {restart}"));
let output = remote::exec(&session, restart)?;
step.done(format!("Restarted: {restart}"));
if !output.trim().is_empty() {
progress::info(output.trim_end());
}
}
Ok(files)
}
struct Plan {
dirs: Vec<String>,
entries: Vec<(PathBuf, String)>,
files: u64,
bytes: u64,
}
fn plan_upload(local_root: &Path) -> Result<Plan> {
let mut plan = Plan {
dirs: Vec::new(),
entries: Vec::new(),
files: 0,
bytes: 0,
};
let mut queue = vec![local_root.to_path_buf()];
while let Some(dir) = queue.pop() {
for entry in std::fs::read_dir(&dir).with_context(|| format!("cannot read {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
let relative = path.strip_prefix(local_root).expect("entry under root").to_string_lossy().replace('\\', "/");
let metadata = entry.metadata().with_context(|| format!("cannot stat {}", path.display()))?;
if metadata.is_dir() {
plan.dirs.push(relative);
queue.push(path);
} else {
plan.files += 1;
plan.bytes += metadata.len();
plan.entries.push((path, relative));
}
}
}
plan.dirs.sort_by_key(|d| d.matches('/').count());
Ok(plan)
}
fn archive_upload(session: &Session, dialect: Dialect, remote_root: &str, plan: &Plan, no_archive: bool) -> Result<Option<(u64, u64)>> {
if !should_archive(plan.files, no_archive) {
return Ok(None);
}
let step = Step::start(format!("Packing {} files ...", plan.files));
if !remote_has_tar(session) {
step.clear();
progress::warn(&format!("{} - uploading file by file", no_tar_note(dialect)));
return Ok(None);
}
let archive = pack(plan)?;
let remote_root = remote_root.trim_end_matches('/');
let remote_archive = remote::join_remote(remote_root, ".turnout-upload.tar.gz");
remote::exec(session, &dialect.mkdir_p(remote_root))?;
step.done(format!("Packed {} files → {}", plan.files, human_bytes(archive.len() as u64)));
let sent = archive.len() as u64;
let transfer = Transfer::start(1, sent);
session
.upload_bytes(&archive, &remote_archive, |chunk| transfer.advance(chunk))
.with_context(|| format!("cannot upload the archive to {remote_archive}"))?;
let elapsed = transfer.elapsed();
transfer.done(format!(
"Uploaded {} in {} · {}",
human_bytes(sent),
human_duration(elapsed),
rate(sent, elapsed)
));
let step = Step::start("Unpacking on the server ...".to_string());
let unpack = remote::exec(
session,
&dialect.and_then(&dialect.untar(&remote_archive, remote_root), &dialect.remove_file(&remote_archive)),
);
if let Err(err) = unpack {
let _ = remote::exec(session, &dialect.remove_file(&remote_archive));
step.clear();
return Err(err).context("cannot unpack the archive on the server");
}
step.done(format!("Unpacked {} files into {}", plan.files, remote_root));
Ok(Some((plan.files, plan.bytes)))
}
fn remote_has_tar(session: &Session) -> bool {
remote::exec(session, "tar --version").is_ok()
}
fn no_tar_note(dialect: Dialect) -> String {
match dialect {
Dialect::Posix => "the server has no usable tar".to_string(),
Dialect::Windows => {
"tar did not answer on this Windows server (it ships with one since Windows 10 1803 - if it is there, this is a turnout bug)".to_string()
}
}
}
fn pack(plan: &Plan) -> Result<Vec<u8>> {
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut builder = tar::Builder::new(encoder);
for (path, relative) in &plan.entries {
let mut file = std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
builder
.append_file(relative, &mut file)
.with_context(|| format!("cannot add {} to the archive", path.display()))?;
}
let encoder = builder.into_inner().context("cannot finish the archive")?;
encoder.finish().context("cannot compress the archive")
}
fn upload(session: &Session, remote_root: &str, plan: &Plan) -> Result<(u64, u64)> {
let remote_root = remote_root.trim_end_matches('/');
session.mkdir(remote_root)?;
for dir in &plan.dirs {
session.mkdir(&format!("{remote_root}/{dir}"))?;
}
let transfer = Transfer::start(plan.files, plan.bytes);
let mut files = 0;
let mut bytes = 0;
for (path, relative) in &plan.entries {
let remote = format!("{remote_root}/{relative}");
let copied = session
.upload(path, &remote, |chunk| transfer.advance(chunk))
.with_context(|| format!("cannot upload {}", path.display()))?;
bytes += copied;
files += 1;
}
let elapsed = transfer.elapsed();
let plural = if files == 1 { "" } else { "s" };
transfer.done(format!(
"Uploaded {files} file{plural} ({}) to {} in {} · {}",
human_bytes(bytes),
remote_root,
human_duration(elapsed),
rate(bytes, elapsed)
));
Ok((files, bytes))
}
#[cfg(test)]
mod tests {
use super::plan_upload;
#[test]
fn plans_a_nested_tree() {
let root = tempfile::tempdir().expect("temp dir");
let deep = root.path().join("assets").join("img");
std::fs::create_dir_all(&deep).expect("create dirs");
std::fs::write(root.path().join("index.html"), "hello").expect("write");
std::fs::write(deep.join("logo.svg"), "12345678").expect("write");
let plan = plan_upload(root.path()).expect("plan");
assert_eq!(plan.files, 2);
assert_eq!(plan.bytes, 13);
assert_eq!(plan.dirs, vec!["assets", "assets/img"]);
let mut relative: Vec<_> = plan.entries.iter().map(|(_, r)| r.as_str()).collect();
relative.sort_unstable();
assert_eq!(relative, vec!["assets/img/logo.svg", "index.html"]);
}
#[test]
fn only_trees_worth_packing_are_packed() {
assert!(super::should_archive(2_000, false), "a dist directory packs");
assert!(super::should_archive(super::ARCHIVE_THRESHOLD, false), "the threshold itself packs");
assert!(!super::should_archive(super::ARCHIVE_THRESHOLD - 1, false), "just below it does not");
assert!(!super::should_archive(1, false), "a single file never packs");
assert!(!super::should_archive(2_000, true), "--no-archive wins over any size");
}
#[test]
fn the_archive_holds_relative_paths_and_content() {
let root = tempfile::tempdir().expect("temp dir");
let deep = root.path().join("assets").join("img");
std::fs::create_dir_all(&deep).expect("create dirs");
std::fs::write(root.path().join("index.html"), "<!doctype html>").expect("write");
std::fs::write(deep.join("logo.svg"), "<svg/>").expect("write");
let plan = super::plan_upload(root.path()).expect("plan");
let archive = super::pack(&plan).expect("pack");
let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive));
let mut tar = tar::Archive::new(decoder);
let mut found = std::collections::BTreeMap::new();
for entry in tar.entries().expect("entries") {
use std::io::Read;
let mut entry = entry.expect("entry");
let path = entry.path().expect("path").to_string_lossy().replace('\\', "/");
let mut content = String::new();
entry.read_to_string(&mut content).expect("read");
found.insert(path, content);
}
assert_eq!(found.get("index.html").map(String::as_str), Some("<!doctype html>"));
assert_eq!(found.get("assets/img/logo.svg").map(String::as_str), Some("<svg/>"));
assert_eq!(found.len(), 2, "only files travel; directories come from the paths: {found:?}");
assert!(
!found.keys().any(|p| p.starts_with('/') || p.starts_with("./")),
"paths must be relative: {found:?}"
);
}
#[test]
fn the_archive_is_smaller_than_the_tree() {
let root = tempfile::tempdir().expect("temp dir");
for index in 0..20 {
std::fs::write(root.path().join(format!("chunk-{index}.js")), "console.log('hello world');\n".repeat(200)).expect("write");
}
let plan = super::plan_upload(root.path()).expect("plan");
let archive = super::pack(&plan).expect("pack");
assert!(
(archive.len() as u64) < plan.bytes / 4,
"expected real compression, got {} from {}",
archive.len(),
plan.bytes
);
}
#[test]
fn an_empty_tree_plans_nothing() {
let root = tempfile::tempdir().expect("temp dir");
let plan = plan_upload(root.path()).expect("plan");
assert_eq!(plan.files, 0);
assert!(plan.entries.is_empty());
}
}