Restructure project layout for better CI/CD integration
- 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:
parent
5e8730df43
commit
aaf662d5b1
87 changed files with 1334 additions and 570 deletions
3
src/backend/mod.rs
Executable file
3
src/backend/mod.rs
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
//! Internal logic for bootloader and system state manipulation.
|
||||
|
||||
mod statefile;
|
||||
112
src/backend/statefile.rs
Executable file
112
src/backend/statefile.rs
Executable 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
271
src/bios.rs
Executable 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(¤t_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
99
src/blockdev.rs
Executable 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
772
src/bootupd.rs
Executable 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(¤t_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
222
src/cli/bootupctl.rs
Executable 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
125
src/cli/bootupd.rs
Executable 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
107
src/cli/mod.rs
Executable 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
232
src/component.rs
Executable 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
187
src/coreos.rs
Executable 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
896
src/efi.rs
Executable 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
21
src/failpoints.rs
Executable 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
40
src/filesystem.rs
Executable 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
785
src/filetree.rs
Executable 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
50
src/freezethaw.rs
Executable 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
3
src/grub2/README.md
Executable 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
|
||||
10
src/grub2/configs.d/01_users.cfg
Executable file
10
src/grub2/configs.d/01_users.cfg
Executable 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
|
||||
1
src/grub2/configs.d/10_blscfg.cfg
Executable file
1
src/grub2/configs.d/10_blscfg.cfg
Executable file
|
|
@ -0,0 +1 @@
|
|||
blscfg
|
||||
8
src/grub2/configs.d/14_menu_show_once.cfg
Executable file
8
src/grub2/configs.d/14_menu_show_once.cfg
Executable 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
|
||||
5
src/grub2/configs.d/30_uefi-firmware.cfg
Executable file
5
src/grub2/configs.d/30_uefi-firmware.cfg
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
if [ "$grub_platform" = "efi" ]; then
|
||||
menuentry 'UEFI Firmware Settings' $menuentry_id_option 'uefi-firmware' {
|
||||
fwsetup
|
||||
}
|
||||
fi
|
||||
3
src/grub2/configs.d/41_custom.cfg
Executable file
3
src/grub2/configs.d/41_custom.cfg
Executable 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
4
src/grub2/configs.d/README.md
Executable 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
24
src/grub2/grub-static-efi.cfg
Executable 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
55
src/grub2/grub-static-pre.cfg
Executable 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
180
src/grubconfigs.rs
Executable 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
84
src/main.rs
Executable 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
170
src/model.rs
Executable 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
101
src/model_legacy.rs
Executable 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
102
src/ostreeutil.rs
Executable 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
172
src/packagesystem.rs
Executable 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
42
src/sha512string.rs
Executable 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
122
src/util.rs
Executable 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue