Move builds to prod-pins and add explicit update channels
Some checks failed
Validate authentication / Validate authentication (push) Failing after 1s

This commit is contained in:
Alois 2026-10-04 19:27:02 +02:00
commit e71d9c3118
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
15 changed files with 473 additions and 722 deletions

View file

@ -0,0 +1,83 @@
use anyhow::{Context, Result, bail};
use std::{collections::BTreeMap, fs, io::Write, path::Path};
const CONFIG: &str = "/etc/iota/update.env";
const RELEASES: &str = "https://git.methanium.net/tensamin/prod-pins/releases/download";
pub fn manifest_url(channel: &str, architecture: &str) -> Result<String> {
if !matches!(channel, "stable" | "canary") {
bail!("update channel must be stable or canary");
}
if !matches!(architecture, "x86_64" | "aarch64") {
bail!("unsupported update architecture: {architecture}");
}
Ok(format!(
"{RELEASES}/{channel}/iota-update-linux-{architecture}.json"
))
}
pub(crate) fn load() -> Result<BTreeMap<String, String>> {
let contents = fs::read_to_string(CONFIG).context("read /etc/iota/update.env")?;
let mut entries = BTreeMap::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (name, value) = line.split_once('=').context("invalid update.env entry")?;
if value.is_empty() || value.chars().any(char::is_whitespace) {
bail!("invalid update.env value for {name}");
}
if entries.insert(name.to_owned(), value.to_owned()).is_some() {
bail!("duplicate update.env entry {name}");
}
}
for name in [
"IOTA_UPDATE_CHANNEL",
"IOTA_UPDATE_PUBLIC_KEY",
"IOTA_UPDATE_SIGNING_KEY_ID",
] {
if !entries.contains_key(name) {
bail!("update.env is missing {name}");
}
}
let url = manifest_url(&entries["IOTA_UPDATE_CHANNEL"], std::env::consts::ARCH)?;
if entries.get("IOTA_UPDATE_MANIFEST") != Some(&url)
|| entries.get("IOTA_UPDATE_SIGNATURE") != Some(&format!("{url}.sig"))
{
bail!("update.env URLs do not match the selected prod-pins channel");
}
Ok(entries)
}
pub fn selected_channel() -> Result<String> {
Ok(load()?["IOTA_UPDATE_CHANNEL"].clone())
}
pub fn select_channel(channel: &str) -> Result<()> {
let url = manifest_url(channel, std::env::consts::ARCH)?;
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::System)
.map_err(|error| anyhow::anyhow!(error))?;
let transaction = crate::transaction::UpdateTransaction::from_paths(&paths)?;
let _lock = transaction.acquire()?;
let mut entries = load()?;
entries.insert("IOTA_UPDATE_CHANNEL".into(), channel.into());
entries.insert("IOTA_UPDATE_MANIFEST".into(), url.clone());
entries.insert("IOTA_UPDATE_SIGNATURE".into(), format!("{url}.sig"));
let path = Path::new(CONFIG);
let parent = path.parent().context("update.env has no parent")?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary
.as_file()
.set_permissions(fs::metadata(path)?.permissions())?;
for (name, value) in entries {
writeln!(temporary, "{name}={value}")?;
}
temporary.as_file().sync_all()?;
temporary
.persist(path)
.map_err(|error| error.error)
.context("save update channel")?;
fs::File::open(parent)?.sync_all()?;
Ok(())
}

View file

