use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use sha2::{Digest, Sha256};
use super::{CliError, Context, Failure, UpdateArgs, write_failed};
use runner_manager_platform::service::InstallRecord;
pub const UPDATE_BASE_URL_VARIABLE: &str = "RUNNER_MANAGER_UPDATE_BASE_URL";
const NPM_PACKAGE: &str = "@ivan-murzak/runner-manager";
const BREW_FORMULA: &str = "IvanMurzak/tap/runner-manager";
const CARGO_CRATE: &str = "runner-manager";
#[derive(Debug, Clone)]
pub(crate) enum AssetSource {
Remote { base: String },
Local { directory: PathBuf },
}
impl std::fmt::Display for AssetSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Remote { base } => f.write_str(base),
Self::Local { directory } => write!(f, "{}", display_path(directory)),
}
}
}
impl AssetSource {
pub(crate) fn resolve(err: &mut dyn Write) -> Result<Self, CliError> {
let Some(raw) = std::env::var_os(UPDATE_BASE_URL_VARIABLE) else {
return Ok(Self::Remote {
base: format!(
"{}/releases/latest/download",
env!("CARGO_PKG_REPOSITORY").trim_end_matches('/')
),
});
};
let raw = raw.to_string_lossy().into_owned();
if !raw.starts_with("http://") && !raw.starts_with("https://") {
let directory = PathBuf::from(&raw);
if !directory.is_dir() {
return Err(CliError::with_remedy(
Failure::InvalidArgument,
format!(
"{UPDATE_BASE_URL_VARIABLE} is not an http(s) URL and not a directory: {raw}"
),
"unset RUNNER_MANAGER_UPDATE_BASE_URL",
));
}
let _ = writeln!(
err,
"warning: reading release assets from {raw} instead of GitHub, because \
{UPDATE_BASE_URL_VARIABLE} is set."
);
return Ok(Self::Local { directory });
}
let parsed = reqwest::Url::parse(&raw).map_err(|source| {
CliError::new(
Failure::InvalidArgument,
format!("{UPDATE_BASE_URL_VARIABLE} is not usable as a URL: {source}"),
)
})?;
let host = parsed.host_str().unwrap_or_default();
if !super::is_loopback_host(host) {
return Err(CliError::with_remedy(
Failure::InvalidArgument,
format!(
"{UPDATE_BASE_URL_VARIABLE} points at {host}, which is not this machine. \
This variable redirects where a replacement executable is downloaded from, \
so it accepts only a loopback origin or a local directory."
),
"unset RUNNER_MANAGER_UPDATE_BASE_URL",
));
}
let _ = writeln!(
err,
"warning: reading release assets from {raw} instead of GitHub, because \
{UPDATE_BASE_URL_VARIABLE} is set."
);
Ok(Self::Remote {
base: raw.trim_end_matches('/').to_string(),
})
}
pub(crate) fn for_version(version: &str, err: &mut dyn Write) -> Result<Self, CliError> {
let resolved = Self::resolve(err)?;
if std::env::var_os(UPDATE_BASE_URL_VARIABLE).is_some() {
return Ok(resolved);
}
Ok(Self::Remote {
base: format!(
"{}/releases/download/v{version}",
env!("CARGO_PKG_REPOSITORY").trim_end_matches('/')
),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct HostTarget {
triple: &'static str,
extension: &'static str,
binary: &'static str,
}
fn host_target() -> Result<HostTarget, CliError> {
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
let target = match (os, arch) {
("windows", "x86_64") => HostTarget {
triple: "x86_64-pc-windows-msvc",
extension: "zip",
binary: "runner-manager.exe",
},
("macos", "aarch64") => HostTarget {
triple: "aarch64-apple-darwin",
extension: "tar.gz",
binary: "runner-manager",
},
("macos", "x86_64") => HostTarget {
triple: "x86_64-apple-darwin",
extension: "tar.gz",
binary: "runner-manager",
},
("linux", "x86_64") => HostTarget {
triple: "x86_64-unknown-linux-gnu",
extension: "tar.gz",
binary: "runner-manager",
},
("linux", "aarch64") => HostTarget {
triple: "aarch64-unknown-linux-gnu",
extension: "tar.gz",
binary: "runner-manager",
},
_ => {
return Err(CliError::with_remedy(
Failure::UnsupportedHost,
format!(
"no release archive is published for {os}/{arch}, so there is nothing to \
update to. This build was compiled for a platform the release does not cover."
),
"cargo install runner-manager",
));
}
};
Ok(target)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Channel {
Archive,
Npm,
Homebrew,
Cargo,
SourceBuild,
ServiceCopy,
}
impl Channel {
const fn describe(&self) -> &'static str {
match self {
Self::Archive => "release archive",
Self::Npm => "npm",
Self::Homebrew => "Homebrew",
Self::Cargo => "cargo install",
Self::SourceBuild => "a build in a checkout",
Self::ServiceCopy => "the copy the service runs",
}
}
}
#[derive(Debug, Clone)]
struct Installation {
channel: Channel,
path: PathBuf,
}
impl Installation {
fn detect(context: &Context) -> Result<Self, CliError> {
let raw = std::env::current_exe().map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot work out which file this process is running from: {source}"),
)
})?;
let path = std::fs::canonicalize(&raw).unwrap_or(raw);
Ok(Self {
channel: classify(&path, context.paths().state_dir()),
path,
})
}
fn refusal(&self) -> Option<CliError> {
match self.channel {
Channel::SourceBuild => Some(CliError::with_remedy(
Failure::UpdateUnsupported,
format!(
"this is a build in a checkout ({}), not an installation. Updating it means \
updating the checkout, which is a thing only its owner should do.",
display_path(&self.path)
),
"git pull && cargo build --release -p runner-manager",
)),
Channel::ServiceCopy => Some(CliError::with_remedy(
Failure::UpdateUnsupported,
format!(
"this is the private copy `service install` made for the service to run ({}), \
not the installation an operator owns. Update the binary you installed; the \
daemon replaces this copy with it by itself.",
display_path(&self.path)
),
"runner-manager service status",
)),
Channel::Archive | Channel::Npm | Channel::Homebrew | Channel::Cargo => None,
}
}
}
fn classify(path: &Path, state_dir: &Path) -> Channel {
let component_named = |wanted: &str| {
path.components()
.any(|component| component.as_os_str() == wanted)
};
if let Ok(service_bin) = std::fs::canonicalize(state_dir.join("bin"))
&& path.parent() == Some(service_bin.as_path())
{
return Channel::ServiceCopy;
}
if component_named("node_modules") {
return Channel::Npm;
}
if component_named("Cellar") {
return Channel::Homebrew;
}
if path.parent() == Some(cargo_bin().as_path()) {
return Channel::Cargo;
}
if let Some(parent) = path.parent()
&& matches!(
parent.file_name().and_then(|name| name.to_str()),
Some("debug" | "release")
)
&& parent.parent().and_then(Path::file_name) == Some(std::ffi::OsStr::new("target"))
{
return Channel::SourceBuild;
}
Channel::Archive
}
fn cargo_bin() -> PathBuf {
let home = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))
.or_else(|| std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".cargo")))
.unwrap_or_default();
let bin = home.join("bin");
std::fs::canonicalize(&bin).unwrap_or(bin)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PublishedArchive {
version: String,
asset: String,
digest: String,
}
fn read_published_archive(
document: &str,
target: HostTarget,
source: &AssetSource,
) -> Result<PublishedArchive, CliError> {
let mut usable = 0_usize;
let mut matched: Vec<PublishedArchive> = Vec::new();
for line in document.lines() {
let fields: Vec<&str> = line.trim_end_matches('\r').split_whitespace().collect();
let [digest, name] = fields[..] else { continue };
if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
usable += 1;
let name = name.strip_prefix('*').unwrap_or(name);
let Some(version) = version_of_asset(name, target) else {
continue;
};
matched.push(PublishedArchive {
version,
asset: name.to_string(),
digest: digest.to_ascii_lowercase(),
});
}
if usable == 0 {
return Err(CliError::with_remedy(
Failure::UnusableResponse,
format!(
"the SHA256SUMS at {source} has no line this command can read. Expected \
'<64 hex digits><spaces><asset name>' on each line; this document is empty, \
truncated, or not a checksum file at all."
),
"runner-manager update --check",
));
}
match matched.len() {
1 => Ok(matched.remove(0)),
0 => Err(CliError::with_remedy(
Failure::UnsupportedHost,
format!(
"the release at {source} publishes no archive for {} (it publishes {usable} \
assets), so there is nothing to update to on this platform.",
target.triple
),
"cargo install runner-manager",
)),
count => Err(CliError::with_remedy(
Failure::UnusableResponse,
format!(
"the release at {source} publishes {count} archives for {}; refusing to guess \
which one is meant.",
target.triple
),
"runner-manager update --check",
)),
}
}
fn version_of_asset(name: &str, target: HostTarget) -> Option<String> {
let rest = name.strip_prefix("runner-manager-")?;
let rest = rest.strip_suffix(&format!(".{}", target.extension))?;
let version = rest.strip_suffix(&format!("-{}", target.triple))?;
parse_version(version).map(|_| version.to_string())
}
fn parse_version(raw: &str) -> Option<(u64, u64, u64)> {
let mut parts = raw.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
fn running_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn dispatch(context: &Context, args: &UpdateArgs, out: &mut dyn Write) -> Result<(), CliError> {
let failed = write_failed("this update");
let mut err = std::io::stderr();
let installation = Installation::detect(context)?;
let target = host_target()?;
let source = AssetSource::resolve(&mut err)?;
writeln!(out, "runner-manager update").map_err(failed)?;
writeln!(out, " installed {}", running_version()).map_err(failed)?;
writeln!(
out,
" installed by {} ({})",
installation.channel.describe(),
display_path(&installation.path)
)
.map_err(failed)?;
writeln!(out, " release assets {source}").map_err(failed)?;
out.flush().map_err(failed)?;
let runtime = super::runtime()?;
let document = runtime.block_on(fetch_text(&source, "SHA256SUMS"))?;
let published = read_published_archive(&document, target, &source)?;
writeln!(out, " published {}", published.version).map_err(failed)?;
let ordering = compare_versions(&published.version, running_version());
if ordering != std::cmp::Ordering::Greater {
writeln!(out).map_err(failed)?;
let sentence = if ordering == std::cmp::Ordering::Equal {
format!(
"runner-manager {} is the newest release. Nothing to do.",
running_version()
)
} else {
format!(
"This host runs {}, which is newer than the {} the release publishes. \
Nothing to do; `update` never installs an older build over a newer one.",
running_version(),
published.version
)
};
writeln!(out, "{sentence}").map_err(failed)?;
return Ok(());
}
let refusal = installation.refusal();
if args.check {
writeln!(out).map_err(failed)?;
writeln!(
out,
"{} is available. Nothing has been changed, because --check was given.",
published.version
)
.map_err(failed)?;
match &refusal {
None => writeln!(out, "Install it with: runner-manager update").map_err(failed)?,
Some(problem) => {
writeln!(out, "`runner-manager update` would refuse here: {problem}")
.map_err(failed)?;
if let Some(remedy) = problem.remedy() {
writeln!(out, " try: {remedy}").map_err(failed)?;
}
}
}
return Ok(());
}
if let Some(problem) = refusal {
return Err(problem);
}
writeln!(out).map_err(failed)?;
match installation.channel {
Channel::Archive => {
runtime.block_on(replace_from_archive(
&source,
&published,
target,
&installation.path,
out,
))?;
}
Channel::Npm => {
run_package_manager(
"npm",
&[
"install",
"--global",
&format!("{NPM_PACKAGE}@{}", published.version),
],
"npm install --global @ivan-murzak/runner-manager",
out,
)?;
confirm_with_version(out)?;
}
Channel::Homebrew => {
if let Err(problem) = run_package_manager(
"brew",
&["update"],
"brew update && brew upgrade IvanMurzak/tap/runner-manager",
out,
) {
writeln!(out, "warning: {}", problem.message()).map_err(failed)?;
}
run_package_manager(
"brew",
&["upgrade", BREW_FORMULA],
"brew upgrade IvanMurzak/tap/runner-manager",
out,
)?;
writeln!(
out,
"\nIf brew reported nothing to upgrade, the tap has not caught up with the \n\
release yet. It is updated by the same release run, usually within minutes."
)
.map_err(failed)?;
confirm_with_version(out)?;
}
Channel::Cargo => {
run_package_manager(
"cargo",
&[
"install",
CARGO_CRATE,
"--version",
&published.version,
"--locked",
"--force",
],
"cargo install runner-manager --locked",
out,
)?;
confirm_with_version(out)?;
}
Channel::SourceBuild | Channel::ServiceCopy => {
return Err(installation.refusal().unwrap_or_else(|| {
CliError::new(Failure::UpdateUnsupported, "nothing to update")
}));
}
}
report_service_consequence(context, &installation, &published.version, out)?;
Ok(())
}
fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering {
match (parse_version(left), parse_version(right)) {
(Some(left), Some(right)) => left.cmp(&right),
_ => std::cmp::Ordering::Less,
}
}
async fn replace_from_archive(
source: &AssetSource,
published: &PublishedArchive,
target: HostTarget,
destination: &Path,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this update");
let work = tempfile::tempdir().map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot create a temporary directory to download into: {source}"),
)
})?;
writeln!(out, "Downloading {}", published.asset).map_err(failed)?;
out.flush().map_err(failed)?;
let archive = work.path().join(&published.asset);
fetch_file(source, &published.asset, &archive).await?;
let actual = sha256_of(&archive)?;
if actual != published.digest {
return Err(CliError::with_remedy(
Failure::UnusableResponse,
format!(
"CHECKSUM MISMATCH: {} does not match the digest published beside it, so nothing \
has been installed and the binary in place is untouched.\n expected \
(SHA256SUMS): {}\n actually downloaded: {actual}\nThat is either a corrupted \
download or a tampered artifact. Retry, and if it happens again report it at \
{}/issues rather than installing by hand.",
published.asset,
published.digest,
env!("CARGO_PKG_REPOSITORY").trim_end_matches('/'),
),
"runner-manager update",
));
}
writeln!(out, "SHA-256 OK: {}", published.digest).map_err(failed)?;
let inside = format!(
"runner-manager-{}-{}/{}",
published.version, target.triple, target.binary
);
let unpacked = work.path().join(target.binary);
if target.extension == "zip" {
extract_from_zip(&archive, &inside, &unpacked)?;
} else {
extract_from_tar_gz(&archive, &inside, &unpacked)?;
}
install_over(&unpacked, destination)?;
writeln!(
out,
"Installed runner-manager {} to {}",
published.version,
display_path(destination)
)
.map_err(failed)?;
Ok(())
}
pub(crate) async fn fetch_text(source: &AssetSource, asset: &str) -> Result<String, CliError> {
match source {
AssetSource::Local { directory } => {
std::fs::read_to_string(directory.join(asset)).map_err(|error| {
CliError::with_remedy(
Failure::GithubUnavailable,
format!(
"cannot read {asset} from {}: {error}",
display_path(directory)
),
"runner-manager update --check",
)
})
}
AssetSource::Remote { base } => {
let url = format!("{base}/{asset}");
let response = reqwest::get(&url)
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|error| unreachable_release(&url, &error))?;
response
.text()
.await
.map_err(|error| unreachable_release(&url, &error))
}
}
}
pub(crate) async fn fetch_file(
source: &AssetSource,
asset: &str,
into: &Path,
) -> Result<(), CliError> {
let write_failure = |error: std::io::Error| {
CliError::new(
Failure::LocalState,
format!(
"cannot write the downloaded archive to {}: {error}",
display_path(into)
),
)
};
match source {
AssetSource::Local { directory } => std::fs::copy(directory.join(asset), into)
.map(|_| ())
.map_err(|error| {
CliError::new(
Failure::GithubUnavailable,
format!(
"cannot read {asset} from {}: {error}",
display_path(directory)
),
)
}),
AssetSource::Remote { base } => {
use futures::StreamExt as _;
use tokio::io::AsyncWriteExt as _;
let url = format!("{base}/{asset}");
let response = reqwest::get(&url)
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|error| unreachable_release(&url, &error))?;
let mut file = tokio::fs::File::create(into).await.map_err(write_failure)?;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| unreachable_release(&url, &error))?;
file.write_all(&chunk).await.map_err(write_failure)?;
}
file.flush().await.map_err(write_failure)?;
file.sync_all().await.map_err(write_failure)?;
Ok(())
}
}
}
fn unreachable_release(url: &str, error: &reqwest::Error) -> CliError {
CliError::with_remedy(
Failure::GithubUnavailable,
format!("cannot fetch {url}: {error}"),
"runner-manager update --check",
)
}
fn sha256_of(path: &Path) -> Result<String, CliError> {
let failed = |error: std::io::Error| {
CliError::new(
Failure::LocalState,
format!(
"cannot read the downloaded archive at {} back to check it: {error}",
display_path(path)
),
)
};
let mut file = std::fs::File::open(path).map_err(failed)?;
let mut hasher = Sha256::new();
let mut buffer = vec![0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer).map_err(failed)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hex::encode(hasher.finalize()))
}
fn extract_from_tar_gz(archive: &Path, inside: &str, into: &Path) -> Result<(), CliError> {
let file = std::fs::File::open(archive).map_err(|error| unreadable_archive(archive, &error))?;
let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file));
let entries = tar
.entries()
.map_err(|error| unreadable_archive(archive, &error))?;
for entry in entries {
let mut entry = entry.map_err(|error| unreadable_archive(archive, &error))?;
let path = entry
.path()
.map_err(|error| unreadable_archive(archive, &error))?
.into_owned();
if path_matches(&path, inside) {
let mut destination =
std::fs::File::create(into).map_err(|error| cannot_stage(into, &error))?;
std::io::copy(&mut entry, &mut destination)
.map_err(|error| cannot_stage(into, &error))?;
return Ok(());
}
}
Err(missing_entry(archive, inside))
}
fn extract_from_zip(archive: &Path, inside: &str, into: &Path) -> Result<(), CliError> {
let file = std::fs::File::open(archive).map_err(|error| unreadable_archive(archive, &error))?;
let mut zip =
zip::ZipArchive::new(file).map_err(|error| unreadable_archive(archive, &error))?;
for index in 0..zip.len() {
let mut entry = zip
.by_index(index)
.map_err(|error| unreadable_archive(archive, &error))?;
let Some(path) = entry.enclosed_name() else {
continue;
};
if path_matches(&path, inside) {
let mut destination =
std::fs::File::create(into).map_err(|error| cannot_stage(into, &error))?;
std::io::copy(&mut entry, &mut destination)
.map_err(|error| cannot_stage(into, &error))?;
return Ok(());
}
}
Err(missing_entry(archive, inside))
}
fn path_matches(entry: &Path, wanted: &str) -> bool {
entry.to_string_lossy().replace('\\', "/") == wanted
}
fn unreadable_archive(archive: &Path, error: &dyn std::fmt::Display) -> CliError {
CliError::with_remedy(
Failure::UnusableResponse,
format!(
"the downloaded archive {} could not be read: {error}. Nothing has been installed.",
display_path(archive)
),
"runner-manager update",
)
}
fn missing_entry(archive: &Path, inside: &str) -> CliError {
CliError::with_remedy(
Failure::UnusableResponse,
format!(
"the downloaded archive {} does not contain {inside}, so there is no binary to \
install. Nothing has been changed.",
display_path(archive)
),
"runner-manager update --check",
)
}
fn cannot_stage(into: &Path, error: &dyn std::fmt::Display) -> CliError {
CliError::new(
Failure::LocalState,
format!(
"cannot write the new binary to {}: {error}",
display_path(into)
),
)
}
fn install_over(new_binary: &Path, destination: &Path) -> Result<(), CliError> {
let failed = |what: &'static str| {
move |error: std::io::Error| {
CliError::with_remedy(
Failure::LocalState,
format!("cannot {what}: {error}"),
"runner-manager update",
)
}
};
if destination.is_dir() {
return Err(CliError::with_remedy(
Failure::LocalState,
format!(
"{} is a directory, not a file, so it cannot be replaced with a binary.",
display_path(destination)
),
"remove it and run runner-manager update again",
));
}
let directory = destination.parent().ok_or_else(|| {
CliError::new(
Failure::LocalState,
format!(
"{} has no parent directory to stage a replacement in",
display_path(destination)
),
)
})?;
let staged = directory.join(format!(
".runner-manager.update-tmp{}",
std::env::consts::EXE_SUFFIX
));
let _ = std::fs::remove_file(&staged);
std::fs::copy(new_binary, &staged).map_err(|error| {
if error.kind() == std::io::ErrorKind::PermissionDenied {
return CliError::with_remedy(
Failure::LocalState,
format!(
"cannot write into {}, so the binary there cannot be replaced: {error}",
display_path(directory)
),
"sudo runner-manager update",
);
}
failed("stage the new binary beside the old one")(error)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
.map_err(failed("make the new binary executable"))?;
}
if cfg!(windows) {
let aside = destination.with_extension("old");
let _ = std::fs::remove_file(&aside);
if destination.exists() {
std::fs::rename(destination, &aside).map_err(|error| {
let _ = std::fs::remove_file(&staged);
failed("move the binary being replaced aside")(error)
})?;
}
if let Err(error) = std::fs::rename(&staged, destination) {
let _ = std::fs::rename(&aside, destination);
let _ = std::fs::remove_file(&staged);
return Err(failed("put the new binary in place")(error));
}
let _ = std::fs::remove_file(&aside);
return Ok(());
}
std::fs::rename(&staged, destination).map_err(|error| {
let _ = std::fs::remove_file(&staged);
failed("put the new binary in place")(error)
})
}
fn run_package_manager(
program: &str,
arguments: &[&str],
remedy: &'static str,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this update");
writeln!(out, "Running: {program} {}", arguments.join(" ")).map_err(failed)?;
out.flush().map_err(failed)?;
let status = Command::new(program).args(arguments).status();
match status {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(CliError::with_remedy(
Failure::UpdateFailed,
match status.code() {
Some(code) => format!(
"`{program} {}` exited {code}. The message above is {program}'s own; nothing \
here can add to it.",
arguments.join(" ")
),
None => format!(
"`{program} {}` was killed by a signal.",
arguments.join(" ")
),
},
remedy,
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(CliError::with_remedy(
Failure::UpdateUnsupported,
format!(
"this copy was installed by {program}, and {program} is not on this PATH, so it \
cannot be asked to update it."
),
remedy,
)),
Err(error) => Err(CliError::with_remedy(
Failure::UpdateFailed,
format!("cannot run {program}: {error}"),
remedy,
)),
}
}
fn confirm_with_version(out: &mut dyn Write) -> Result<(), CliError> {
writeln!(out, "\nConfirm with: runner-manager --version").map_err(write_failed("this update"))
}
fn report_service_consequence(
context: &Context,
installation: &Installation,
version: &str,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this update");
let record = match InstallRecord::read(context.paths()) {
Ok(Some(record)) => record,
Ok(None) | Err(_) => return Ok(()),
};
writeln!(out).map_err(failed)?;
match record.source_binary.as_deref() {
Some(source) if same_file(source, &installation.path) => {
writeln!(
out,
"The service runs its own copy of this binary. It will finish every job it is \
holding, replace that copy with {version}, and be restarted by the service \
manager. Nothing else is needed."
)
.map_err(failed)?;
}
Some(source) => {
writeln!(
out,
"The service was installed from {}, not from the binary just replaced, so it will \
keep running the version there. Re-register it from this one:",
display_path(source)
)
.map_err(failed)?;
writeln!(out, " runner-manager service install").map_err(failed)?;
}
None => {
writeln!(
out,
"This host's service registration names a binary directly rather than a copy this \
product owns, so it cannot pick up {version} by itself. Re-register it:"
)
.map_err(failed)?;
writeln!(out, " runner-manager service install").map_err(failed)?;
}
}
Ok(())
}
fn same_file(left: &Path, right: &Path) -> bool {
match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
fn display_path(path: &Path) -> String {
let rendered = path.display().to_string();
match rendered.strip_prefix(r"\\?\") {
Some(stripped) if !stripped.starts_with("UNC\\") => stripped.to_string(),
_ => rendered,
}
}
#[cfg(test)]
mod tests {
use super::*;
const LINUX: HostTarget = HostTarget {
triple: "x86_64-unknown-linux-gnu",
extension: "tar.gz",
binary: "runner-manager",
};
fn sums(lines: &[&str]) -> String {
format!("{}\n", lines.join("\n"))
}
fn a_source() -> AssetSource {
AssetSource::Remote {
base: "https://example.invalid/download".to_string(),
}
}
#[test]
fn the_archive_for_this_target_is_read_out_of_a_checksum_document() {
let document = sums(&[
&format!(
"{} runner-manager-1.2.3-aarch64-apple-darwin.tar.gz",
"a".repeat(64)
),
&format!(
"{} runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz",
"b".repeat(64)
),
]);
let found = read_published_archive(&document, LINUX, &a_source()).expect("one match");
assert_eq!(found.version, "1.2.3");
assert_eq!(found.digest, "b".repeat(64));
assert_eq!(
found.asset,
"runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz"
);
}
#[test]
fn the_binary_mode_checksum_line_is_accepted_too() {
let document = sums(&[&format!(
"{} *runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz",
"c".repeat(64)
)]);
let found = read_published_archive(&document, LINUX, &a_source()).expect("one match");
assert_eq!(found.digest, "c".repeat(64));
}
#[test]
fn an_unreadable_document_and_a_missing_platform_are_different_classes() {
let unreadable =
read_published_archive("<html>404</html>\n", LINUX, &a_source()).unwrap_err();
assert_eq!(unreadable.class(), Failure::UnusableResponse);
let elsewhere = sums(&[&format!(
"{} runner-manager-1.2.3-aarch64-apple-darwin.tar.gz",
"d".repeat(64)
)]);
let missing = read_published_archive(&elsewhere, LINUX, &a_source()).unwrap_err();
assert_eq!(missing.class(), Failure::UnsupportedHost);
}
#[test]
fn two_archives_for_one_target_are_refused_rather_than_guessed() {
let document = sums(&[
&format!(
"{} runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz",
"e".repeat(64)
),
&format!(
"{} runner-manager-1.2.4-x86_64-unknown-linux-gnu.tar.gz",
"f".repeat(64)
),
]);
let refused = read_published_archive(&document, LINUX, &a_source()).unwrap_err();
assert_eq!(refused.class(), Failure::UnusableResponse);
}
#[test]
fn a_detached_signature_is_not_mistaken_for_the_archive() {
assert_eq!(
version_of_asset(
"runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz.sig",
LINUX
),
None
);
assert_eq!(
version_of_asset(
"runner-manager-1.2.3-x86_64-unknown-linux-gnu.tar.gz",
LINUX
)
.as_deref(),
Some("1.2.3")
);
}
#[test]
fn versions_order_numerically() {
use std::cmp::Ordering;
assert_eq!(compare_versions("0.1.10", "0.1.9"), Ordering::Greater);
assert_eq!(compare_versions("0.1.9", "0.1.10"), Ordering::Less);
assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal);
assert_eq!(compare_versions("1.2.3-rc.1", "1.0.0"), Ordering::Less);
}
#[test]
fn each_install_layout_is_recognised() {
let state = PathBuf::from("/var/lib/runner-manager/state");
assert_eq!(
classify(
Path::new(
"/usr/lib/node_modules/@ivan-murzak/runner-manager-linux-x64/bin/runner-manager"
),
&state
),
Channel::Npm
);
assert_eq!(
classify(
Path::new("/opt/homebrew/Cellar/runner-manager/0.1.17/bin/runner-manager"),
&state
),
Channel::Homebrew
);
assert_eq!(
classify(
Path::new("/home/me/checkout/target/release/runner-manager"),
&state
),
Channel::SourceBuild
);
assert_eq!(
classify(Path::new("/home/me/.local/bin/runner-manager"), &state),
Channel::Archive
);
assert_eq!(
classify(Path::new("/opt/release/runner-manager"), &state),
Channel::Archive
);
}
}