use std::{
collections::BTreeMap,
fs,
io::{self, Read},
os::unix::fs::{MetadataExt, PermissionsExt},
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
use semver::Version;
use serde::{Deserialize, Serialize};
use crate::{
config::EffectiveLogsConfig,
diag::{Diagnostic, SgCode},
runtime,
status::ProjectRunMode,
};
pub const LIVE_REEXEC_PROTOCOL: u16 = 1;
pub const HANDOFF_SCHEMA_VERSION: u16 = 1;
const TARGET_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const TARGET_PROBE_INTERVAL: Duration = Duration::from_millis(20);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LiveUpgradeInfo {
pub version: Version,
pub protocol: u16,
}
impl LiveUpgradeInfo {
pub fn current() -> Self {
Self {
version: Version::parse(env!("CARGO_PKG_VERSION"))
.expect("Cargo package version must be valid semver"),
protocol: LIVE_REEXEC_PROTOCOL,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpgradeTarget {
pub path: PathBuf,
pub info: LiveUpgradeInfo,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HandoffProcess {
pub service: String,
pub pid: u32,
pub pgid: i32,
pub started: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HandoffLogPipe {
pub project: String,
pub service: String,
pub stream: String,
pub fd: i32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pending: Vec<u8>,
pub settings: EffectiveLogsConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HandoffDaemonState {
pub processes: Vec<HandoffProcess>,
pub manual_stops: Vec<String>,
pub restart_suppressed: Vec<String>,
pub restart_counts: BTreeMap<String, u32>,
pub stopped_for_dependency: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandoffProject {
pub project_id: String,
pub config_path: PathBuf,
pub config_hash: String,
pub mode: ProjectRunMode,
pub active: bool,
pub daemon: HandoffDaemonState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupervisorHandoff {
pub schema: u16,
pub protocol: u16,
pub source_binary: PathBuf,
pub source_version: Version,
pub target_version: Version,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rollback_reason: Option<String>,
pub lock_fd: i32,
pub listener_fd: i32,
pub service_filter: Option<String>,
pub pipe_stderr: bool,
pub primary: HandoffProject,
pub projects: BTreeMap<String, HandoffProject>,
pub log_pipes: Vec<HandoffLogPipe>,
}
impl SupervisorHandoff {
pub fn persist(&self) -> io::Result<PathBuf> {
let directory = runtime::state_dir();
runtime::create_private_dir(&directory)?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = directory.join(format!(
"upgrade-handoff-{}-{stamp}.json",
std::process::id()
));
self.write_to(&path)?;
Ok(path)
}
pub fn write_to(&self, path: &Path) -> io::Result<()> {
let encoded = serde_json::to_vec(self).map_err(io::Error::other)?;
runtime::write_private_file(path, encoded)?;
fs::OpenOptions::new().read(true).open(path)?.sync_all()
}
pub fn load(path: &Path) -> io::Result<Self> {
let metadata = fs::metadata(path)?;
if !metadata.is_file() || metadata.mode() & 0o022 != 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"supervisor handoff file is not private",
));
}
let handoff: Self = serde_json::from_slice(&fs::read(path)?)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
if handoff.schema != HANDOFF_SCHEMA_VERSION
|| handoff.protocol != LIVE_REEXEC_PROTOCOL
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"unsupported handoff schema {} or protocol {}",
handoff.schema, handoff.protocol
),
));
}
Ok(handoff)
}
}
pub fn rollback_handoff(path: &Path, reason: impl Into<String>) -> io::Result<()> {
use std::ffi::CString;
let mut state = SupervisorHandoff::load(path)?;
state.target_version = state.source_version.clone();
state.rollback_reason = Some(reason.into());
state.write_to(path)?;
let values = [
state.source_binary.to_string_lossy().to_string(),
"supervise".to_string(),
"--config".to_string(),
state.primary.config_path.to_string_lossy().to_string(),
"--handoff".to_string(),
path.to_string_lossy().to_string(),
];
let args = values
.iter()
.map(|value| {
CString::new(value.as_str()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"rollback argument contains a NUL byte",
)
})
})
.collect::<io::Result<Vec<_>>>()?;
nix::unistd::execv(&args[0], &args)
.map(|_| ())
.map_err(io::Error::other)
}
impl UpgradeTarget {
pub fn inspect(
path: &Path,
current: &LiveUpgradeInfo,
) -> Result<Self, Box<Diagnostic>> {
let canonical = trusted_executable(path)?;
let info = probe_target(&canonical)?;
validate_compatibility(current, &info)?;
Ok(Self {
path: canonical,
info,
})
}
}
pub fn environment_unsafe(reason: impl Into<String>) -> Diagnostic {
Diagnostic::error(
SgCode::UpgradeEnvironmentUnsafe,
"the supervisor is not ready for a live upgrade",
)
.note(reason)
.help_cmd("inspect current work", "sysg status")
.help_docs()
}
pub fn handoff_failed(reason: impl Into<String>) -> Diagnostic {
Diagnostic::error(
SgCode::UpgradeHandoffFailed,
"the supervisor could not hand off its runtime",
)
.note(reason)
.help_cmd("read the supervisor log", "sysg logs --supervisor")
.help_docs()
}
pub fn resume_failed(reason: impl Into<String>) -> Diagnostic {
Diagnostic::error(
SgCode::UpgradeResumeFailed,
"the replacement supervisor could not resume the runtime",
)
.note(reason)
.help_cmd("read the supervisor log", "sysg logs --supervisor")
.help_docs()
}
pub fn validate_resident_version(
resident: &str,
target: &LiveUpgradeInfo,
) -> Result<Version, Box<Diagnostic>> {
let resident = Version::parse(resident).map_err(|err| {
incompatible(format!(
"resident supervisor reported invalid version `{resident}`: {err}"
))
})?;
if resident.major != target.version.major
|| resident.minor != target.version.minor
|| resident >= target.version
{
return Err(incompatible(format!(
"resident {resident} cannot live-upgrade to {}",
target.version
)));
}
Ok(resident)
}
fn trusted_executable(path: &Path) -> Result<PathBuf, Box<Diagnostic>> {
let canonical = fs::canonicalize(path).map_err(|err| {
target_invalid(format!("could not resolve `{}`: {err}", path.display()))
})?;
let metadata = fs::metadata(&canonical).map_err(|err| {
target_invalid(format!(
"could not inspect `{}`: {err}",
canonical.display()
))
})?;
if !metadata.is_file() {
return Err(target_invalid(format!(
"`{}` is not a regular file",
canonical.display()
)));
}
if metadata.permissions().mode() & 0o111 == 0 {
return Err(target_invalid(format!(
"`{}` is not executable",
canonical.display()
)));
}
if metadata.mode() & 0o022 != 0 {
return Err(target_invalid(format!(
"`{}` is writable by its group or other users",
canonical.display()
)));
}
let owner = metadata.uid();
let current = unsafe { libc::geteuid() };
if owner != current && owner != 0 {
return Err(target_invalid(format!(
"`{}` is owned by uid {owner}, not uid {current} or root",
canonical.display()
)));
}
Ok(canonical)
}
fn probe_target(path: &Path) -> Result<LiveUpgradeInfo, Box<Diagnostic>> {
let mut child = Command::new(path)
.arg("upgrade-info")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|err| target_invalid(format!("could not execute candidate: {err}")))?;
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() < TARGET_PROBE_TIMEOUT => {
thread::sleep(TARGET_PROBE_INTERVAL);
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(target_invalid(format!(
"candidate did not report metadata within {}s",
TARGET_PROBE_TIMEOUT.as_secs()
)));
}
Err(err) => {
return Err(target_invalid(format!(
"could not wait for candidate metadata: {err}"
)));
}
}
};
if !status.success() {
return Err(target_invalid(format!(
"candidate metadata command exited with {status}"
)));
}
let mut output = String::new();
child
.stdout
.take()
.ok_or_else(|| target_invalid("candidate metadata output was unavailable"))?
.read_to_string(&mut output)
.map_err(|err| {
target_invalid(format!("could not read candidate metadata: {err}"))
})?;
serde_json::from_str(output.trim())
.map_err(|err| target_invalid(format!("candidate metadata was invalid: {err}")))
}
fn validate_compatibility(
current: &LiveUpgradeInfo,
target: &LiveUpgradeInfo,
) -> Result<(), Box<Diagnostic>> {
if target.protocol != current.protocol {
return Err(incompatible(format!(
"live-reexec protocol {} cannot hand off to protocol {}",
current.protocol, target.protocol
)));
}
if target.version.major != current.version.major
|| target.version.minor != current.version.minor
{
return Err(incompatible(format!(
"live upgrade supports patch releases within {}.{}, not {}",
current.version.major, current.version.minor, target.version
)));
}
if target.version <= current.version {
return Err(incompatible(format!(
"target {} must be newer than resident {}",
target.version, current.version
)));
}
Ok(())
}
fn target_invalid(reason: impl Into<String>) -> Box<Diagnostic> {
Box::new(
Diagnostic::error(
SgCode::UpgradeTargetInvalid,
"the upgrade target is not a trusted sysg executable",
)
.note(reason)
.help_docs(),
)
}
fn incompatible(reason: impl Into<String>) -> Box<Diagnostic> {
Box::new(
Diagnostic::error(
SgCode::UpgradeIncompatible,
"the upgrade target is not live-reexec compatible",
)
.note(reason)
.help_docs(),
)
}