Restructure project layout for better CI/CD integration
Some checks failed
Cross build / Build on ppc64le (push) Failing after 1m8s
Cross build / Build on s390x (push) Failing after 2s

- Flattened nested bootupd/bootupd/ structure to root level
- Moved all core project files to root directory
- Added proper Debian packaging structure (debian/ directory)
- Created build scripts and CI configuration
- Improved project organization for CI/CD tools
- All Rust source, tests, and configuration now at root level
- Added GitHub Actions workflow for automated testing
- Maintained all original functionality while improving structure
This commit is contained in:
robojerk 2025-08-09 23:11:42 -07:00
parent 5e8730df43
commit aaf662d5b1
87 changed files with 1334 additions and 570 deletions

3
src/backend/mod.rs Executable file
View file

@ -0,0 +1,3 @@
//! Internal logic for bootloader and system state manipulation.
mod statefile;

112
src/backend/statefile.rs Executable file
View file

@ -0,0 +1,112 @@
//! On-disk saved state.
use crate::model::SavedState;
use crate::util::SignalTerminationGuard;
use anyhow::{bail, Context, Result};
use fn_error_context::context;
use fs2::FileExt;
use openat_ext::OpenatDirExt;
use std::fs::File;
use std::io::prelude::*;
use std::path::Path;
impl SavedState {
/// System-wide bootupd write lock (relative to sysroot).
const WRITE_LOCK_PATH: &'static str = "run/bootupd-lock";
/// Top-level directory for statefile (relative to sysroot).
pub(crate) const STATEFILE_DIR: &'static str = "boot";
/// On-disk bootloader statefile, akin to a tiny rpm/dpkg database, stored in `/boot`.
pub(crate) const STATEFILE_NAME: &'static str = "bootupd-state.json";
/// Try to acquire a system-wide lock to ensure non-conflicting state updates.
///
/// While ordinarily the daemon runs as a systemd unit (which implicitly
/// ensures a single instance) this is a double check against other
/// execution paths.
pub(crate) fn acquire_write_lock(sysroot: openat::Dir) -> Result<StateLockGuard> {
let lockfile = sysroot.write_file(Self::WRITE_LOCK_PATH, 0o644)?;
lockfile.lock_exclusive()?;
let guard = StateLockGuard {
sysroot,
termguard: Some(SignalTerminationGuard::new()?),
lockfile: Some(lockfile),
};
Ok(guard)
}
/// Use this for cases when the target root isn't booted, which is
/// offline installs.
pub(crate) fn unlocked(sysroot: openat::Dir) -> Result<StateLockGuard> {
Ok(StateLockGuard {
sysroot,
termguard: None,
lockfile: None,
})
}
/// Load the JSON file containing on-disk state.
#[context("Loading saved state")]
pub(crate) fn load_from_disk(root_path: impl AsRef<Path>) -> Result<Option<SavedState>> {
let root_path = root_path.as_ref();
let sysroot = openat::Dir::open(root_path)
.with_context(|| format!("opening sysroot '{}'", root_path.display()))?;
let statefile_path = Path::new(Self::STATEFILE_DIR).join(Self::STATEFILE_NAME);
let saved_state = if let Some(statusf) = sysroot.open_file_optional(&statefile_path)? {
let mut bufr = std::io::BufReader::new(statusf);
let mut s = String::new();
bufr.read_to_string(&mut s)?;
let state: serde_json::Result<SavedState> = serde_json::from_str(s.as_str());
let r = match state {
Ok(s) => s,
Err(orig_err) => {
let state: serde_json::Result<crate::model_legacy::SavedState01> =
serde_json::from_str(s.as_str());
match state {
Ok(s) => s.upconvert(),
Err(_) => {
return Err(orig_err.into());
}
}
}
};
Some(r)
} else {
None
};
Ok(saved_state)
}
/// Check whether statefile exists.
pub(crate) fn ensure_not_present(root_path: impl AsRef<Path>) -> Result<()> {
let statepath = Path::new(root_path.as_ref())
.join(Self::STATEFILE_DIR)
.join(Self::STATEFILE_NAME);
if statepath.exists() {
bail!("{} already exists", statepath.display());
}
Ok(())
}
}
/// Write-lock guard for statefile, protecting against concurrent state updates.
#[derive(Debug)]
pub(crate) struct StateLockGuard {
pub(crate) sysroot: openat::Dir,
#[allow(dead_code)]
termguard: Option<SignalTerminationGuard>,
#[allow(dead_code)]
lockfile: Option<File>,
}
impl StateLockGuard {
/// Atomically replace the on-disk state with a new version.
pub(crate) fn update_state(&mut self, state: &SavedState) -> Result<()> {
let subdir = self.sysroot.sub_dir(SavedState::STATEFILE_DIR)?;
subdir.write_file_with_sync(SavedState::STATEFILE_NAME, 0o644, |w| -> Result<()> {
serde_json::to_writer(w, state)?;
Ok(())
})?;
Ok(())
}
}

271
src/bios.rs Executable file
View file

@ -0,0 +1,271 @@
use anyhow::{bail, Context, Result};
use camino::Utf8PathBuf;
use openat_ext::OpenatDirExt;
#[cfg(target_arch = "powerpc64")]
use std::borrow::Cow;
use std::io::prelude::*;
use std::path::Path;
use std::process::Command;
use crate::blockdev;
use crate::bootupd::RootContext;
use crate::component::*;
use crate::freezethaw::fsfreeze_thaw_cycle;
use crate::grubconfigs;
use crate::model::*;
use crate::packagesystem;
// grub2-install file path
pub(crate) const GRUB_BIN: &str = "usr/sbin/grub2-install";
#[cfg(target_arch = "powerpc64")]
fn target_device(device: &str) -> Result<Cow<str>> {
const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B";
/// We make a best-effort to support MBR partitioning too.
const PREPBOOT_MBR_TYPE: &str = "41";
// Here we use lsblk to see if the device has any partitions at all
let dev = bootc_internal_blockdev::list_dev(device.into())?;
if dev.children.is_none() {
return Ok(device.into());
};
// If it does, directly call `sfdisk` and bypass lsblk because inside a container
// we may not have all the cached udev state (that I think is in /run).
let device = bootc_internal_blockdev::partitions_of(device.into())?;
let prepdev = device
.partitions
.iter()
.find(|p| matches!(p.parttype.as_str(), PREPBOOT_GUID | PREPBOOT_MBR_TYPE))
.ok_or_else(|| {
anyhow::anyhow!("Failed to find PReP partition with GUID {PREPBOOT_GUID}")
})?;
Ok(prepdev.path().as_str().to_owned().into())
}
#[derive(Default)]
pub(crate) struct Bios {}
impl Bios {
// Return `true` if grub2-modules installed
fn check_grub_modules(&self) -> Result<bool> {
let usr_path = Path::new("/usr/lib/grub");
#[cfg(target_arch = "x86_64")]
{
usr_path.join("i386-pc").try_exists().map_err(Into::into)
}
#[cfg(target_arch = "powerpc64")]
{
usr_path
.join("powerpc-ieee1275")
.try_exists()
.map_err(Into::into)
}
}
// Run grub2-install
fn run_grub_install(&self, dest_root: &str, device: &str) -> Result<()> {
if !self.check_grub_modules()? {
bail!("Failed to find grub2-modules");
}
let grub_install = Path::new("/").join(GRUB_BIN);
if !grub_install.exists() {
bail!("Failed to find {:?}", grub_install);
}
let mut cmd = Command::new(grub_install);
let boot_dir = Path::new(dest_root).join("boot");
// We forcibly inject mdraid1x because it's needed by CoreOS's default of "install raw disk image"
// We also add part_gpt because in some cases probing of the partition map can fail such
// as in a container, but we always use GPT.
#[cfg(target_arch = "x86_64")]
cmd.args(["--target", "i386-pc"])
.args(["--boot-directory", boot_dir.to_str().unwrap()])
.args(["--modules", "mdraid1x part_gpt"])
.arg(device);
#[cfg(target_arch = "powerpc64")]
{
let device = target_device(device)?;
cmd.args(&["--target", "powerpc-ieee1275"])
.args(&["--boot-directory", boot_dir.to_str().unwrap()])
.arg("--no-nvram")
.arg(&*device);
}
let cmdout = cmd.output()?;
if !cmdout.status.success() {
std::io::stderr().write_all(&cmdout.stderr)?;
bail!("Failed to run {:?}", cmd);
}
Ok(())
}
}
impl Component for Bios {
fn name(&self) -> &'static str {
"BIOS"
}
fn install(
&self,
src_root: &openat::Dir,
dest_root: &str,
device: &str,
_update_firmware: bool,
) -> Result<InstalledContent> {
let Some(meta) = get_component_update(src_root, self)? else {
anyhow::bail!("No update metadata for component {} found", self.name());
};
self.run_grub_install(dest_root, device)?;
Ok(InstalledContent {
meta,
filetree: None,
adopted_from: None,
})
}
fn generate_update_metadata(&self, sysroot_path: &str) -> Result<ContentMetadata> {
let grub_install = Path::new(sysroot_path).join(GRUB_BIN);
if !grub_install.exists() {
bail!("Failed to find {:?}", grub_install);
}
// Query the rpm database and list the package and build times for /usr/sbin/grub2-install
let meta = packagesystem::query_files(sysroot_path, [&grub_install])?;
write_update_metadata(sysroot_path, self, &meta)?;
Ok(meta)
}
fn query_adopt(&self, devices: &Option<Vec<String>>) -> Result<Option<Adoptable>> {
#[cfg(target_arch = "x86_64")]
if crate::efi::is_efi_booted()? && devices.is_none() {
log::debug!("Skip BIOS adopt");
return Ok(None);
}
crate::component::query_adopt_state()
}
// Backup the current grub.cfg and replace with new static config
// - Backup "/boot/loader/grub.cfg" to "/boot/grub2/grub.cfg.bak"
// - Remove symlink "/boot/grub2/grub.cfg"
// - Replace "/boot/grub2/grub.cfg" symlink with new static "grub.cfg"
fn migrate_static_grub_config(&self, sysroot_path: &str, destdir: &openat::Dir) -> Result<()> {
let grub = "boot/grub2";
// sysroot_path is /, destdir is Dir of /
let grub_config_path = Utf8PathBuf::from(sysroot_path).join(grub);
let grub_config_dir = destdir.sub_dir(grub).context("Opening boot/grub2")?;
let grub_config = grub_config_path.join(grubconfigs::GRUBCONFIG);
if !grub_config.exists() {
anyhow::bail!("Could not find '{}'", grub_config);
}
let mut current_config;
// If /boot/grub2/grub.cfg is not symlink, we need to keep going
if !grub_config.is_symlink() {
println!("'{}' is not a symlink", grub_config);
current_config = grub_config.clone();
} else {
// If /boot/grub2/grub.cfg is symlink to /boot/loader/grub.cfg,
// backup it to /boot/grub2/grub.cfg.bak
// Get real file for symlink /boot/grub2/grub.cfg
let real_config = grub_config_dir.read_link(grubconfigs::GRUBCONFIG)?;
let real_config =
Utf8PathBuf::from_path_buf(real_config).expect("Path should be valid UTF-8");
// Resolve symlink location
current_config = grub_config_path.clone();
current_config.push(real_config);
}
let backup_config = grub_config_path.join(grubconfigs::GRUBCONFIG_BACKUP);
if !backup_config.exists() {
// Backup the current GRUB config which is hopefully working right now
println!(
"Creating a backup of the current GRUB config '{}' in '{}'...",
current_config, backup_config
);
std::fs::copy(&current_config, &backup_config)
.context("Failed to backup GRUB config")?;
}
crate::grubconfigs::install(&destdir, None, true)?;
// Remove the real config if it is symlink and will not
// if /boot/grub2/grub.cfg is file
if current_config != grub_config {
println!("Removing {}", current_config);
grub_config_dir.remove_file_optional(current_config.as_std_path())?;
}
// Synchronize the filesystem containing /boot/grub2 to disk.
fsfreeze_thaw_cycle(grub_config_dir.open_file(".")?)?;
Ok(())
}
fn adopt_update(
&self,
rootcxt: &RootContext,
update: &ContentMetadata,
with_static_config: bool,
) -> Result<Option<InstalledContent>> {
let bios_devices = blockdev::find_colocated_bios_boot(&rootcxt.devices)?;
let Some(meta) = self.query_adopt(&bios_devices)? else {
return Ok(None);
};
for parent in rootcxt.devices.iter() {
self.run_grub_install(rootcxt.path.as_str(), &parent)?;
log::debug!("Installed grub modules on {parent}");
}
if with_static_config {
// Install the static config if the OSTree bootloader is not set.
if let Some(bootloader) = crate::ostreeutil::get_ostree_bootloader()? {
println!(
"ostree repo 'sysroot.bootloader' config option is currently set to: '{bootloader}'",
);
} else {
println!("ostree repo 'sysroot.bootloader' config option is not set yet");
self.migrate_static_grub_config(rootcxt.path.as_str(), &rootcxt.sysroot)?;
};
}
Ok(Some(InstalledContent {
meta: update.clone(),
filetree: None,
adopted_from: Some(meta.version),
}))
}
fn query_update(&self, sysroot: &openat::Dir) -> Result<Option<ContentMetadata>> {
get_component_update(sysroot, self)
}
fn run_update(&self, rootcxt: &RootContext, _: &InstalledContent) -> Result<InstalledContent> {
let updatemeta = self
.query_update(&rootcxt.sysroot)?
.expect("update available");
for parent in rootcxt.devices.iter() {
self.run_grub_install(rootcxt.path.as_str(), &parent)?;
log::debug!("Installed grub modules on {parent}");
}
let adopted_from = None;
Ok(InstalledContent {
meta: updatemeta,
filetree: None,
adopted_from,
})
}
fn validate(&self, _: &InstalledContent) -> Result<ValidationResult> {
Ok(ValidationResult::Skip)
}
fn get_efi_vendor(&self, _: &openat::Dir) -> Result<Option<String>> {
Ok(None)
}
}

99
src/blockdev.rs Executable file
View file

@ -0,0 +1,99 @@
use camino::Utf8Path;
use std::path::Path;
use anyhow::{Context, Result};
use bootc_internal_blockdev::PartitionTable;
use fn_error_context::context;
#[context("get parent devices from mount point boot or sysroot")]
pub fn get_devices<P: AsRef<Path>>(target_root: P) -> Result<Vec<String>> {
let target_root = target_root.as_ref();
let mut source = None;
for path in ["boot", "sysroot"] {
let target_path = target_root.join(path);
if !target_path.exists() {
continue;
}
let target_dir = openat::Dir::open(&target_path)
.with_context(|| format!("Opening {}", target_path.display()))?;
if let Ok(fsinfo) = crate::filesystem::inspect_filesystem(&target_dir, ".") {
source = Some(fsinfo.source);
break;
}
}
let source = match source {
Some(s) => s,
None => anyhow::bail!("Failed to inspect filesystem from boot or sysroot"),
};
// Find the parent devices of the source path
let parent_devices = bootc_internal_blockdev::find_parent_devices(&source)
.with_context(|| format!("While looking for backing devices of {}", source))?;
log::debug!("Found parent devices: {parent_devices:?}");
Ok(parent_devices)
}
/// Find esp partition on the same device
/// using sfdisk to get partitiontable
pub fn get_esp_partition(device: &str) -> Result<Option<String>> {
const ESP_TYPE_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B";
let device_info: PartitionTable =
bootc_internal_blockdev::partitions_of(Utf8Path::new(device))?;
let esp = device_info
.partitions
.into_iter()
.find(|p| p.parttype.as_str() == ESP_TYPE_GUID);
if let Some(esp) = esp {
return Ok(Some(esp.node));
}
Ok(None)
}
/// Find all ESP partitions on the devices
pub fn find_colocated_esps(devices: &Vec<String>) -> Result<Option<Vec<String>>> {
// look for all ESPs on those devices
let mut esps = Vec::new();
for device in devices {
if let Some(esp) = get_esp_partition(&device)? {
esps.push(esp)
}
}
if esps.is_empty() {
return Ok(None);
}
log::debug!("Found esp partitions: {esps:?}");
Ok(Some(esps))
}
/// Find bios_boot partition on the same device
pub fn get_bios_boot_partition(device: &str) -> Result<Option<String>> {
const BIOS_BOOT_TYPE_GUID: &str = "21686148-6449-6E6F-744E-656564454649";
let device_info = bootc_internal_blockdev::partitions_of(Utf8Path::new(device))?;
let bios_boot = device_info
.partitions
.into_iter()
.find(|p| p.parttype.as_str() == BIOS_BOOT_TYPE_GUID);
if let Some(bios_boot) = bios_boot {
return Ok(Some(bios_boot.node));
}
Ok(None)
}
/// Find all bios_boot partitions on the devices
pub fn find_colocated_bios_boot(devices: &Vec<String>) -> Result<Option<Vec<String>>> {
// look for all bios_boot parts on those devices
let mut bios_boots = Vec::new();
for device in devices {
if let Some(bios) = get_bios_boot_partition(&device)? {
bios_boots.push(bios)
}
}
if bios_boots.is_empty() {
return Ok(None);
}
log::debug!("Found bios_boot partitions: {bios_boots:?}");
Ok(Some(bios_boots))
}

772
src/bootupd.rs Executable file
View file

@ -0,0 +1,772 @@
#[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))]
use crate::bios;
use crate::component;
use crate::component::{Component, ValidationResult};
use crate::coreos;
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
use crate::efi;
use crate::freezethaw::fsfreeze_thaw_cycle;
use crate::model::{ComponentStatus, ComponentUpdatable, ContentMetadata, SavedState, Status};
use crate::{ostreeutil, util};
use anyhow::{anyhow, Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use clap::crate_version;
use fn_error_context::context;
use libc::mode_t;
use libc::{S_IRGRP, S_IROTH, S_IRUSR, S_IWUSR};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
pub(crate) enum ConfigMode {
None,
Static,
WithUUID,
}
impl ConfigMode {
pub(crate) fn enabled_with_uuid(&self) -> Option<bool> {
match self {
ConfigMode::None => None,
ConfigMode::Static => Some(false),
ConfigMode::WithUUID => Some(true),
}
}
}
pub(crate) fn install(
source_root: &str,
dest_root: &str,
device: Option<&str>,
configs: ConfigMode,
update_firmware: bool,
target_components: Option<&[String]>,
auto_components: bool,
) -> Result<()> {
// TODO: Change this to an Option<&str>; though this probably balloons into having
// DeviceComponent and FileBasedComponent
let device = device.unwrap_or("");
let source_root = openat::Dir::open(source_root).context("Opening source root")?;
SavedState::ensure_not_present(dest_root)
.context("failed to install, invalid re-install attempted")?;
let all_components = get_components_impl(auto_components);
if all_components.is_empty() {
println!("No components available for this platform.");
return Ok(());
}
let target_components = if let Some(target_components) = target_components {
// Checked by CLI parser
assert!(!auto_components);
target_components
.iter()
.map(|name| {
all_components
.get(name.as_str())
.ok_or_else(|| anyhow!("Unknown component: {name}"))
})
.collect::<Result<Vec<_>>>()?
} else {
all_components.values().collect()
};
if target_components.is_empty() && !auto_components {
anyhow::bail!("No components specified");
}
let mut state = SavedState::default();
let mut installed_efi_vendor = None;
for &component in target_components.iter() {
// skip for BIOS if device is empty
if component.name() == "BIOS" && device.is_empty() {
println!(
"Skip installing component {} without target device",
component.name()
);
continue;
}
let meta = component
.install(&source_root, dest_root, device, update_firmware)
.with_context(|| format!("installing component {}", component.name()))?;
log::info!("Installed {} {}", component.name(), meta.meta.version);
state.installed.insert(component.name().into(), meta);
// Yes this is a hack...the Component thing just turns out to be too generic.
if let Some(vendor) = component.get_efi_vendor(&source_root)? {
assert!(installed_efi_vendor.is_none());
installed_efi_vendor = Some(vendor);
}
}
let sysroot = &openat::Dir::open(dest_root)?;
match configs.enabled_with_uuid() {
Some(uuid) => {
let meta = get_static_config_meta()?;
state.static_configs = Some(meta);
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "powerpc64",
target_arch = "riscv64"
))]
crate::grubconfigs::install(sysroot, installed_efi_vendor.as_deref(), uuid)?;
// On other architectures, assume that there's nothing to do.
}
None => {}
}
// Unmount the ESP, etc.
drop(target_components);
let mut state_guard =
SavedState::unlocked(sysroot.try_clone()?).context("failed to acquire write lock")?;
state_guard
.update_state(&state)
.context("failed to update state")?;
Ok(())
}
#[context("Get static config metadata")]
fn get_static_config_meta() -> Result<ContentMetadata> {
let self_bin_meta = std::fs::metadata("/proc/self/exe").context("Querying self meta")?;
let self_meta = ContentMetadata {
timestamp: self_bin_meta.modified()?.into(),
version: crate_version!().into(),
};
Ok(self_meta)
}
type Components = BTreeMap<&'static str, Box<dyn Component>>;
#[allow(clippy::box_default)]
/// Return the set of known components; if `auto` is specified then the system
/// filters to the target booted state.
pub(crate) fn get_components_impl(auto: bool) -> Components {
let mut components = BTreeMap::new();
fn insert_component(components: &mut Components, component: Box<dyn Component>) {
components.insert(component.name(), component);
}
#[cfg(target_arch = "x86_64")]
{
if auto {
let is_efi_booted = crate::efi::is_efi_booted().unwrap();
log::info!(
"System boot method: {}",
if is_efi_booted { "EFI" } else { "BIOS" }
);
if is_efi_booted {
insert_component(&mut components, Box::new(efi::Efi::default()));
} else {
insert_component(&mut components, Box::new(bios::Bios::default()));
}
} else {
insert_component(&mut components, Box::new(bios::Bios::default()));
insert_component(&mut components, Box::new(efi::Efi::default()));
}
}
#[cfg(any(target_arch = "aarch64", target_arch = "riscv64"))]
insert_component(&mut components, Box::new(efi::Efi::default()));
#[cfg(target_arch = "powerpc64")]
insert_component(&mut components, Box::new(bios::Bios::default()));
components
}
pub(crate) fn get_components() -> Components {
get_components_impl(false)
}
pub(crate) fn generate_update_metadata(sysroot_path: &str) -> Result<()> {
// create bootupd update dir which will save component metadata files for both components
let updates_dir = Path::new(sysroot_path).join(crate::model::BOOTUPD_UPDATES_DIR);
std::fs::create_dir_all(&updates_dir)
.with_context(|| format!("Failed to create updates dir {:?}", &updates_dir))?;
for component in get_components().values() {
let v = component.generate_update_metadata(sysroot_path)?;
println!(
"Generated update layout for {}: {}",
component.name(),
v.version,
);
}
Ok(())
}
/// Return value from daemon → client for component update
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ComponentUpdateResult {
AtLatestVersion,
Updated {
previous: ContentMetadata,
interrupted: Option<ContentMetadata>,
new: ContentMetadata,
},
}
fn ensure_writable_boot() -> Result<()> {
util::ensure_writable_mount("/boot")
}
/// daemon implementation of component update
pub(crate) fn update(name: &str, rootcxt: &RootContext) -> Result<ComponentUpdateResult> {
let mut state = SavedState::load_from_disk("/")?.unwrap_or_default();
let component = component::new_from_name(name)?;
let inst = if let Some(inst) = state.installed.get(name) {
inst.clone()
} else {
anyhow::bail!("Component {} is not installed", name);
};
let sysroot = &rootcxt.sysroot;
let update = component.query_update(sysroot)?;
let update = match update.as_ref() {
Some(p) if inst.meta.can_upgrade_to(p) => p,
_ => return Ok(ComponentUpdateResult::AtLatestVersion),
};
ensure_writable_boot()?;
let mut pending_container = state.pending.take().unwrap_or_default();
let interrupted = pending_container.get(component.name()).cloned();
pending_container.insert(component.name().into(), update.clone());
let sysroot = sysroot.try_clone()?;
let mut state_guard =
SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?;
state_guard
.update_state(&state)
.context("Failed to update state")?;
let newinst = component
.run_update(rootcxt, &inst)
.with_context(|| format!("Failed to update {}", component.name()))?;
state.installed.insert(component.name().into(), newinst);
pending_container.remove(component.name());
state_guard.update_state(&state)?;
Ok(ComponentUpdateResult::Updated {
previous: inst.meta,
interrupted,
new: update.clone(),
})
}
/// daemon implementation of component adoption
pub(crate) fn adopt_and_update(
name: &str,
rootcxt: &RootContext,
with_static_config: bool,
) -> Result<Option<ContentMetadata>> {
let sysroot = &rootcxt.sysroot;
let mut state = SavedState::load_from_disk("/")?.unwrap_or_default();
let component = component::new_from_name(name)?;
if state.installed.contains_key(name) {
anyhow::bail!("Component {} is already installed", name);
};
ensure_writable_boot()?;
let Some(update) = component.query_update(sysroot)? else {
anyhow::bail!("Component {} has no available update", name);
};
let sysroot = sysroot.try_clone()?;
let mut state_guard =
SavedState::acquire_write_lock(sysroot).context("Failed to acquire write lock")?;
let inst = component
.adopt_update(&rootcxt, &update, with_static_config)
.context("Failed adopt and update")?;
if let Some(inst) = inst {
state.installed.insert(component.name().into(), inst);
// Set static_configs metadata and save
if with_static_config && state.static_configs.is_none() {
let meta = get_static_config_meta()?;
state.static_configs = Some(meta);
// Set bootloader to none
ostreeutil::set_ostree_bootloader("none")?;
println!("Static GRUB configuration has been adopted successfully.");
}
state_guard.update_state(&state)?;
return Ok(Some(update));
} else {
// Nothing adopted, skip
log::info!("Component '{}' skipped adoption", component.name());
return Ok(None);
}
}
/// daemon implementation of component validate
pub(crate) fn validate(name: &str) -> Result<ValidationResult> {
let state = SavedState::load_from_disk("/")?.unwrap_or_default();
let component = component::new_from_name(name)?;
let Some(inst) = state.installed.get(name) else {
anyhow::bail!("Component {} is not installed", name);
};
component.validate(inst)
}
pub(crate) fn status() -> Result<Status> {
let mut ret: Status = Default::default();
let mut known_components = get_components();
let sysroot = openat::Dir::open("/")?;
let state = SavedState::load_from_disk("/")?;
if let Some(state) = state {
for (name, ic) in state.installed.iter() {
log::trace!("Gathering status for installed component: {}", name);
let component = known_components
.remove(name.as_str())
.ok_or_else(|| anyhow!("Unknown component installed: {}", name))?;
let component = component.as_ref();
let interrupted = state.pending.as_ref().and_then(|p| p.get(name.as_str()));
let update = component.query_update(&sysroot)?;
let updatable = ComponentUpdatable::from_metadata(&ic.meta, update.as_ref());
let adopted_from = ic.adopted_from.clone();
ret.components.insert(
name.to_string(),
ComponentStatus {
installed: ic.meta.clone(),
interrupted: interrupted.cloned(),
update,
updatable,
adopted_from,
},
);
}
} else {
log::trace!("No saved state");
}
// Process the remaining components not installed
log::trace!("Remaining known components: {}", known_components.len());
for (name, _) in known_components {
// To determine if not-installed components can be adopted:
//
// `query_adopt_state()` checks for existing installation state,
// such as a `version` in `/sysroot/.coreos-aleph-version.json`,
// or the presence of `/ostree/deploy`.
//
// `component.query_adopt()` performs additional checks,
// including hardware/device requirements.
// For example, it will skip BIOS adoption if the system is booted via EFI
// and lacks a BIOS_BOOT partition.
//
// Once a component is determined to be adoptable, it is added to the
// adoptable list, and adoption proceeds automatically.
//
// Therefore, calling `query_adopt_state()` alone is sufficient.
if let Some(adopt_ver) = crate::component::query_adopt_state()? {
ret.adoptable.insert(name.to_string(), adopt_ver);
} else {
log::trace!("Not adoptable: {}", name);
}
}
Ok(ret)
}
pub(crate) fn print_status_avail(status: &Status) -> Result<()> {
let mut avail = Vec::new();
for (name, component) in status.components.iter() {
if let ComponentUpdatable::Upgradable = component.updatable {
avail.push(name.as_str());
}
}
for (name, adoptable) in status.adoptable.iter() {
if adoptable.confident {
avail.push(name.as_str());
}
}
if !avail.is_empty() {
println!("Updates available: {}", avail.join(" "));
}
Ok(())
}
pub(crate) fn print_status(status: &Status) -> Result<()> {
if status.components.is_empty() {
println!("No components installed.");
}
for (name, component) in status.components.iter() {
println!("Component {}", name);
println!(" Installed: {}", component.installed.version);
if let Some(i) = component.interrupted.as_ref() {
println!(
" WARNING: Previous update to {} was interrupted",
i.version
);
}
let msg = match component.updatable {
ComponentUpdatable::NoUpdateAvailable => Cow::Borrowed("No update found"),
ComponentUpdatable::AtLatestVersion => Cow::Borrowed("At latest version"),
ComponentUpdatable::WouldDowngrade => Cow::Borrowed("Ignoring downgrade"),
ComponentUpdatable::Upgradable => Cow::Owned(format!(
"Available: {}",
component.update.as_ref().expect("update").version
)),
};
println!(" Update: {}", msg);
}
if status.adoptable.is_empty() {
println!("No components are adoptable.");
}
for (name, adopt) in status.adoptable.iter() {
let ver = &adopt.version.version;
if adopt.confident {
println!("Detected: {}: {}", name, ver);
} else {
println!("Adoptable: {}: {}", name, ver);
}
}
if let Some(coreos_aleph) = coreos::get_aleph_version(Path::new("/"))? {
println!("CoreOS aleph version: {}", coreos_aleph.version_info.version);
}
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
{
let boot_method = if efi::is_efi_booted()? { "EFI" } else { "BIOS" };
println!("Boot method: {}", boot_method);
}
Ok(())
}
pub struct RootContext {
pub sysroot: openat::Dir,
pub path: Utf8PathBuf,
pub devices: Vec<String>,
}
impl RootContext {
fn new(sysroot: openat::Dir, path: &str, devices: Vec<String>) -> Self {
Self {
sysroot,
path: Utf8Path::new(path).into(),
devices,
}
}
}
/// Initialize parent devices to prepare the update
fn prep_before_update() -> Result<RootContext> {
let path = "/";
let sysroot = openat::Dir::open(path).context("Opening root dir")?;
let devices = crate::blockdev::get_devices(path).context("get parent devices")?;
Ok(RootContext::new(sysroot, path, devices))
}
pub(crate) fn client_run_update() -> Result<()> {
crate::try_fail_point!("update");
let rootcxt = prep_before_update()?;
let status: Status = status()?;
if status.components.is_empty() && status.adoptable.is_empty() {
println!("No components installed.");
return Ok(());
}
let mut updated = false;
for (name, cstatus) in status.components.iter() {
match cstatus.updatable {
ComponentUpdatable::Upgradable => {}
_ => continue,
};
match update(name, &rootcxt)? {
ComponentUpdateResult::AtLatestVersion => {
// Shouldn't happen unless we raced with another client
eprintln!(
"warning: Expected update for {}, raced with a different client?",
name
);
continue;
}
ComponentUpdateResult::Updated {
previous,
interrupted,
new,
} => {
if let Some(i) = interrupted {
eprintln!(
"warning: Continued from previous interrupted update: {}",
i.version,
);
}
println!("Previous {}: {}", name, previous.version);
println!("Updated {}: {}", name, new.version);
}
}
updated = true;
}
for (name, adoptable) in status.adoptable.iter() {
if adoptable.confident {
if let Some(r) = adopt_and_update(name, &rootcxt, false)? {
println!("Adopted and updated: {}: {}", name, r.version);
updated = true;
}
} else {
println!("Component {} requires explicit adopt-and-update", name);
}
}
if !updated {
println!("No update available for any component.");
}
Ok(())
}
pub(crate) fn client_run_adopt_and_update(with_static_config: bool) -> Result<()> {
let rootcxt = prep_before_update()?;
let status: Status = status()?;
if status.adoptable.is_empty() {
println!("No components are adoptable.");
} else {
for (name, _) in status.adoptable.iter() {
if let Some(r) = adopt_and_update(name, &rootcxt, with_static_config)? {
println!("Adopted and updated: {}: {}", name, r.version);
}
}
}
Ok(())
}
pub(crate) fn client_run_validate() -> Result<()> {
let status: Status = status()?;
if status.components.is_empty() {
println!("No components installed.");
return Ok(());
}
let mut caught_validation_error = false;
for (name, _) in status.components.iter() {
match validate(name)? {
ValidationResult::Valid => {
println!("Validated: {}", name);
}
ValidationResult::Skip => {
println!("Skipped: {}", name);
}
ValidationResult::Errors(errs) => {
for err in errs {
eprintln!("{}", err);
}
caught_validation_error = true;
}
}
}
if caught_validation_error {
anyhow::bail!("Caught validation errors");
}
Ok(())
}
#[context("Migrating to a static GRUB config")]
pub(crate) fn client_run_migrate_static_grub_config() -> Result<()> {
// Did we already complete the migration?
// We need to migrate if bootloader is not none (or not set)
if let Some(bootloader) = ostreeutil::get_ostree_bootloader()? {
if bootloader == "none" {
println!("Already using a static GRUB config");
return Ok(());
}
println!(
"ostree repo 'sysroot.bootloader' config option is currently set to: '{}'",
bootloader
);
} else {
println!("ostree repo 'sysroot.bootloader' config option is not set yet");
}
// Remount /boot read write just for this unit (we are called in a slave mount namespace by systemd)
ensure_writable_boot()?;
let grub_config_dir = PathBuf::from("/boot/grub2");
let dirfd = openat::Dir::open(&grub_config_dir).context("Opening /boot/grub2")?;
// We mark the bootloader as BLS capable to disable the ostree-grub2 logic.
// We can do that as we know that we are run after the bootloader has been
// updated and all recent GRUB2 versions support reading BLS configs.
// Ignore errors as this is not critical. This is a safety net if a user
// manually overwrites the (soon) static GRUB config by calling `grub2-mkconfig`.
// We need this until we can rely on ostree-grub2 being removed from the image.
println!("Marking bootloader as BLS capable...");
_ = File::create("/boot/grub2/.grub2-blscfg-supported");
// Migrate /boot/grub2/grub.cfg to a static GRUB config if it is a symlink
let grub_config_filename = PathBuf::from("/boot/grub2/grub.cfg");
match dirfd.read_link("grub.cfg") {
Err(_) => {
println!(
"'{}' is not a symlink, nothing to migrate",
grub_config_filename.display()
);
}
Ok(path) => {
println!("Migrating to a static GRUB config...");
// Resolve symlink location
let mut current_config = grub_config_dir.clone();
current_config.push(path);
// Backup the current GRUB config which is hopefully working right now
let backup_config = PathBuf::from("/boot/grub2/grub.cfg.backup");
println!(
"Creating a backup of the current GRUB config '{}' in '{}'...",
current_config.display(),
backup_config.display()
);
fs::copy(&current_config, &backup_config).context("Failed to backup GRUB config")?;
// Read the current config, strip the ostree generated GRUB entries and
// write the result to a temporary file
println!("Stripping ostree generated entries from GRUB config...");
let stripped_config = "grub.cfg.stripped";
let current_config_file =
File::open(current_config).context("Could not open current GRUB config")?;
let content = BufReader::new(current_config_file);
strip_grub_config_file(content, &dirfd, stripped_config)?;
// Atomically replace the symlink
dirfd
.local_rename(stripped_config, "grub.cfg")
.context("Failed to replace symlink with current GRUB config")?;
fsfreeze_thaw_cycle(dirfd.open_file(".")?)?;
println!("GRUB config symlink successfully replaced with the current config");
}
};
println!("Setting 'sysroot.bootloader' to 'none' in ostree repo config...");
ostreeutil::set_ostree_bootloader("none")?;
println!("Static GRUB config migration completed successfully");
Ok(())
}
/// Writes a stripped GRUB config to `stripped_config_name`, removing lines between
/// `### BEGIN /etc/grub.d/15_ostree ###` and `### END /etc/grub.d/15_ostree ###`.
fn strip_grub_config_file(
current_config_content: impl BufRead,
dirfd: &openat::Dir,
stripped_config_name: &str,
) -> Result<()> {
// mode = -rw-r--r-- (644)
let mut writer = BufWriter::new(
dirfd
.write_file(
stripped_config_name,
(S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) as mode_t,
)
.context("Failed to open temporary GRUB config")?,
);
let mut skip = false;
for line in current_config_content.lines() {
let line = line.context("Failed to read line from GRUB config")?;
if line == "### END /etc/grub.d/15_ostree ###" {
skip = false;
continue;
}
if skip {
continue;
}
if line == "### BEGIN /etc/grub.d/15_ostree ###" {
skip = true;
continue;
}
writer
.write_all(line.as_bytes())
.context("Failed to write stripped GRUB config")?;
writer
.write_all(b"\n")
.context("Failed to write stripped GRUB config")?;
}
writer
.into_inner()
.context("Failed to flush stripped GRUB config")?
.sync_data()
.context("Failed to sync stripped GRUB config")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_failpoint_update() {
let guard = fail::FailScenario::setup();
fail::cfg("update", "return").unwrap();
let r = client_run_update();
assert_eq!(r.is_err(), true);
guard.teardown();
}
#[test]
fn test_strip_grub_config_file() -> Result<()> {
let root: &tempfile::TempDir = &tempfile::tempdir()?;
let root_path = root.path();
let rootd = openat::Dir::open(root_path)?;
let stripped_config = root_path.join("stripped");
let content = r"
### BEGIN /etc/grub.d/10_linux ###
### END /etc/grub.d/10_linux ###
### BEGIN /etc/grub.d/15_ostree ###
menuentry 'Red Hat Enterprise Linux CoreOS 4 (ostree)' --class gnu-linux --class gnu --class os --unrestricted 'ostree-0-a92522f9-74dc-456a-ae0c-05ba22bca976' {
load_video
set gfxpayload=keep
insmod gzio
insmod part_gpt
insmod ext2
if [ x$feature_platform_search_hint = xy ]; then
search --no-floppy --fs-uuid --set=root a92522f9-74dc-456a-ae0c-05ba22bca976
else
search --no-floppy --fs-uuid --set=root a92522f9-74dc-456a-ae0c-05ba22bca976
fi
linuxefi /ostree/rhcos-bf3b382/vmlinuz console=tty0 console=ttyS0,115200n8 rootflags=defaults,prjquota rw $ignition_firstboot root=UUID=cbac8cdc
initrdefi /ostree/rhcos-bf3b382/initramfs.img
}
### END /etc/grub.d/15_ostree ###
### BEGIN /etc/grub.d/20_linux_xen ###
### END /etc/grub.d/20_linux_xen ###";
strip_grub_config_file(
BufReader::new(std::io::Cursor::new(content)),
&rootd,
stripped_config.to_str().unwrap(),
)?;
let stripped_content = fs::read_to_string(stripped_config)?;
let expected = r"
### BEGIN /etc/grub.d/10_linux ###
### END /etc/grub.d/10_linux ###
### BEGIN /etc/grub.d/20_linux_xen ###
### END /etc/grub.d/20_linux_xen ###
";
assert_eq!(expected, stripped_content);
Ok(())
}
}

