use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use crate::artifact::{self, Artifact, Plan};
use crate::detached::Remote;
use crate::job;
use crate::model::{HealthCheck, Probe};
use crate::progress::{self, Step, Transfer, human_bytes, human_duration, rate};
use crate::remote::{self, Resolved};
use crate::shell::Dialect;
use crate::ssh::Session;
use crate::store;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Reached {
BeforeUpload,
Uploaded,
RolledBack,
}
impl Reached {
fn note(self, path: &crate::model::Path) -> Option<String> {
match self {
Reached::BeforeUpload | Reached::RolledBack => None,
Reached::Uploaded => Some(match &path.restart {
Some(restart) => format!(
"The files are already in {} - only `{restart}` did not finish, so the service is still running what it had.\n\
Re-run it there, or deploy again with --no-build once the cause is fixed.",
path.dir
),
None => format!("The files are already in {}; the deploy failed after writing them.", path.dir),
}),
}
}
}
pub struct Flags {
pub no_build: bool,
pub backup: bool,
pub clear: bool,
pub no_archive: bool,
pub diff: bool,
pub json: bool,
pub open: bool,
pub console: crate::cli::Console,
}
pub fn run(names: Vec<String>, overrides: remote::Overrides, flags: Flags) -> Result<()> {
let targets = resolve_all(names, overrides)?;
let verbose = flags.console.verbose;
let console = job::Mode::resolve(job::Mode::Quiet, verbose);
let apps = store::load_apps()?;
let is_command = |name: &str| apps.iter().any(|app| app.commands.contains_key(name));
if !flags.diff {
let placement = crate::commands::jobs::placement("deploy", flags.console, console, &is_command);
if placement.detached() {
let preferred = placement == crate::commands::jobs::Placement::Preferred;
return in_background(&targets, &flags_for(&flags), preferred);
}
}
let artifacts = prepare(&targets, flags.no_build, console)?;
if flags.diff {
return compare_all(&targets, &artifacts, flags.clear, flags.json);
}
let options = Options {
backup: flags.backup,
clear: flags.clear,
no_archive: flags.no_archive,
};
for (index, target) in targets.iter().enumerate() {
if let Err(err) = deploy_one(target, artifact_for(&artifacts, target), &options, flags.open) {
let rest: Vec<String> = targets[index + 1..].iter().map(label).collect();
if !rest.is_empty() {
progress::warn(&format!("not attempted: {} - a deploy stops at the first target that fails", rest.join(", ")));
}
return Err(err);
}
}
if targets.len() > 1 {
println!("Deployed to {}.", targets.iter().map(label).collect::<Vec<_>>().join(", "));
}
Ok(())
}
fn artifact_for<'a>(artifacts: &'a [Artifact], target: &Resolved) -> &'a Artifact {
artifacts
.iter()
.find(|artifact| artifact.app == target.app.name)
.expect("every app was prepared")
}
fn label(target: &Resolved) -> String {
target.target.clone().unwrap_or_else(|| format!("{} → {}", target.app.name, target.server.name))
}
fn resolve_all(names: Vec<String>, overrides: remote::Overrides) -> Result<Vec<Resolved>> {
if names.len() <= 1 {
return Ok(vec![remote::resolve(names.into_iter().next(), overrides)?]);
}
if overrides.server.is_some() || overrides.credential.is_some() || overrides.path.is_some() {
bail!("--server, --credential and --path change one deploy - name a single target with them");
}
let mut seen = Vec::new();
for name in &names {
if seen.contains(name) {
bail!("'{name}' is named twice - each target is deployed once");
}
seen.push(name.clone());
}
names
.into_iter()
.map(|name| remote::resolve(Some(name), remote::Overrides::default()))
.collect()
}
fn prepare(targets: &[Resolved], no_build: bool, console: job::Mode) -> Result<Vec<Artifact>> {
let mut artifacts: Vec<Artifact> = Vec::new();
for target in targets {
let app = &target.app;
if artifacts.iter().any(|artifact| artifact.app == app.name) {
continue;
}
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))?;
let build = app.commands.get("build").filter(|_| !no_build);
if let Some(build) = build {
eprintln!("[{}] {build}", app.name);
let mut job = job::Job::claim(&app.name, "build", false)?;
let outcome = job::run(
job::Program::Shell(build),
&project,
&[],
console,
&format!("Building {}", app.name),
&mut job,
None,
)?;
if !outcome.status.success() {
bail!("build failed with {} - nothing uploaded", outcome.status);
}
}
let local = project.join(dist);
if !local.is_dir() {
bail!("artifact directory {} does not exist - did the build produce it?", local.display());
}
let plan = artifact::plan_upload(&local)?;
if plan.files == 0 {
bail!("artifact directory {} is empty - nothing to upload", local.display());
}
if build.is_none() {
warn_if_stale(app, &project, &local, &plan);
}
artifacts.push(Artifact::new(&app.name, plan));
}
Ok(artifacts)
}
fn warn_if_stale(app: &crate::model::App, project: &Path, local: &Path, plan: &Plan) {
let Some(built) = plan.newest else { return };
let Some(stale) = artifact::stale(project, local, built, &[Path::new(app.env_file_name())]) else {
return;
};
let remedy = if app.commands.contains_key("build") {
"deploy without --no-build to build it first".to_string()
} else {
format!("build it before deploying - '{}' has no build command for turnout to run", app.name)
};
progress::warn(&format!(
"{} is older than its sources: {} changed {} after the last build - {remedy}",
app.dist_dir.as_deref().unwrap_or("the build"),
stale.source,
crate::progress::human_span(stale.after.as_secs())
));
}
fn deploy_one(target: &Resolved, artifact: &Artifact, options: &Options, open: bool) -> Result<()> {
let (app, server) = (&target.app, &target.server);
let heading = match &target.target {
Some(name) => format!("Deploying {name}: {} → {}", app.name, server.name),
None => format!("Deploying {} → {}", app.name, server.name),
};
progress::intro(heading);
if let Some(unchecked) = &target.unchecked {
progress::warn(unchecked);
}
let connect = || remote::connect(&target.server, &target.credential);
let mut reached = Reached::BeforeUpload;
match deploy_over_ssh(target, artifact, options, &mut reached, &connect) {
Ok(files) => {
let what = target.target.clone().unwrap_or_else(|| app.name.clone());
crate::journal::record("deploy", Some(&what), Some(&server.name), Some(&format!("{files} files")));
progress::outro(format!("Deploy of '{}' to '{}' finished", app.name, server.name));
if target.target.is_none() {
offer_target(target)?;
}
if open {
println!("Opening {}", target.address);
crate::front::open_in_browser(&target.address)?;
}
Ok(())
}
Err(err) => {
progress::outro_error(format!("Deploy of '{}' to '{}' failed", app.name, server.name));
match reached.note(&target.path) {
Some(note) => Err(err.context(note)),
None => Err(err),
}
}
}
}
fn in_background(targets: &[Resolved], flags: &[&str], preferred: bool) -> Result<()> {
let first = &targets[0];
let app = &first.app;
if let Some(other) = targets.iter().find(|target| target.app.name != app.name) {
bail!(
"'{}' and '{}' deploy different apps, and a background deploy belongs to one - detach them one at a time",
label(first),
label(other)
);
}
let mut args = vec!["deploy".to_string()];
for target in targets {
match &target.target {
Some(name) => args.push(name.clone()),
None => {
if store::load_targets()?.iter().any(|t| t.name == app.name) {
bail!(
"a target is also named '{0}', so the background deploy cannot name the app - deploy a target by name, or without --detach",
app.name
);
}
args.extend([
app.name.clone(),
"--server".into(),
target.server.name.clone(),
"--credential".into(),
target.credential.name.clone(),
"--path".into(),
target.path.name.clone(),
]);
}
}
}
args.extend(flags.iter().map(|flag| flag.to_string()));
let project = crate::utils::project_dir(Path::new(&app.path))?;
crate::commands::jobs::detach(crate::commands::jobs::Detach {
app: &app.name,
command: "deploy",
dir: &project,
env: &[],
label: &format!("Deploying {}", app.name),
ready: false,
own: None,
open: false,
itself: true,
program: args,
link: Some(first.address.clone()),
preferred,
})
}
fn flags_for(flags: &Flags) -> Vec<&'static str> {
[
(flags.no_build, "--no-build"),
(flags.backup, "--backup"),
(flags.clear, "--clear"),
(flags.no_archive, "--no-archive"),
]
.into_iter()
.filter_map(|(on, flag)| on.then_some(flag))
.collect()
}
fn offer_target(target: &Resolved) -> Result<()> {
match crate::commands::target::offer_to_save(&target.app.name, &target.server.name, &target.credential.name, &target.path.name) {
Ok(Some(name)) => {
println!("Saved as target '{name}' - next time: `turnout deploy {name}`.");
Ok(())
}
Ok(None) => Ok(()),
Err(err) => {
progress::warn(&format!("could not save the target: {err:#}"));
Ok(())
}
}
}
struct Options {
backup: bool,
clear: bool,
no_archive: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kept {
Asked,
ForRollback,
}
fn deploy_over_ssh(target: &Resolved, artifact: &Artifact, options: &Options, reached: &mut Reached, connect: &dyn Fn() -> Result<Session>) -> Result<u64> {
let (server, path) = (&target.server, &target.path);
let where_to = target.connection_label();
let step = Step::start(format!("Connecting to {where_to} ..."));
let mut session = connect()?;
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])?;
let kept = if options.backup {
Some(Kept::Asked)
} else {
target.health.as_ref().map(|_| Kept::ForRollback)
};
let backup = match kept {
Some(kept) => back_up(&session, dialect, &path.dir, kept)?,
None => None,
};
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));
*reached = Reached::Uploaded;
}
let files = match archive_upload(&session, dialect, &path.dir, artifact, options.no_archive)? {
Some(files) => files,
None => upload(&session, &path.dir, &artifact.plan)?,
};
*reached = Reached::Uploaded;
let restarted = match &path.restart {
Some(restart) => run_restart(&mut session, dialect, path, restart, connect),
None => Ok(()),
};
let Some(check) = &target.health else {
return restarted.map(|()| files);
};
let verdict = match restarted {
Ok(()) => {
let step = Step::start(format!("Checking {} ...", check.describe(&target.address)));
match wait_healthy(check, target, &session, dialect, |answer| {
step.update(format!("Checking {} - {answer}", check.describe(&target.address)))
}) {
Ok(answer) => {
step.done(format!("Healthy: {} - {answer}", check.describe(&target.address)));
Ok(())
}
Err(last) => {
step.clear();
Err(format!(
"the health check failed: {} {last} after {}s of asking",
check.describe(&target.address),
check.wait().as_secs()
))
}
}
}
Err(err) => Err(format!("{err:#}")),
};
match (verdict, backup) {
(Ok(()), Some(name)) if kept == Some(Kept::ForRollback) => {
if let Err(err) = remote::remove_backup(&session, dialect, &path.dir, &name) {
progress::warn(&format!("could not remove the rollback backup {name}: {err:#}"));
}
Ok(files)
}
(Ok(()), _) => Ok(files),
(Err(why), None) => bail!("{why} - and this was the first deploy to {}, so there is nothing to roll back to", path.dir),
(Err(why), Some(name)) => {
progress::warn(&why);
let after = roll_back(&mut session, dialect, target, &name, connect).with_context(|| {
format!(
"{why}, and rolling back to {name} failed too - the stand is in between; the backup is in {}",
remote::backups_dir(&path.dir)
)
})?;
*reached = Reached::RolledBack;
bail!("{why} - rolled back to {name}: {after}")
}
}
}
fn back_up(session: &Session, dialect: Dialect, dir: &str, kept: Kept) -> Result<Option<String>> {
if !remote::dir_exists(session, dialect, dir) {
if kept == Kept::Asked {
progress::warn(&format!("nothing to back up yet - {dir} is not there; the deploy creates it"));
}
return Ok(None);
}
let step = Step::start(format!("Backing up {dir} ..."));
let name = remote::run_backup(session, dialect, dir)?;
let name = name.trim().to_string();
match kept {
Kept::Asked => step.done(format!("Backup {name} created in {}", remote::backups_dir(dir))),
Kept::ForRollback => step.done(format!("Backup {name} kept for a rollback until the check passes")),
}
Ok(Some(name))
}
fn run_restart(session: &mut Session, dialect: Dialect, path: &crate::model::Path, restart: &str, connect: &dyn Fn() -> Result<Session>) -> Result<()> {
let step = Step::start(format!("Running: {restart}"));
let output = remote::restart(session, dialect, path, restart, connect, |line| {
step.update(format!("Running: {restart} · {line}"))
})?;
step.done(format!("Restarted: {restart}"));
if !output.trim().is_empty() {
progress::info(output.trim_end());
}
Ok(())
}
fn roll_back(session: &mut Session, dialect: Dialect, target: &Resolved, backup: &str, connect: &dyn Fn() -> Result<Session>) -> Result<String> {
let path = &target.path;
let step = Step::start(format!("Rolling back to {backup} ..."));
remote::restore_backup(session, dialect, &path.dir, backup)?;
step.done(format!("Rolled back {} to {backup}", path.dir));
if let Some(restart) = &path.restart {
run_restart(session, dialect, path, restart, connect)?;
}
let Some(check) = &target.health else {
return Ok("restored".to_string());
};
let step = Step::start(format!("Checking {} again ...", check.describe(&target.address)));
Ok(match wait_healthy(check, target, session, dialect, |_| {}) {
Ok(answer) => {
step.done(format!("Healthy again: {} - {answer}", check.describe(&target.address)));
format!("the stand works again ({answer})")
}
Err(last) => {
step.clear();
format!("and it still fails the check ({last}) - the cause is not only in this deploy")
}
})
}
const HEALTH_EVERY: Duration = if cfg!(test) { Duration::from_millis(100) } else { Duration::from_secs(2) };
fn wait_healthy(check: &HealthCheck, target: &Resolved, remote: &impl Remote, dialect: Dialect, mut heard: impl FnMut(&str)) -> Result<String, String> {
let started = Instant::now();
loop {
let answer = match &check.probe {
Probe::Url(url) => crate::health::ask(&crate::model::health_url(url, &target.address), target.server.accept_invalid_certs),
Probe::Command(command) => match remote.exec_bytes(&dialect.run_in(&target.path.dir, command)) {
Ok(_) => Ok("exit 0".to_string()),
Err(err) => Err(last_words(&format!("{err:#}"))),
},
};
match answer {
Ok(answer) => return Ok(answer),
Err(last) if started.elapsed() + HEALTH_EVERY > check.wait() => return Err(last),
Err(last) => {
heard(&last);
std::thread::sleep(HEALTH_EVERY);
}
}
}
}
fn last_words(error: &str) -> String {
match error.split_once("exited with ") {
Some((_, rest)) => format!("exited with {}", rest.trim().trim_end_matches(':')),
None => error.to_string(),
}
}
#[derive(Serialize)]
struct Compared {
target: String,
server: String,
dir: String,
#[serde(flatten)]
diff: crate::manifest::Diff,
}
fn compare_all(targets: &[Resolved], artifacts: &[Artifact], clear: bool, json: bool) -> Result<()> {
let mut all = Vec::new();
for target in targets {
let artifact = artifact_for(artifacts, target);
let path = &target.path;
if json {
let session = remote::connect(&target.server, &target.credential)?;
let dialect = remote::dialect(&session, &target.server);
remote::check_quotable(dialect, &[&path.dir])?;
all.push(Compared {
target: label(target),
server: target.server.name.clone(),
dir: path.dir.clone(),
diff: crate::manifest::diff(&session, dialect, &path.dir, &artifact.plan, clear)?,
});
continue;
}
progress::intro(format!("Comparing {}: {} → {}", label(target), target.app.name, target.server.name));
let where_to = target.connection_label();
let step = Step::start(format!("Connecting to {where_to} ..."));
let session = remote::connect(&target.server, &target.credential)?;
let dialect = remote::dialect(&session, &target.server);
step.done(format!("Connected to {where_to}"));
remote::check_quotable(dialect, &[&path.dir])?;
let step = Step::start(format!("Comparing {} files with {} ...", artifact.plan.files, path.dir));
let diff = crate::manifest::diff(&session, dialect, &path.dir, &artifact.plan, clear)?;
step.done(format!("Compared {} files with {}", artifact.plan.files, path.dir));
for line in diff.lines() {
progress::info(&line);
}
progress::outro(if diff.changes() {
"Nothing was written - deploy without --diff to make these changes"
} else {
"Nothing was written - the stand already has this build"
});
}
if json {
println!("{}", crate::output::json(&all)?);
}
Ok(())
}
fn archive_upload(session: &Session, dialect: Dialect, remote_root: &str, artifact: &Artifact, no_archive: bool) -> Result<Option<u64>> {
let plan = &artifact.plan;
if !artifact::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 = artifact.archive()?;
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))
}
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 upload(session: &Session, remote_root: &str, plan: &Plan) -> Result<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 entry in &plan.entries {
let remote = format!("{remote_root}/{}", entry.relative);
let copied = session
.upload(&entry.path, &remote, |chunk| transfer.advance(chunk))
.with_context(|| format!("cannot upload {}", entry.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)
}
#[cfg(test)]
mod tests {
use super::*;
fn a_path(restart: Option<&str>) -> crate::model::Path {
crate::model::Path {
name: "wwwroot".into(),
dir: "/var/www/site".into(),
restart: restart.map(str::to_string),
}
}
#[test]
fn a_failure_after_the_upload_says_the_files_are_already_there() {
let note = Reached::Uploaded.note(&a_path(Some("docker compose up -d"))).expect("a note");
assert!(note.contains("/var/www/site"), "it must name where the files landed: {note}");
assert!(note.contains("docker compose up -d"), "and which command did not finish: {note}");
assert!(
note.contains("still running what it had"),
"the point is that the service did not pick them up: {note}"
);
}
#[test]
fn a_failure_after_the_upload_reports_even_without_a_restart() {
let note = Reached::Uploaded.note(&a_path(None)).expect("a note");
assert!(note.contains("/var/www/site"), "{note}");
assert!(!note.contains("did not finish"), "there was no command to not finish: {note}");
}
#[test]
fn a_failure_before_the_upload_claims_nothing() {
assert!(Reached::BeforeUpload.note(&a_path(Some("systemctl restart site"))).is_none());
assert!(Reached::BeforeUpload.note(&a_path(None)).is_none());
assert!(Reached::RolledBack.note(&a_path(Some("systemctl restart site"))).is_none());
}
#[test]
fn a_failed_command_is_quoted_from_its_code_on() {
assert_eq!(
last_words("remote command 'cd /x && curl -f localhost' exited with 22: curl: (22) 503"),
"exited with 22: curl: (22) 503"
);
assert_eq!(last_words("remote command 'cd /x && test ! -f broken.txt' exited with 1: "), "exited with 1");
assert_eq!(last_words("connection reset"), "connection reset");
}
fn a_stand_that_wakes_up(failures: usize) -> (u16, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
use std::io::{Read, Write};
use std::sync::atomic::Ordering;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let asked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = asked.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut buffer = [0u8; 1024];
let _ = stream.read(&mut buffer);
let seen = counter.fetch_add(1, Ordering::SeqCst);
let status = if seen < failures { "503 Service Unavailable" } else { "200 OK" };
let _ = write!(stream, "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
}
});
(port, asked)
}
fn a_target(dir: &str, restart: Option<&str>, health: Option<HealthCheck>, address: &str) -> Resolved {
Resolved {
app: crate::model::App {
name: "web".into(),
path: ".".into(),
commands: Default::default(),
dist_dir: Some("dist".into()),
gateway_port: None,
gateway_env: None,
env_file: None,
dev_port: None,
servers: Vec::new(),
},
server: crate::model::Server {
name: "stand".into(),
label: None,
url: address.into(),
host: Some("127.0.0.1".into()),
port: 22,
accept_invalid_certs: false,
shell: None,
credential: None,
},
credential: crate::model::Credential {
name: "deploy".into(),
user: "deploy".into(),
auth: crate::model::Auth::Password,
key: None,
},
path: crate::model::Path {
name: "site".into(),
dir: dir.into(),
restart: restart.map(str::to_string),
},
target: Some("web-stand".into()),
address: address.into(),
health,
unchecked: None,
}
}
#[test]
fn the_check_waits_for_a_stand_coming_up() {
let (port, asked) = a_stand_that_wakes_up(2);
let address = format!("http://127.0.0.1:{port}");
let check = HealthCheck {
probe: Probe::Url("/health".into()),
wait: Some(5),
};
let target = a_target("/srv", None, Some(check.clone()), &address);
let stand = crate::ssh::tests::Stand::spawn();
let session = stand.session();
let mut heard = Vec::new();
let answer = wait_healthy(&check, &target, &session, Dialect::Posix, |line| heard.push(line.to_string())).unwrap();
assert!(answer.starts_with("200 in "), "{answer}");
assert_eq!(heard, vec!["answered 503", "answered 503"]);
assert_eq!(asked.load(std::sync::atomic::Ordering::SeqCst), 3);
let (port, _) = a_stand_that_wakes_up(usize::MAX);
let never = HealthCheck {
probe: Probe::Url(format!("http://127.0.0.1:{port}/health")),
wait: Some(0),
};
let started = Instant::now();
assert_eq!(wait_healthy(&never, &target, &session, Dialect::Posix, |_| {}).unwrap_err(), "answered 503");
assert!(started.elapsed() < Duration::from_secs(2), "a zero wait asks once");
}
fn a_build(files: &[(&str, &str)]) -> (tempfile::TempDir, Artifact) {
let root = tempfile::tempdir().unwrap();
for (path, content) in files {
std::fs::write(root.path().join(path), content).unwrap();
}
for index in 0..artifact::ARCHIVE_THRESHOLD {
std::fs::write(root.path().join(format!("chunk-{index}.js")), "x").unwrap();
}
let plan = artifact::plan_upload(root.path()).unwrap();
let artifact = Artifact::new("web", plan);
(root, artifact)
}
fn backups(dir: &Path) -> Vec<String> {
let backups = std::path::PathBuf::from(remote::backups_dir(&dir.display().to_string()));
match std::fs::read_dir(backups) {
Ok(entries) => entries.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect(),
Err(_) => Vec::new(),
}
}
#[test]
fn a_deploy_that_fails_its_check_is_rolled_back() {
let stand = crate::ssh::tests::Stand::spawn_shell();
let connect = || Ok(stand.session());
let server = tempfile::tempdir().unwrap();
let site = server.path().join("site");
let dir = site.display().to_string();
let (restart, probe) = if cfg!(windows) {
("echo restarted> restarted.txt", "findstr ok health.txt")
} else {
("echo restarted > restarted.txt", "grep -q ok health.txt")
};
let check = HealthCheck {
probe: Probe::Command(probe.into()),
wait: Some(1),
};
let target = a_target(&dir, Some(restart), Some(check), "http://127.0.0.1:1");
let options = Options {
backup: false,
clear: false,
no_archive: false,
};
let (_v1, first) = a_build(&[("health.txt", "ok"), ("index.html", "v1")]);
let mut reached = Reached::BeforeUpload;
deploy_over_ssh(&target, &first, &options, &mut reached, &connect).expect("a healthy first deploy");
assert!(site.join("restarted.txt").is_file(), "the restart ran in the deploy directory");
assert!(backups(&site).is_empty(), "a first deploy has nothing to back up: {:?}", backups(&site));
let (_v2, broken) = a_build(&[("health.txt", "down"), ("index.html", "v2"), ("only-in-v2.js", "x")]);
let mut reached = Reached::BeforeUpload;
let err = format!("{:#}", deploy_over_ssh(&target, &broken, &options, &mut reached, &connect).unwrap_err());
assert!(err.contains("the health check failed"), "{err}");
assert!(err.contains("rolled back to"), "{err}");
assert!(err.contains("the stand works again"), "the old build passes the check: {err}");
assert!(reached == Reached::RolledBack);
assert_eq!(std::fs::read_to_string(site.join("index.html")).unwrap(), "v1", "the first build is back");
assert!(!site.join("only-in-v2.js").exists(), "nothing of the failed deploy stays");
assert_eq!(backups(&site).len(), 1, "the archive the stand was rolled back to is kept");
let (_v3, fixed) = a_build(&[("health.txt", "ok"), ("index.html", "v3")]);
let mut reached = Reached::BeforeUpload;
deploy_over_ssh(&target, &fixed, &options, &mut reached, &connect).expect("a healthy deploy");
assert_eq!(std::fs::read_to_string(site.join("index.html")).unwrap(), "v3");
assert_eq!(backups(&site).len(), 1, "a rollback backup goes once the check passes: {:?}", backups(&site));
}
#[test]
fn a_first_deploy_that_fails_its_check_says_there_is_nothing_to_go_back_to() {
let stand = crate::ssh::tests::Stand::spawn_shell();
let connect = || Ok(stand.session());
let server = tempfile::tempdir().unwrap();
let dir = server.path().join("site").display().to_string();
let probe = if cfg!(windows) { "findstr ok health.txt" } else { "grep -q ok health.txt" };
let check = HealthCheck {
probe: Probe::Command(probe.into()),
wait: Some(0),
};
let target = a_target(&dir, None, Some(check), "http://127.0.0.1:1");
let (_build, broken) = a_build(&[("health.txt", "down")]);
let options = Options {
backup: true,
clear: false,
no_archive: false,
};
let mut reached = Reached::BeforeUpload;
let err = format!("{:#}", deploy_over_ssh(&target, &broken, &options, &mut reached, &connect).unwrap_err());
assert!(err.contains("nothing to roll back to"), "{err}");
assert!(reached == Reached::Uploaded, "the files are there, and the note must say so");
}
}