use std::path::{Path, PathBuf};
use crate::daemon::launch::Half;
pub const DEFAULT_REF: &str = "main";
#[derive(Debug, Clone)]
pub struct Report {
pub from_version: Option<String>,
pub to_version: String,
pub reference: String,
pub commit: String,
pub install_dir: PathBuf,
pub replaced: Vec<PathBuf>,
pub dry_run: bool,
}
pub fn run(reference: &str, dry_run: bool) -> Result<Report, String> {
let install_dir = install_root_bin(
std::env::var_os("QRUST_HOME").map(PathBuf::from),
std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from),
)?;
let checkout = crate::daemon::machine::machine_dir()?.join("upgrade").join("wsb");
run_in(&install_dir, &checkout, &repository()?, reference, dry_run)
}
fn run_in(
install_dir: &Path,
checkout: &Path,
repository: &str,
reference: &str,
dry_run: bool,
) -> Result<Report, String> {
installed_pair(install_dir)?;
writable(install_dir)?;
crate::outln!("upgrade: the installed pair is in {}", install_dir.display());
if let Ok(own) = std::env::current_exe() {
if own.parent() != Some(install_dir) {
crate::outln!(
"upgrade: this wsb runs from {}, which is not the installed one and is left as it is",
own.display()
);
}
}
crate::outln!("upgrade: fetching {repository} ({reference})");
let commit = fetch(checkout, repository, reference)?;
crate::outln!("upgrade: building wsb and wsb-daemon at {}", &commit[..commit.len().min(12)]);
let built = build(install_dir, &checkout.join("wsb"))?;
let from_version = match started_version(&install_dir.join(Half::Cli.file_name()), Half::Cli) {
Ok(version) => Some(version),
Err(why) => {
crate::outln!("upgrade: the installed wsb does not report its version ({why}); it is replaced all the same");
None
}
};
let (to_version, replaced) = replace(&built, install_dir, dry_run)?;
Ok(Report {
from_version,
to_version,
reference: reference.to_string(),
commit,
install_dir: install_dir.to_path_buf(),
replaced,
dry_run,
})
}
fn install_root_bin(qrust_home: Option<PathBuf>, home: Option<PathBuf>) -> Result<PathBuf, String> {
let non_empty = |p: Option<PathBuf>| p.filter(|p| !p.as_os_str().is_empty());
if let Some(root) = non_empty(qrust_home) {
return Ok(root.join("bin"));
}
non_empty(home).map(|home| home.join(".qrust").join("bin")).ok_or_else(|| {
"neither QRUST_HOME nor a home directory is set, so where QuickRust installed wsb \
cannot be known"
.to_string()
})
}
fn installed_pair(directory: &Path) -> Result<(), String> {
let missing: Vec<&str> = [Half::Cli, Half::Daemon]
.into_iter()
.map(Half::file_name)
.filter(|name| !directory.join(name).is_file())
.collect();
if missing.is_empty() {
return Ok(());
}
Err(format!(
"wsb is not installed in {} ({} missing), so there is nothing to upgrade there.\n \
Install the pair with `qrust install wsb wsb-daemon --force`, or from a checkout's wsb/ \
with `{}`.",
directory.display(),
missing.join(", "),
crate::daemon::launch::INSTALL_PAIR
))
}
fn writable(directory: &Path) -> Result<(), String> {
let probe = directory.join(format!(".wsb-upgrade-probe-{}", std::process::id()));
match std::fs::write(&probe, b"") {
Ok(()) => std::fs::remove_file(&probe)
.map_err(|e| format!("cannot remove the probe {}: {e}", probe.display())),
Err(e) => Err(format!(
"wsb is installed in {}, which cannot be written to: {e}\n \
An upgrade replaces both binaries there. Run it with the rights that directory \
needs, or install wsb somewhere you own.",
directory.display()
)),
}
}
fn repository() -> Result<String, String> {
let declared = env!("CARGO_PKG_REPOSITORY").trim();
if declared.is_empty() {
return Err(
"this wsb was built from a package that declares no `repository`, so it does not \
know where to upgrade itself from"
.to_string(),
);
}
Ok(declared.to_string())
}
fn fetch(checkout: &Path, repository: &str, reference: &str) -> Result<String, String> {
if !checkout.join(".git").exists() {
if checkout.exists() {
std::fs::remove_dir_all(checkout).map_err(|e| {
format!("cannot clear the incomplete checkout at {}: {e}", checkout.display())
})?;
}
if let Some(parent) = checkout.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
git(None, &["clone", "--quiet", "--no-checkout", repository, &checkout.display().to_string()])?;
}
git(Some(checkout), &["remote", "set-url", "origin", repository])?;
git(
Some(checkout),
&[
"fetch",
"--quiet",
"--prune",
"--force",
"origin",
"+refs/heads/*:refs/remotes/origin/*",
"+refs/tags/*:refs/tags/*",
],
)?;
let commit = [format!("refs/remotes/origin/{reference}"), format!("refs/tags/{reference}")]
.iter()
.find_map(|name| resolved(checkout, name))
.ok_or_else(|| format!("{repository} has no branch or tag named '{reference}'"))?;
git(Some(checkout), &["-c", "advice.detachedHead=false", "checkout", "--quiet", "--force", "--detach", &commit])?;
git(Some(checkout), &["clean", "-ffdxq"])?;
Ok(commit)
}
fn resolved(checkout: &Path, name: &str) -> Option<String> {
let output = crate::git_backend::git_command()
.current_dir(checkout)
.args(["rev-parse", "--verify", "--quiet", &format!("{name}^{{commit}}")])
.output()
.ok()?;
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
(output.status.success() && !commit.is_empty()).then_some(commit)
}
fn git(directory: Option<&Path>, args: &[&str]) -> Result<(), String> {
let mut command = crate::git_backend::git_command();
if let Some(directory) = directory {
command.current_dir(directory);
}
let output = command
.args(args)
.output()
.map_err(|e| format!("cannot run git {}: {e}", args.join(" ")))?;
if output.status.success() {
return Ok(());
}
Err(format!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
))
}
#[derive(Debug, Clone)]
struct Built {
cli: PathBuf,
daemon: PathBuf,
}
fn build(install_dir: &Path, worktree: &Path) -> Result<Built, String> {
let tool = |name: &str| -> Result<PathBuf, String> {
let path = install_dir.join(if cfg!(windows) { format!("{name}.exe") } else { name.to_string() });
if path.is_file() {
return Ok(path);
}
Err(format!(
"QuickRust's {name} is not in {}, beside the installed pair. wsb is installed and \
rebuilt with QuickRust; `qrust setup` puts it there.",
install_dir.display()
))
};
let cargo = tool("cargo")?;
let qrust = tool("qrust")?;
let arguments = ["build", "--release", "-p", "wsb", "-p", "wsb-daemon"];
let status = crate::git_backend::command_without_repo_scope(&cargo)
.args(arguments)
.current_dir(worktree)
.status()
.map_err(|e| format!("cannot run {}: {e}", cargo.display()))?;
if !status.success() {
return Err(format!(
"building wsb failed (cargo {}). Nothing installed has been touched.",
arguments.join(" ")
));
}
let built = |half: Half| -> Result<PathBuf, String> {
let name = match half {
Half::Cli => "wsb",
Half::Daemon => "wsb-daemon",
};
let output = crate::git_backend::command_without_repo_scope(&qrust)
.args(["path", "bin", name, "--release"])
.current_dir(worktree)
.output()
.map_err(|e| format!("cannot run {}: {e}", qrust.display()))?;
if !output.status.success() {
return Err(format!(
"qrust could not say where {name} was built: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
if !path.is_file() {
return Err(format!(
"cargo built wsb, and {} — where qrust says {name} is — is not there. Nothing \
installed has been touched.",
path.display()
));
}
Ok(path)
};
Ok(Built {
cli: built(Half::Cli)?,
daemon: built(Half::Daemon)?,
})
}
fn started_version(binary: &Path, half: Half) -> Result<String, String> {
let output = crate::git_backend::command_without_repo_scope(binary)
.arg("--version")
.output()
.map_err(|e| format!("{} cannot be started: {e}", binary.display()))?;
if !output.status.success() {
return Err(format!(
"{} did not start cleanly ({}): {}",
binary.display(),
output.status.code().map_or("killed by a signal".to_string(), |code| format!("exit {code}")),
String::from_utf8_lossy(&output.stderr).trim()
));
}
let said = String::from_utf8_lossy(&output.stdout);
let name = Half::file_name(half).trim_end_matches(".exe");
let mut words = said.split_whitespace();
match (words.next(), words.next(), words.next()) {
(Some(named), Some(version), None) if named == name => Ok(version.to_string()),
_ => Err(format!(
"{} does not report itself as {name}: `--version` said {:?}",
binary.display(),
said.trim()
)),
}
}
fn replace(built: &Built, install_dir: &Path, dry_run: bool) -> Result<(String, Vec<PathBuf>), String> {
let not_installed = |why: String| format!("{why}. Nothing was replaced.");
let cli_version = started_version(&built.cli, Half::Cli).map_err(not_installed)?;
let daemon_version = started_version(&built.daemon, Half::Daemon).map_err(not_installed)?;
if cli_version != daemon_version {
return Err(not_installed(format!(
"the built wsb is {cli_version} and the built wsb-daemon is {daemon_version}: not one \
build, and the pair shares a database schema"
)));
}
let pairs = [(Half::Cli, &built.cli), (Half::Daemon, &built.daemon)];
let destinations: Vec<PathBuf> = pairs.iter().map(|(half, _)| install_dir.join(half.file_name())).collect();
if dry_run {
return Ok((cli_version, destinations));
}
let mut staged: Vec<PathBuf> = Vec::new();
for (half, source) in pairs {
match stage(source, install_dir, half.file_name()) {
Ok(temporary) => staged.push(temporary),
Err(e) => {
for temporary in &staged {
let _ = std::fs::remove_file(temporary);
}
return Err(not_installed(e));
}
}
}
let mut replaced: Vec<PathBuf> = Vec::new();
for (index, (temporary, destination)) in staged.iter().zip(&destinations).enumerate() {
if let Err(e) = std::fs::rename(temporary, destination) {
for left in &staged[index..] {
let _ = std::fs::remove_file(left);
}
return Err(if replaced.is_empty() {
format!("cannot put the new binary at {}: {e}. Nothing was replaced.", destination.display())
} else {
format!(
"cannot put the new binary at {}: {e}.\n \
{} was ALREADY replaced, so the pair is mismatched — wsb and its daemon \
share a database schema and must be one build.\n \
Run `wsb upgrade` again, or reinstall both, before starting a daemon.",
destination.display(),
replaced.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
)
});
}
replaced.push(destination.clone());
}
Ok((cli_version, replaced))
}
fn stage(built: &Path, install_dir: &Path, name: &str) -> Result<PathBuf, String> {
let temporary = install_dir.join(format!(".{name}.upgrade-{}", std::process::id()));
std::fs::copy(built, &temporary)
.map_err(|e| format!("cannot stage {} into {}: {e}", built.display(), install_dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("cannot make {} executable: {e}", temporary.display()))?;
}
Ok(temporary)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MachineStep {
Restarted { worktree: PathBuf },
Stopped,
}
pub fn bring_the_machine_over(install_dir: &Path, here: &Path) -> Result<MachineStep, String> {
let wsb = install_dir.join(Half::Cli.file_name());
installed_wsb(&wsb, here, &["daemon", "stop"])?;
installed_wsb(&wsb, here, &["migrate"])?;
let state_dir = crate::daemon::machine::machine_dir()?;
let Some(record) = crate::daemon::service::record(&state_dir)? else {
return Ok(MachineStep::Stopped);
};
let port = recorded(&record.arguments, "--port")?;
let peer_port = recorded(&record.arguments, "--peer-port")?;
for arguments in [
vec!["daemon", "install", "--port", port, "--peer-port", peer_port],
vec!["daemon", "restart"],
] {
installed_wsb(&wsb, &record.worktree_root, &arguments)?;
}
Ok(MachineStep::Restarted {
worktree: record.worktree_root,
})
}
fn recorded<'a>(arguments: &'a [String], flag: &str) -> Result<&'a str, String> {
arguments
.iter()
.position(|argument| argument == flag)
.and_then(|at| arguments.get(at + 1))
.map(String::as_str)
.ok_or_else(|| {
format!(
"the service record's arguments have no {flag}, so the service cannot be \
reinstalled on the port it had: {arguments:?}"
)
})
}
fn installed_wsb(wsb: &Path, directory: &Path, arguments: &[&str]) -> Result<(), String> {
let output = crate::git_backend::command_without_repo_scope(wsb)
.args(arguments)
.current_dir(directory)
.output()
.map_err(|e| format!("cannot run {}: {e}", wsb.display()))?;
for line in String::from_utf8_lossy(&output.stdout).lines() {
crate::outln!(" {line}");
}
for line in String::from_utf8_lossy(&output.stderr).lines() {
crate::errln!(" {line}");
}
if output.status.success() {
return Ok(());
}
Err(format!(
"the new pair is installed, and `wsb {}` failed in {}; run it again once what it said is \
fixed — the machine is not on the new pair until it has run",
arguments.join(" "),
directory.display()
))
}
#[cfg(test)]
#[path = "upgrade_tests.rs"]
mod tests;