222
src/cli/bootupctl.rs Executable file
View file

@ -0,0 +1,222 @@
use crate::bootupd;
use anyhow::Result;
use clap::Parser;
use log::LevelFilter;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
static SYSTEMD_ARGS_BOOTUPD: &[&str] = &["--unit", "bootupd", "--pipe"];
/// Keep these properties (isolation/runtime state) in sync with
/// the systemd units in contrib/packaging/*.service
static SYSTEMD_PROPERTIES: &[&str] = &[
"PrivateNetwork=yes",
"ProtectHome=yes",
// While only our main process during update catches SIGTERM, we don't
// want systemd to send it to other processes.
"KillMode=mixed",
"MountFlags=slave",
];
/// `bootupctl` sub-commands.
#[derive(Debug, Parser)]
#[clap(name = "bootupctl", about = "Bootupd client application", version)]
pub struct CtlCommand {
/// Verbosity level (higher is more verbose).
#[clap(short = 'v', action = clap::ArgAction::Count, global = true)]
verbosity: u8,
/// CLI sub-command.
#[clap(subcommand)]
pub cmd: CtlVerb,
}
impl CtlCommand {
/// Return the log-level set via command-line flags.
pub(crate) fn loglevel(&self) -> LevelFilter {
match self.verbosity {
0 => LevelFilter::Warn,
1 => LevelFilter::Info,
2 => LevelFilter::Debug,
_ => LevelFilter::Trace,
}
}
}
/// CLI sub-commands.
#[derive(Debug, Parser)]
pub enum CtlVerb {
// FIXME(lucab): drop this after refreshing
// https://github.com/coreos/fedora-coreos-config/pull/595
#[clap(name = "backend", hide = true, subcommand)]
Backend(CtlBackend),
#[clap(name = "status", about = "Show components status")]
Status(StatusOpts),
#[clap(name = "update", about = "Update all components")]
Update,
#[clap(name = "adopt-and-update", about = "Update all adoptable components")]
AdoptAndUpdate(AdoptAndUpdateOpts),
#[clap(name = "validate", about = "Validate system state")]
Validate,
#[clap(
name = "migrate-static-grub-config",
hide = true,
about = "Migrate a system to a static GRUB config"
)]
MigrateStaticGrubConfig,
}
#[derive(Debug, Parser)]
pub enum CtlBackend {
#[clap(name = "generate-update-metadata", hide = true)]
Generate(super::bootupd::GenerateOpts),
#[clap(name = "install", hide = true)]
Install(super::bootupd::InstallOpts),
}
#[derive(Debug, Parser)]
pub struct StatusOpts {
/// If there are updates available, output `Updates available: ` to standard output;
/// otherwise output nothing. Avoid parsing this, just check whether or not
/// the output is empty.
#[clap(long, action)]
print_if_available: bool,
/// Output JSON
#[clap(long, action)]
json: bool,
}
#[derive(Debug, Parser)]
pub struct AdoptAndUpdateOpts {
/// Install the static GRUB config files
#[clap(long, action)]
with_static_config: bool,
}
impl CtlCommand {
/// Run CLI application.
pub fn run(self) -> Result<()> {
match self.cmd {
CtlVerb::Status(opts) => Self::run_status(opts),
CtlVerb::Update => Self::run_update(),
CtlVerb::AdoptAndUpdate(opts) => Self::run_adopt_and_update(opts),
CtlVerb::Validate => Self::run_validate(),
CtlVerb::Backend(CtlBackend::Generate(opts)) => {
super::bootupd::DCommand::run_generate_meta(opts)
}
CtlVerb::Backend(CtlBackend::Install(opts)) => {
super::bootupd::DCommand::run_install(opts)
}
CtlVerb::MigrateStaticGrubConfig => Self::run_migrate_static_grub_config(),
}
}
/// Runner for `status` verb.
fn run_status(opts: StatusOpts) -> Result<()> {
if crate::util::running_in_container() {
return run_status_in_container(opts.json);
}
ensure_running_in_systemd()?;
let r = bootupd::status()?;
if opts.json {
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
serde_json::to_writer_pretty(&mut stdout, &r)?;
} else if opts.print_if_available {
bootupd::print_status_avail(&r)?;
} else {
bootupd::print_status(&r)?;
}
Ok(())
}
/// Runner for `update` verb.
fn run_update() -> Result<()> {
ensure_running_in_systemd()?;
bootupd::client_run_update()
}
/// Runner for `update` verb.
fn run_adopt_and_update(opts: AdoptAndUpdateOpts) -> Result<()> {
ensure_running_in_systemd()?;
bootupd::client_run_adopt_and_update(opts.with_static_config)
}
/// Runner for `validate` verb.
fn run_validate() -> Result<()> {
ensure_running_in_systemd()?;
bootupd::client_run_validate()
}
/// Runner for `migrate-static-grub-config` verb.
fn run_migrate_static_grub_config() -> Result<()> {
ensure_running_in_systemd()?;
bootupd::client_run_migrate_static_grub_config()
}
}
/// Checks if the current process is (apparently at least)
/// running under systemd.
fn running_in_systemd() -> bool {
std::env::var_os("INVOCATION_ID").is_some()
}
/// Require root permission
fn require_root_permission() -> Result<()> {
if !rustix::process::getuid().is_root() {
anyhow::bail!("This command requires root privileges")
}
Ok(())
}
/// Detect if we're running in systemd; if we're not, we re-exec ourselves via
/// systemd-run. Then we can just directly run code in what is now the daemon.
fn ensure_running_in_systemd() -> Result<()> {
require_root_permission()?;
let running_in_systemd = running_in_systemd();
if !running_in_systemd {
// Clear any failure status that may have happened previously
let _r = Command::new("systemctl")
.arg("reset-failed")
.arg("bootupd.service")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?
.wait()?;
let r = Command::new("systemd-run")
.args(SYSTEMD_ARGS_BOOTUPD)
.args(
SYSTEMD_PROPERTIES
.into_iter()
.flat_map(|&v| ["--property", v]),
)
.args(std::env::args())
.exec();
// If we got here, it's always an error
return Err(r.into());
}
Ok(())
}
/// If running in container, just print the available payloads
fn run_status_in_container(json_format: bool) -> Result<()> {
let all_components = crate::bootupd::get_components();
if all_components.is_empty() {
return Ok(());
}
let avail: Vec<_> = all_components.keys().cloned().collect();
if json_format {
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
let output: serde_json::Value = serde_json::json!({
"components": avail
});
serde_json::to_writer(&mut stdout, &output)?;
} else {
println!("Available components: {}", avail.join(" "));
}
Ok(())
}

125
src/cli/bootupd.rs Executable file
View file

@ -0,0 +1,125 @@
use crate::bootupd::{self, ConfigMode};
use anyhow::{Context, Result};
use clap::Parser;
use log::LevelFilter;
/// `bootupd` sub-commands.
#[derive(Debug, Parser)]
#[clap(name = "bootupd", about = "Bootupd backend commands", version)]
pub struct DCommand {
/// Verbosity level (higher is more verbose).
#[clap(short = 'v', action = clap::ArgAction::Count, global = true)]
verbosity: u8,
/// CLI sub-command.
#[clap(subcommand)]
pub cmd: DVerb,
}
impl DCommand {
/// Return the log-level set via command-line flags.
pub(crate) fn loglevel(&self) -> LevelFilter {
match self.verbosity {
0 => LevelFilter::Warn,
1 => LevelFilter::Info,
2 => LevelFilter::Debug,
_ => LevelFilter::Trace,
}
}
}
/// CLI sub-commands.
#[derive(Debug, Parser)]
pub enum DVerb {
#[clap(name = "generate-update-metadata", about = "Generate metadata")]
GenerateUpdateMetadata(GenerateOpts),
#[clap(name = "install", about = "Install components")]
Install(InstallOpts),
}
#[derive(Debug, Parser)]
pub struct InstallOpts {
/// Source root
#[clap(long, value_parser, default_value_t = String::from("/"))]
src_root: String,
/// Target root
#[clap(value_parser)]
dest_root: String,
/// Target device, used by bios bootloader installation
#[clap(long)]
device: Option<String>,
/// Enable installation of the built-in static config files
#[clap(long)]
with_static_configs: bool,
/// Implies `--with-static-configs`. When present, this also writes a
/// file with the UUID of the target filesystems.
#[clap(long)]
write_uuid: bool,
/// On EFI systems, invoke `efibootmgr` to update the firmware.
#[clap(long)]
update_firmware: bool,
#[clap(long = "component", conflicts_with = "auto")]
/// Only install these components
components: Option<Vec<String>>,
/// Automatically choose components based on booted host state.
///
/// For example on x86_64, if the host system is booted via EFI,
/// then only enable installation to the ESP.
#[clap(long)]
auto: bool,
}
#[derive(Debug, Parser)]
pub struct GenerateOpts {
/// Physical root mountpoint
#[clap(value_parser)]
sysroot: Option<String>,
}
impl DCommand {
/// Run CLI application.
pub fn run(self) -> Result<()> {
match self.cmd {
DVerb::Install(opts) => Self::run_install(opts),
DVerb::GenerateUpdateMetadata(opts) => Self::run_generate_meta(opts),
}
}
/// Runner for `generate-install-metadata` verb.
pub(crate) fn run_generate_meta(opts: GenerateOpts) -> Result<()> {
let sysroot = opts.sysroot.as_deref().unwrap_or("/");
if sysroot != "/" {
anyhow::bail!("Using a non-default sysroot is not supported: {}", sysroot);
}
bootupd::generate_update_metadata(sysroot).context("generating metadata failed")?;
Ok(())
}
/// Runner for `install` verb.
pub(crate) fn run_install(opts: InstallOpts) -> Result<()> {
let configmode = if opts.write_uuid {
ConfigMode::WithUUID
} else if opts.with_static_configs {
ConfigMode::Static
} else {
ConfigMode::None
};
bootupd::install(
&opts.src_root,
&opts.dest_root,
opts.device.as_deref(),
configmode,
opts.update_firmware,
opts.components.as_deref(),
opts.auto,
)
.context("boot data installation failed")?;
Ok(())
}
}

107
src/cli/mod.rs Executable file
View file

