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

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

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

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

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

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

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

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