1474 lines
49 KiB
Rust
1474 lines
49 KiB
Rust
pub mod manifest;
|
|
pub mod transaction;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use chrono::{DateTime, Utc};
|
|
use iota_ipc::{
|
|
ClientMessage, DaemonMessage, HealthStatus, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
|
|
StartupPhase, read_msg, write_msg,
|
|
};
|
|
use manifest::{Artifact, ReleaseManifest, verify_signature};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
collections::BTreeMap,
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
process::Command,
|
|
time::Duration,
|
|
};
|
|
use tokio::net::UnixStream;
|
|
use transaction::{Activation, UpdateTransaction};
|
|
|
|
const MANIFEST_ENV: &str = "IOTA_UPDATE_MANIFEST";
|
|
const SIGNATURE_ENV: &str = "IOTA_UPDATE_SIGNATURE";
|
|
const PUBLIC_KEY_ENV: &str = "IOTA_UPDATE_PUBLIC_KEY";
|
|
const CHANNEL_ENV: &str = "IOTA_UPDATE_CHANNEL";
|
|
const SIGNING_KEY_ID_ENV: &str = "IOTA_UPDATE_SIGNING_KEY_ID";
|
|
const ACTIVATION_TIMEOUT_ENV: &str = "IOTA_UPDATE_ACTIVATION_TIMEOUT_SECONDS";
|
|
const ACTIVATION_RETRY_ENV: &str = "IOTA_UPDATE_ACTIVATION_RETRY_MILLISECONDS";
|
|
const DEFAULT_ACTIVATION_TIMEOUT_SECONDS: u64 = 60;
|
|
const DEFAULT_ACTIVATION_RETRY_MILLISECONDS: u64 = 250;
|
|
const REQUIRED_HOST_ARTIFACTS: &str = include_str!("../artifacts.tsv");
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ActivationPolicy {
|
|
pub timeout: Duration,
|
|
pub retry_interval: Duration,
|
|
}
|
|
|
|
impl ActivationPolicy {
|
|
fn from_environment() -> Result<Self> {
|
|
Ok(Self {
|
|
timeout: Duration::from_secs(environment_u64(
|
|
ACTIVATION_TIMEOUT_ENV,
|
|
DEFAULT_ACTIVATION_TIMEOUT_SECONDS,
|
|
)?),
|
|
retry_interval: Duration::from_millis(environment_u64(
|
|
ACTIVATION_RETRY_ENV,
|
|
DEFAULT_ACTIVATION_RETRY_MILLISECONDS,
|
|
)?),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct UpdatePolicy {
|
|
channel: String,
|
|
signing_key_id: String,
|
|
activation: ActivationPolicy,
|
|
}
|
|
|
|
impl UpdatePolicy {
|
|
fn from_environment() -> Result<Self> {
|
|
Ok(Self {
|
|
channel: required_environment(CHANNEL_ENV)?,
|
|
signing_key_id: required_environment(SIGNING_KEY_ID_ENV)?,
|
|
activation: ActivationPolicy::from_environment()?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
|
struct UpdateState {
|
|
#[serde(default)]
|
|
channels: BTreeMap<String, ChannelState>,
|
|
#[serde(default)]
|
|
pending_activation: Option<PendingActivation>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
struct PendingActivation {
|
|
previous_target: PathBuf,
|
|
new_target: PathBuf,
|
|
channel: String,
|
|
release_sequence: u64,
|
|
product_version: String,
|
|
daemon_was_active: bool,
|
|
selected: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
struct ChannelState {
|
|
highest_accepted_sequence: u64,
|
|
highest_accepted_version: String,
|
|
active_version: String,
|
|
#[serde(default)]
|
|
last_failed_sequence: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum CandidateDecision {
|
|
NoUpdate,
|
|
Install,
|
|
}
|
|
|
|
pub trait DaemonSupervisor {
|
|
fn is_active(&self) -> Result<bool>;
|
|
fn restart(&self) -> Result<()>;
|
|
fn main_pid(&self) -> Result<u32>;
|
|
}
|
|
|
|
struct SystemdSupervisor;
|
|
|
|
impl DaemonSupervisor for SystemdSupervisor {
|
|
fn is_active(&self) -> Result<bool> {
|
|
let status = Command::new("systemctl")
|
|
.args(["is-active", "--quiet", "iota-daemon.service"])
|
|
.status()
|
|
.context("query iota-daemon.service state")?;
|
|
Ok(status.success())
|
|
}
|
|
|
|
fn restart(&self) -> Result<()> {
|
|
let status = Command::new("systemctl")
|
|
.args(["restart", "iota-daemon.service"])
|
|
.status()
|
|
.context("restart iota-daemon.service")?;
|
|
if !status.success() {
|
|
bail!("systemctl restart iota-daemon.service failed with {status}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn main_pid(&self) -> Result<u32> {
|
|
let output = Command::new("systemctl")
|
|
.args([
|
|
"show",
|
|
"--property=MainPID",
|
|
"--value",
|
|
"iota-daemon.service",
|
|
])
|
|
.output()
|
|
.context("query iota-daemon.service MainPID")?;
|
|
if !output.status.success() {
|
|
bail!("could not query iota-daemon.service MainPID");
|
|
}
|
|
let text =
|
|
String::from_utf8(output.stdout).context("systemctl returned non-UTF-8 MainPID")?;
|
|
text.trim()
|
|
.parse()
|
|
.context("parse iota-daemon.service MainPID")
|
|
}
|
|
}
|
|
|
|
pub async fn check_update() -> Result<bool> {
|
|
let Some(release) = configured_release().await? else {
|
|
return Ok(false);
|
|
};
|
|
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
|
|
.map_err(|error| anyhow::anyhow!(error))?;
|
|
let policy = UpdatePolicy::from_environment()?;
|
|
let transaction = UpdateTransaction::from_paths(&paths)?;
|
|
let _lock = transaction.acquire()?;
|
|
let state = load_or_initialize_update_state(&paths)?;
|
|
if state.pending_activation.is_some() {
|
|
bail!("an interrupted update requires iota-updater apply recovery");
|
|
}
|
|
let decision = evaluate_candidate(
|
|
&release.manifest,
|
|
current_version(&paths.install_root)?.as_deref(),
|
|
state.channels.get(&policy.channel),
|
|
&policy.channel,
|
|
&policy.signing_key_id,
|
|
Utc::now(),
|
|
)?;
|
|
Ok(decision == CandidateDecision::Install)
|
|
}
|
|
|
|
pub async fn apply_update() -> Result<bool> {
|
|
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
|
|
.map_err(|error| anyhow::anyhow!(error))?;
|
|
let policy = UpdatePolicy::from_environment()?;
|
|
apply_update_with(&paths, &SystemdSupervisor, &policy).await
|
|
}
|
|
|
|
async fn apply_update_with(
|
|
paths: &iota_paths::IotaPaths,
|
|
supervisor: &impl DaemonSupervisor,
|
|
policy: &UpdatePolicy,
|
|
) -> Result<bool> {
|
|
let transaction = UpdateTransaction::from_paths(paths)?;
|
|
let _lock = transaction.acquire()?;
|
|
let mut state = load_or_initialize_update_state(paths)?;
|
|
recover_interrupted_activation(
|
|
paths,
|
|
&transaction,
|
|
&mut state,
|
|
supervisor,
|
|
&policy.activation,
|
|
)
|
|
.await?;
|
|
let Some(release) = configured_release().await? else {
|
|
return Ok(false);
|
|
};
|
|
let decision = evaluate_candidate(
|
|
&release.manifest,
|
|
current_version(&paths.install_root)?.as_deref(),
|
|
state.channels.get(&policy.channel),
|
|
&policy.channel,
|
|
&policy.signing_key_id,
|
|
Utc::now(),
|
|
)?;
|
|
if decision == CandidateDecision::NoUpdate {
|
|
return Ok(false);
|
|
}
|
|
validate_manifest_compatibility(
|
|
&release.manifest,
|
|
&paths.database_file(),
|
|
iota_ipc::MIN_PROTOCOL_VERSION,
|
|
iota_ipc::PROTOCOL_VERSION,
|
|
)?;
|
|
|
|
if transaction.staging.exists() {
|
|
fs::remove_dir_all(&transaction.staging).with_context(|| {
|
|
format!(
|
|
"remove stale update staging directory {}",
|
|
transaction.staging.display()
|
|
)
|
|
})?;
|
|
}
|
|
for artifact in &release.artifacts {
|
|
let source = download_to_temporary_file(&artifact.url).await?;
|
|
transaction.stage_artifact(source.path(), artifact)?;
|
|
}
|
|
fs::write(
|
|
transaction.staging.join("manifest.json"),
|
|
serde_json::to_vec_pretty(&release.manifest)?,
|
|
)
|
|
.context("write staged release manifest")?;
|
|
let daemon_was_active = supervisor.is_active()?;
|
|
begin_activation_state(
|
|
paths,
|
|
&mut state,
|
|
&transaction,
|
|
&release.manifest,
|
|
daemon_was_active,
|
|
)?;
|
|
let activation = match transaction.activate(&release.manifest.product_version) {
|
|
Ok(activation) => activation,
|
|
Err(error) => {
|
|
remove_unselected_candidate(&state, &transaction.root)?;
|
|
clear_pending_activation(paths, &mut state)?;
|
|
return Err(error);
|
|
}
|
|
};
|
|
let activation_result = async {
|
|
validate_activation_receipt(&state, &activation)?;
|
|
if daemon_was_active {
|
|
supervisor.restart()?;
|
|
wait_for_ready_daemon(
|
|
&iota_paths::socket_path(iota_paths::Scope::System),
|
|
&activation.new_target,
|
|
supervisor,
|
|
&policy.activation,
|
|
)
|
|
.await?;
|
|
}
|
|
commit_update_state(paths, &mut state, &release.manifest)?;
|
|
Ok::<_, anyhow::Error>(())
|
|
}
|
|
.await;
|
|
|
|
if let Err(update_error) = activation_result {
|
|
restore_previous_release(
|
|
paths,
|
|
&transaction,
|
|
&activation,
|
|
daemon_was_active,
|
|
supervisor,
|
|
&policy.activation,
|
|
)
|
|
.await?;
|
|
record_failed_release(paths, &mut state, &release.manifest)?;
|
|
return Err(
|
|
update_error.context("candidate release failed runtime activation and was rolled back")
|
|
);
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
async fn recover_interrupted_activation(
|
|
paths: &iota_paths::IotaPaths,
|
|
transaction: &UpdateTransaction,
|
|
state: &mut UpdateState,
|
|
supervisor: &impl DaemonSupervisor,
|
|
policy: &ActivationPolicy,
|
|
) -> Result<()> {
|
|
let Some(pending) = state.pending_activation.clone() else {
|
|
return Ok(());
|
|
};
|
|
validate_pending_candidate(&pending, &transaction.root)?;
|
|
let current_target = transaction.current_target()?;
|
|
let candidate_was_selected = pending.selected || current_target == pending.new_target;
|
|
if current_target == pending.new_target {
|
|
transaction
|
|
.restore_activation(&Activation {
|
|
previous_target: pending.previous_target.clone(),
|
|
new_target: pending.new_target.clone(),
|
|
})
|
|
.context("restore previous release after interrupted activation")?;
|
|
} else if current_target != pending.previous_target {
|
|
bail!(
|
|
"cannot recover interrupted activation: current target {} matches neither {} nor {}",
|
|
current_target.display(),
|
|
pending.previous_target.display(),
|
|
pending.new_target.display()
|
|
);
|
|
}
|
|
if candidate_was_selected && pending.daemon_was_active {
|
|
supervisor
|
|
.restart()
|
|
.context("restart previous daemon after interrupted activation")?;
|
|
wait_for_ready_daemon(
|
|
&iota_paths::socket_path(iota_paths::Scope::System),
|
|
&activation_target_path(&paths.install_root, &pending.previous_target),
|
|
supervisor,
|
|
policy,
|
|
)
|
|
.await
|
|
.context("previous daemon failed during interrupted update recovery")?;
|
|
}
|
|
if candidate_was_selected {
|
|
record_failed_sequence(paths, state, &pending.channel, pending.release_sequence)?;
|
|
} else {
|
|
remove_unselected_candidate(state, &transaction.root)?;
|
|
clear_pending_activation(paths, state)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_unselected_candidate(state: &UpdateState, install_root: &Path) -> Result<()> {
|
|
let Some(pending) = state.pending_activation.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
validate_pending_candidate(pending, install_root)?;
|
|
if pending.new_target.exists() {
|
|
fs::remove_dir_all(&pending.new_target).with_context(|| {
|
|
format!(
|
|
"remove unselected candidate release {}",
|
|
pending.new_target.display()
|
|
)
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_pending_candidate(pending: &PendingActivation, install_root: &Path) -> Result<()> {
|
|
validate_version(&pending.product_version)?;
|
|
let expected = install_root.join("versions").join(&pending.product_version);
|
|
if pending.new_target != expected {
|
|
bail!(
|
|
"pending candidate path {} does not match expected path {}",
|
|
pending.new_target.display(),
|
|
expected.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn begin_activation_state(
|
|
paths: &iota_paths::IotaPaths,
|
|
state: &mut UpdateState,
|
|
transaction: &UpdateTransaction,
|
|
manifest: &ReleaseManifest,
|
|
daemon_was_active: bool,
|
|
) -> Result<()> {
|
|
let new_target = transaction
|
|
.root
|
|
.join("versions")
|
|
.join(&manifest.product_version);
|
|
if new_target.exists() {
|
|
bail!("release version already exists: {}", new_target.display());
|
|
}
|
|
let mut updated = state.clone();
|
|
updated.pending_activation = Some(PendingActivation {
|
|
previous_target: transaction.current_target()?,
|
|
new_target,
|
|
channel: manifest.channel.clone(),
|
|
release_sequence: manifest.release_sequence,
|
|
product_version: manifest.product_version.clone(),
|
|
daemon_was_active,
|
|
selected: true,
|
|
});
|
|
save_update_state(&paths.update_status_file(), &updated)?;
|
|
*state = updated;
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_activation_receipt(state: &UpdateState, activation: &Activation) -> Result<()> {
|
|
let pending = state
|
|
.pending_activation
|
|
.as_ref()
|
|
.context("activation has no persisted rollback receipt")?;
|
|
if pending.previous_target != activation.previous_target
|
|
|| pending.new_target != activation.new_target
|
|
{
|
|
bail!("activation receipt does not match persisted rollback state");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn clear_pending_activation(paths: &iota_paths::IotaPaths, state: &mut UpdateState) -> Result<()> {
|
|
let mut updated = state.clone();
|
|
updated.pending_activation = None;
|
|
save_update_state(&paths.update_status_file(), &updated)?;
|
|
*state = updated;
|
|
Ok(())
|
|
}
|
|
|
|
async fn restore_previous_release(
|
|
paths: &iota_paths::IotaPaths,
|
|
transaction: &UpdateTransaction,
|
|
activation: &Activation,
|
|
daemon_was_active: bool,
|
|
supervisor: &impl DaemonSupervisor,
|
|
policy: &ActivationPolicy,
|
|
) -> Result<()> {
|
|
transaction
|
|
.restore_activation(activation)
|
|
.context("restore previous release after failed activation")?;
|
|
if daemon_was_active {
|
|
supervisor
|
|
.restart()
|
|
.context("restart previous daemon after failed activation")?;
|
|
wait_for_ready_daemon(
|
|
&iota_paths::socket_path(iota_paths::Scope::System),
|
|
&activation_target_path(&paths.install_root, &activation.previous_target),
|
|
supervisor,
|
|
policy,
|
|
)
|
|
.await
|
|
.context("previous daemon failed after update rollback")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn activation_target_path(install_root: &Path, target: &Path) -> PathBuf {
|
|
if target.is_absolute() {
|
|
target.to_owned()
|
|
} else {
|
|
install_root.join(target)
|
|
}
|
|
}
|
|
|
|
async fn probe_daemon(socket: &Path) -> Result<iota_ipc::HelloAck> {
|
|
let mut stream = UnixStream::connect(socket)
|
|
.await
|
|
.with_context(|| format!("connect to {}", socket.display()))?;
|
|
write_msg(
|
|
&mut stream,
|
|
&ClientMessage::Hello {
|
|
supported_versions: (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).collect(),
|
|
},
|
|
)
|
|
.await
|
|
.context("send updater IPC hello")?;
|
|
match read_msg::<_, DaemonMessage>(&mut stream).await? {
|
|
DaemonMessage::HelloAck(ack) => Ok(ack),
|
|
other => bail!("expected daemon HelloAck, received {other:?}"),
|
|
}
|
|
}
|
|
|
|
async fn wait_for_ready_daemon(
|
|
socket: &Path,
|
|
expected_release: &Path,
|
|
supervisor: &impl DaemonSupervisor,
|
|
policy: &ActivationPolicy,
|
|
) -> Result<()> {
|
|
let deadline = tokio::time::Instant::now() + policy.timeout;
|
|
loop {
|
|
if let Ok(ack) = probe_daemon(socket).await
|
|
&& ack.startup_phase == StartupPhase::Ready
|
|
&& ack.health != HealthStatus::Failed
|
|
{
|
|
verify_running_executable(supervisor, expected_release)?;
|
|
return Ok(());
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
bail!("updated daemon did not reach the required ready state");
|
|
}
|
|
tokio::time::sleep(policy.retry_interval).await;
|
|
}
|
|
}
|
|
|
|
fn verify_running_executable(
|
|
supervisor: &impl DaemonSupervisor,
|
|
expected_release: &Path,
|
|
) -> Result<()> {
|
|
let pid = supervisor.main_pid()?;
|
|
if pid == 0 {
|
|
bail!("iota-daemon.service has no MainPID");
|
|
}
|
|
let actual = fs::canonicalize(format!("/proc/{pid}/exe"))
|
|
.context("resolve running daemon executable")?;
|
|
let expected = fs::canonicalize(expected_release.join("bin/iota-daemon"))
|
|
.context("resolve expected daemon executable")?;
|
|
if actual != expected {
|
|
bail!(
|
|
"running daemon executable mismatch: expected {}, got {}",
|
|
expected.display(),
|
|
actual.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn evaluate_candidate(
|
|
manifest: &ReleaseManifest,
|
|
current_version: Option<&str>,
|
|
channel_state: Option<&ChannelState>,
|
|
expected_channel: &str,
|
|
expected_key_id: &str,
|
|
now: DateTime<Utc>,
|
|
) -> Result<CandidateDecision> {
|
|
validate_manifest_identity(manifest, expected_channel, expected_key_id)?;
|
|
validate_manifest_freshness(manifest, now)?;
|
|
if manifest.release_sequence == 0 {
|
|
bail!("release sequence must be greater than zero");
|
|
}
|
|
if let Some(state) = channel_state {
|
|
if manifest.release_sequence < state.highest_accepted_sequence {
|
|
bail!(
|
|
"release sequence {} is older than trusted sequence {}",
|
|
manifest.release_sequence,
|
|
state.highest_accepted_sequence
|
|
);
|
|
}
|
|
if manifest.release_sequence == state.highest_accepted_sequence
|
|
&& manifest.product_version != state.highest_accepted_version
|
|
{
|
|
bail!(
|
|
"release sequence {} was already associated with version {}",
|
|
manifest.release_sequence,
|
|
state.highest_accepted_version
|
|
);
|
|
}
|
|
if state.last_failed_sequence == Some(manifest.release_sequence) {
|
|
bail!(
|
|
"release sequence {} previously failed activation",
|
|
manifest.release_sequence
|
|
);
|
|
}
|
|
if manifest.release_sequence == state.highest_accepted_sequence {
|
|
return Ok(CandidateDecision::NoUpdate);
|
|
}
|
|
}
|
|
if current_version == Some(manifest.product_version.as_str()) {
|
|
return Ok(CandidateDecision::NoUpdate);
|
|
}
|
|
Ok(CandidateDecision::Install)
|
|
}
|
|
|
|
fn validate_manifest_identity(
|
|
manifest: &ReleaseManifest,
|
|
expected_channel: &str,
|
|
expected_key_id: &str,
|
|
) -> Result<()> {
|
|
if manifest.channel != expected_channel {
|
|
bail!(
|
|
"release channel mismatch: expected {}, got {}",
|
|
expected_channel,
|
|
manifest.channel
|
|
);
|
|
}
|
|
if manifest.release_signing_key_id != expected_key_id {
|
|
bail!(
|
|
"release signing key id mismatch: expected {}, got {}",
|
|
expected_key_id,
|
|
manifest.release_signing_key_id
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_manifest_freshness(manifest: &ReleaseManifest, now: DateTime<Utc>) -> Result<()> {
|
|
let published_at = DateTime::parse_from_rfc3339(&manifest.published_at)
|
|
.context("parse manifest published_at")?
|
|
.with_timezone(&Utc);
|
|
let expires_at = DateTime::parse_from_rfc3339(&manifest.expires_at)
|
|
.context("parse manifest expires_at")?
|
|
.with_timezone(&Utc);
|
|
if expires_at <= published_at {
|
|
bail!("release manifest expires_at must be after published_at");
|
|
}
|
|
if now > expires_at {
|
|
bail!("release manifest expired at {}", manifest.expires_at);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn load_or_initialize_update_state(paths: &iota_paths::IotaPaths) -> Result<UpdateState> {
|
|
let path = paths.update_status_file();
|
|
if path.exists() {
|
|
let bytes =
|
|
fs::read(&path).with_context(|| format!("read update status {}", path.display()))?;
|
|
return serde_json::from_slice(&bytes)
|
|
.with_context(|| format!("parse update status {}", path.display()));
|
|
}
|
|
let installed = read_installed_manifest(&paths.install_root.join("current")).context(
|
|
"initialize anti-rollback state from installed manifest; legacy installations require an installer migration",
|
|
)?;
|
|
if installed.release_sequence == 0 {
|
|
bail!("installed release sequence must be greater than zero");
|
|
}
|
|
let mut state = UpdateState::default();
|
|
state.channels.insert(
|
|
installed.channel.clone(),
|
|
ChannelState {
|
|
highest_accepted_sequence: installed.release_sequence,
|
|
highest_accepted_version: installed.product_version.clone(),
|
|
active_version: installed.product_version,
|
|
last_failed_sequence: None,
|
|
},
|
|
);
|
|
save_update_state(&path, &state)?;
|
|
Ok(state)
|
|
}
|
|
|
|
fn save_update_state(path: &Path, state: &UpdateState) -> Result<()> {
|
|
let parent = path.parent().context("update status path has no parent")?;
|
|
fs::create_dir_all(parent)?;
|
|
let mut temporary =
|
|
tempfile::NamedTempFile::new_in(parent).context("create temporary update status file")?;
|
|
serde_json::to_writer_pretty(temporary.as_file_mut(), state)
|
|
.context("serialize update status")?;
|
|
temporary
|
|
.as_file_mut()
|
|
.sync_all()
|
|
.context("flush update status")?;
|
|
temporary
|
|
.persist(path)
|
|
.map_err(|error| error.error)
|
|
.context("replace update status")?;
|
|
fs::File::open(parent)
|
|
.context("open update status directory")?
|
|
.sync_all()
|
|
.context("flush update status directory")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn commit_update_state(
|
|
paths: &iota_paths::IotaPaths,
|
|
state: &mut UpdateState,
|
|
manifest: &ReleaseManifest,
|
|
) -> Result<()> {
|
|
let mut updated = state.clone();
|
|
updated.channels.insert(
|
|
manifest.channel.clone(),
|
|
ChannelState {
|
|
highest_accepted_sequence: manifest.release_sequence,
|
|
highest_accepted_version: manifest.product_version.clone(),
|
|
active_version: manifest.product_version.clone(),
|
|
last_failed_sequence: None,
|
|
},
|
|
);
|
|
updated.pending_activation = None;
|
|
save_update_state(&paths.update_status_file(), &updated)?;
|
|
*state = updated;
|
|
Ok(())
|
|
}
|
|
|
|
fn record_failed_release(
|
|
paths: &iota_paths::IotaPaths,
|
|
state: &mut UpdateState,
|
|
manifest: &ReleaseManifest,
|
|
) -> Result<()> {
|
|
record_failed_sequence(paths, state, &manifest.channel, manifest.release_sequence)
|
|
}
|
|
|
|
fn record_failed_sequence(
|
|
paths: &iota_paths::IotaPaths,
|
|
state: &mut UpdateState,
|
|
channel_name: &str,
|
|
release_sequence: u64,
|
|
) -> Result<()> {
|
|
let mut updated = state.clone();
|
|
let active_version = current_version(&paths.install_root)?
|
|
.context("active release has no product version while recording failed update")?;
|
|
let channel = updated
|
|
.channels
|
|
.entry(channel_name.to_owned())
|
|
.or_insert_with(|| ChannelState {
|
|
highest_accepted_sequence: 0,
|
|
highest_accepted_version: String::new(),
|
|
active_version,
|
|
last_failed_sequence: None,
|
|
});
|
|
channel.last_failed_sequence = Some(release_sequence);
|
|
updated.pending_activation = None;
|
|
save_update_state(&paths.update_status_file(), &updated)?;
|
|
*state = updated;
|
|
Ok(())
|
|
}
|
|
|
|
fn required_environment(name: &'static str) -> Result<String> {
|
|
let value = std::env::var(name).with_context(|| format!("{name} is required"))?;
|
|
if value.is_empty() {
|
|
bail!("{name} must not be empty");
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn environment_u64(name: &'static str, default: u64) -> Result<u64> {
|
|
let value = match std::env::var(name) {
|
|
Ok(value) => value
|
|
.parse::<u64>()
|
|
.with_context(|| format!("{name} must be an unsigned integer"))?,
|
|
Err(std::env::VarError::NotPresent) => default,
|
|
Err(std::env::VarError::NotUnicode(_)) => bail!("{name} must be valid UTF-8"),
|
|
};
|
|
if value == 0 {
|
|
bail!("{name} must be greater than zero");
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
/// Switches `current` to an already-installed release version.
|
|
pub fn rollback(version: &str) -> Result<()> {
|
|
validate_version(version)?;
|
|
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
|
|
.map_err(|error| anyhow::anyhow!(error))?;
|
|
let transaction = UpdateTransaction::from_paths(&paths)?;
|
|
let _lock = transaction.acquire()?;
|
|
let mut state = load_or_initialize_update_state(&paths)?;
|
|
if state.pending_activation.is_some() {
|
|
bail!("an interrupted update requires iota-updater apply recovery");
|
|
}
|
|
let release_dir = transaction.root.join("versions").join(version);
|
|
if !release_dir.is_dir() {
|
|
bail!(
|
|
"installed release version does not exist: {}",
|
|
release_dir.display()
|
|
);
|
|
}
|
|
let active_manifest = read_installed_manifest(&transaction.root.join("current"))?;
|
|
let target_manifest = read_installed_manifest(&release_dir)?;
|
|
validate_rollback_manifests(&active_manifest, &target_manifest, version)?;
|
|
validate_manifest_compatibility(
|
|
&target_manifest,
|
|
&paths.database_file(),
|
|
iota_ipc::MIN_PROTOCOL_VERSION,
|
|
iota_ipc::PROTOCOL_VERSION,
|
|
)?;
|
|
if active_manifest.channel != target_manifest.channel {
|
|
bail!(
|
|
"rollback target channel {} does not match active channel {}",
|
|
target_manifest.channel,
|
|
active_manifest.channel
|
|
);
|
|
}
|
|
let previous_target = fs::read_link(transaction.root.join("current"))
|
|
.context("read current release link before rollback")?;
|
|
transaction.rollback(version)?;
|
|
let activation = Activation {
|
|
previous_target,
|
|
new_target: release_dir,
|
|
};
|
|
if let Err(error) = set_active_version(&paths, &mut state, &target_manifest) {
|
|
transaction
|
|
.restore_activation(&activation)
|
|
.context("restore active release after rollback state commit failed")?;
|
|
return Err(error.context("persist rollback update state"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn set_active_version(
|
|
paths: &iota_paths::IotaPaths,
|
|
state: &mut UpdateState,
|
|
manifest: &ReleaseManifest,
|
|
) -> Result<()> {
|
|
let mut updated = state.clone();
|
|
let channel = updated
|
|
.channels
|
|
.get_mut(&manifest.channel)
|
|
.context("rollback channel has no trusted baseline")?;
|
|
channel.active_version = manifest.product_version.clone();
|
|
save_update_state(&paths.update_status_file(), &updated)?;
|
|
*state = updated;
|
|
Ok(())
|
|
}
|
|
|
|
struct ConfiguredRelease {
|
|
manifest: ReleaseManifest,
|
|
artifacts: Vec<Artifact>,
|
|
}
|
|
|
|
async fn configured_release() -> Result<Option<ConfiguredRelease>> {
|
|
let Some(manifest_location) = std::env::var_os(MANIFEST_ENV) else {
|
|
return Ok(None);
|
|
};
|
|
let manifest_location = manifest_location
|
|
.into_string()
|
|
.map_err(|_| anyhow::anyhow!("{MANIFEST_ENV} must be valid UTF-8"))?;
|
|
if manifest_location.is_empty() {
|
|
bail!("{MANIFEST_ENV} must not be empty");
|
|
}
|
|
let signature_location =
|
|
std::env::var(SIGNATURE_ENV).unwrap_or_else(|_| format!("{manifest_location}.sig"));
|
|
let key_text = std::env::var(PUBLIC_KEY_ENV)
|
|
.with_context(|| format!("{PUBLIC_KEY_ENV} is required when {MANIFEST_ENV} is set"))?;
|
|
configured_release_from_locations(&manifest_location, &signature_location, &key_text)
|
|
.await
|
|
.map(Some)
|
|
}
|
|
|
|
async fn configured_release_from_locations(
|
|
manifest_location: &str,
|
|
signature_location: &str,
|
|
key_text: &str,
|
|
) -> Result<ConfiguredRelease> {
|
|
let manifest_bytes = read_location(manifest_location).await?;
|
|
let manifest: ReleaseManifest =
|
|
serde_json::from_slice(&manifest_bytes).context("parse release manifest")?;
|
|
validate_version(&manifest.product_version)?;
|
|
let signature_text = String::from_utf8(read_location(signature_location).await?)
|
|
.context("release signature must be UTF-8 hex")?;
|
|
let signature = hex::decode(signature_text.trim()).context("decode release signature hex")?;
|
|
let key = hex::decode(key_text.trim()).context("decode release public key hex")?;
|
|
let public_key: [u8; 32] = key
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("release public key must be 32 bytes"))?;
|
|
verify_signature(&manifest, &signature, &public_key)?;
|
|
let artifacts = select_host_artifacts(&manifest, std::env::consts::OS, std::env::consts::ARCH)?;
|
|
Ok(ConfiguredRelease {
|
|
manifest,
|
|
artifacts,
|
|
})
|
|
}
|
|
|
|
fn select_host_artifacts(
|
|
manifest: &ReleaseManifest,
|
|
operating_system: &str,
|
|
architecture: &str,
|
|
) -> Result<Vec<Artifact>> {
|
|
let requirements = required_host_artifacts()?;
|
|
let artifacts: Vec<Artifact> = manifest
|
|
.artifacts
|
|
.iter()
|
|
.filter(|artifact| artifact.os == operating_system && artifact.architecture == architecture)
|
|
.cloned()
|
|
.collect();
|
|
|
|
for artifact in &artifacts {
|
|
if !requirements
|
|
.iter()
|
|
.any(|(role, path)| *role == artifact.role.as_str() && *path == artifact.path.as_str())
|
|
{
|
|
bail!(
|
|
"release has unexpected artifact role/path for {operating_system}/{architecture}: {}/{}",
|
|
artifact.role,
|
|
artifact.path
|
|
);
|
|
}
|
|
}
|
|
for (role, path) in &requirements {
|
|
let count = artifacts
|
|
.iter()
|
|
.filter(|artifact| artifact.role == *role && artifact.path == *path)
|
|
.count();
|
|
if count != 1 {
|
|
bail!(
|
|
"release must contain exactly one {role} artifact at {path} for {operating_system}/{architecture}; found {count}"
|
|
);
|
|
}
|
|
}
|
|
if artifacts.len() != requirements.len() {
|
|
bail!(
|
|
"release has an invalid artifact count for {operating_system}/{architecture}: expected {}, found {}",
|
|
requirements.len(),
|
|
artifacts.len()
|
|
);
|
|
}
|
|
Ok(artifacts)
|
|
}
|
|
|
|
fn required_host_artifacts() -> Result<Vec<(&'static str, &'static str)>> {
|
|
REQUIRED_HOST_ARTIFACTS
|
|
.lines()
|
|
.filter(|line| !line.is_empty())
|
|
.map(|line| {
|
|
line.split_once('\t')
|
|
.context("invalid embedded updater artifact contract")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
async fn read_location(location: &str) -> Result<Vec<u8>> {
|
|
if location.starts_with("https://") || location.starts_with("http://") {
|
|
let response = reqwest::get(location)
|
|
.await
|
|
.with_context(|| format!("download {location}"))?
|
|
.error_for_status()
|
|
.with_context(|| format!("download {location}"))?;
|
|
return Ok(response.bytes().await?.to_vec());
|
|
}
|
|
let path = location.strip_prefix("file://").unwrap_or(location);
|
|
fs::read(path).with_context(|| format!("read update input {path}"))
|
|
}
|
|
|
|
async fn download_to_temporary_file(location: &str) -> Result<tempfile::NamedTempFile> {
|
|
let file = tempfile::NamedTempFile::new().context("create temporary update artifact")?;
|
|
fs::write(file.path(), read_location(location).await?)
|
|
.with_context(|| format!("write downloaded update artifact from {location}"))?;
|
|
Ok(file)
|
|
}
|
|
|
|
fn current_version(install_root: &Path) -> Result<Option<String>> {
|
|
let manifest_path = install_root.join("current/manifest.json");
|
|
if !manifest_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let bytes = fs::read(&manifest_path).with_context(|| {
|
|
format!(
|
|
"read installed release manifest {}",
|
|
manifest_path.display()
|
|
)
|
|
})?;
|
|
let value: serde_json::Value = serde_json::from_slice(&bytes).with_context(|| {
|
|
format!(
|
|
"parse installed release manifest {}",
|
|
manifest_path.display()
|
|
)
|
|
})?;
|
|
Ok(value
|
|
.get("product_version")
|
|
.and_then(|value| value.as_str())
|
|
.map(str::to_owned))
|
|
}
|
|
|
|
fn read_installed_manifest(release_dir: &Path) -> Result<ReleaseManifest> {
|
|
let manifest_path = release_dir.join("manifest.json");
|
|
let bytes = fs::read(&manifest_path).with_context(|| {
|
|
format!(
|
|
"read installed release manifest {}",
|
|
manifest_path.display()
|
|
)
|
|
})?;
|
|
serde_json::from_slice(&bytes).with_context(|| {
|
|
format!(
|
|
"parse installed release manifest {}",
|
|
manifest_path.display()
|
|
)
|
|
})
|
|
}
|
|
|
|
fn validate_manifest_compatibility(
|
|
manifest: &ReleaseManifest,
|
|
database_path: &Path,
|
|
installed_ipc_min: u16,
|
|
installed_ipc_max: u16,
|
|
) -> Result<()> {
|
|
if manifest.supported_ipc_min > manifest.supported_ipc_max {
|
|
bail!(
|
|
"release has an invalid IPC range: {}..={}",
|
|
manifest.supported_ipc_min,
|
|
manifest.supported_ipc_max
|
|
);
|
|
}
|
|
if installed_ipc_min > installed_ipc_max {
|
|
bail!("installed Iota has an invalid IPC compatibility range");
|
|
}
|
|
if manifest.supported_ipc_max < installed_ipc_min
|
|
|| manifest.supported_ipc_min > installed_ipc_max
|
|
{
|
|
bail!(
|
|
"release IPC range {}..={} is incompatible with installed range {}..={}",
|
|
manifest.supported_ipc_min,
|
|
manifest.supported_ipc_max,
|
|
installed_ipc_min,
|
|
installed_ipc_max
|
|
);
|
|
}
|
|
|
|
if let Some(installed_schema) = installed_data_schema(database_path)?
|
|
&& installed_schema < manifest.minimum_data_schema
|
|
{
|
|
bail!(
|
|
"release requires data schema {} or newer, but installed database uses schema {}",
|
|
manifest.minimum_data_schema,
|
|
installed_schema
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_rollback_manifests(
|
|
active: &ReleaseManifest,
|
|
target: &ReleaseManifest,
|
|
requested_version: &str,
|
|
) -> Result<()> {
|
|
if !active.rollback_compatible {
|
|
bail!(
|
|
"active release {} does not permit rollback",
|
|
active.product_version
|
|
);
|
|
}
|
|
if target.product_version != requested_version {
|
|
bail!(
|
|
"rollback target manifest version {} does not match requested version {requested_version}",
|
|
target.product_version
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn installed_data_schema(database_path: &Path) -> Result<Option<u64>> {
|
|
if !database_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let connection = rusqlite::Connection::open_with_flags(
|
|
database_path,
|
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
|
)
|
|
.with_context(|| format!("open installed database {}", database_path.display()))?;
|
|
let user_version: i64 = connection
|
|
.pragma_query_value(None, "user_version", |row| row.get(0))
|
|
.with_context(|| format!("read data schema from {}", database_path.display()))?;
|
|
let user_version = user_version.try_into().with_context(|| {
|
|
format!(
|
|
"installed database has a negative data schema: {}",
|
|
database_path.display()
|
|
)
|
|
})?;
|
|
Ok(Some(user_version))
|
|
}
|
|
|
|
fn validate_version(version: &str) -> Result<()> {
|
|
let mut characters = version.chars();
|
|
if !characters
|
|
.next()
|
|
.is_some_and(|character| character.is_ascii_alphanumeric())
|
|
|| !characters.all(|character| {
|
|
character.is_ascii_alphanumeric() || matches!(character, '.' | '+' | '_' | '-')
|
|
})
|
|
{
|
|
bail!("release product_version contains unsupported characters");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ed25519_dalek::{Signer, SigningKey};
|
|
|
|
struct CurrentProcessSupervisor;
|
|
|
|
impl DaemonSupervisor for CurrentProcessSupervisor {
|
|
fn is_active(&self) -> Result<bool> {
|
|
Ok(true)
|
|
}
|
|
|
|
fn restart(&self) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
fn main_pid(&self) -> Result<u32> {
|
|
Ok(std::process::id())
|
|
}
|
|
}
|
|
|
|
fn test_paths(root: &Path) -> iota_paths::IotaPaths {
|
|
let state_dir = root.join("state");
|
|
iota_paths::IotaPaths {
|
|
scope: iota_paths::Scope::User,
|
|
config_dir: root.join("config"),
|
|
config_file: root.join("config/config.yaml"),
|
|
state_dir: state_dir.clone(),
|
|
storage_dir: state_dir.join("storage"),
|
|
identity_dir: state_dir.join("identity"),
|
|
cache_dir: root.join("cache"),
|
|
runtime_dir: Some(root.join("run")),
|
|
log_dir: root.join("logs"),
|
|
asset_dir: root.join("web"),
|
|
install_root: root.join("install"),
|
|
ipc_endpoint: iota_paths::IpcEndpoint::UnixSocket(root.join("run/iota.sock")),
|
|
}
|
|
}
|
|
|
|
fn artifact(role: &str, path: &str, os: &str, architecture: &str) -> Artifact {
|
|
Artifact {
|
|
role: role.into(),
|
|
os: os.into(),
|
|
architecture: architecture.into(),
|
|
path: path.into(),
|
|
url: format!("file:///release/{}", path.replace('/', "-")),
|
|
sha256: "00".repeat(32),
|
|
size: 1,
|
|
}
|
|
}
|
|
|
|
fn release_manifest(artifacts: Vec<Artifact>) -> ReleaseManifest {
|
|
ReleaseManifest {
|
|
product_version: "1.2.3".into(),
|
|
channel: "stable".into(),
|
|
release_sequence: 12,
|
|
published_at: "2026-09-10T00:00:00Z".into(),
|
|
expires_at: "2030-09-10T00:00:00Z".into(),
|
|
minimum_data_schema: 1,
|
|
supported_ipc_min: 1,
|
|
supported_ipc_max: 1,
|
|
artifacts,
|
|
release_signing_key_id: "test".into(),
|
|
rollback_compatible: true,
|
|
}
|
|
}
|
|
|
|
fn host_artifacts() -> Vec<Artifact> {
|
|
required_host_artifacts()
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|(role, path)| artifact(role, path, std::env::consts::OS, std::env::consts::ARCH))
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn loads_a_signed_manifest_and_selects_complete_host_release() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let mut artifacts = host_artifacts();
|
|
artifacts.push(artifact("daemon", "bin/iota-daemon", "other", "other"));
|
|
let manifest = release_manifest(artifacts);
|
|
let signing_key = SigningKey::from_bytes(&[7; 32]);
|
|
let signature = signing_key.sign(&manifest::canonical_bytes(&manifest).unwrap());
|
|
let manifest_path = directory.path().join("manifest.json");
|
|
let signature_path = directory.path().join("manifest.json.sig");
|
|
fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
|
fs::write(&signature_path, hex::encode(signature.to_bytes())).unwrap();
|
|
|
|
let release = configured_release_from_locations(
|
|
manifest_path.to_str().unwrap(),
|
|
signature_path.to_str().unwrap(),
|
|
&hex::encode(signing_key.verifying_key().to_bytes()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(release.manifest.product_version, "1.2.3");
|
|
assert_eq!(release.artifacts.len(), 3);
|
|
assert!(
|
|
release
|
|
.artifacts
|
|
.iter()
|
|
.all(|artifact| artifact.os == std::env::consts::OS)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_host_release_missing_an_executable() {
|
|
let mut artifacts = host_artifacts();
|
|
artifacts.retain(|artifact| artifact.path != "bin/iota-updater");
|
|
let manifest = release_manifest(artifacts);
|
|
|
|
let error = select_host_artifacts(&manifest, std::env::consts::OS, std::env::consts::ARCH)
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("bin/iota-updater"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_or_unexpected_host_artifacts() {
|
|
let mut duplicate = host_artifacts();
|
|
duplicate.push(duplicate[0].clone());
|
|
let duplicate_error = select_host_artifacts(
|
|
&release_manifest(duplicate),
|
|
std::env::consts::OS,
|
|
std::env::consts::ARCH,
|
|
)
|
|
.unwrap_err();
|
|
assert!(duplicate_error.to_string().contains("exactly one"));
|
|
|
|
let mut unexpected = host_artifacts();
|
|
unexpected.push(artifact(
|
|
"helper",
|
|
"bin/iota-helper",
|
|
std::env::consts::OS,
|
|
std::env::consts::ARCH,
|
|
));
|
|
let unexpected_error = select_host_artifacts(
|
|
&release_manifest(unexpected),
|
|
std::env::consts::OS,
|
|
std::env::consts::ARCH,
|
|
)
|
|
.unwrap_err();
|
|
assert!(unexpected_error.to_string().contains("unexpected artifact"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_manifest_without_an_overlapping_ipc_range() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let mut manifest = release_manifest(host_artifacts());
|
|
manifest.supported_ipc_min = 5;
|
|
manifest.supported_ipc_max = 6;
|
|
|
|
let error =
|
|
validate_manifest_compatibility(&manifest, &directory.path().join("missing.db"), 2, 4)
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("incompatible"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_manifest_requiring_a_newer_installed_data_schema() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let database = directory.path().join("messages.sqlite3");
|
|
let connection = rusqlite::Connection::open(&database).unwrap();
|
|
connection.pragma_update(None, "user_version", 25).unwrap();
|
|
let mut manifest = release_manifest(host_artifacts());
|
|
manifest.minimum_data_schema = 26;
|
|
manifest.supported_ipc_min = 2;
|
|
manifest.supported_ipc_max = 4;
|
|
|
|
let error = validate_manifest_compatibility(&manifest, &database, 2, 4).unwrap_err();
|
|
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("installed database uses schema 25")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_compatible_manifest_when_no_database_exists() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let mut manifest = release_manifest(host_artifacts());
|
|
manifest.minimum_data_schema = 26;
|
|
manifest.supported_ipc_min = 4;
|
|
manifest.supported_ipc_max = 5;
|
|
|
|
validate_manifest_compatibility(&manifest, &directory.path().join("missing.db"), 2, 4)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_rollback_when_active_release_disallows_it() {
|
|
let mut active = release_manifest(host_artifacts());
|
|
active.rollback_compatible = false;
|
|
let mut target = release_manifest(host_artifacts());
|
|
target.product_version = "1.1.0".into();
|
|
|
|
let error = validate_rollback_manifests(&active, &target, "1.1.0").unwrap_err();
|
|
|
|
assert!(error.to_string().contains("does not permit rollback"));
|
|
}
|
|
|
|
#[test]
|
|
fn candidate_policy_rejects_replay_and_sequence_reuse() {
|
|
let state = ChannelState {
|
|
highest_accepted_sequence: 12,
|
|
highest_accepted_version: "1.2.3".into(),
|
|
active_version: "1.1.0".into(),
|
|
last_failed_sequence: None,
|
|
};
|
|
let mut replay = release_manifest(host_artifacts());
|
|
replay.release_sequence = 11;
|
|
assert!(
|
|
evaluate_candidate(
|
|
&replay,
|
|
Some("1.1.0"),
|
|
Some(&state),
|
|
"stable",
|
|
"test",
|
|
Utc::now(),
|
|
)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("older than trusted sequence")
|
|
);
|
|
|
|
let mut reused = release_manifest(host_artifacts());
|
|
reused.product_version = "1.2.4".into();
|
|
assert!(
|
|
evaluate_candidate(
|
|
&reused,
|
|
Some("1.1.0"),
|
|
Some(&state),
|
|
"stable",
|
|
"test",
|
|
Utc::now(),
|
|
)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("already associated")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepted_sequence_is_not_reinstalled_after_explicit_rollback() {
|
|
let state = ChannelState {
|
|
highest_accepted_sequence: 12,
|
|
highest_accepted_version: "1.2.3".into(),
|
|
active_version: "1.1.0".into(),
|
|
last_failed_sequence: None,
|
|
};
|
|
let decision = evaluate_candidate(
|
|
&release_manifest(host_artifacts()),
|
|
Some("1.1.0"),
|
|
Some(&state),
|
|
"stable",
|
|
"test",
|
|
Utc::now(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(decision, CandidateDecision::NoUpdate);
|
|
}
|
|
|
|
#[test]
|
|
fn candidate_policy_rejects_expired_wrong_channel_and_failed_release() {
|
|
let mut expired = release_manifest(host_artifacts());
|
|
expired.published_at = "2020-01-01T00:00:00Z".into();
|
|
expired.expires_at = "2020-01-02T00:00:00Z".into();
|
|
assert!(
|
|
evaluate_candidate(&expired, None, None, "stable", "test", Utc::now(),)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("expired")
|
|
);
|
|
|
|
let manifest = release_manifest(host_artifacts());
|
|
assert!(
|
|
evaluate_candidate(&manifest, None, None, "dev", "test", Utc::now())
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("channel mismatch")
|
|
);
|
|
|
|
let state = ChannelState {
|
|
highest_accepted_sequence: 11,
|
|
highest_accepted_version: "1.2.2".into(),
|
|
active_version: "1.2.2".into(),
|
|
last_failed_sequence: Some(12),
|
|
};
|
|
assert!(
|
|
evaluate_candidate(
|
|
&manifest,
|
|
Some("1.2.2"),
|
|
Some(&state),
|
|
"stable",
|
|
"test",
|
|
Utc::now(),
|
|
)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("previously failed activation")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_state_is_replaced_atomically() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let path = directory.path().join("update-status.json");
|
|
let mut state = UpdateState::default();
|
|
state.channels.insert(
|
|
"stable".into(),
|
|
ChannelState {
|
|
highest_accepted_sequence: 12,
|
|
highest_accepted_version: "1.2.3".into(),
|
|
active_version: "1.2.3".into(),
|
|
last_failed_sequence: None,
|
|
},
|
|
);
|
|
save_update_state(&path, &state).unwrap();
|
|
let loaded: UpdateState = serde_json::from_slice(&fs::read(path).unwrap()).unwrap();
|
|
assert_eq!(loaded.channels["stable"].highest_accepted_sequence, 12);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn readiness_probe_checks_ipc_state_and_running_executable() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let socket = directory.path().join("iota.sock");
|
|
let release = directory.path().join("versions/1.2.3");
|
|
fs::create_dir_all(release.join("bin")).unwrap();
|
|
std::os::unix::fs::symlink(
|
|
fs::canonicalize("/proc/self/exe").unwrap(),
|
|
release.join("bin/iota-daemon"),
|
|
)
|
|
.unwrap();
|
|
let listener = tokio::net::UnixListener::bind(&socket).unwrap();
|
|
let server = tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
let hello = read_msg::<_, ClientMessage>(&mut stream).await.unwrap();
|
|
assert!(matches!(hello, ClientMessage::Hello { .. }));
|
|
write_msg(
|
|
&mut stream,
|
|
&DaemonMessage::HelloAck(iota_ipc::HelloAck {
|
|
protocol_version: PROTOCOL_VERSION,
|
|
daemon_version: "test".into(),
|
|
instance_id: "test".into(),
|
|
startup_phase: StartupPhase::Ready,
|
|
capabilities: Vec::new(),
|
|
lifecycle: iota_ipc::LifecyclePhase::Ready,
|
|
health: HealthStatus::Degraded,
|
|
deployment_mode: iota_ipc::DeploymentMode::default(),
|
|
supervisor: iota_ipc::SupervisorKind::default(),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
});
|
|
wait_for_ready_daemon(
|
|
&socket,
|
|
&release,
|
|
&CurrentProcessSupervisor,
|
|
&ActivationPolicy {
|
|
timeout: Duration::from_secs(1),
|
|
retry_interval: Duration::from_millis(10),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
server.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupted_selected_candidate_is_restored_and_marked_failed() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let paths = test_paths(directory.path());
|
|
let transaction = UpdateTransaction::new(&paths.install_root);
|
|
let old_release = paths.install_root.join("versions/1.0.0");
|
|
let new_release = paths.install_root.join("versions/1.1.0");
|
|
fs::create_dir_all(&old_release).unwrap();
|
|
fs::create_dir_all(&new_release).unwrap();
|
|
fs::write(
|
|
old_release.join("manifest.json"),
|
|
br#"{"product_version":"1.0.0"}"#,
|
|
)
|
|
.unwrap();
|
|
fs::create_dir_all(&paths.install_root).unwrap();
|
|
std::os::unix::fs::symlink(&new_release, paths.install_root.join("current")).unwrap();
|
|
let mut state = UpdateState::default();
|
|
state.channels.insert(
|
|
"stable".into(),
|
|
ChannelState {
|
|
highest_accepted_sequence: 1,
|
|
highest_accepted_version: "1.0.0".into(),
|
|
active_version: "1.0.0".into(),
|
|
last_failed_sequence: None,
|
|
},
|
|
);
|
|
state.pending_activation = Some(PendingActivation {
|
|
previous_target: old_release.clone(),
|
|
new_target: new_release,
|
|
channel: "stable".into(),
|
|
release_sequence: 2,
|
|
product_version: "1.1.0".into(),
|
|
daemon_was_active: false,
|
|
selected: true,
|
|
});
|
|
recover_interrupted_activation(
|
|
&paths,
|
|
&transaction,
|
|
&mut state,
|
|
&CurrentProcessSupervisor,
|
|
&ActivationPolicy {
|
|
timeout: Duration::from_secs(1),
|
|
retry_interval: Duration::from_millis(10),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(transaction.current_target().unwrap(), old_release);
|
|
assert_eq!(state.channels["stable"].last_failed_sequence, Some(2));
|
|
assert!(state.pending_activation.is_none());
|
|
}
|
|
}
|