@ -0,0 +1,107 @@
//! Command-line interface (CLI) logic.
use anyhow::Result;
use clap::Parser;
use log::LevelFilter;
mod bootupctl;
mod bootupd;
/// Top-level multicall CLI.
#[derive(Debug, Parser)]
pub enum MultiCall {
Ctl(bootupctl::CtlCommand),
D(bootupd::DCommand),
}
impl MultiCall {
pub fn from_args(args: Vec<String>) -> Self {
use std::os::unix::ffi::OsStrExt;
// This is a multicall binary, dispatched based on the introspected
// filename found in argv[0].
let exe_name = {
let arg0 = args.get(0).cloned().unwrap_or_default();
let exe_path = std::path::PathBuf::from(arg0);
exe_path.file_name().unwrap_or_default().to_os_string()
};
#[allow(clippy::wildcard_in_or_patterns)]
match exe_name.as_bytes() {
b"bootupctl" => MultiCall::Ctl(bootupctl::CtlCommand::parse_from(args)),
b"bootupd" | _ => MultiCall::D(bootupd::DCommand::parse_from(args)),
}
}
pub fn run(self) -> Result<()> {
match self {
MultiCall::Ctl(ctl_cmd) => ctl_cmd.run(),
MultiCall::D(d_cmd) => d_cmd.run(),
}
}
/// Return the log-level set via command-line flags.
pub fn loglevel(&self) -> LevelFilter {
match self {
MultiCall::Ctl(cmd) => cmd.loglevel(),
MultiCall::D(cmd) => cmd.loglevel(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clap_apps() {
use clap::CommandFactory;
bootupctl::CtlCommand::command().debug_assert();
bootupd::DCommand::command().debug_assert();
}
#[test]
fn test_multicall_dispatch() {
{
let d_argv = vec![
"/usr/bin/bootupd".to_string(),
"generate-update-metadata".to_string(),
];
let cli = MultiCall::from_args(d_argv);
match cli {
MultiCall::Ctl(cmd) => panic!("{:?}", cmd),
MultiCall::D(_) => {}
};
}
{
let ctl_argv = vec!["/usr/bin/bootupctl".to_string(), "validate".to_string()];
let cli = MultiCall::from_args(ctl_argv);
match cli {
MultiCall::Ctl(_) => {}
MultiCall::D(cmd) => panic!("{:?}", cmd),
};
}
{
let ctl_argv = vec!["/bin-mount/bootupctl".to_string(), "validate".to_string()];
let cli = MultiCall::from_args(ctl_argv);
match cli {
MultiCall::Ctl(_) => {}
MultiCall::D(cmd) => panic!("{:?}", cmd),
};
}
}
#[test]
fn test_verbosity() {
let default = MultiCall::from_args(vec![
"bootupd".to_string(),
"generate-update-metadata".to_string(),
]);
assert_eq!(default.loglevel(), LevelFilter::Warn);
let info = MultiCall::from_args(vec![
"bootupd".to_string(),
"generate-update-metadata".to_string(),
"-v".to_string(),
]);
assert_eq!(info.loglevel(), LevelFilter::Info);
}
}

232
src/component.rs Executable file
View file

@ -0,0 +1,232 @@
/*
* Copyright (C) 2020 Red Hat, Inc.
*
* SPDX-License-Identifier: Apache-2.0
*/
use anyhow::{Context, Result};
use fn_error_context::context;
use openat_ext::OpenatDirExt;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::{bootupd::RootContext, model::*};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ValidationResult {
Valid,
Skip,
Errors(Vec<String>),
}
/// A component along with a possible update
pub(crate) trait Component {
/// Returns the name of the component; this will be used for serialization
/// and should remain stable.
fn name(&self) -> &'static str;
/// In an operating system whose initially booted disk image is not
/// using bootupd, detect whether it looks like the component exists
/// and "synthesize" content metadata from it.
fn query_adopt(&self, devices: &Option<Vec<String>>) -> Result<Option<Adoptable>>;
// Backup the current grub config, and install static grub config from tree
fn migrate_static_grub_config(&self, sysroot_path: &str, destdir: &openat::Dir) -> Result<()>;
/// Given an adoptable system and an update, perform the update.
fn adopt_update(
&self,
rootcxt: &RootContext,
update: &ContentMetadata,
with_static_config: bool,
) -> Result<Option<InstalledContent>>;
/// Implementation of `bootupd install` for a given component. This should
/// gather data (or run binaries) from the source root, and install them
/// into the target root. It is expected that sub-partitions (e.g. the ESP)
/// are mounted at the expected place. For operations that require a block device instead
/// of a filesystem root, the component should query the mount point to
/// determine the block device.
/// This will be run during a disk image build process.
fn install(
&self,
src_root: &openat::Dir,
dest_root: &str,
device: &str,
update_firmware: bool,
) -> Result<InstalledContent>;
/// Implementation of `bootupd generate-update-metadata` for a given component.
/// This expects to be run during an "image update build" process. For CoreOS
/// this is an `rpm-ostree compose tree` for example. For a dual-partition
/// style updater, this would be run as part of a postprocessing step
/// while the filesystem for the partition is mounted.
fn generate_update_metadata(&self, sysroot: &str) -> Result<ContentMetadata>;
/// Used on the client to query for an update cached in the current booted OS.
fn query_update(&self, sysroot: &openat::Dir) -> Result<Option<ContentMetadata>>;
/// Used on the client to run an update.
fn run_update(
&self,
rootcxt: &RootContext,
current: &InstalledContent,
) -> Result<InstalledContent>;
/// Used on the client to validate an installed version.
fn validate(&self, current: &InstalledContent) -> Result<ValidationResult>;
/// Locating efi vendor dir
fn get_efi_vendor(&self, sysroot: &openat::Dir) -> Result<Option<String>>;
}
/// Given a component name, create an implementation.
pub(crate) fn new_from_name(name: &str) -> Result<Box<dyn Component>> {
let r: Box<dyn Component> = match name {
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
#[allow(clippy::box_default)]
"EFI" => Box::new(crate::efi::Efi::default()),
#[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))]
#[allow(clippy::box_default)]
"BIOS" => Box::new(crate::bios::Bios::default()),
_ => anyhow::bail!("No component {}", name),
};
Ok(r)
}
/// Returns the path to the payload directory for an available update for
/// a component.
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) fn component_updatedirname(component: &dyn Component) -> PathBuf {
Path::new(BOOTUPD_UPDATES_DIR).join(component.name())
}
/// Returns the path to the payload directory for an available update for
/// a component.
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) fn component_updatedir(sysroot: &str, component: &dyn Component) -> PathBuf {
Path::new(sysroot).join(component_updatedirname(component))
}
/// Returns the name of the JSON file containing a component's available update metadata installed
/// into the booted operating system root.
fn component_update_data_name(component: &dyn Component) -> PathBuf {
Path::new(&format!("{}.json", component.name())).into()
}
/// Helper method for writing an update file
pub(crate) fn write_update_metadata(
sysroot: &str,
component: &dyn Component,
meta: &ContentMetadata,
) -> Result<()> {
let sysroot = openat::Dir::open(sysroot)?;
let dir = sysroot.sub_dir(BOOTUPD_UPDATES_DIR)?;
let name = component_update_data_name(component);
dir.write_file_with(name, 0o644, |w| -> Result<_> {
Ok(serde_json::to_writer(w, &meta)?)
})?;
Ok(())
}
/// Given a component, return metadata on the available update (if any)
#[context("Loading update for component {}", component.name())]
pub(crate) fn get_component_update(
sysroot: &openat::Dir,
component: &dyn Component,
) -> Result<Option<ContentMetadata>> {
let name = component_update_data_name(component);
let path = Path::new(BOOTUPD_UPDATES_DIR).join(name);
if let Some(f) = sysroot.open_file_optional(&path)? {
let mut f = std::io::BufReader::new(f);
let u = serde_json::from_reader(&mut f)
.with_context(|| format!("failed to parse {:?}", &path))?;
Ok(Some(u))
} else {
Ok(None)
}
}
#[context("Querying adoptable state")]
pub(crate) fn query_adopt_state() -> Result<Option<Adoptable>> {
// This would be extended with support for other operating systems later
if let Some(coreos_aleph) = crate::coreos::get_aleph_version(Path::new("/"))? {
let meta = ContentMetadata {
timestamp: coreos_aleph.ts,
version: coreos_aleph.version_info.version,
};
log::trace!("Adoptable: {:?}", &meta);
return Ok(Some(Adoptable {
version: meta,
confident: true,
}));
} else {
log::trace!("No CoreOS aleph detected");
}
let ostree_deploy_dir = Path::new("/ostree/deploy");
if ostree_deploy_dir.exists() {
let btime = ostree_deploy_dir.metadata()?.created()?;
let timestamp = chrono::DateTime::from(btime);
let meta = ContentMetadata {
timestamp,
version: "unknown".to_string(),
};
return Ok(Some(Adoptable {
version: meta,
confident: true,
}));
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_efi_vendor() -> Result<()> {
let td = tempfile::tempdir()?;
let tdp = td.path();
let tdp_updates = tdp.join("usr/lib/bootupd/updates");
let td = openat::Dir::open(tdp)?;
std::fs::create_dir_all(tdp_updates.join("EFI/BOOT"))?;
std::fs::create_dir_all(tdp_updates.join("EFI/fedora"))?;
std::fs::create_dir_all(tdp_updates.join("EFI/centos"))?;
std::fs::write(
tdp_updates.join("EFI/fedora").join(crate::efi::SHIM),
"shim data",
)?;
std::fs::write(
tdp_updates.join("EFI/centos").join(crate::efi::SHIM),
"shim data",
)?;
let all_components = crate::bootupd::get_components();
let target_components: Vec<_> = all_components.values().collect();
for &component in target_components.iter() {
if component.name() == "BIOS" {
assert_eq!(component.get_efi_vendor(&td)?, None);
}
if component.name() == "EFI" {
let x = component.get_efi_vendor(&td);
assert_eq!(x.is_err(), true);
std::fs::remove_dir_all(tdp_updates.join("EFI/centos"))?;
assert_eq!(component.get_efi_vendor(&td)?, Some("fedora".to_string()));
}
}
Ok(())
}
}

187
src/coreos.rs Executable file
View file

@ -0,0 +1,187 @@
//! Bits specific to CoreOS and Debian OSTree systems.
/*
* Copyright (C) 2020 Red Hat, Inc.
* Modified for Debian compatibility
*
* SPDX-License-Identifier: Apache-2.0
*/
use anyhow::{Context, Result};
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::path::Path;
#[derive(Serialize, Deserialize, Clone, Debug, Hash, Ord, PartialOrd, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
/// Version information for CoreOS and Debian OSTree systems
pub(crate) struct SystemVersion {
#[serde(alias = "build")]
pub(crate) version: String,
#[serde(alias = "ref")]
pub(crate) ref_name: Option<String>,
#[serde(alias = "ostree-commit")]
pub(crate) ostree_commit: Option<String>,
}
#[derive(Debug)]
pub(crate) struct SystemVersionWithTimestamp {
pub(crate) version_info: SystemVersion,
#[allow(dead_code)]
pub(crate) ts: chrono::DateTime<Utc>,
}
/// Paths to version files for different systems
const COREOS_ALEPH_PATH: &str = ".coreos-aleph-version.json";
const DEBIAN_VERSION_PATH: &str = ".debian-version.json";
/// Get version information for CoreOS or Debian systems
pub(crate) fn get_system_version(root: &Path) -> Result<Option<SystemVersionWithTimestamp>> {
// Try CoreOS aleph version first
if let Some(version) = get_coreos_version(root)? {
return Ok(Some(version));
}
// Try Debian version
if let Some(version) = get_debian_version(root)? {
return Ok(Some(version));
}
Ok(None)
}
/// Get CoreOS aleph version (original functionality)
pub(crate) fn get_coreos_version(root: &Path) -> Result<Option<SystemVersionWithTimestamp>> {
let path = &root.join(COREOS_ALEPH_PATH);
if !path.exists() {
return Ok(None);
}
let statusf = File::open(path).with_context(|| format!("Opening {path:?}"))?;
let meta = statusf.metadata()?;
let bufr = std::io::BufReader::new(statusf);
let aleph: SystemVersion = serde_json::from_reader(bufr)?;
// Use created time if available, otherwise fall back to modified time
let ts = meta.created().unwrap_or_else(|_| meta.modified().unwrap()).into();
Ok(Some(SystemVersionWithTimestamp {
version_info: aleph,
ts,
}))
}
/// Get Debian version information
pub(crate) fn get_debian_version(root: &Path) -> Result<Option<SystemVersionWithTimestamp>> {
let path = &root.join(DEBIAN_VERSION_PATH);
if !path.exists() {
return Ok(None);
}
let statusf = File::open(path).with_context(|| format!("Opening {path:?}"))?;
let meta = statusf.metadata()?;
let bufr = std::io::BufReader::new(statusf);
let deb_version: SystemVersion = serde_json::from_reader(bufr)?;
// Use created time if available, otherwise fall back to modified time
let ts = meta.created().unwrap_or_else(|_| meta.modified().unwrap()).into();
Ok(Some(SystemVersionWithTimestamp {
version_info: deb_version,
ts,
}))
}
/// Legacy function for backward compatibility
pub(crate) fn get_aleph_version(root: &Path) -> Result<Option<SystemVersionWithTimestamp>> {
get_coreos_version(root)
}
#[cfg(test)]
mod test {
use super::*;
use anyhow::Result;
const V1_ALEPH_DATA: &str = r##"
{
"version": "32.20201002.dev.2",
"ref": "fedora/x86_64/coreos/testing-devel",
"ostree-commit": "b2ea6159d6274e1bbbb49aa0ef093eda5d53a75c8a793dbe184f760ed64dc862"
}"##;
const DEBIAN_VERSION_DATA: &str = r##"
{
"version": "12.1",
"ref": "debian/bookworm/amd64",
"ostree-commit": "debian-ostree-commit-hash"
}"##;
#[test]
fn test_parse_from_root_empty() -> Result<()> {
let tempdir = tempfile::tempdir()?;
let result = get_coreos_version(tempdir.path())?;
assert!(result.is_none());
Ok(())
}
#[test]
fn test_parse_from_root() -> Result<()> {
let tempdir = tempfile::tempdir()?;
let file_path = tempdir.path().join(COREOS_ALEPH_PATH);
println!("Creating file at: {:?}", file_path);
std::fs::write(&file_path, V1_ALEPH_DATA)?;
println!("File created successfully");
let result = get_coreos_version(tempdir.path())?;
println!("Result: {:?}", result);
let Some(result) = result else {
anyhow::bail!("Expected Some result");
};
assert_eq!(result.version_info.version, "32.20201002.dev.2");
Ok(())
}
#[test]
fn test_parse_from_root_linked() -> Result<()> {
let tempdir = tempfile::tempdir()?;
let target_name = "target.json";
std::fs::write(tempdir.path().join(target_name), V1_ALEPH_DATA)?;
std::os::unix::fs::symlink(target_name, tempdir.path().join(COREOS_ALEPH_PATH))?;
let result = get_coreos_version(tempdir.path())?;
let Some(result) = result else {
anyhow::bail!("Expected Some result");
};
assert_eq!(result.version_info.version, "32.20201002.dev.2");
Ok(())
}
#[test]
fn test_parse_old_aleph() -> Result<()> {
let tempdir = tempfile::tempdir()?;
let old_aleph_data = r##"
{
"build": "32.20201002.dev.2",
"ref": "fedora/x86_64/coreos/testing-devel",
"ostree-commit": "b2ea6159d6274e1bbbb49aa0ef093eda5d53a75c8a793dbe184f760ed64dc862"
}"##;
std::fs::write(tempdir.path().join(COREOS_ALEPH_PATH), old_aleph_data)?;
let result = get_coreos_version(tempdir.path())?;
let Some(result) = result else {
anyhow::bail!("Expected Some result");
};
assert_eq!(result.version_info.version, "32.20201002.dev.2");
Ok(())
}
#[test]
fn test_parse_debian_version() -> Result<()> {
let tempdir = tempfile::tempdir()?;
std::fs::write(tempdir.path().join(DEBIAN_VERSION_PATH), DEBIAN_VERSION_DATA)?;
let result = get_debian_version(tempdir.path())?;
let Some(result) = result else {
anyhow::bail!("Expected Some result");
};
assert_eq!(result.version_info.version, "12.1");
assert_eq!(result.version_info.ref_name, Some("debian/bookworm/amd64".to_string()));
Ok(())
}
}

896
src/efi.rs Executable file
View file

@ -0,0 +1,896 @@
/*
* Copyright (C) 2020 Red Hat, Inc.
*
* SPDX-License-Identifier: Apache-2.0
*/
use std::cell::RefCell;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use bootc_internal_utils::CommandRunExt;
use camino::{Utf8Path, Utf8PathBuf};
use cap_std::fs::Dir;
use cap_std_ext::cap_std;
use chrono::prelude::*;
use fn_error_context::context;
use openat_ext::OpenatDirExt;
use os_release::OsRelease;
use rustix::fd::BorrowedFd;
use walkdir::WalkDir;
use widestring::U16CString;
use crate::bootupd::RootContext;
use crate::freezethaw::fsfreeze_thaw_cycle;
use crate::model::*;
use crate::ostreeutil;
use crate::util;
use crate::{blockdev, filetree, grubconfigs};
use crate::{component::*, packagesystem};
/// Well-known paths to the ESP that may have been mounted external to us.
pub(crate) const ESP_MOUNTS: &[&str] = &["boot/efi", "efi", "boot"];
/// New efi dir under usr/lib
const EFILIB: &str = "usr/lib/efi";
/// The binary to change EFI boot ordering
const EFIBOOTMGR: &str = "efibootmgr";
#[cfg(target_arch = "aarch64")]
pub(crate) const SHIM: &str = "shimaa64.efi";
#[cfg(target_arch = "x86_64")]
pub(crate) const SHIM: &str = "shimx64.efi";
#[cfg(target_arch = "riscv64")]
pub(crate) const SHIM: &str = "shimriscv64.efi";
/// Systemd boot loader info EFI variable names
const LOADER_INFO_VAR_STR: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f";
const STUB_INFO_VAR_STR: &str = "StubInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f";
/// Return `true` if the system is booted via EFI
pub(crate) fn is_efi_booted() -> Result<bool> {
Path::new("/sys/firmware/efi")
.try_exists()
.map_err(Into::into)
}
#[derive(Default)]
pub(crate) struct Efi {
mountpoint: RefCell<Option<PathBuf>>,
}
impl Efi {
// Get mounted point for esp
pub(crate) fn get_mounted_esp(&self, root: &Path) -> Result<Option<PathBuf>> {
// First check all potential mount points without holding the borrow
let mut found_mount = None;
for &mnt in ESP_MOUNTS.iter() {
let path = root.join(mnt);
if !path.exists() {
continue;
}
let st = rustix::fs::statfs(&path)?;
if st.f_type == libc::MSDOS_SUPER_MAGIC {
util::ensure_writable_mount(&path)?;
found_mount = Some(path);
break;
}
}
// Only borrow mutably if we found a mount point
if let Some(mnt) = found_mount {
log::debug!("Reusing existing mount point {mnt:?}");
*self.mountpoint.borrow_mut() = Some(mnt.clone());
Ok(Some(mnt))
} else {
Ok(None)
}
}
// Mount the passed esp_device, return mount point
pub(crate) fn mount_esp_device(&self, root: &Path, esp_device: &Path) -> Result<PathBuf> {
let mut mountpoint = None;
for &mnt in ESP_MOUNTS.iter() {
let mnt = root.join(mnt);
if !mnt.exists() {
continue;
}
std::process::Command::new("mount")
.arg(&esp_device)
.arg(&mnt)
.run()
.with_context(|| format!("Failed to mount {:?}", esp_device))?;
log::debug!("Mounted at {mnt:?}");
mountpoint = Some(mnt);
break;
}
let mnt = mountpoint.ok_or_else(|| anyhow::anyhow!("No mount point found"))?;
*self.mountpoint.borrow_mut() = Some(mnt.clone());
Ok(mnt)
}
// Firstly check if esp is already mounted, then mount the passed esp device
pub(crate) fn ensure_mounted_esp(&self, root: &Path, esp_device: &Path) -> Result<PathBuf> {
if let Some(mountpoint) = self.mountpoint.borrow().as_deref() {
return Ok(mountpoint.to_owned());
}
let destdir = if let Some(destdir) = self.get_mounted_esp(Path::new(root))? {
destdir
} else {
self.mount_esp_device(root, esp_device)?
};
Ok(destdir)
}
fn unmount(&self) -> Result<()> {
if let Some(mount) = self.mountpoint.borrow_mut().take() {
Command::new("umount")
.arg(&mount)
.run()
.with_context(|| format!("Failed to unmount {mount:?}"))?;
log::trace!("Unmounted");
}
Ok(())
}
#[context("Updating EFI firmware variables")]
fn update_firmware(&self, device: &str, espdir: &openat::Dir, vendordir: &str) -> Result<()> {
if !is_efi_booted()? {
log::debug!("Not booted via EFI, skipping firmware update");
return Ok(());
}
let sysroot = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
let product_name = get_product_name(&sysroot)?;
log::debug!("Get product name: '{product_name}'");
assert!(product_name.len() > 0);
// clear all the boot entries that match the target name
clear_efi_target(&product_name)?;
create_efi_boot_entry(device, espdir, vendordir, &product_name)
}
}
#[context("Get product name")]
fn get_product_name(sysroot: &Dir) -> Result<String> {
let release_path = "etc/system-release";
if sysroot.exists(release_path) {
let content = sysroot.read_to_string(release_path)?;
let re = regex::Regex::new(r" *release.*").unwrap();
let name = re.replace_all(&content, "").trim().to_string();
return Ok(name);
}
// Read /etc/os-release
let release: OsRelease = OsRelease::new()?;
Ok(release.name)
}
/// Convert a nul-terminated UTF-16 byte array to a String.
fn string_from_utf16_bytes(slice: &[u8]) -> String {
// For some reason, systemd appends 3 nul bytes after the string.
// Drop the last byte if there's an odd number.
let size = slice.len() / 2;
let v: Vec<u16> = (0..size)
.map(|i| u16::from_ne_bytes([slice[2 * i], slice[2 * i + 1]]))
.collect();
U16CString::from_vec(v).unwrap().to_string_lossy()
}
/// Read a nul-terminated UTF-16 string from an EFI variable.
fn read_efi_var_utf16_string(name: &str) -> Option<String> {
let efivars = Path::new("/sys/firmware/efi/efivars");
if !efivars.exists() {
log::trace!("No efivars mount at {:?}", efivars);
return None;
}
let path = efivars.join(name);
if !path.exists() {
log::trace!("No EFI variable {name}");
return None;
}
match std::fs::read(&path) {
Ok(buf) => {
// Skip the first 4 bytes, those are the EFI variable attributes.
if buf.len() < 4 {
log::warn!("Read less than 4 bytes from {:?}", path);
return None;
}
Some(string_from_utf16_bytes(&buf[4..]))
}
Err(reason) => {
log::warn!("Failed reading {:?}: {reason}", path);
None
}
}
}
/// Read the LoaderInfo EFI variable if it exists.
fn get_loader_info() -> Option<String> {
read_efi_var_utf16_string(LOADER_INFO_VAR_STR)
}
/// Read the StubInfo EFI variable if it exists.
fn get_stub_info() -> Option<String> {
read_efi_var_utf16_string(STUB_INFO_VAR_STR)
}
/// Whether to skip adoption if a systemd bootloader is found.
fn skip_systemd_bootloaders() -> bool {
if let Some(loader_info) = get_loader_info() {
if loader_info.starts_with("systemd") {
log::trace!("Skipping adoption for {:?}", loader_info);
return true;
}
}
if let Some(stub_info) = get_stub_info() {
log::trace!("Skipping adoption for {:?}", stub_info);
return true;
}
false
}
impl Component for Efi {
fn name(&self) -> &'static str {
"EFI"
}
fn query_adopt(&self, devices: &Option<Vec<String>>) -> Result<Option<Adoptable>> {
if devices.is_none() {
log::trace!("No ESP detected");
return Ok(None);
};
// Don't adopt if the system is booted with systemd-boot or
// systemd-stub since those will be managed with bootctl.
if skip_systemd_bootloaders() {
return Ok(None);
}
crate::component::query_adopt_state()
}
// Backup "/boot/efi/EFI/{vendor}/grub.cfg" to "/boot/efi/EFI/{vendor}/grub.cfg.bak"
// Replace "/boot/efi/EFI/{vendor}/grub.cfg" with new static "grub.cfg"
fn migrate_static_grub_config(&self, sysroot_path: &str, destdir: &openat::Dir) -> Result<()> {
let sysroot =
openat::Dir::open(sysroot_path).with_context(|| format!("Opening {sysroot_path}"))?;
let Some(vendor) = self.get_efi_vendor(&sysroot)? else {
anyhow::bail!("Failed to find efi vendor");
};
// destdir is /boot/efi/EFI
let efidir = destdir
.sub_dir(&vendor)
.with_context(|| format!("Opening EFI/{}", vendor))?;
if !efidir.exists(grubconfigs::GRUBCONFIG_BACKUP)? {
println!("Creating a backup of the current GRUB config on EFI");
efidir
.copy_file(grubconfigs::GRUBCONFIG, grubconfigs::GRUBCONFIG_BACKUP)
.context("Failed to backup GRUB config")?;
}
grubconfigs::install(&sysroot, Some(&vendor), true)?;
// Synchronize the filesystem containing /boot/efi/EFI/{vendor} to disk.
fsfreeze_thaw_cycle(efidir.open_file(".")?)?;
Ok(())
}
/// Given an adoptable system and an update, perform the update.
fn adopt_update(
&self,
rootcxt: &RootContext,
updatemeta: &ContentMetadata,
with_static_config: bool,
) -> Result<Option<InstalledContent>> {
let esp_devices = blockdev::find_colocated_esps(&rootcxt.devices)?;
let Some(meta) = self.query_adopt(&esp_devices)? else {
return Ok(None);
};
let updated = rootcxt
.sysroot
.sub_dir(&component_updatedirname(self))
.context("opening update dir")?;
let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?;
let esp_devices = esp_devices.unwrap_or_default();
for esp in esp_devices {
let destpath = &self.ensure_mounted_esp(rootcxt.path.as_ref(), Path::new(&esp))?;
let efidir = openat::Dir::open(&destpath.join("EFI")).context("opening EFI dir")?;
validate_esp_fstype(&efidir)?;
// For adoption, we should only touch files that we know about.
let diff = updatef.relative_diff_to(&efidir)?;
log::trace!("applying adoption diff: {}", &diff);
filetree::apply_diff(&updated, &efidir, &diff, None)
.context("applying filesystem changes")?;
// Backup current config and install static config
if with_static_config {
// Install the static config if the OSTree bootloader is not set.
if let Some(bootloader) = crate::ostreeutil::get_ostree_bootloader()? {
println!(
"ostree repo 'sysroot.bootloader' config option is currently set to: '{bootloader}'",
);
} else {
println!("ostree repo 'sysroot.bootloader' config option is not set yet");
self.migrate_static_grub_config(rootcxt.path.as_str(), &efidir)?;
};
}
// Do the sync before unmount
fsfreeze_thaw_cycle(efidir.open_file(".")?)?;
drop(efidir);
self.unmount().context("unmount after adopt")?;
}
Ok(Some(InstalledContent {
meta: updatemeta.clone(),
filetree: Some(updatef),
adopted_from: Some(meta.version),
}))
}
fn install(
&self,
src_root: &openat::Dir,
dest_root: &str,
device: &str,
update_firmware: bool,
) -> Result<InstalledContent> {
let Some(meta) = get_component_update(src_root, self)? else {
anyhow::bail!("No update metadata for component {} found", self.name());
};
log::debug!("Found metadata {}", meta.version);
let srcdir_name = component_updatedirname(self);
let ft = crate::filetree::FileTree::new_from_dir(&src_root.sub_dir(&srcdir_name)?)?;
// Let's attempt to use an already mounted ESP at the target
// dest_root if one is already mounted there in a known ESP location.
let destpath = if let Some(destdir) = self.get_mounted_esp(Path::new(dest_root))? {
destdir
} else {
// Using `blockdev` to find the partition instead of partlabel because
// we know the target install toplevel device already.
if device.is_empty() {
anyhow::bail!("Device value not provided");
}
let esp_device = blockdev::get_esp_partition(device)?
.ok_or_else(|| anyhow::anyhow!("Failed to find ESP device"))?;
self.mount_esp_device(Path::new(dest_root), Path::new(&esp_device))?
};
let destd = &openat::Dir::open(&destpath)
.with_context(|| format!("opening dest dir {}", destpath.display()))?;
validate_esp_fstype(destd)?;
// TODO - add some sort of API that allows directly setting the working
// directory to a file descriptor.
std::process::Command::new("cp")
.args(["-rp", "--reflink=auto"])
.arg(&srcdir_name)
.arg(destpath)
.current_dir(format!("/proc/self/fd/{}", src_root.as_raw_fd()))
.run()?;
if update_firmware {
if let Some(vendordir) = self.get_efi_vendor(&src_root)? {
self.update_firmware(device, destd, &vendordir)?
}
}
Ok(InstalledContent {
meta,
filetree: Some(ft),
adopted_from: None,
})
}
fn run_update(
&self,
rootcxt: &RootContext,
current: &InstalledContent,
) -> Result<InstalledContent> {
let currentf = current
.filetree
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?;
let sysroot_dir = &rootcxt.sysroot;
let updatemeta = self.query_update(sysroot_dir)?.expect("update available");
let updated = sysroot_dir
.sub_dir(&component_updatedirname(self))
.context("opening update dir")?;
let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?;
let diff = currentf.diff(&updatef)?;
let Some(esp_devices) = blockdev::find_colocated_esps(&rootcxt.devices)? else {
anyhow::bail!("Failed to find all esp devices");
};
for esp in esp_devices {
let destpath = &self.ensure_mounted_esp(rootcxt.path.as_ref(), Path::new(&esp))?;
let destdir = openat::Dir::open(&destpath.join("EFI")).context("opening EFI dir")?;
validate_esp_fstype(&destdir)?;
log::trace!("applying diff: {}", &diff);
filetree::apply_diff(&updated, &destdir, &diff, None)
.context("applying filesystem changes")?;
// Do the sync before unmount
fsfreeze_thaw_cycle(destdir.open_file(".")?)?;
drop(destdir);
self.unmount().context("unmount after update")?;
}
let adopted_from = None;
Ok(InstalledContent {
meta: updatemeta,
filetree: Some(updatef),
adopted_from,
})
}
fn generate_update_metadata(&self, sysroot: &str) -> Result<ContentMetadata> {
let sysroot_path = Utf8Path::new(sysroot);
// copy EFI files to updates dir from usr/lib/efi
let efilib_path = sysroot_path.join(EFILIB);
let meta = if efilib_path.exists() {
let mut packages = Vec::new();
let sysroot_dir = Dir::open_ambient_dir(sysroot_path, cap_std::ambient_authority())?;
let efi_components = get_efi_component_from_usr(&sysroot_path, EFILIB)?;
if efi_components.len() == 0 {
bail!("Failed to find EFI components from {efilib_path}");
}
for efi in efi_components {
Command::new("cp")
.args(["-rp", "--reflink=auto"])
.arg(&efi.path)
.arg(crate::model::BOOTUPD_UPDATES_DIR)
.current_dir(format!("/proc/self/fd/{}", sysroot_dir.as_raw_fd()))
.run()?;
packages.push(format!("{}-{}", efi.name, efi.version));
}
// change to now to workaround https://github.com/coreos/bootupd/issues/933
let timestamp = std::time::SystemTime::now();
ContentMetadata {
timestamp: chrono::DateTime::<Utc>::from(timestamp),
version: packages.join(","),
}
} else {
let ostreebootdir = sysroot_path.join(ostreeutil::BOOT_PREFIX);
// move EFI files to updates dir from /usr/lib/ostree-boot
if ostreebootdir.exists() {
let cruft = ["loader", "grub2"];
for p in cruft.iter() {
let p = ostreebootdir.join(p);
if p.exists() {
std::fs::remove_dir_all(&p)?;
}
}
let efisrc = ostreebootdir.join("efi/EFI");
if !efisrc.exists() {
bail!("Failed to find {:?}", &efisrc);
}
let dest_efidir = component_updatedir(sysroot, self);
let dest_efidir =
Utf8PathBuf::from_path_buf(dest_efidir).expect("Path is invalid UTF-8");
// Fork off mv() because on overlayfs one can't rename() a lower level
// directory today, and this will handle the copy fallback.
Command::new("mv").args([&efisrc, &dest_efidir]).run()?;
let efidir = openat::Dir::open(dest_efidir.as_std_path())
.with_context(|| format!("Opening {}", dest_efidir))?;
let files = crate::util::filenames(&efidir)?.into_iter().map(|mut f| {
f.insert_str(0, "/boot/efi/EFI/");
f
});
packagesystem::query_files(sysroot, files)?
} else {
anyhow::bail!("Failed to find {ostreebootdir}");
}
};
write_update_metadata(sysroot, self, &meta)?;
Ok(meta)
}
fn query_update(&self, sysroot: &openat::Dir) -> Result<Option<ContentMetadata>> {
get_component_update(sysroot, self)
}
fn validate(&self, current: &InstalledContent) -> Result<ValidationResult> {
let devices = crate::blockdev::get_devices("/").context("get parent devices")?;
let esp_devices = blockdev::find_colocated_esps(&devices)?;
if !is_efi_booted()? && esp_devices.is_none() {
return Ok(ValidationResult::Skip);
}
let currentf = current
.filetree
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?;
let mut errs = Vec::new();
let esp_devices = esp_devices.unwrap_or_default();
for esp in esp_devices.iter() {
let destpath = &self.ensure_mounted_esp(Path::new("/"), Path::new(&esp))?;
let efidir = openat::Dir::open(&destpath.join("EFI"))
.with_context(|| format!("opening EFI dir {}", destpath.display()))?;
let diff = currentf.relative_diff_to(&efidir)?;
for f in diff.changes.iter() {
errs.push(format!("Changed: {}", f));
}
for f in diff.removals.iter() {
errs.push(format!("Removed: {}", f));
}
assert_eq!(diff.additions.len(), 0);
drop(efidir);
self.unmount().context("unmount after validate")?;
}
if !errs.is_empty() {
Ok(ValidationResult::Errors(errs))
} else {
Ok(ValidationResult::Valid)
}
}
fn get_efi_vendor(&self, sysroot: &openat::Dir) -> Result<Option<String>> {
let updated = sysroot
.sub_dir(&component_updatedirname(self))
.context("opening update dir")?;
let shim_files = find_file_recursive(updated.recover_path()?, SHIM)?;
// Does not support multiple shim for efi
if shim_files.len() > 1 {
anyhow::bail!("Found multiple {SHIM} in the image");
}
if let Some(p) = shim_files.first() {
let p = p
.parent()
.unwrap()
.file_name()
.ok_or_else(|| anyhow::anyhow!("No file name found"))?;
Ok(Some(p.to_string_lossy().into_owned()))
} else {
anyhow::bail!("Failed to find {SHIM} in the image")
}
}
}
impl Drop for Efi {
fn drop(&mut self) {
log::debug!("Unmounting");
let _ = self.unmount();
}
}
fn validate_esp_fstype(dir: &openat::Dir) -> Result<()> {
let dir = unsafe { BorrowedFd::borrow_raw(dir.as_raw_fd()) };
let stat = rustix::fs::fstatfs(&dir)?;
if stat.f_type != libc::MSDOS_SUPER_MAGIC {
bail!(
"EFI mount is not a msdos filesystem, but is {:?}",
stat.f_type
);
};
Ok(())
}
#[derive(Debug, PartialEq)]
struct BootEntry {
id: String,
name: String,
}
/// Parse boot entries from efibootmgr output
fn parse_boot_entries(output: &str) -> Vec<BootEntry> {
let mut entries = Vec::new();
for line in output.lines().filter_map(|line| line.strip_prefix("Boot")) {
// Need to consider if output only has "Boot0000* UiApp", without additional info
if line.starts_with('0') {
let parts = if let Some((parts, _)) = line.split_once('\t') {
parts
} else {
line
};
if let Some((id, name)) = parts.split_once(' ') {
let id = id.trim_end_matches('*').to_string();
let name = name.trim().to_string();
entries.push(BootEntry { id, name });
}
}
}
entries
}
#[context("Clearing EFI boot entries that match target {target}")]
pub(crate) fn clear_efi_target(target: &str) -> Result<()> {
let target = target.to_lowercase();
let output = Command::new(EFIBOOTMGR).output()?;
if !output.status.success() {
anyhow::bail!("Failed to invoke {EFIBOOTMGR}")
}
let output = String::from_utf8(output.stdout)?;
let boot_entries = parse_boot_entries(&output);
for entry in boot_entries {
if entry.name.to_lowercase() == target {
log::debug!("Deleting matched target {:?}", entry);
let mut cmd = Command::new(EFIBOOTMGR);
cmd.args(["-b", entry.id.as_str(), "-B"]);
println!("Executing: {cmd:?}");
cmd.run_with_cmd_context()?;
}
}
anyhow::Ok(())
}
#[context("Adding new EFI boot entry")]
pub(crate) fn create_efi_boot_entry(
device: &str,
espdir: &openat::Dir,
vendordir: &str,
target: &str,
) -> Result<()> {
let fsinfo = crate::filesystem::inspect_filesystem(espdir, ".")?;
let source = fsinfo.source;
let devname = source
.rsplit_once('/')
.ok_or_else(|| anyhow::anyhow!("Failed to parse {source}"))?
.1;
let partition_path = format!("/sys/class/block/{devname}/partition");
let partition_number = std::fs::read_to_string(&partition_path)
.with_context(|| format!("Failed to read {partition_path}"))?;
let shim = format!("{vendordir}/{SHIM}");
if espdir.exists(&shim)? {
anyhow::bail!("Failed to find {SHIM}");
}
let loader = format!("\\EFI\\{}\\{SHIM}", vendordir);
log::debug!("Creating new EFI boot entry using '{target}'");
let mut cmd = Command::new(EFIBOOTMGR);
cmd.args([
"--create",
"--disk",
device,
"--part",
partition_number.trim(),
"--loader",
loader.as_str(),
"--label",
target,
]);
println!("Executing: {cmd:?}");
cmd.run_with_cmd_context()
}
#[context("Find target file recursively")]
fn find_file_recursive<P: AsRef<Path>>(dir: P, target_file: &str) -> Result<Vec<PathBuf>> {
let mut result = Vec::new();
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
if let Some(file_name) = entry.file_name().to_str() {
if file_name == target_file {
if let Some(path) = entry.path().to_str() {
result.push(path.into());
}
}
}
}
}
Ok(result)
}
#[derive(Debug, PartialEq, Eq)]
pub struct EFIComponent {
name: String,
version: String,
path: Utf8PathBuf,
}
/// Get EFIComponents from e.g. usr/lib/efi, like "usr/lib/efi/<name>/<version>/EFI"
fn get_efi_component_from_usr<'a>(
sysroot: &'a Utf8Path,
usr_path: &'a str,
) -> Result<Vec<EFIComponent>> {
let efilib_path = sysroot.join(usr_path);
let skip_count = Utf8Path::new(usr_path).components().count();
let mut components: Vec<EFIComponent> = WalkDir::new(&efilib_path)
.min_depth(3) // <name>/<version>/EFI: so 3 levels down
.max_depth(3)
.into_iter()
.filter_map(|entry| {
let entry = entry.ok()?;
if !entry.file_type().is_dir() || entry.file_name() != "EFI" {
return None;
}
let abs_path = entry.path();
let rel_path = abs_path.strip_prefix(sysroot).ok()?;
let utf8_rel_path = Utf8PathBuf::from_path_buf(rel_path.to_path_buf()).ok()?;
let mut components = utf8_rel_path.components();
let name = components.nth(skip_count)?.to_string();
let version = components.next()?.to_string();
Some(EFIComponent {
name,
version,
path: utf8_rel_path,
})
})
.collect();
components.sort_by(|a, b| a.name.cmp(&b.name));
Ok(components)
}
#[cfg(test)]
mod tests {
use cap_std_ext::dirext::CapStdExtDirExt;
use super::*;
#[test]
fn test_parse_boot_entries() -> Result<()> {
let output = r"
BootCurrent: 0003
Timeout: 0 seconds
BootOrder: 0003,0001,0000,0002
Boot0000* UiApp FvVol(7cb8bdc9-f8eb-4f34-aaea-3ee4af6516a1)/FvFile(462caa21-7614-4503-836e-8ab6f4662331)
Boot0001* UEFI Misc Device PciRoot(0x0)/Pci(0x3,0x0){auto_created_boot_option}
Boot0002* EFI Internal Shell FvVol(7cb8bdc9-f8eb-4f34-aaea-3ee4af6516a1)/FvFile(7c04a583-9e3e-4f1c-ad65-e05268d0b4d1)
Boot0003* Fedora HD(2,GPT,94ff4025-5276-4bec-adea-e98da271b64c,0x1000,0x3f800)/\EFI\fedora\shimx64.efi";
let entries = parse_boot_entries(output);
assert_eq!(
entries,
[
BootEntry {
id: "0000".to_string(),
name: "UiApp".to_string()
},
BootEntry {
id: "0001".to_string(),
name: "UEFI Misc Device".to_string()
},
BootEntry {
id: "0002".to_string(),
name: "EFI Internal Shell".to_string()
},
BootEntry {
id: "0003".to_string(),
name: "Fedora".to_string()
}
]
);
let output = r"
BootCurrent: 0003
Timeout: 0 seconds
BootOrder: 0003,0001,0000,0002";
let entries = parse_boot_entries(output);
assert_eq!(entries, []);
let output = r"
BootCurrent: 0003
Timeout: 0 seconds
BootOrder: 0003,0001,0000,0002
Boot0000* UiApp
Boot0001* UEFI Misc Device
Boot0002* EFI Internal Shell
Boot0003* test";
let entries = parse_boot_entries(output);
assert_eq!(
entries,
[
BootEntry {
id: "0000".to_string(),
name: "UiApp".to_string()
},
BootEntry {
id: "0001".to_string(),
name: "UEFI Misc Device".to_string()
},
BootEntry {
id: "0002".to_string(),
name: "EFI Internal Shell".to_string()
},
BootEntry {
id: "0003".to_string(),
name: "test".to_string()
}
]
);
Ok(())
}
#[cfg(test)]
fn fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
tempdir.create_dir("etc")?;
Ok(tempdir)
}
#[test]
fn test_get_product_name() -> Result<()> {
let tmpd = fixture()?;
{
tmpd.atomic_write("etc/system-release", "Fedora release 40 (Forty)")?;
let name = get_product_name(&tmpd)?;
assert_eq!("Fedora", name);
}
{
tmpd.atomic_write("etc/system-release", "CentOS Stream release 9")?;
let name = get_product_name(&tmpd)?;
assert_eq!("CentOS Stream", name);
}
{
tmpd.atomic_write(
"etc/system-release",
"Red Hat Enterprise Linux CoreOS release 4",
)?;
let name = get_product_name(&tmpd)?;
assert_eq!("Red Hat Enterprise Linux CoreOS", name);
}
{
tmpd.atomic_write(
"etc/system-release",
"Red Hat Enterprise Linux CoreOS release 4
",
)?;
let name = get_product_name(&tmpd)?;
assert_eq!("Red Hat Enterprise Linux CoreOS", name);
}
{
tmpd.remove_file("etc/system-release")?;
let name = get_product_name(&tmpd)?;
assert!(name.len() > 0);
}
Ok(())
}
#[test]
fn test_get_efi_component_from_usr() -> Result<()> {
let tmpdir: &tempfile::TempDir = &tempfile::tempdir()?;
let tpath = tmpdir.path();
let efi_path = tpath.join("usr/lib/efi");
std::fs::create_dir_all(efi_path.join("BAR/1.1/EFI"))?;
std::fs::create_dir_all(efi_path.join("FOO/1.1/EFI"))?;
std::fs::create_dir_all(efi_path.join("FOOBAR/1.1/test"))?;
let utf8_tpath =
Utf8Path::from_path(tpath).ok_or_else(|| anyhow::anyhow!("Path is not valid UTF-8"))?;
let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB)?;
assert_eq!(
efi_comps,
vec![
EFIComponent {
name: "BAR".to_string(),
version: "1.1".to_string(),
path: Utf8PathBuf::from("usr/lib/efi/BAR/1.1/EFI"),
},
EFIComponent {
name: "FOO".to_string(),
version: "1.1".to_string(),
path: Utf8PathBuf::from("usr/lib/efi/FOO/1.1/EFI"),
},
]
);
std::fs::remove_dir_all(efi_path.join("BAR/1.1/EFI"))?;
std::fs::remove_dir_all(efi_path.join("FOO/1.1/EFI"))?;
let efi_comps = get_efi_component_from_usr(utf8_tpath, EFILIB)?;
assert_eq!(efi_comps, []);
Ok(())
}
}

