use std::fs;
use std::path::{Path, PathBuf};
use shep_client::shep_core::config::AppConfig;
use super::*;
use crate::fixtures;
use crate::paths::Tree;
use crate::restore::test_support::{
Recording, Refuse, write_record, write_target_never_cut_over, write_target_with_full_origin,
write_target_with_origin, write_target_with_origin_absent,
};
use crate::state::State;
#[tokio::test]
async fn a_record_that_cannot_be_read_is_reported_rather_than_skipped() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "garbled");
std::fs::create_dir_all(tree.root()).expect("create the tree");
std::fs::write(tree.state_file(), "this is not toml").expect("write deploy.toml");
let results = all(&Recording::new(&[], Refuse::Never), home.path()).await;
assert_eq!(results.len(), 1, "the target must be reported, not skipped");
assert!(
matches!(&results[0], Restored::Failed { sheep, .. } if sheep == "garbled"),
"an unreadable record must be a reported failure, got: {:?}",
results[0]
);
}
#[tokio::test]
async fn a_sheep_is_restored_to_its_whole_pre_adoption_definition() {
let home = tempfile::tempdir().expect("tempdir");
let origin: AppConfig = toml::from_str(
"name = \"web\"\nscript = \"server.js\"\ncwd = \"/srv/web\"\ninstances = 3\n\
[env]\nPORT = \"8080\"\n",
)
.expect("an app");
write_target_with_full_origin(home.path(), "web", &origin);
let daemon = Recording::new(&["web"], Refuse::Never);
let results = all(&daemon, home.path()).await;
assert!(
matches!(&results[0], Restored::Returned { .. }),
"{results:?}"
);
let started = daemon.started();
let put_back = started.first().expect("one Start");
assert_eq!(put_back.instances, 3, "instances come back");
assert_eq!(
put_back.env.get("PORT").map(String::as_str),
Some("8080"),
"env comes back"
);
assert_eq!(put_back.cwd.as_deref(), Some("/srv/web"));
assert_eq!(put_back.script, "server.js");
}
#[tokio::test]
async fn the_report_names_the_origins_cwd_when_the_record_carries_one() {
let home = tempfile::tempdir().expect("tempdir");
let origin: AppConfig =
toml::from_str("name = \"web\"\nscript = \"server.js\"\ncwd = \"/srv/web\"\n")
.expect("an app");
let state = State {
deployed: Some(fixtures::OTHER_SHA.to_owned()),
origin_cwd: Some(PathBuf::from("/srv/stale")),
origin_script: Some("stale.js".to_owned()),
checkout: PathBuf::from("/srv/web"),
origin: Some(origin),
..fixtures::state()
};
write_record(home.path(), "web", &state);
let daemon = Recording::new(&["web"], Refuse::Never);
let results = all(&daemon, home.path()).await;
assert!(
matches!(&results[0], Restored::Returned { to, .. } if to == Path::new("/srv/web")),
"{results:?}"
);
let started = daemon.started();
assert_eq!(started[0].cwd.as_deref(), Some("/srv/web"));
assert_eq!(started[0].script, "server.js");
}
#[tokio::test]
async fn an_origin_without_a_cwd_is_put_back_at_the_legacy_one() {
let home = tempfile::tempdir().expect("tempdir");
let origin: AppConfig =
toml::from_str("name = \"web\"\nscript = \"server.js\"\n").expect("an app");
assert!(origin.cwd.is_none());
let state = State {
deployed: Some(fixtures::OTHER_SHA.to_owned()),
origin_cwd: Some(PathBuf::from("/srv/web")),
origin_script: Some("server.js".to_owned()),
checkout: PathBuf::from("/srv/web"),
origin: Some(origin),
..fixtures::state()
};
write_record(home.path(), "web", &state);
let daemon = Recording::new(&["web"], Refuse::Never);
let results = all(&daemon, home.path()).await;
assert!(
matches!(&results[0], Restored::Returned { to, .. } if to == Path::new("/srv/web")),
"{results:?}"
);
assert_eq!(daemon.started()[0].cwd.as_deref(), Some("/srv/web"));
}
#[tokio::test]
async fn a_pre_existing_sheep_goes_back_to_its_own_checkout() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
let daemon = Recording::with_registered(&["bpm"]);
let results = all(&daemon, home.path()).await;
assert!(matches!(results[0], Restored::Returned { .. }));
let started = daemon.started();
assert_eq!(started[0].cwd.as_deref(), Some("/srv/reactmap"));
assert_eq!(started[0].script, "bun .");
}
#[tokio::test]
async fn the_old_registration_is_removed_before_the_new_one_is_started() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
let daemon = Recording::with_registered(&["bpm"]);
all(&daemon, home.path()).await;
assert_eq!(
daemon.calls(),
vec!["delete", "start"],
"deleting after starting would leave two registrations"
);
}
#[tokio::test]
async fn a_bootstrapped_sheep_is_left_running_and_named_in_the_report() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin_absent(home.path(), "ctm");
let daemon = Recording::with_registered(&["ctm"]);
let results = all(&daemon, home.path()).await;
assert!(daemon.calls().is_empty(), "nothing is stopped or started");
let text = report(&results);
assert!(text.contains("ctm still running from"), "{text}");
assert!(text.contains("deploy/ctm/current"), "{text}");
}
#[tokio::test]
async fn a_cutover_that_never_landed_is_not_restored() {
let home = tempfile::tempdir().expect("tempdir");
write_target_never_cut_over(home.path(), "ctm", "/srv/ctm", "./run.sh");
let daemon = Recording::with_registered(&["ctm"]);
let results = all(&daemon, home.path()).await;
assert!(
daemon.calls().is_empty(),
"a sheep that never moved must not be stopped or started: {:?}",
daemon.calls()
);
let text = report(&results);
assert!(text.contains("ctm was never moved"), "{text}");
assert!(text.contains("/srv/ctm"), "{text}");
}
#[tokio::test]
async fn a_cutover_that_registered_is_restored_even_without_a_deployed_sha() {
let home = tempfile::tempdir().expect("tempdir");
write_target_never_cut_over(home.path(), "ctm", "/srv/ctm", "./run.sh");
let tree = Tree::for_sheep(home.path(), "ctm");
let daemon = Recording::with_registered(&["ctm"]).registered_at(&tree.current());
let results = all(&daemon, home.path()).await;
let text = report(&results);
assert!(
!text.contains("never moved"),
"a sheep the cutover registered against the tree was moved: {text}"
);
assert!(
!daemon.calls().is_empty(),
"it must actually be put back, not merely reported"
);
}
#[tokio::test]
async fn a_failure_is_reported_and_the_rest_still_run() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "aaa", "/srv/a", "./a");
write_target_with_origin(home.path(), "zzz", "/srv/z", "./z");
let daemon = Recording::refusing_first_start_only(&["aaa", "zzz"]);
let results = all(&daemon, home.path()).await;
assert_eq!(results.len(), 2);
assert!(
matches!(results[0], Restored::Reset { .. }),
"{:?}",
results[0]
);
assert!(matches!(results[1], Restored::Returned { .. }));
assert!(report(&results).contains("aaa"), "the failure is named");
}
#[tokio::test]
async fn a_refused_restore_puts_the_shepherds_own_config_back() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
let daemon = Recording::refusing_first_start_only(&["bpm"]);
let results = all(&daemon, home.path()).await;
assert!(
matches!(results[0], Restored::Reset { .. }),
"{:?}",
results[0]
);
assert_eq!(daemon.started().len(), 2, "the restore, then the fallback");
assert_eq!(
daemon.started()[1].cwd.as_deref(),
Some("/srv/deploy-tree/current"),
"the fallback re-registers what the shepherd had, not the restore"
);
}
#[tokio::test]
async fn a_rescued_restore_is_worded_as_a_reset_not_left_alone() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
let daemon = Recording::refusing_first_start_only(&["bpm"]);
let results = all(&daemon, home.path()).await;
let text = report(&results);
assert!(!text.contains("left as it is"), "{text}");
assert!(text.contains("stopped it and started it again"), "{text}");
}
#[tokio::test]
async fn a_sheep_left_deleted_says_so_in_those_words() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
let daemon = Recording::refusing_every_start(&["bpm"]);
let results = all(&daemon, home.path()).await;
assert!(
matches!(results[0], Restored::Lost { .. }),
"{:?}",
results[0]
);
let text = report(&results);
assert!(text.contains("NO LONGER REGISTERED"), "{text}");
assert!(text.contains("will not come back"), "{text}");
}
#[tokio::test]
async fn the_deploy_tree_is_left_on_disk() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
all(&Recording::with_registered(&["bpm"]), home.path()).await;
assert!(home.path().join("deploy/bpm/deploy.toml").is_file());
}
#[tokio::test]
async fn a_roll_read_failure_is_reported_once_dog_wide_with_the_real_cause() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "aaa", "/srv/a", "./a");
write_target_with_origin(home.path(), "zzz", "/srv/z", "./z");
let daemon = Recording::with_unreadable_roll();
let results = all(&daemon, home.path()).await;
assert_eq!(
results.len(),
1,
"one row for the whole roll failure, not one per sheep: {results:?}"
);
assert!(matches!(results[0], Restored::RollUnreadable { .. }));
let text = report(&results);
assert!(text.contains("aaa"), "{text}");
assert!(text.contains("zzz"), "{text}");
assert!(text.contains("newer"), "{text}");
assert!(
daemon.calls().is_empty(),
"nothing was deleted or started without a readable roll"
);
}
#[tokio::test]
async fn a_describe_that_is_refused_leaves_the_sheep_untouched() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
let daemon = Recording::refusing_describe(&["bpm"]);
let results = all(&daemon, home.path()).await;
assert!(
matches!(results[0], Restored::Failed { .. }),
"{:?}",
results[0]
);
assert!(daemon.calls().is_empty(), "nothing was deleted or started");
}
#[tokio::test]
async fn a_delete_that_fails_partway_through_is_reported_as_partly_deleted() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
let daemon = Recording::refusing_delete_partway(&["bpm"]);
let results = all(&daemon, home.path()).await;
assert!(
matches!(results[0], Restored::PartlyDeleted { .. }),
"{:?}",
results[0]
);
assert_eq!(
daemon.calls(),
vec!["delete"],
"the first delete lands before the second refuses, and nothing after it runs"
);
assert!(
daemon.started().is_empty(),
"start is never reached once a delete fails"
);
}
#[tokio::test]
async fn a_partly_deleted_sheep_is_worded_as_neither_running_nor_removed() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
let daemon = Recording::refusing_delete_partway(&["bpm"]);
let results = all(&daemon, home.path()).await;
let text = report(&results);
assert!(!text.contains("left as it is"), "{text}");
assert!(!text.contains("gone from the flock"), "{text}");
assert!(!text.contains("never touched"), "{text}");
assert!(
text.contains("neither fully running nor fully removed"),
"{text}"
);
assert!(text.contains("shep describe bpm"), "{text}");
assert!(text.contains("shep flock"), "{text}");
}
#[tokio::test]
async fn a_delete_refused_with_nothing_landed_yet_stays_failed() {
let home = tempfile::tempdir().expect("tempdir");
write_target_with_origin(home.path(), "bpm", "/srv/reactmap", "bun .");
let daemon = Recording::refusing_first_delete_only(&["bpm"]);
let results = all(&daemon, home.path()).await;
assert!(
matches!(results[0], Restored::Failed { .. }),
"{:?}",
results[0]
);
let text = report(&results);
assert!(text.contains("nothing was changed by this dog"), "{text}");
assert!(
!text.contains("neither fully running nor fully removed"),
"{text}"
);
}
#[test]
fn a_report_with_a_failure_still_names_every_other_row() {
let results = vec![
Restored::Failed {
sheep: "aaa".to_owned(),
why: "refused".to_owned(),
},
Restored::Returned {
sheep: "zzz".to_owned(),
to: PathBuf::from("/srv/z"),
},
];
let text = report(&results);
assert!(text.contains("aaa"), "{text}");
assert!(text.contains("zzz"), "{text}");
}
#[tokio::test]
async fn a_deploy_directory_that_cannot_be_listed_is_reported_not_silenced() {
let home = tempfile::tempdir().expect("tempdir");
fs::write(home.path().join("deploy"), b"not a directory").expect("write file");
let results = all(&Recording::new(&[], Refuse::Never), home.path()).await;
assert_eq!(
results.len(),
1,
"the failure must be reported, not swallowed: {results:?}"
);
assert!(
matches!(&results[0], Restored::Unlisted { dir, .. } if dir.ends_with("deploy")),
"got: {:?}",
results[0]
);
assert!(
!report(&results).is_empty(),
"silence here reads as success"
);
}
#[test]
fn an_empty_report_says_there_was_nothing_to_restore() {
assert_eq!(report(&[]), "no deploy targets, nothing to restore\n");
}
#[tokio::test]
async fn a_registration_reached_through_a_symlinked_parent_is_recognised() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
fs::create_dir_all(&home).expect("create home");
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&home, &link).expect("symlink parent");
let tree = Tree::for_sheep(&home, "ctm");
fs::create_dir_all(tree.root()).expect("create tree");
let release = tmp.path().join("release");
fs::create_dir_all(&release).expect("create release dir");
std::os::unix::fs::symlink(&release, tree.current()).expect("symlink current");
write_target_never_cut_over(&home, "ctm", "/srv/ctm", "./run.sh");
let cwd_through_link = link.join("deploy").join("ctm").join("current");
let daemon = Recording::with_registered(&["ctm"]).registered_at(&cwd_through_link);
let results = all(&daemon, &home).await;
let text = report(&results);
assert!(
!text.contains("never moved"),
"the two spellings resolve to the same directory: {text}"
);
assert!(
!daemon.calls().is_empty(),
"it must actually be put back, not merely reported"
);
}
#[tokio::test]
async fn a_registration_through_a_symlinked_parent_is_recognised_when_current_dangles() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
fs::create_dir_all(&home).expect("create home");
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&home, &link).expect("symlink parent");
let tree = Tree::for_sheep(&home, "ctm");
fs::create_dir_all(tree.root()).expect("create tree");
std::os::unix::fs::symlink(tree.release("gone"), tree.current()).expect("dangling current");
write_target_never_cut_over(&home, "ctm", "/srv/ctm", "./run.sh");
let cwd_through_link = link.join("deploy").join("ctm").join("current");
let daemon = Recording::with_registered(&["ctm"]).registered_at(&cwd_through_link);
let results = all(&daemon, &home).await;
let text = report(&results);
assert!(!text.contains("never moved"), "{text}");
assert!(!daemon.calls().is_empty(), "put back, not merely reported");
}