use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
pub const CURRENT_VERSION: u32 = 3;
struct Step {
from: u32,
describes: &'static str,
apply: fn(&Path) -> Result<()>,
rewrites: bool,
}
const STEPS: &[Step] = &[
Step {
from: 1,
describes: "refused: the v0.9.0 entity split has no automatic migration",
apply: refuse_schema_1,
rewrites: false,
},
Step {
from: 2,
describes: "deploy targets moved out of the servers into a catalog of their own",
apply: targets_from_server_deploy,
rewrites: true,
},
];
fn refuse_schema_1(dir: &Path) -> Result<()> {
let saved = copy_for_reference(dir);
let where_to_read = match &saved {
Ok(path) => format!("A copy to read the old values from is in\n {}\n", path.display()),
Err(err) => format!("(could not set a copy aside: {err:#} - the originals in that directory are still readable)\n"),
};
bail!(
"these settings were written by turnout 0.8 or older (schema 1), and v0.9.0 changed how they are stored.\n\
\n\
What changed: a server used to hold the login and every app's remote directory. Now\n\
logins are credentials and directories are paths - both named entities you can reuse\n\
across servers.\n\
\n\
There is no automatic conversion: the new entities need names, and turnout would have\n\
to invent them. Your files in {} are untouched.\n\
{where_to_read}\n\
To move over:\n \
1. turnout setup\n \
2. turnout server add / credential add / path add, reading the old values from the copy\n\
\n\
See https://lacodda.github.io/turnout/guides/upgrading-to-0-9/ for the walkthrough.",
dir.display()
)
}
fn targets_from_server_deploy(dir: &Path) -> Result<()> {
let servers_file = dir.join("servers.json");
let mut servers: Vec<serde_json::Value> = read_json_array(&servers_file)?;
let mut targets: Vec<crate::model::Target> = read_json_array(&dir.join("targets.json"))?;
let mut created = Vec::new();
let mut skipped = Vec::new();
for server in servers.iter_mut() {
let Some(server_name) = server.get("name").and_then(|v| v.as_str()).map(String::from) else {
continue;
};
let Some(deploy) = server.as_object_mut().and_then(|o| o.remove("deploy")) else {
continue;
};
let credential = server.get("credential").and_then(|v| v.as_str()).map(String::from);
let Some(entries) = deploy.as_object() else {
continue;
};
for (app, path) in entries {
let Some(path) = path.as_str() else { continue };
let Some(credential) = credential.clone() else {
skipped.push(format!("{app} on {server_name}"));
continue;
};
let name = crate::model::unique_target_name(app, &server_name, &targets);
created.push(format!("{name} ({app} -> {server_name}:{path})"));
targets.push(crate::model::Target {
name,
app: app.clone(),
server: server_name.clone(),
credential,
path: path.to_string(),
});
}
}
targets.sort_by(|a, b| a.name.cmp(&b.name));
write_json(&dir.join("targets.json"), &targets)?;
write_json(&servers_file, &servers)?;
for line in &created {
eprintln!(" target {line}");
}
if !skipped.is_empty() {
eprintln!(
" no target for {} - the server had no credential to log in with;
re-create with `turnout target add`",
skipped.join(", ")
);
}
Ok(())
}
fn read_json_array<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Vec<T>> {
if !path.exists() {
return Ok(Vec::new());
}
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("{} is not valid JSON", path.display()))
}
fn write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
let json = serde_json::to_string_pretty(value)?;
std::fs::write(path, json).with_context(|| format!("cannot write {}", path.display()))
}
fn copy_for_reference(dir: &Path) -> Result<PathBuf> {
let existing = dir.join("settings-backup-v1");
if existing.is_dir() {
return Ok(existing);
}
let backup = backup_dir(dir, 1);
std::fs::create_dir_all(&backup).with_context(|| format!("cannot create {}", backup.display()))?;
copy_data_files(dir, &backup)?;
Ok(backup)
}
pub fn retire_catalogs(dir: &Path, from: u32) -> Result<PathBuf> {
let existing = dir.join(format!("settings-backup-v{from}"));
let aside = if existing.is_dir() { existing } else { backup_dir(dir, from) };
std::fs::create_dir_all(&aside).with_context(|| format!("cannot create {}", aside.display()))?;
for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let name = entry.file_name();
let target = aside.join(&name);
if target.exists() {
std::fs::remove_file(&path).with_context(|| format!("cannot remove {}", path.display()))?;
continue;
}
std::fs::rename(&path, &target).with_context(|| format!("cannot move {} aside", path.display()))?;
}
Ok(aside)
}
pub fn run(dir: &Path, from: u32) -> Result<u32> {
if from == CURRENT_VERSION {
return Ok(from);
}
if from > CURRENT_VERSION {
bail!(
"this data directory was written by a newer turnout (schema {from}, this build reads {CURRENT_VERSION}).\n\
Update turnout with `turnout self-update`, or point TURNOUT_DATA_DIR at a different directory."
);
}
let pending: Vec<&Step> = STEPS.iter().filter(|step| step.from >= from).collect();
if pending.len() as u32 != CURRENT_VERSION - from {
bail!(
"cannot migrate this data directory from schema {from} to {CURRENT_VERSION}: no upgrade path.\n\
Back up {} and run `turnout setup` to start fresh.",
dir.display()
);
}
let mut copied = false;
for step in pending {
if step.rewrites && !copied {
let backup = backup_dir(dir, from);
std::fs::create_dir_all(&backup).with_context(|| format!("cannot create {}", backup.display()))?;
copy_data_files(dir, &backup)?;
eprintln!("Migrating settings from schema {from} to {CURRENT_VERSION}.");
eprintln!(" A copy of the old files is in {}", backup.display());
copied = true;
}
match (step.apply)(dir) {
Ok(()) => eprintln!(" {}", step.describes),
Err(err) if !step.rewrites => return Err(err),
Err(err) => return Err(err).with_context(|| format!("migration {} -> {} failed", step.from, step.from + 1)),
}
}
Ok(from)
}
fn backup_dir(dir: &Path, from: u32) -> PathBuf {
let mut candidate = dir.join(format!("settings-backup-v{from}"));
let mut suffix = 2;
while candidate.exists() {
candidate = dir.join(format!("settings-backup-v{from}-{suffix}"));
suffix += 1;
}
candidate
}
fn copy_data_files(dir: &Path, backup: &Path) -> Result<()> {
for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let is_json = path.extension().and_then(|e| e.to_str()) == Some("json");
if !is_json {
continue;
}
let name = entry.file_name();
std::fs::copy(&path, backup.join(&name)).with_context(|| format!("cannot back up {}", path.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_current_directory_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("apps.json"), "[]").unwrap();
assert_eq!(run(dir.path(), CURRENT_VERSION).unwrap(), CURRENT_VERSION);
let entries: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(entries.len(), 1, "{entries:?}");
}
#[test]
fn a_newer_directory_is_refused() {
let dir = tempfile::tempdir().unwrap();
let err = run(dir.path(), CURRENT_VERSION + 1).unwrap_err().to_string();
assert!(err.contains("newer turnout"), "{err}");
assert!(err.contains("self-update"), "the error must say how to move forward: {err}");
}
#[test]
fn a_gap_in_the_upgrade_path_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let err = run(dir.path(), 0).unwrap_err().to_string();
assert!(err.contains("no upgrade path"), "{err}");
assert!(err.contains("setup"), "the error must offer a way out: {err}");
}
#[test]
fn a_schema_1_directory_is_refused_with_a_way_forward() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("servers.json"), "[{\"name\":\"prod\"}]").unwrap();
let err = run(dir.path(), 1).unwrap_err().to_string();
assert!(err.contains("schema 1"), "{err}");
assert!(err.contains("turnout setup"), "{err}");
assert!(err.contains("credential") && err.contains("path"), "it must name what changed: {err}");
assert!(
!err.contains("migration 1 -> 2 failed"),
"the explanation must not be buried in a wrapper: {err}"
);
assert!(
!err.contains("turnout export"),
"the way out must not go through a command that also fails: {err}"
);
assert_eq!(
std::fs::read_to_string(dir.path().join("servers.json")).unwrap(),
"[{\"name\":\"prod\"}]",
"the originals must be untouched"
);
let copy = dir.path().join("settings-backup-v1").join("servers.json");
assert!(copy.is_file(), "a readable copy must be set aside");
assert!(err.contains("settings-backup-v1"), "and the message must say where it is: {err}");
}
#[test]
fn deploy_maps_become_named_targets() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("servers.json"),
r#"[{"name":"prod","url":"https://prod.example.com","credential":"deploy","deploy":{"web":"wwwroot","api":"api-root"}}]"#,
)
.unwrap();
run(dir.path(), 2).unwrap();
let targets: Vec<serde_json::Value> = serde_json::from_str(&std::fs::read_to_string(dir.path().join("targets.json")).unwrap()).unwrap();
assert_eq!(targets.len(), 2, "{targets:?}");
let web = targets.iter().find(|t| t["name"] == "web-prod").expect("web-prod");
assert_eq!(web["app"], "web");
assert_eq!(web["server"], "prod");
assert_eq!(web["path"], "wwwroot");
assert_eq!(web["credential"], "deploy");
assert!(targets.iter().any(|t| t["name"] == "api-prod"), "{targets:?}");
let servers: Vec<serde_json::Value> = serde_json::from_str(&std::fs::read_to_string(dir.path().join("servers.json")).unwrap()).unwrap();
assert!(servers[0].get("deploy").is_none(), "{servers:?}");
assert_eq!(servers[0]["url"], "https://prod.example.com", "the rest of the server survives");
}
#[test]
fn a_server_without_a_credential_yields_no_target() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("servers.json"),
r#"[{"name":"prod","url":"https://prod.example.com","deploy":{"web":"wwwroot"}}]"#,
)
.unwrap();
run(dir.path(), 2).unwrap();
let targets: Vec<serde_json::Value> = serde_json::from_str(&std::fs::read_to_string(dir.path().join("targets.json")).unwrap()).unwrap();
assert!(targets.is_empty(), "{targets:?}");
let servers: Vec<serde_json::Value> = serde_json::from_str(&std::fs::read_to_string(dir.path().join("servers.json")).unwrap()).unwrap();
assert!(servers[0].get("deploy").is_none(), "the field goes either way: {servers:?}");
}
#[test]
fn a_second_run_does_not_duplicate_what_it_already_made() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("servers.json"),
r#"[{"name":"prod","url":"https://prod.example.com","credential":"deploy","deploy":{"web":"wwwroot"}}]"#,
)
.unwrap();
run(dir.path(), 2).unwrap();
run(dir.path(), 2).unwrap();
let targets: Vec<serde_json::Value> = serde_json::from_str(&std::fs::read_to_string(dir.path().join("targets.json")).unwrap()).unwrap();
assert_eq!(targets.len(), 1, "{targets:?}");
}
#[test]
fn migrated_targets_parse_as_the_entity() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("servers.json"),
r#"[{"name":"kib-2","url":"https://kib2.example.com","credential":"deploy","deploy":{"my-app":"wwwroot"}}]"#,
)
.unwrap();
run(dir.path(), 2).unwrap();
let targets: Vec<crate::model::Target> = serde_json::from_str(&std::fs::read_to_string(dir.path().join("targets.json")).unwrap()).unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].name, "my-app-kib-2");
crate::model::validate_name(&targets[0].name).expect("a generated name must be a valid entity name");
}
#[test]
fn a_refusing_chain_leaves_no_backup_even_when_a_later_step_rewrites() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("servers.json"), "[{\"name\":\"prod\"}]").unwrap();
assert!(run(dir.path(), 1).is_err());
let folders: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with("settings-backup"))
.collect();
assert_eq!(folders, vec!["settings-backup-v1"], "{folders:?}");
assert!(!dir.path().join("targets.json").exists(), "a refused chain writes nothing");
}
#[test]
fn the_reference_copy_is_made_once() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("servers.json"), "[{\"name\":\"prod\"}]").unwrap();
let _ = run(dir.path(), 1);
std::fs::write(dir.path().join("settings-backup-v1").join("servers.json"), "edited by hand").unwrap();
let _ = run(dir.path(), 1);
let copies: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with("settings-backup"))
.collect();
assert_eq!(copies, vec!["settings-backup-v1"], "{copies:?}");
assert_eq!(
std::fs::read_to_string(dir.path().join("settings-backup-v1").join("servers.json")).unwrap(),
"edited by hand",
"an existing copy must not be overwritten"
);
}
#[test]
fn retiring_catalogs_moves_them_into_the_folder_the_user_was_told_about() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("servers.json"), "[{\"name\":\"pi\"}]").unwrap();
std::fs::write(dir.path().join("apps.json"), "[]").unwrap();
std::fs::write(dir.path().join("journal.jsonl"), "{}").unwrap();
let _ = run(dir.path(), 1);
let aside = retire_catalogs(dir.path(), 1).unwrap();
assert_eq!(aside, dir.path().join("settings-backup-v1"), "a second folder would strand half the files");
assert!(!dir.path().join("servers.json").exists(), "catalogs must be gone from the data dir");
assert!(dir.path().join("journal.jsonl").exists(), "the journal is not a catalog and stays");
assert_eq!(
std::fs::read_to_string(aside.join("servers.json")).unwrap(),
"[{\"name\":\"pi\"}]",
"the old values must survive - they are what gets re-entered"
);
let folders: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with("settings-backup"))
.collect();
assert_eq!(folders, vec!["settings-backup-v1"], "{folders:?}");
}
#[test]
fn retiring_catalogs_works_without_a_previous_copy() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("servers.json"), "[]").unwrap();
let aside = retire_catalogs(dir.path(), 1).unwrap();
assert!(aside.join("servers.json").is_file());
assert!(!dir.path().join("servers.json").exists());
}
#[test]
fn backups_never_overwrite_each_other() {
let dir = tempfile::tempdir().unwrap();
let first = backup_dir(dir.path(), 1);
std::fs::create_dir_all(&first).unwrap();
let second = backup_dir(dir.path(), 1);
assert_ne!(first, second);
assert!(second.to_string_lossy().ends_with("-2"), "{}", second.display());
}
#[test]
fn only_the_json_turnout_owns_is_backed_up() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("apps.json"), "[]").unwrap();
std::fs::write(dir.path().join("meta.json"), "{}").unwrap();
std::fs::write(dir.path().join("journal.jsonl"), "{}").unwrap();
std::fs::create_dir(dir.path().join("nested")).unwrap();
let backup = dir.path().join("backup");
std::fs::create_dir(&backup).unwrap();
copy_data_files(dir.path(), &backup).unwrap();
let mut copied: Vec<String> = std::fs::read_dir(&backup)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into())
.collect();
copied.sort();
assert_eq!(copied, vec!["apps.json", "meta.json"], "journals and directories stay put");
}
}