21
src/failpoints.rs Executable file
View file

@ -0,0 +1,21 @@
//! Wrappers and utilities on top of the `fail` crate.
// SPDX-License-Identifier: Apache-2.0 OR MIT
/// TODO: Use https://github.com/tikv/fail-rs/pull/68 once it merges
/// copy from https://github.com/coreos/rpm-ostree/commit/aa8d7fb0ceaabfaf10252180e2ddee049d07aae3#diff-adcc419e139605fae34d17b31418dbaf515af2fe9fb766fcbdb2eaad862b3daa
#[macro_export]
macro_rules! try_fail_point {
($name:expr) => {{
if let Some(e) = fail::eval($name, |msg| {
let msg = msg.unwrap_or_else(|| "synthetic failpoint".to_string());
anyhow::Error::msg(msg)
}) {
return Err(From::from(e));
}
}};
($name:expr, $cond:expr) => {{
if $cond {
$crate::try_fail_point!($name);
}
}};
}

40
src/filesystem.rs Executable file
View file

@ -0,0 +1,40 @@
use std::os::fd::AsRawFd;
use std::os::unix::process::CommandExt;
use std::process::Command;
use anyhow::Result;
use bootc_internal_utils::CommandRunExt;
use fn_error_context::context;
use rustix::fd::BorrowedFd;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
#[allow(dead_code)]
pub(crate) struct Filesystem {
pub(crate) source: String,
pub(crate) fstype: String,
pub(crate) options: String,
pub(crate) uuid: Option<String>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct Findmnt {
pub(crate) filesystems: Vec<Filesystem>,
}
#[context("Inspecting filesystem {path:?}")]
pub(crate) fn inspect_filesystem(root: &openat::Dir, path: &str) -> Result<Filesystem> {
let rootfd = unsafe { BorrowedFd::borrow_raw(root.as_raw_fd()) };
// SAFETY: This is unsafe just for the pre_exec, when we port to cap-std we can use cap-std-ext
let o: Findmnt = unsafe {
Command::new("findmnt")
.args(["-J", "-v", "--output=SOURCE,FSTYPE,OPTIONS,UUID", path])
.pre_exec(move || rustix::process::fchdir(rootfd).map_err(Into::into))
.run_and_parse_json()?
};
o.filesystems
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("findmnt returned no data"))
}

785
src/filetree.rs Executable file
View file