@ -1,3 +1,4 @@
pub mod config;
pub mod manifest;
pub mod transaction;
@ -80,9 +81,10 @@ struct UpdatePolicy {
impl UpdatePolicy {
fn from_environment() -> Result<Self> {
let config = config::load()?;
Ok(Self {
channel: required_environment(CHANNEL_ENV)?,
signing_key_id: required_environment(SIGNING_KEY_ID_ENV)?,
channel: config[CHANNEL_ENV].clone(),
signing_key_id: config[SIGNING_KEY_ID_ENV].clone(),
activation: ActivationPolicy::from_environment()?,
})
}
@ -105,6 +107,8 @@ struct PendingActivation {
product_version: String,
daemon_was_active: bool,
selected: bool,
#[serde(default)]
existing_target: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -172,21 +176,21 @@ impl DaemonSupervisor for SystemdSupervisor {
}
pub async fn check_update() -> Result<bool> {
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 policy = UpdatePolicy::from_environment()?;
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(),
current_channel_version(&paths.install_root, &policy.channel)?.as_deref(),
state.channels.get(&policy.channel),
&policy.channel,
&policy.signing_key_id,
@ -214,6 +218,12 @@ async fn apply_update_with_observer(
) -> Result<bool> {
let transaction = UpdateTransaction::from_paths(paths)?;
let _lock = transaction.acquire()?;
let configured_policy = UpdatePolicy::from_environment()?;
if policy.channel != configured_policy.channel
|| policy.signing_key_id != configured_policy.signing_key_id
{
bail!("update configuration changed; retry apply");
}
let mut state = load_or_initialize_update_state(paths)?;
recover_interrupted_activation(
paths,
@ -228,7 +238,7 @@ async fn apply_update_with_observer(
};
let decision = evaluate_candidate(
&release.manifest,
current_version(&paths.install_root)?.as_deref(),
current_channel_version(&paths.install_root, &policy.channel)?.as_deref(),
state.channels.get(&policy.channel),
&policy.channel,
&policy.signing_key_id,
@ -281,7 +291,24 @@ async fn apply_update_with_observer(
UpdatePhase::ActivationStarted,
Some(&release.manifest.product_version),
);
let activation = match transaction.activate(&release.manifest.product_version) {
let activation = match if state
.pending_activation
.as_ref()
.is_some_and(|pending| pending.existing_target)
{
let previous_target = transaction.current_target()?;
transaction
.rollback(&release.manifest.product_version)
.map(|()| Activation {
previous_target,
new_target: transaction
.root
.join("versions")
.join(&release.manifest.product_version),
})
} else {
transaction.activate(&release.manifest.product_version)
} {
Ok(activation) => activation,
Err(error) => {
remove_unselected_candidate(&state, &transaction.root)?;
@ -395,7 +422,7 @@ fn remove_unselected_candidate(state: &UpdateState, install_root: &Path) -> Resu
return Ok(());
};
validate_pending_candidate(pending, install_root)?;
if pending.new_target.exists() {
if !pending.existing_target && pending.new_target.exists() {
fs::remove_dir_all(&pending.new_target).with_context(|| {
format!(
"remove unselected candidate release {}",
@ -430,8 +457,20 @@ fn begin_activation_state(
.root
.join("versions")
.join(&manifest.product_version);
if new_target.exists() {
bail!("release version already exists: {}", new_target.display());
let existing_target = new_target.exists();
if existing_target {
let mut installed = read_installed_manifest(&new_target)?;
// Refreshing signed expiry does not change the installed release contents.
installed.published_at = manifest.published_at.clone();
installed.expires_at = manifest.expires_at.clone();
if manifest::canonical_bytes(&installed)? != manifest::canonical_bytes(manifest)? {
bail!("existing release version has different signed metadata");
}
for artifact in
select_host_artifacts(manifest, std::env::consts::OS, std::env::consts::ARCH)?
{
manifest::verify_artifact(&new_target.join(&artifact.path), &artifact)?;
}
}
let mut updated = state.clone();
updated.pending_activation = Some(PendingActivation {
@ -442,6 +481,7 @@ fn begin_activation_state(
product_version: manifest.product_version.clone(),
daemon_was_active,
selected: true,
existing_target,
});
save_update_state(&paths.update_status_file(), &updated)?;
*state = updated;
@ -603,7 +643,11 @@ fn evaluate_candidate(
);
}
if manifest.release_sequence == state.highest_accepted_sequence {
return Ok(CandidateDecision::NoUpdate);
return Ok(if current_version.is_none() {
CandidateDecision::Install
} else {
CandidateDecision::NoUpdate
});
}
}
if current_version == Some(manifest.product_version.as_str()) {
@ -754,14 +798,6 @@ fn record_failed_sequence(
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
@ -848,22 +884,14 @@ struct ConfiguredRelease {
}
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)
let config = config::load()?;
configured_release_from_locations(
&config[MANIFEST_ENV],
&config[SIGNATURE_ENV],
&config[PUBLIC_KEY_ENV],
)
.await
.map(Some)
}
async fn configured_release_from_locations(
@ -990,6 +1018,11 @@ fn current_version(install_root: &Path) -> Result<Option<String>> {
.map(str::to_owned))
}
fn current_channel_version(install_root: &Path, channel: &str) -> Result<Option<String>> {
let manifest = read_installed_manifest(&install_root.join("current"))?;
Ok((manifest.channel == channel).then_some(manifest.product_version))
}
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(|| {
@ -1507,6 +1540,7 @@ mod tests {
product_version: "1.1.0".into(),
daemon_was_active: false,
selected: true,
existing_target: false,
});
recover_interrupted_activation(
&paths,

View file

@ -5,7 +5,15 @@ async fn main() -> Result<()> {
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
match command.as_str() {
"check" => println!("{}", iota_updater::check_update().await?),
"status" => println!("updater ready"),
"status" => println!("channel: {}", iota_updater::config::selected_channel()?),
"channel" => {
if let Some(channel) = std::env::args().nth(2) {
iota_updater::config::select_channel(&channel)?;
println!("Selected {channel}. Run check, then apply to update.");
} else {
println!("{}", iota_updater::config::selected_channel()?);
}
}
"apply" => {
struct StderrObserver;
impl iota_updater::UpdateObserver for StderrObserver {
@ -27,7 +35,7 @@ async fn main() -> Result<()> {
}
_ => {
return Err(anyhow::anyhow!(
"usage: iota-updater check|status|apply|rollback VERSION"
"usage: iota-updater check|status|apply|channel [stable|canary]|rollback VERSION"
));
}
}