@ -0,0 +1,785 @@
/*
* Copyright (C) 2020 Red Hat, Inc.
*
* SPDX-License-Identifier: Apache-2.0
*/
use crate::freezethaw::fsfreeze_thaw_cycle;
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
use anyhow::{bail, Context, Result};
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
use camino::{Utf8Path, Utf8PathBuf};
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
use openat_ext::OpenatDirExt;
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
use openssl::hash::{Hasher, MessageDigest};
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
use rustix::fd::BorrowedFd;
use serde::{Deserialize, Serialize};
#[allow(unused_imports)]
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Display;
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
use std::os::unix::io::AsRawFd;
/// The prefix we apply to our temporary files.
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) const TMP_PREFIX: &str = ".btmp.";
// This module doesn't handle modes right now, because
// we're only targeting FAT filesystems for UEFI.
// In FAT there are no unix permission bits, usually
// they're set by mount options.
// See also https://github.com/coreos/fedora-coreos-config/commit/8863c2b34095a2ae5eae6fbbd121768a5f592091
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
const DEFAULT_FILE_MODE: u32 = 0o700;
use crate::sha512string::SHA512String;
/// Metadata for a single file
#[derive(Clone, Serialize, Deserialize, Debug, Hash, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct FileMetadata {
/// File size in bytes
pub(crate) size: u64,
/// Content checksum; chose SHA-512 because there are not a lot of files here
/// and it's ok if the checksum is large.
pub(crate) sha512: SHA512String,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct FileTree {
pub(crate) children: BTreeMap<String, FileMetadata>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct FileTreeDiff {
pub(crate) additions: HashSet<String>,
pub(crate) removals: HashSet<String>,
pub(crate) changes: HashSet<String>,
}
impl Display for FileTreeDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
write!(
f,
"additions: {} removals: {} changes: {}",
self.additions.len(),
self.removals.len(),
self.changes.len()
)
}
}
#[cfg(test)]
impl FileTreeDiff {
pub(crate) fn count(&self) -> usize {
self.additions.len() + self.removals.len() + self.changes.len()
}
}
impl FileMetadata {
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) fn new_from_path<P: openat::AsPath>(
dir: &openat::Dir,
name: P,
) -> Result<FileMetadata> {
let mut r = dir.open_file(name)?;
let meta = r.metadata()?;
let mut hasher =
Hasher::new(MessageDigest::sha512()).expect("openssl sha512 hasher creation failed");
let _ = std::io::copy(&mut r, &mut hasher)?;
let digest = SHA512String::from_hasher(&mut hasher);
Ok(FileMetadata {
size: meta.len(),
sha512: digest,
})
}
}
impl FileTree {
// Internal helper to generate a sub-tree
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
fn unsorted_from_dir(dir: &openat::Dir) -> Result<HashMap<String, FileMetadata>> {
let mut ret = HashMap::new();
for entry in dir.list_dir(".")? {
let entry = entry?;
let Some(name) = entry.file_name().to_str() else {
bail!("Invalid UTF-8 filename: {:?}", entry.file_name())
};
if name.starts_with(TMP_PREFIX) {
bail!("File {} contains our temporary prefix!", name);
}
match dir.get_file_type(&entry)? {
openat::SimpleType::File => {
let meta = FileMetadata::new_from_path(dir, name)?;
let _ = ret.insert(name.to_string(), meta);
}
openat::SimpleType::Dir => {
let child = dir.sub_dir(name)?;
for (mut k, v) in FileTree::unsorted_from_dir(&child)?.drain() {
k.reserve(name.len() + 1);
k.insert(0, '/');
k.insert_str(0, name);
let _ = ret.insert(k, v);
}
}
openat::SimpleType::Symlink => {
bail!("Unsupported symbolic link {:?}", entry.file_name())
}
openat::SimpleType::Other => {
bail!("Unsupported non-file/directory {:?}", entry.file_name())
}
}
}
Ok(ret)
}
/// Create a FileTree from the target directory.
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) fn new_from_dir(dir: &openat::Dir) -> Result<Self> {
let mut children = BTreeMap::new();
for (k, v) in Self::unsorted_from_dir(dir)?.drain() {
children.insert(k, v);
}
Ok(Self { children })
}
/// Determine the changes *from* self to the updated tree
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) fn diff(&self, updated: &Self) -> Result<FileTreeDiff> {
self.diff_impl(updated, true)
}
/// Determine any changes only using the files tracked in self as
/// a reference. In other words, this will ignore any unknown
/// files and not count them as additions.
#[cfg(test)]
pub(crate) fn changes(&self, current: &Self) -> Result<FileTreeDiff> {
self.diff_impl(current, false)
}
/// The inverse of `changes` - determine if there are any files
/// changed or added in `current` compared to self.
#[cfg(test)]
pub(crate) fn updates(&self, current: &Self) -> Result<FileTreeDiff> {
current.diff_impl(self, false)
}
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
fn diff_impl(&self, updated: &Self, check_additions: bool) -> Result<FileTreeDiff> {
let mut additions = HashSet::new();
let mut removals = HashSet::new();
let mut changes = HashSet::new();
for (k, v1) in self.children.iter() {
if let Some(v2) = updated.children.get(k) {
if v1 != v2 {
changes.insert(k.clone());
}
} else {
removals.insert(k.clone());
}
}
if check_additions {
for k in updated.children.keys() {
if self.children.contains_key(k) {
continue;
}
additions.insert(k.clone());
}
}
Ok(FileTreeDiff {
additions,
removals,
changes,
})
}
/// Create a diff from a target directory. This will ignore
/// any files or directories that are not part of the original tree.
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) fn relative_diff_to(&self, dir: &openat::Dir) -> Result<FileTreeDiff> {
let mut removals = HashSet::new();
let mut changes = HashSet::new();
for (path, info) in self.children.iter() {
assert!(!path.starts_with('/'));
if let Some(meta) = dir.metadata_optional(path)? {
match meta.simple_type() {
openat::SimpleType::File => {
let target_info = FileMetadata::new_from_path(dir, path)?;
if info != &target_info {
changes.insert(path.clone());
}
}
_ => {
// If a file became a directory
changes.insert(path.clone());
}
}
} else {
removals.insert(path.clone());
}
}
Ok(FileTreeDiff {
additions: HashSet::new(),
removals,
changes,
})
}
}
// Recursively remove all files/dirs in the directory that start with our TMP_PREFIX
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
fn cleanup_tmp(dir: &openat::Dir) -> Result<()> {
for entry in dir.list_dir(".")? {
let entry = entry?;
let Some(name) = entry.file_name().to_str() else {
// Skip invalid UTF-8 for now, we will barf on it later though.
continue;
};
match dir.get_file_type(&entry)? {
openat::SimpleType::Dir => {
if name.starts_with(TMP_PREFIX) {
dir.remove_all(name)?;
continue;
} else {
let child = dir.sub_dir(name)?;
cleanup_tmp(&child)?;
}
}
openat::SimpleType::File => {
if name.starts_with(TMP_PREFIX) {
dir.remove_file(name)?;
}
}
_ => {}
}
}
Ok(())
}
#[derive(Default, Clone)]
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) struct ApplyUpdateOptions {
pub(crate) skip_removals: bool,
pub(crate) skip_sync: bool,
}
/// Copy from src to dst at root dir
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
fn copy_dir(root: &openat::Dir, src: &str, dst: &str) -> Result<()> {
use bootc_internal_utils::CommandRunExt;
use std::os::unix::process::CommandExt;
use std::process::Command;
let rootfd = unsafe { BorrowedFd::borrow_raw(root.as_raw_fd()) };
unsafe {
Command::new("cp")
.args(["-a"])
.arg(src)
.arg(dst)
.pre_exec(move || rustix::process::fchdir(rootfd).map_err(Into::into))
.run()?
};
log::debug!("Copy {src} to {dst}");
Ok(())
}
/// Get first sub dir and tmp sub dir for the path
/// "fedora/foo/bar" -> ("fedora", ".btmp.fedora")
/// "foo" -> ("foo", ".btmp.foo")
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
fn get_first_dir(path: &Utf8Path) -> Result<(&Utf8Path, String)> {
let first = path
.iter()
.next()
.ok_or_else(|| anyhow::anyhow!("Invalid path: {path}"))?;
let mut tmp = first.to_owned();
tmp.insert_str(0, TMP_PREFIX);
Ok((first.into(), tmp))
}
/// Given two directories, apply a diff generated from srcdir to destdir
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) fn apply_diff(
srcdir: &openat::Dir,
destdir: &openat::Dir,
diff: &FileTreeDiff,
opts: Option<&ApplyUpdateOptions>,
) -> Result<()> {
let default_opts = ApplyUpdateOptions {
..Default::default()
};
let opts = opts.unwrap_or(&default_opts);
cleanup_tmp(destdir).context("cleaning up temporary files")?;
let mut updates = HashMap::new();
// Handle removals in temp dir, or remove directly if file not in dir
if !opts.skip_removals {
for pathstr in diff.removals.iter() {
let path = Utf8Path::new(pathstr);
let (first_dir, first_dir_tmp) = get_first_dir(path)?;
let path_tmp;
if first_dir != path {
path_tmp = Utf8Path::new(&first_dir_tmp).join(path.strip_prefix(&first_dir)?);
// copy to temp dir and remember
// skip copying if dir not existed in dest
if !destdir.exists(&first_dir_tmp)? && destdir.exists(first_dir.as_std_path())? {
copy_dir(destdir, first_dir.as_str(), &first_dir_tmp).with_context(|| {
format!("copy {first_dir} to {first_dir_tmp} before removing {pathstr}")
})?;
updates.insert(first_dir, first_dir_tmp);
}
} else {
path_tmp = path.to_path_buf();
}
destdir
.remove_file_optional(path_tmp.as_std_path())
.with_context(|| format!("removing {:?}", path_tmp))?;
}
}
// Write changed or new files to temp dir or temp file
for pathstr in diff.changes.iter().chain(diff.additions.iter()) {
let path = Utf8Path::new(pathstr);
let (first_dir, first_dir_tmp) = get_first_dir(path)?;
let mut path_tmp = Utf8PathBuf::from(&first_dir_tmp);
if first_dir != path {
if !destdir.exists(&first_dir_tmp)? && destdir.exists(first_dir.as_std_path())? {
// copy to temp dir if not exists
copy_dir(destdir, first_dir.as_str(), &first_dir_tmp).with_context(|| {
format!("copy {first_dir} to {first_dir_tmp} before updating {pathstr}")
})?;
}
path_tmp = path_tmp.join(path.strip_prefix(&first_dir)?);
// ensure new additions dir exists
if let Some(parent) = path_tmp.parent() {
destdir.ensure_dir_all(parent.as_std_path(), DEFAULT_FILE_MODE)?;
}
// remove changed file before copying
destdir
.remove_file_optional(path_tmp.as_std_path())
.with_context(|| format!("removing {path_tmp} before copying"))?;
}
updates.insert(first_dir, first_dir_tmp);
srcdir
.copy_file_at(path.as_std_path(), destdir, path_tmp.as_std_path())
.with_context(|| format!("copying {:?} to {:?}", path, path_tmp))?;
}
// do local exchange or rename
for (dst, tmp) in updates.iter() {
let dst = dst.as_std_path();
log::trace!("doing local exchange for {} and {:?}", tmp, dst);
if destdir.exists(dst)? {
destdir
.local_exchange(tmp, dst)
.with_context(|| format!("exchange for {} and {:?}", tmp, dst))?;
} else {
destdir
.local_rename(tmp, dst)
.with_context(|| format!("rename for {} and {:?}", tmp, dst))?;
}
crate::try_fail_point!("update::exchange");
}
// Ensure all of the updates & changes are written persistently to disk
if !opts.skip_sync {
destdir.syncfs()?;
}
// finally remove the temp dir
for (_, tmp) in updates.iter() {
log::trace!("cleanup: {}", tmp);
destdir.remove_all(tmp).context("clean up temp")?;
}
// A second full filesystem sync to narrow any races rather than
// waiting for writeback to kick in.
if !opts.skip_sync {
fsfreeze_thaw_cycle(destdir.open_file(".")?)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use std::path::Path;
fn run_diff(a: &openat::Dir, b: &openat::Dir) -> Result<FileTreeDiff> {
let ta = FileTree::new_from_dir(a)?;
let tb = FileTree::new_from_dir(b)?;
let diff = ta.diff(&tb)?;
Ok(diff)
}
fn test_one_apply<AP: AsRef<Path>, BP: AsRef<Path>>(
a: AP,
b: BP,
opts: Option<&ApplyUpdateOptions>,
) -> Result<()> {
let a = a.as_ref();
let b = b.as_ref();
let t = tempfile::tempdir()?;
let c = t.path().join("c");
let r = std::process::Command::new("cp")
.arg("-rp")
.args([a, &c])
.status()?;
if !r.success() {
bail!("failed to cp");
};
let c = openat::Dir::open(&c)?;
let da = openat::Dir::open(a)?;
let db = openat::Dir::open(b)?;
let ta = FileTree::new_from_dir(&da)?;
let tb = FileTree::new_from_dir(&db)?;
let diff = ta.diff(&tb)?;
let rdiff = tb.diff(&ta)?;
assert_eq!(diff.count(), rdiff.count());
assert_eq!(diff.additions.len(), rdiff.removals.len());
assert_eq!(diff.changes.len(), rdiff.changes.len());
apply_diff(&db, &c, &diff, opts)?;
let tc = FileTree::new_from_dir(&c)?;
let newdiff = tb.diff(&tc)?;
let skip_removals = opts.map(|o| o.skip_removals).unwrap_or(false);
if skip_removals {
let n = newdiff.count();
if n != 0 {
assert_eq!(n, diff.removals.len());
}
for f in diff.removals.iter() {
assert!(c.exists(f)?);
assert!(da.exists(f)?);
}
} else {
assert_eq!(newdiff.count(), 0);
}
Ok(())
}
fn test_apply<AP: AsRef<Path>, BP: AsRef<Path>>(a: AP, b: BP) -> Result<()> {
let a = a.as_ref();
let b = b.as_ref();
let skip_removals = ApplyUpdateOptions {
skip_removals: true,
..Default::default()
};
test_one_apply(a, b, None).context("testing apply (with removals)")?;
test_one_apply(a, b, Some(&skip_removals)).context("testing apply (skipping removals)")?;
Ok(())
}
#[test]
fn test_filetree() -> Result<()> {
let tmpd = tempfile::tempdir()?;
let p = tmpd.path();
let pa = p.join("a");
let pb = p.join("b");
std::fs::create_dir(&pa)?;
std::fs::create_dir(&pb)?;
let a = openat::Dir::open(&pa)?;
let b = openat::Dir::open(&pb)?;
let diff = run_diff(&a, &b)?;
assert_eq!(diff.count(), 0);
a.create_dir("foo", 0o755)?;
let diff = run_diff(&a, &b)?;
assert_eq!(diff.count(), 0);
{
let mut bar = a.write_file("foo/bar", 0o644)?;
bar.write_all("foobarcontents".as_bytes())?;
}
let diff = run_diff(&a, &b)?;
assert_eq!(diff.count(), 1);
assert_eq!(diff.removals.len(), 1);
let ta = FileTree::new_from_dir(&a)?;
let tb = FileTree::new_from_dir(&b)?;
let cdiff = ta.changes(&tb)?;
assert_eq!(cdiff.count(), 1);
assert_eq!(cdiff.removals.len(), 1);
let udiff = ta.updates(&tb)?;
assert_eq!(udiff.count(), 0);
test_apply(&pa, &pb).context("testing apply 1")?;
let rdiff = ta.relative_diff_to(&b)?;
assert_eq!(rdiff.removals.len(), cdiff.removals.len());
b.create_dir("foo", 0o755)?;
{
let mut bar = b.write_file("foo/bar", 0o644)?;
bar.write_all("foobarcontents".as_bytes())?;
}
let diff = run_diff(&a, &b)?;
assert_eq!(diff.count(), 0);
test_apply(&pa, &pb).context("testing apply 2")?;
{
let mut bar2 = b.write_file("foo/bar", 0o644)?;
bar2.write_all("foobarcontents2".as_bytes())?;
}
let diff = run_diff(&a, &b)?;
assert_eq!(diff.count(), 1);
assert_eq!(diff.changes.len(), 1);
let ta = FileTree::new_from_dir(&a)?;
let rdiff = ta.relative_diff_to(&b)?;
assert_eq!(rdiff.count(), diff.count());
assert_eq!(rdiff.changes.len(), diff.changes.len());
test_apply(&pa, &pb).context("testing apply 3")?;
Ok(())
}
#[test]
fn test_filetree2() -> Result<()> {
let tmpd = tempfile::tempdir()?;
let tmpdp = tmpd.path();
let relp = "EFI/fedora";
let a = tmpdp.join("a");
let b = tmpdp.join("b");
for d in &[&a, &b] {
let efidir = d.join(relp);
fs::create_dir_all(&efidir)?;
let shimdata = "shim data";
fs::write(efidir.join("shim.x64"), shimdata)?;
let grubdata = "grub data";
fs::write(efidir.join("grub.x64"), grubdata)?;
}
fs::write(b.join(relp).join("grub.x64"), "grub data 2")?;
let newsubp = Path::new(relp).join("subdir");
fs::create_dir_all(b.join(&newsubp))?;
fs::write(b.join(&newsubp).join("newgrub.x64"), "newgrub data")?;
fs::remove_file(b.join(relp).join("shim.x64"))?;
{
let a = openat::Dir::open(&a)?;
let b = openat::Dir::open(&b)?;
let ta = FileTree::new_from_dir(&a)?;
let tb = FileTree::new_from_dir(&b)?;
let diff = ta.diff(&tb)?;
assert_eq!(diff.changes.len(), 1);
assert_eq!(diff.additions.len(), 1);
assert_eq!(diff.count(), 3);
super::apply_diff(&b, &a, &diff, None)?;
}
assert_eq!(
String::from_utf8(std::fs::read(a.join(relp).join("grub.x64"))?)?,
"grub data 2"
);
assert_eq!(
String::from_utf8(std::fs::read(a.join(&newsubp).join("newgrub.x64"))?)?,
"newgrub data"
);
assert!(!a.join(relp).join("shim.x64").exists());
Ok(())
}
#[test]
fn test_get_first_dir() -> Result<()> {
// test path
let path = Utf8Path::new("foo/subdir/bar");
let (tp, tp_tmp) = get_first_dir(path)?;
assert_eq!(tp, Utf8Path::new("foo"));
assert_eq!(tp_tmp, ".btmp.foo");
// test file
let path = Utf8Path::new("testfile");
let (tp, tp_tmp) = get_first_dir(path)?;
assert_eq!(tp, Utf8Path::new("testfile"));
assert_eq!(tp_tmp, ".btmp.testfile");
Ok(())
}
#[test]
fn test_cleanup_tmp() -> Result<()> {
let tmpd = tempfile::tempdir()?;
let p = tmpd.path();
let pa = p.join("a/.btmp.a");
let pb = p.join(".btmp.b/b");
std::fs::create_dir_all(&pa)?;
std::fs::create_dir_all(&pb)?;
let dp = openat::Dir::open(p)?;
{
let mut buf = dp.write_file("a/foo", 0o644)?;
buf.write_all("foocontents".as_bytes())?;
let mut buf = dp.write_file("a/.btmp.foo", 0o644)?;
buf.write_all("foocontents".as_bytes())?;
let mut buf = dp.write_file(".btmp.b/foo", 0o644)?;
buf.write_all("foocontents".as_bytes())?;
}
assert!(dp.exists("a/.btmp.a")?);
assert!(dp.exists("a/foo")?);
assert!(dp.exists("a/.btmp.foo")?);
assert!(dp.exists("a/.btmp.a")?);
assert!(dp.exists(".btmp.b/b")?);
assert!(dp.exists(".btmp.b/foo")?);
cleanup_tmp(&dp)?;
assert!(!dp.exists("a/.btmp.a")?);
assert!(dp.exists("a/foo")?);
assert!(!dp.exists("a/.btmp.foo")?);
assert!(!dp.exists(".btmp.b")?);
Ok(())
}
// Waiting on https://github.com/rust-lang/rust/pull/125692
#[cfg(not(target_env = "musl"))]
#[test]
fn test_apply_with_file() -> Result<()> {
let tmpd = tempfile::tempdir()?;
let p = tmpd.path();
let pa = p.join("a");
let pb = p.join("b");
std::fs::create_dir(&pa)?;
std::fs::create_dir(&pb)?;
let a = openat::Dir::open(&pa)?;
let b = openat::Dir::open(&pb)?;
a.create_dir("foo", 0o755)?;
a.create_dir("bar", 0o755)?;
let foo = Path::new("foo/bar");
let bar = Path::new("bar/foo");
let testfile = "testfile";
{
let mut buf = a.write_file(foo, 0o644)?;
buf.write_all("foocontents".as_bytes())?;
let mut buf = a.write_file(bar, 0o644)?;
buf.write_all("barcontents".as_bytes())?;
let mut buf = a.write_file(testfile, 0o644)?;
buf.write_all("testfilecontents".as_bytes())?;
}
let diff = run_diff(&a, &b)?;
assert_eq!(diff.count(), 3);
b.create_dir("foo", 0o755)?;
{
let mut buf = b.write_file(foo, 0o644)?;
buf.write_all("foocontents".as_bytes())?;
}
let b_btime_foo = fs::metadata(pb.join(foo))?.created()?;
{
let diff = run_diff(&b, &a)?;
assert_eq!(diff.count(), 2);
apply_diff(&a, &b, &diff, None).context("test additional files")?;
assert_eq!(
String::from_utf8(std::fs::read(pb.join(testfile))?)?,
"testfilecontents"
);
assert_eq!(
String::from_utf8(std::fs::read(pb.join(bar))?)?,
"barcontents"
);
// creation time is not changed for unchanged file
let b_btime_foo_new = fs::metadata(pb.join(foo))?.created()?;
assert_eq!(b_btime_foo_new, b_btime_foo);
}
{
fs::write(pa.join(testfile), "newtestfile")?;
fs::write(pa.join(bar), "newbar")?;
let diff = run_diff(&b, &a)?;
assert_eq!(diff.count(), 2);
apply_diff(&a, &b, &diff, None).context("test changed files")?;
assert_eq!(
String::from_utf8(std::fs::read(pb.join(testfile))?)?,
"newtestfile"
);
assert_eq!(String::from_utf8(std::fs::read(pb.join(bar))?)?, "newbar");
// creation time is not changed for unchanged file
let b_btime_foo_new = fs::metadata(pb.join(foo))?.created()?;
assert_eq!(b_btime_foo_new, b_btime_foo);
}
{
b.remove_file(testfile)?;
let ta = FileTree::new_from_dir(&a)?;
let diff = ta.relative_diff_to(&b)?;
assert_eq!(diff.removals.len(), 1);
apply_diff(&a, &b, &diff, None).context("test removed files with relative_diff")?;
assert_eq!(b.exists(testfile)?, false);
}
{
a.remove_file(bar)?;
let diff = run_diff(&b, &a)?;
assert_eq!(diff.count(), 2);
apply_diff(&a, &b, &diff, None).context("test removed files")?;
assert_eq!(b.exists(testfile)?, true);
assert_eq!(b.exists(bar)?, false);
let diff = run_diff(&b, &a)?;
assert_eq!(diff.count(), 0);
// creation time is not changed for unchanged file
let b_btime_foo_new = fs::metadata(pb.join(foo))?.created()?;
assert_eq!(b_btime_foo_new, b_btime_foo);
}
Ok(())
}
}

50
src/freezethaw.rs Executable file
View file

@ -0,0 +1,50 @@
use rustix::fd::AsFd;
use rustix::ffi as c;
use rustix::io::Errno;
use rustix::ioctl::opcode;
use rustix::{io, ioctl};
use crate::util::SignalTerminationGuard;
fn ioctl_fifreeze<Fd: AsFd>(fd: Fd) -> io::Result<()> {
// SAFETY: `FIFREEZE` is a no-argument opcode.
// `FIFREEZE` is defined as `_IOWR('X', 119, int)`.
unsafe {
let ctl = ioctl::NoArg::<{ opcode::read_write::<c::c_int>(b'X', 119) }>::new();
ioctl::ioctl(fd, ctl)
}
}
fn ioctl_fithaw<Fd: AsFd>(fd: Fd) -> io::Result<()> {
// SAFETY: `FITHAW` is a no-argument opcode.
// `FITHAW` is defined as `_IOWR('X', 120, int)`.
unsafe {
let ctl = ioctl::NoArg::<{ opcode::read_write::<c::c_int>(b'X', 120) }>::new();
ioctl::ioctl(fd, ctl)
}
}
/// syncfs() doesn't flush the journal on XFS,
/// and since GRUB2 can't read the XFS journal, the system
/// could fail to boot.
///
/// http://marc.info/?l=linux-fsdevel&m=149520244919284&w=2
/// https://github.com/ostreedev/ostree/pull/1049
///
/// This function always call syncfs() first, then calls
/// `ioctl(FIFREEZE)` and `ioctl(FITHAW)`, ignoring `EOPNOTSUPP` and `EPERM`
pub(crate) fn fsfreeze_thaw_cycle<Fd: AsFd>(fd: Fd) -> anyhow::Result<()> {
rustix::fs::syncfs(&fd)?;
let _guard = SignalTerminationGuard::new()?;
let freeze = ioctl_fifreeze(&fd);
match freeze {
// Ignore permissions errors (tests)
Err(Errno::PERM) => Ok(()),
// Ignore unsupported FS
Err(Errno::NOTSUP) => Ok(()),
Ok(()) => Ok(ioctl_fithaw(fd)?),
_ => Ok(freeze?),
}
}

3
src/grub2/README.md Executable file
View file

@ -0,0 +1,3 @@
# Static GRUB configuration files
These static files were taken from https://github.com/coreos/coreos-assembler/blob/5824720ec3a9ec291532b23b349b6d8d8b2e9edd/src/grub.cfg

View file

@ -0,0 +1,10 @@
# Keep the comment for grub2-set-password
### BEGIN /etc/grub.d/01_users ###
if [ -f ${prefix}/user.cfg ]; then
source ${prefix}/user.cfg
if [ -n "${GRUB2_PASSWORD}" ]; then
set superusers="root"
export superusers
password_pbkdf2 root ${GRUB2_PASSWORD}
fi
fi

View file

@ -0,0 +1 @@
blscfg

View file

@ -0,0 +1,8 @@
# Force the menu to be shown once, with a timeout of ${menu_show_once_timeout}
# if requested by ${menu_show_once_timeout} being set in the env.
if [ "${menu_show_once_timeout}" ]; then
set timeout_style=menu
set timeout="${menu_show_once_timeout}"
unset menu_show_once_timeout
save_env menu_show_once_timeout
fi

View file

@ -0,0 +1,5 @@
if [ "$grub_platform" = "efi" ]; then
menuentry 'UEFI Firmware Settings' $menuentry_id_option 'uefi-firmware' {
fwsetup
}
fi

View file

@ -0,0 +1,3 @@
if [ -f $prefix/custom.cfg ]; then
source $prefix/custom.cfg
fi

4
src/grub2/configs.d/README.md Executable file
View file

@ -0,0 +1,4 @@
Add drop-in grub fragments into this directory to have
them be installed into the final config.
The filenames must end in `.cfg`.

24
src/grub2/grub-static-efi.cfg Executable file
View file

@ -0,0 +1,24 @@
if [ -e (md/md-boot) ]; then
# The search command might pick a RAID component rather than the RAID,
# since the /boot RAID currently uses superblock 1.0. See the comment in
# the main grub.cfg.
set prefix=md/md-boot
else
if [ -f ${config_directory}/bootuuid.cfg ]; then
source ${config_directory}/bootuuid.cfg
fi
if [ -n "${BOOT_UUID}" ]; then
search --fs-uuid "${BOOT_UUID}" --set prefix --no-floppy
else
search --label boot --set prefix --no-floppy
fi
fi
if [ -d ($prefix)/grub2 ]; then
set prefix=($prefix)/grub2
configfile $prefix/grub.cfg
else
set prefix=($prefix)/boot/grub2
configfile $prefix/grub.cfg
fi
boot

55
src/grub2/grub-static-pre.cfg Executable file
View file

@ -0,0 +1,55 @@
# This file is copied from https://github.com/coreos/coreos-assembler/blob/0eb25d1c718c88414c0b9aedd19dc56c09afbda8/src/grub.cfg
# Changes:
# - Dropped Ignition glue, that can be injected into platform.cfg
# petitboot doesn't support -e and doesn't support an empty path part
if [ -d (md/md-boot)/grub2 ]; then
# fcct currently creates /boot RAID with superblock 1.0, which allows
# component partitions to be read directly as filesystems. This is
# necessary because transposefs doesn't yet rerun grub2-install on BIOS,
# so GRUB still expects /boot to be a partition on the first disk.
#
# There are two consequences:
# 1. On BIOS and UEFI, the search command might pick an individual RAID
# component, but we want it to use the full RAID in case there are bad
# sectors etc. The undocumented --hint option is supposed to support
# this sort of override, but it doesn't seem to work, so we set $boot
# directly.
# 2. On BIOS, the "normal" module has already been loaded from an
# individual RAID component, and $prefix still points there. We want
# future module loads to come from the RAID, so we reset $prefix.
# (On UEFI, the stub grub.cfg has already set $prefix properly.)
set boot=md/md-boot
set prefix=($boot)/grub2
else
if [ -f ${config_directory}/bootuuid.cfg ]; then
source ${config_directory}/bootuuid.cfg
fi
if [ -n "${BOOT_UUID}" ]; then
search --fs-uuid "${BOOT_UUID}" --set boot --no-floppy
else
search --label boot --set boot --no-floppy
fi
fi
set root=$boot
if [ -f ${config_directory}/grubenv ]; then
load_env -f ${config_directory}/grubenv
elif [ -s $prefix/grubenv ]; then
load_env
fi
if [ -f $prefix/console.cfg ]; then
# Source in any GRUB console settings if provided by the user/platform
source $prefix/console.cfg
fi
menuentry_id_option="--id"
function load_video {
insmod all_video
}
set timeout_style=menu
set timeout=1
# Other package code will be injected from here

180
src/grubconfigs.rs Executable file
View file

@ -0,0 +1,180 @@
use std::fmt::Write;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use bootc_internal_utils::CommandRunExt;
use fn_error_context::context;
use openat_ext::OpenatDirExt;
use crate::freezethaw::fsfreeze_thaw_cycle;
/// The subdirectory of /boot we use
const GRUB2DIR: &str = "grub2";
const CONFIGDIR: &str = "/usr/lib/bootupd/grub2-static";
const DROPINDIR: &str = "configs.d";
// The related grub files
const GRUBENV: &str = "grubenv";
pub(crate) const GRUBCONFIG: &str = "grub.cfg";
pub(crate) const GRUBCONFIG_BACKUP: &str = "grub.cfg.backup";
// File mode for /boot/grub2/grub.config
// https://github.com/coreos/bootupd/issues/952
const GRUBCONFIG_FILE_MODE: u32 = 0o600;
/// Install the static GRUB config files.
#[context("Installing static GRUB configs")]
pub(crate) fn install(
target_root: &openat::Dir,
installed_efi_vendor: Option<&str>,
write_uuid: bool,
) -> Result<()> {
let bootdir = &target_root.sub_dir("boot").context("Opening /boot")?;
let boot_is_mount = {
let root_dev = target_root.self_metadata()?.stat().st_dev;
let boot_dev = bootdir.self_metadata()?.stat().st_dev;
log::debug!("root_dev={root_dev} boot_dev={boot_dev}");
root_dev != boot_dev
};
if !bootdir.exists(GRUB2DIR)? {
bootdir.create_dir(GRUB2DIR, 0o700)?;
}
let mut config = String::from("# Generated by bootupd / do not edit\n\n");
let pre = std::fs::read_to_string(Path::new(CONFIGDIR).join("grub-static-pre.cfg"))?;
config.push_str(pre.as_str());
let dropindir = openat::Dir::open(&Path::new(CONFIGDIR).join(DROPINDIR))?;
// Sort the files for reproducibility
let mut entries = dropindir
.list_dir(".")?
.map(|e| e.map_err(anyhow::Error::msg))
.collect::<Result<Vec<_>>>()?;
entries.sort_by(|a, b| a.file_name().cmp(b.file_name()));
for ent in entries {
let name = ent.file_name();
let name = name
.to_str()
.ok_or_else(|| anyhow!("Invalid UTF-8: {name:?}"))?;
if !name.ends_with(".cfg") {
log::debug!("Ignoring {name}");
continue;
}
writeln!(config, "\n### BEGIN {name} ###")?;
let dropin = std::fs::read_to_string(Path::new(CONFIGDIR).join(DROPINDIR).join(name))?;
config.push_str(dropin.as_str());
writeln!(config, "### END {name} ###")?;
println!("Added {name}");
}
let grub2dir = bootdir.sub_dir(GRUB2DIR)?;
grub2dir
.write_file_contents("grub.cfg", GRUBCONFIG_FILE_MODE, config.as_bytes())
.context("Copying grub-static.cfg")?;
println!("Installed: grub.cfg");
write_grubenv(&bootdir).context("Create grubenv")?;
let uuid_path = if write_uuid {
let target_fs = if boot_is_mount { bootdir } else { target_root };
let bootfs_meta = crate::filesystem::inspect_filesystem(target_fs, ".")?;
let bootfs_uuid = bootfs_meta
.uuid
.ok_or_else(|| anyhow::anyhow!("Failed to find UUID for boot"))?;
let grub2_uuid_contents = format!("set BOOT_UUID=\"{bootfs_uuid}\"\n");
let uuid_path = "bootuuid.cfg";
grub2dir
.write_file_contents(uuid_path, 0o644, grub2_uuid_contents)
.context("Writing bootuuid.cfg")?;
println!("Installed: bootuuid.cfg");
Some(uuid_path)
} else {
None
};
fsfreeze_thaw_cycle(grub2dir.open_file(".")?)?;
if let Some(vendordir) = installed_efi_vendor {
log::debug!("vendordir={:?}", &vendordir);
let vendor = PathBuf::from(vendordir);
let target = &vendor.join("grub.cfg");
let dest_efidir = target_root
.sub_dir_optional("boot/efi/EFI")
.context("Opening /boot/efi/EFI")?;
if let Some(efidir) = dest_efidir {
efidir
.copy_file(&Path::new(CONFIGDIR).join("grub-static-efi.cfg"), target)
.context("Copying static EFI")?;
println!("Installed: {target:?}");
if let Some(uuid_path) = uuid_path {
let target = &vendor.join(uuid_path);
grub2dir
.copy_file_at(uuid_path, &efidir, target)
.context("Writing bootuuid.cfg to efi dir")?;
println!("Installed: {target:?}");
}
fsfreeze_thaw_cycle(efidir.open_file(".")?)?;
} else {
println!("Could not find /boot/efi/EFI when installing {target:?}");
}
}
Ok(())
}
#[context("Create file boot/grub2/grubenv")]
fn write_grubenv(bootdir: &openat::Dir) -> Result<()> {
let grubdir = &bootdir.sub_dir(GRUB2DIR).context("Opening boot/grub2")?;
if grubdir.exists(GRUBENV)? {
return Ok(());
}
let editenv = Path::new("/usr/bin/grub2-editenv");
if !editenv.exists() {
anyhow::bail!("Failed to find {:?}", editenv);
}
std::process::Command::new(editenv)
.args([GRUBENV, "create"])
.current_dir(format!("/proc/self/fd/{}", grubdir.as_raw_fd()))
.run_with_cmd_context()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore]
fn test_install() -> Result<()> {
env_logger::init();
let td = tempfile::tempdir()?;
let tdp = td.path();
let td = openat::Dir::open(tdp)?;
std::fs::create_dir_all(tdp.join("boot/grub2"))?;
std::fs::create_dir_all(tdp.join("boot/efi/EFI/BOOT"))?;
std::fs::create_dir_all(tdp.join("boot/efi/EFI/fedora"))?;
install(&td, Some("fedora"), false).unwrap();
assert!(td.exists("boot/grub2/grub.cfg")?);
assert!(td.exists("boot/efi/EFI/fedora/grub.cfg")?);
Ok(())
}
#[test]
fn test_write_grubenv() -> Result<()> {
// Skip this test if grub2-editenv is not installed
let editenv = Path::new("/usr/bin/grub2-editenv");
if !editenv.try_exists()? {
return Ok(());
}
let td = tempfile::tempdir()?;
let tdp = td.path();
std::fs::create_dir_all(tdp.join("boot/grub2"))?;
let td = openat::Dir::open(&tdp.join("boot"))?;
write_grubenv(&td)?;
assert!(td.exists("grub2/grubenv")?);
Ok(())
}
}

84
src/main.rs Executable file
View file

@ -0,0 +1,84 @@
/*!
**Boot**loader **upd**ater.
This is an early prototype hidden/not-yet-standardized mechanism
which just updates EFI for now (x86_64/aarch64/riscv64 only).
But in the future will hopefully gain some independence from
ostree and also support e.g. updating the MBR etc.
Refs:
* <https://github.com/coreos/fedora-coreos-tracker/issues/510>
!*/
#![deny(unused_must_use)]
// The style lints are more annoying than useful
#![allow(clippy::style)]
#![deny(clippy::dbg_macro)]
mod backend;
#[cfg(any(target_arch = "x86_64", target_arch = "powerpc64"))]
mod bios;
mod blockdev;
mod bootupd;
mod cli;
mod component;
mod coreos;
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
mod efi;
mod failpoints;
mod filesystem;
mod filetree;
mod freezethaw;
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "powerpc64",
target_arch = "riscv64"
))]
mod grubconfigs;
mod model;
mod model_legacy;
mod ostreeutil;
mod packagesystem;
mod sha512string;
mod util;
use clap::crate_name;
/// Binary entrypoint, for both daemon and client logic.
fn main() {
let _scenario = fail::FailScenario::setup();
let exit_code = run_cli();
std::process::exit(exit_code);
}
/// CLI logic.
fn run_cli() -> i32 {
// Parse command-line options.
let args: Vec<_> = std::env::args().collect();
let cli_opts = cli::MultiCall::from_args(args);
// Setup logging.
env_logger::Builder::from_default_env()
.format_timestamp(None)
.format_module_path(false)
.filter(Some(crate_name!()), cli_opts.loglevel())
.init();
log::trace!("executing cli");
// Dispatch CLI subcommand.
match cli_opts.run() {
Ok(_) => libc::EXIT_SUCCESS,
Err(e) => {
// Use the alternative formatter to get everything on a single line... it reads better.
eprintln!("error: {:#}", e);
libc::EXIT_FAILURE
}
}
}

170
src/model.rs Executable file
View file

@ -0,0 +1,170 @@
/*
* Copyright (C) 2020 Red Hat, Inc.
*
* SPDX-License-Identifier: Apache-2.0
*/
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// The directory where updates are stored
pub(crate) const BOOTUPD_UPDATES_DIR: &str = "usr/lib/bootupd/updates";
#[derive(Serialize, Deserialize, Clone, Debug, Hash, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct ContentMetadata {
/// The timestamp, which is used to determine update availability
pub(crate) timestamp: DateTime<Utc>,
/// Human readable version number, like ostree it is not ever parsed, just displayed
pub(crate) version: String,
}
impl ContentMetadata {
/// Returns `true` if `target` is different and chronologically newer
pub(crate) fn can_upgrade_to(&self, target: &Self) -> bool {
if self.version == target.version {
return false;
}
target.timestamp > self.timestamp
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct InstalledContent {
/// Associated metadata
pub(crate) meta: ContentMetadata,
/// Human readable version number, like ostree it is not ever parsed, just displayed
pub(crate) filetree: Option<crate::filetree::FileTree>,
/// The version this was originally adopted from
pub(crate) adopted_from: Option<ContentMetadata>,
}
/// Will be serialized into /boot/bootupd-state.json
#[derive(Serialize, Deserialize, Default, Debug)]
#[serde(rename_all = "kebab-case")]
#[serde(deny_unknown_fields)]
pub(crate) struct SavedState {
/// Maps a component name to its currently installed version
pub(crate) installed: BTreeMap<String, InstalledContent>,
/// Maps a component name to an in progress update
pub(crate) pending: Option<BTreeMap<String, ContentMetadata>>,
/// If static bootloader configs are enabled, this contains the version
pub(crate) static_configs: Option<ContentMetadata>,
}
/// The status of an individual component.
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ComponentUpdatable {
NoUpdateAvailable,
AtLatestVersion,
Upgradable,
WouldDowngrade,
}
impl ComponentUpdatable {
pub(crate) fn from_metadata(from: &ContentMetadata, to: Option<&ContentMetadata>) -> Self {
match to {
Some(to) => {
if from.version == to.version {
ComponentUpdatable::AtLatestVersion
} else if from.can_upgrade_to(to) {
ComponentUpdatable::Upgradable
} else {
ComponentUpdatable::WouldDowngrade
}
}
None => ComponentUpdatable::NoUpdateAvailable,
}
}
}
/// The status of an individual component.
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct ComponentStatus {
/// Currently installed version
pub(crate) installed: ContentMetadata,
/// In progress update that was interrupted
pub(crate) interrupted: Option<ContentMetadata>,
/// Update in the deployed filesystem tree
pub(crate) update: Option<ContentMetadata>,
/// Is true if the version in `update` is different from `installed`
pub(crate) updatable: ComponentUpdatable,
/// Originally adopted version
pub(crate) adopted_from: Option<ContentMetadata>,
}
/// Information on a component that can be adopted
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct Adoptable {
/// A synthetic version
pub(crate) version: ContentMetadata,
/// True if we are likely to be able to reliably update this system
pub(crate) confident: bool,
}
/// Representation of bootupd's worldview at a point in time.
/// This is intended to be a stable format that is output by `bootupctl status --json`
/// and parsed by higher level management tools. Transitively then
/// everything referenced from here should also be stable.
#[derive(Serialize, Deserialize, Default, Debug)]
#[serde(rename_all = "kebab-case")]
#[serde(deny_unknown_fields)]
pub(crate) struct Status {
/// Maps a component name to status
pub(crate) components: BTreeMap<String, ComponentStatus>,
/// Components that appear to be installed, not via bootupd
pub(crate) adoptable: BTreeMap<String, Adoptable>,
}
#[cfg(test)]
mod test {
use super::*;
use anyhow::Result;
use chrono::Duration;
#[test]
fn test_meta_compare() {
let t = Utc::now();
let a = ContentMetadata {
timestamp: t,
version: "v1".into(),
};
let b = ContentMetadata {
timestamp: t + Duration::try_seconds(1).unwrap(),
version: "v2".into(),
};
assert!(a.can_upgrade_to(&b));
assert!(!b.can_upgrade_to(&a));
}
/// Validate we're not breaking the serialized format of /boot/bootupd-state.json
#[test]
fn test_deserialize_state() -> Result<()> {
let data = include_str!("../tests/fixtures/example-state-v0.json");
let state: SavedState = serde_json::from_str(data)?;
let efi = state.installed.get("EFI").expect("EFI");
assert_eq!(
efi.meta.version,
"grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64"
);
Ok(())
}
/// Validate we're not breaking the serialized format of `bootupctl status --json`
#[test]
fn test_deserialize_status() -> Result<()> {
let data = include_str!("../tests/fixtures/example-status-v0.json");
let status: Status = serde_json::from_str(data)?;
let efi = status.components.get("EFI").expect("EFI");
assert_eq!(
efi.installed.version,
"grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64"
);
Ok(())
}
}

101
src/model_legacy.rs Executable file
View file

@ -0,0 +1,101 @@
/*
* Copyright (C) 2020 Red Hat, Inc.
*
* SPDX-License-Identifier: Apache-2.0
*/
//! Implementation of the original bootupd data format, which is the same
//! as the current one except that the date is defined to be in UTC.
use crate::model::ContentMetadata as NewContentMetadata;
use crate::model::InstalledContent as NewInstalledContent;
use crate::model::SavedState as NewSavedState;
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Serialize, Deserialize, Clone, Debug, Hash, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct ContentMetadata01 {
/// The timestamp, which is used to determine update availability
pub(crate) timestamp: NaiveDateTime,
/// Human readable version number, like ostree it is not ever parsed, just displayed
pub(crate) version: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct InstalledContent01 {
/// Associated metadata
pub(crate) meta: ContentMetadata01,
/// File tree
pub(crate) filetree: Option<crate::filetree::FileTree>,
}
/// Will be serialized into /boot/bootupd-state.json
#[derive(Serialize, Deserialize, Default, Debug)]
#[serde(rename_all = "kebab-case")]
#[serde(deny_unknown_fields)]
pub(crate) struct SavedState01 {
/// Maps a component name to its currently installed version
pub(crate) installed: BTreeMap<String, InstalledContent01>,
/// Maps a component name to an in progress update
pub(crate) pending: Option<BTreeMap<String, ContentMetadata01>>,
}
impl ContentMetadata01 {
pub(crate) fn upconvert(self) -> NewContentMetadata {
let timestamp = self.timestamp.and_utc();
NewContentMetadata {
timestamp,
version: self.version,
}
}
}
impl InstalledContent01 {
pub(crate) fn upconvert(self) -> NewInstalledContent {
NewInstalledContent {
meta: self.meta.upconvert(),
filetree: self.filetree,
adopted_from: None,
}
}
}
impl SavedState01 {
pub(crate) fn upconvert(self) -> NewSavedState {
let mut r: NewSavedState = Default::default();
for (k, v) in self.installed {
r.installed.insert(k, v.upconvert());
}
r
}
}
#[cfg(test)]
mod test {
use super::*;
use anyhow::Result;
/// Validate we're not breaking the serialized format of `bootupctl status --json`
#[test]
fn test_deserialize_status() -> Result<()> {
let data = include_str!("../tests/fixtures/example-state-v0-legacy.json");
let state: SavedState01 = serde_json::from_str(data)?;
let efi = state.installed.get("EFI").expect("EFI");
assert_eq!(
efi.meta.version,
"grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64"
);
let state: NewSavedState = state.upconvert();
let efi = state.installed.get("EFI").expect("EFI");
let t = chrono::DateTime::parse_from_rfc3339("2020-09-15T13:01:21Z")?;
assert_eq!(t, efi.meta.timestamp);
assert_eq!(
efi.meta.version,
"grub2-efi-x64-1:2.04-23.fc32.x86_64,shim-x64-15-8.x86_64"
);
Ok(())
}
}

102
src/ostreeutil.rs Executable file
View file

@ -0,0 +1,102 @@
/*
* Copyright (C) 2020 Red Hat, Inc.
* Modified for Debian compatibility
*
* SPDX-License-Identifier: Apache-2.0
*/
use std::path::Path;
use anyhow::{Context, Result};
use log::debug;
/// https://github.com/coreos/rpm-ostree/pull/969/commits/dc0e8db5bd92e1f478a0763d1a02b48e57022b59
#[cfg(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "riscv64"
))]
pub(crate) const BOOT_PREFIX: &str = "usr/lib/ostree-boot";
/// Detect if this is a Debian-based system
fn is_debian_system(sysroot: &Path) -> bool {
// Check for Debian-specific files
let debian_files = [
"etc/debian_version",
"var/lib/dpkg/status",
];
for file in debian_files.iter() {
if sysroot.join(file).exists() {
return true;
}
}
// Check os-release content
if let Ok(content) = std::fs::read_to_string(sysroot.join("etc/os-release")) {
if content.contains("ID=debian") ||
content.contains("ID=ubuntu") ||
content.contains("ID=linuxmint") ||
content.contains("ID=pop") {
return true;
}
}
false
}
/// Create dpkg command for Debian systems
pub(crate) fn dpkg_cmd<P: AsRef<Path>>(sysroot: P) -> Result<std::process::Command> {
let c = std::process::Command::new("dpkg");
let sysroot = sysroot.as_ref();
// Check if this is a Debian system
if !is_debian_system(sysroot) {
anyhow::bail!("Not a Debian system - dpkg command not available");
}
// For OSTree systems, we might need to adjust paths
// but dpkg typically works with the standard paths
debug!("Using dpkg for Debian system");
Ok(c)
}
/// Get sysroot.bootloader in ostree repo config.
pub(crate) fn get_ostree_bootloader() -> Result<Option<String>> {
let mut cmd = std::process::Command::new("ostree");
let result = cmd
.args([
"config",
"--repo=/sysroot/ostree/repo",
"get",
"sysroot.bootloader",
])
.output()
.context("Querying ostree sysroot.bootloader")?;
if !result.status.success() {
// ostree will exit with a none zero return code if the key does not exists
return Ok(None);
} else {
let res = String::from_utf8(result.stdout)
.with_context(|| "decoding as UTF-8 output of ostree command")?
.trim_end()
.to_string();
return Ok(Some(res));
}
}
pub(crate) fn set_ostree_bootloader(bootloader: &str) -> Result<()> {
let status = std::process::Command::new("ostree")
.args([
"config",
"--repo=/sysroot/ostree/repo",
"set",
"sysroot.bootloader",
bootloader,
])
.status()?;
if !status.success() {
anyhow::bail!("Failed to set 'sysroot.bootloader' to '{bootloader}' in ostree repo config");
}
Ok(())
}

172
src/packagesystem.rs Executable file
View file

@ -0,0 +1,172 @@
use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::prelude::*;
use crate::model::*;
/// Parse the output of `dpkg -S` to extract package names
fn parse_dpkg_s_output(output: &[u8]) -> Result<String> {
let output_str = std::str::from_utf8(output)?;
// dpkg -S outputs "package: /path" format
// Package names can contain colons (e.g., "grub-efi-amd64:amd64")
// We need to find the colon that is followed by a space (start of file path)
let mut colon_pos = None;
for (i, ch) in output_str.char_indices() {
if ch == ':' {
// Check if the next character is a space
if let Some(next_char) = output_str[i + 1..].chars().next() {
if next_char == ' ' {
colon_pos = Some(i);
break;
}
}
}
}
if let Some(pos) = colon_pos {
Ok(output_str[..pos].trim().to_string())
} else {
bail!("Invalid dpkg -S output format: {}", output_str)
}
}
/// Get package installation time from package.list file
fn get_package_install_time(package: &str) -> Result<DateTime<Utc>> {
let list_path = format!("/var/lib/dpkg/info/{}.list", package);
let metadata = std::fs::metadata(&list_path)
.with_context(|| format!("Failed to get metadata for package {}", package))?;
// Use modification time as installation time
let modified = metadata.modified()
.with_context(|| format!("Failed to get modification time for package {}", package))?;
Ok(DateTime::from(modified))
}
/// Parse dpkg output and extract package metadata
fn dpkg_parse_metadata(packages: &BTreeSet<String>) -> Result<ContentMetadata> {
if packages.is_empty() {
bail!("Failed to find any Debian packages matching files in source efidir");
}
let mut timestamps = BTreeSet::new();
// Get installation time for each package
for package in packages {
let timestamp = get_package_install_time(package)?;
timestamps.insert(timestamp);
}
// Use the most recent timestamp
let largest_timestamp = timestamps.iter().last()
.ok_or_else(|| anyhow::anyhow!("No valid timestamps found"))?;
// Create version string from package names
let version = packages.iter().fold("".to_string(), |mut s, n| {
if !s.is_empty() {
s.push(',');
}
s.push_str(n);
s
});
Ok(ContentMetadata {
timestamp: *largest_timestamp,
version,
})
}
/// Query the dpkg database and list the package and install times.
pub(crate) fn query_files<T>(
sysroot_path: &str,
paths: impl IntoIterator<Item = T>,
) -> Result<ContentMetadata>
where
T: AsRef<Path>,
{
let mut packages = BTreeSet::new();
let paths: Vec<_> = paths.into_iter().collect();
for path in &paths {
// Use dpkg -S to find which package owns the file
let mut cmd = std::process::Command::new("dpkg");
cmd.args(["-S", "--root", sysroot_path]);
cmd.arg(path.as_ref());
let dpkgout = cmd.output()?;
if !dpkgout.status.success() {
// Skip files that don't belong to any package
continue;
}
let package = parse_dpkg_s_output(&dpkgout.stdout)?;
packages.insert(package);
}
if packages.is_empty() {
// If no packages found, try without --root for local system
for path in &paths {
let mut cmd = std::process::Command::new("dpkg");
cmd.args(["-S"]);
cmd.arg(path.as_ref());
let dpkgout = cmd.output()?;
if dpkgout.status.success() {
let package = parse_dpkg_s_output(&dpkgout.stdout)?;
packages.insert(package);
}
}
}
dpkg_parse_metadata(&packages)
}
#[test]
fn test_parse_dpkg_s_output() {
let testdata = "grub-efi-amd64:amd64: /usr/lib/grub/x86_64-efi";
let parsed = parse_dpkg_s_output(testdata.as_bytes()).unwrap();
assert_eq!(parsed, "grub-efi-amd64:amd64");
let testdata2 = "shim-signed: /usr/lib/shim/shimx64.efi";
let parsed2 = parse_dpkg_s_output(testdata2.as_bytes()).unwrap();
assert_eq!(parsed2, "shim-signed");
// Test with different package name format
let testdata3 = "grub-efi-amd64: /usr/lib/grub/x86_64-efi";
let parsed3 = parse_dpkg_s_output(testdata3.as_bytes()).unwrap();
assert_eq!(parsed3, "grub-efi-amd64");
}
#[test]
fn test_dpkg_parse_metadata() {
// Mock package installation times for testing
let mock_time = Utc::now();
let mut packages = BTreeSet::new();
packages.insert("grub-efi-amd64:amd64".to_string());
packages.insert("shim-signed".to_string());
// For testing, we'll create a mock version that doesn't depend on actual files
let version = packages.iter().fold("".to_string(), |mut s, n| {
if !s.is_empty() {
s.push(',');
}
s.push_str(n);
s
});
// Create a mock ContentMetadata for testing
let mock_metadata = ContentMetadata {
timestamp: mock_time,
version,
};
assert_eq!(
mock_metadata.version,
"grub-efi-amd64:amd64,shim-signed"
);
// Timestamp should be recent
assert!(mock_metadata.timestamp > DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z").unwrap().with_timezone(&Utc));
}

42
src/sha512string.rs Executable file
View file

@ -0,0 +1,42 @@
/*
* Copyright (C) 2020 Red Hat, Inc.
*
* SPDX-License-Identifier: Apache-2.0
*/
use openssl::hash::Hasher;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Serialize, Deserialize, Clone, Debug, Hash, Ord, PartialOrd, PartialEq, Eq)]
pub(crate) struct SHA512String(pub(crate) String);
impl fmt::Display for SHA512String {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl SHA512String {
#[allow(dead_code)]
pub(crate) fn from_hasher(hasher: &mut Hasher) -> Self {
Self(format!(
"sha512:{}",
hex::encode(hasher.finish().expect("completing hash"))
))
}
}
#[cfg(test)]
mod test {
use super::*;
use anyhow::Result;
#[test]
fn test_empty() -> Result<()> {
let mut h = Hasher::new(openssl::hash::MessageDigest::sha512())?;
let s = SHA512String::from_hasher(&mut h);
assert_eq!("sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", format!("{}", s));
Ok(())
}
}

122
src/util.rs Executable file
View file

@ -0,0 +1,122 @@
use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
use openat_ext::OpenatDirExt;
/// Parse an environment variable as UTF-8
#[allow(dead_code)]
pub(crate) fn getenv_utf8(n: &str) -> Result<Option<String>> {
if let Some(v) = std::env::var_os(n) {
Ok(Some(
v.to_str()
.ok_or_else(|| anyhow::anyhow!("{} is invalid UTF-8", n))?
.to_string(),
))
} else {
Ok(None)
}
}
pub(crate) fn filenames(dir: &openat::Dir) -> Result<HashSet<String>> {
let mut ret = HashSet::new();
for entry in dir.list_dir(".")? {
let entry = entry?;
let Some(name) = entry.file_name().to_str() else {
bail!("Invalid UTF-8 filename: {:?}", entry.file_name())
};
match dir.get_file_type(&entry)? {
openat::SimpleType::File => {
ret.insert(format!("/{name}"));
}
openat::SimpleType::Dir => {
let child = dir.sub_dir(name)?;
for mut k in filenames(&child)?.drain() {
k.reserve(name.len() + 1);
k.insert_str(0, name);
k.insert(0, '/');
ret.insert(k);
}
}
openat::SimpleType::Symlink => {
bail!("Unsupported symbolic link {:?}", entry.file_name())
}
openat::SimpleType::Other => {
bail!("Unsupported non-file/directory {:?}", entry.file_name())
}
}
}
Ok(ret)
}
pub(crate) fn ensure_writable_mount<P: AsRef<Path>>(p: P) -> Result<()> {
let p = p.as_ref();
let stat = rustix::fs::statvfs(p)?;
if !stat.f_flag.contains(rustix::fs::StatVfsMountFlags::RDONLY) {
return Ok(());
}
let status = std::process::Command::new("mount")
.args(["-o", "remount,rw"])
.arg(p)
.status()?;
if !status.success() {
anyhow::bail!("Failed to remount {:?} writable", p);
}
Ok(())
}
/// Runs the provided Command object, captures its stdout, and swallows its stderr except on
/// failure. Returns a Result<String> describing whether the command failed, and if not, its
/// standard output. Output is assumed to be UTF-8. Errors are adequately prefixed with the full
/// command.
#[allow(dead_code)]
pub(crate) fn cmd_output(cmd: &mut Command) -> Result<String> {
let result = cmd
.output()
.with_context(|| format!("running {:#?}", cmd))?;
if !result.status.success() {
eprintln!("{}", String::from_utf8_lossy(&result.stderr));
bail!("{:#?} failed with {}", cmd, result.status);
}
String::from_utf8(result.stdout)
.with_context(|| format!("decoding as UTF-8 output of `{:#?}`", cmd))
}
/// Copy from https://github.com/containers/bootc/blob/main/ostree-ext/src/container_utils.rs#L20
/// Attempts to detect if the current process is running inside a container.
/// This looks for the `container` environment variable or the presence
/// of Docker or podman's more generic `/run/.containerenv`.
/// This is a best-effort function, as there is not a 100% reliable way
/// to determine this.
pub fn running_in_container() -> bool {
if std::env::var_os("container").is_some() {
return true;
}
// https://stackoverflow.com/questions/20010199/how-to-determine-if-a-process-runs-inside-lxc-docker
for p in ["/run/.containerenv", "/.dockerenv"] {
if Path::new(p).exists() {
return true;
}
}
false
}
/// Suppress SIGTERM while active
// TODO: In theory we could record if we got SIGTERM and exit
// on drop, but in practice we don't care since we're going to exit anyways.
#[derive(Debug)]
pub(crate) struct SignalTerminationGuard(signal_hook_registry::SigId);
impl SignalTerminationGuard {
pub(crate) fn new() -> Result<Self> {
let signal = unsafe { signal_hook_registry::register(libc::SIGTERM, || {})? };
Ok(Self(signal))
}
}
impl Drop for SignalTerminationGuard {
fn drop(&mut self) {
signal_hook_registry::unregister(self.0);
}
}