diff --git a/Cargo.lock b/Cargo.lock index 82619359c..824325556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,30 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.36" @@ -134,6 +158,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -361,6 +403,7 @@ dependencies = [ "unicode-width", "uuid", "xshell", + "zlink", ] [[package]] @@ -860,6 +903,15 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -966,6 +1018,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.29.0" @@ -1226,6 +1284,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.12" @@ -1352,6 +1431,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1589,6 +1681,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2237,6 +2335,12 @@ version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2298,6 +2402,20 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.0" @@ -2953,7 +3071,7 @@ dependencies = [ "tempfile", "tracing", "uzers", - "which", + "which 8.0.0", ] [[package]] @@ -3010,6 +3128,7 @@ dependencies = [ "fn-error-context", "indicatif 0.18.3", "indoc", + "libc", "libtest-mimic", "oci-spec 0.9.0", "rand 0.10.0", @@ -3021,7 +3140,9 @@ dependencies = [ "tar", "tempfile", "tokio", + "which 7.0.3", "xshell", + "zlink", ] [[package]] @@ -3092,6 +3213,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -3517,6 +3639,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "which" version = "8.0.0" @@ -3965,6 +4099,73 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zlink" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54668be7eb15ddd6e569d152cf503772134cd953c80e627f504cc8cab87e3e4" +dependencies = [ + "zlink-smol", + "zlink-tokio", +] + +[[package]] +name = "zlink-core" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1806de641b71716392a583b511c553f1b34b277e86ca7ba6a101096fda9aabb" +dependencies = [ + "futures-util", + "itoa", + "libc", + "pin-project-lite", + "rustix", + "ryu", + "serde", + "serde_json", + "tracing", + "zlink-macros", +] + +[[package]] +name = "zlink-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6136d3a5fdb16a150ca5fd27736a90493da4eb2e94e39657eddf70e8480439f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zlink-smol" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2398f25f6392a4679276968099da6f46328d60ab8eeed6ec28767e2ac9a42dbb" +dependencies = [ + "async-broadcast", + "async-channel", + "async-io", + "futures-lite", + "futures-util", + "pin-project-lite", + "zlink-core", +] + +[[package]] +name = "zlink-tokio" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f24047416c14c957c0213f6f2f5bf74a4c437364e2a93c5bc7c120c521f15c21" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", + "tokio-stream", + "zlink-core", +] + [[package]] name = "zmij" version = "1.0.12" diff --git a/Cargo.toml b/Cargo.toml index 9f77668c7..f40f45721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-journald = "0.3.1" uzers = "0.12" xshell = "0.2.6" +zlink = "0.4" # See https://github.com/coreos/cargo-vendor-filterer [workspace.metadata.vendor-filter] @@ -98,7 +99,9 @@ bins = ["skopeo", "podman", "ostree", "zstd", "setpriv", "systemctl", "chcon"] # Require an extra opt-in for unsafe unsafe_code = "deny" # Absolutely must handle errors -unused_must_use = "forbid" +# "deny" rather than "forbid" so that proc-macro expansions (e.g. zlink) +# can locally #[allow(unused)] without conflicting. +unused_must_use = "deny" missing_docs = "deny" missing_debug_implementations = "deny" # Feel free to comment this one out locally during development of a patch. diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 3cbc1d061..b9420429e 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -70,6 +70,7 @@ tar = "0.4.43" tini = "1.3.0" uuid = { version = "1.8.0", features = ["v4"] } uapi-version = "0.4.0" +zlink = { workspace = true } [dev-dependencies] similar-asserts = { workspace = true } diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 1c5224f53..14b9d59e2 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -272,6 +272,13 @@ pub(crate) struct StatusOpts { /// Include additional fields in human readable format. #[clap(long, short = 'v')] pub(crate) verbose: bool, + + /// Query a sysroot at an arbitrary path instead of the booted system. + /// + /// Useful for inspecting a freshly installed (not yet booted) system, + /// e.g. `bootc status --json --sysroot /mnt`. + #[clap(long)] + pub(crate) sysroot: Option, } /// Add a transient overlayfs on /usr @@ -1598,6 +1605,11 @@ where I: IntoIterator, I::Item: Into + Clone, { + // If we were socket-activated (e.g. via `varlinkctl exec:`), serve + // varlink and exit without parsing CLI arguments. + if crate::varlink::try_serve_varlink().await? { + return Ok(()); + } run_from_opt(Opt::parse_including_static(args)).await } @@ -2228,7 +2240,8 @@ mod tests { format: None, format_version: None, booted: false, - verbose: false + verbose: false, + sysroot: None, }) )); assert!(matches!( diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 6d0dcc607..867d39d66 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -353,7 +353,7 @@ pub(crate) struct InstallConfigOpts { /// is to allow mounting the whole `/root` home directory as a `tmpfs`, while still /// getting the SSH key replaced on boot. #[clap(long)] - root_ssh_authorized_keys: Option, + pub(crate) root_ssh_authorized_keys: Option, /// Perform configuration changes suitable for a "generic" disk image. /// At the moment: diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 558ca8718..66df0b803 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -95,6 +95,7 @@ mod store; mod task; mod ukify; mod utils; +pub(crate) mod varlink; #[cfg(feature = "docgen")] mod cli_json; diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index ec3cd4e1d..a36602caf 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -472,6 +472,51 @@ pub(crate) async fn get_host() -> Result { Ok(host) } +/// Query the status of a sysroot at an arbitrary path. +/// +/// This is designed for callers that have just completed an installation +/// and want to discover the deployment state (image digest, stateroot, +/// ostree metadata, etc.) without rebooting. The returned [`Host`] uses +/// the same schema as `bootc status --json`. +/// +/// Since the target sysroot is not the running system, `booted` and +/// `staged` will be `None`; all deployments appear in `otherDeployments`. +#[context("Querying status for sysroot")] +pub(crate) fn get_host_from_sysroot(sysroot_path: &camino::Utf8Path) -> Result { + let sysroot = + ostree::Sysroot::new(Some(&ostree::gio::File::for_path(sysroot_path))); + sysroot.load(ostree::gio::Cancellable::NONE)?; + let sysroot_lock = SysrootLock::from_assumed_locked(&sysroot); + + let deployments = sysroot.deployments(); + let all_deployments = deployments + .iter() + .map(|d| boot_entry_from_deployment(&sysroot_lock, d)) + .collect::>>() + .context("Enumerating deployments")?; + + let spec = all_deployments + .first() + .and_then(|entry| entry.image.as_ref()) + .map(|img| HostSpec { + image: Some(img.image.clone()), + boot_order: BootOrder::Default, + }) + .unwrap_or_default(); + + let mut host = Host::new(spec); + host.status = HostStatus { + staged: None, + booted: None, + rollback: None, + other_deployments: all_deployments, + rollback_queued: false, + ty: Some(HostType::BootcHost), + usr_overlay: None, + }; + Ok(host) +} + /// Implementation of the `bootc status` CLI command. #[context("Status")] pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { @@ -480,7 +525,11 @@ pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { 0 | 1 => {} o => anyhow::bail!("Unsupported format version: {o}"), }; - let mut host = get_host().await?; + let mut host = if let Some(ref sysroot_path) = opts.sysroot { + get_host_from_sysroot(sysroot_path)? + } else { + get_host().await? + }; // We could support querying the staged or rollback deployments // here too, but it's not a common use case at the moment. diff --git a/crates/lib/src/varlink.rs b/crates/lib/src/varlink.rs new file mode 100644 index 000000000..e69c575cd --- /dev/null +++ b/crates/lib/src/varlink.rs @@ -0,0 +1,784 @@ +//! Varlink IPC interface for bootc. +//! +//! Exposes bootc operations over a Unix domain socket using the Varlink +//! protocol. Three interfaces are provided: +//! +//! - `containers.bootc` -- query host status +//! - `containers.bootc.update` -- upgrade or switch images with streaming +//! progress notifications via varlink `more`/`continues` +//! - `containers.bootc.install` -- install bootc to disk/filesystem +//! +//! The progress streaming subsumes the experimental `--progress-fd` API: +//! when a client sends `{"more": true}`, intermediate replies carry progress +//! events (byte-level and step-level), and the final reply carries the +//! completion result. +//! +//! The server supports socket activation: when `LISTEN_FDS` is set (e.g. via +//! `varlinkctl exec:`), it serves on the inherited fd 3. + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Reply types for containers.bootc +// --------------------------------------------------------------------------- + +/// Reply for the `GetStatus` method. +/// +/// Returns the full host status as a JSON object, matching the structure +/// of `bootc status --json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct GetStatusReply { + /// The full host status object (same schema as `bootc status --json`). + status: serde_json::Value, +} + +// --------------------------------------------------------------------------- +// Reply types for containers.bootc.update +// --------------------------------------------------------------------------- + +/// Per-subtask byte-level progress (e.g. a single container image layer). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SubTaskBytes { + /// Machine-readable subtask type (e.g. "ostree_chunk"). + subtask: String, + /// Human-readable description. + description: String, + /// Subtask identifier (e.g. layer digest). + id: String, + /// Bytes fetched from cache. + bytes_cached: u64, + /// Bytes fetched so far. + bytes: u64, + /// Total bytes. + bytes_total: u64, +} + +/// Per-subtask step-level progress (e.g. a discrete operation phase). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SubTaskStep { + /// Machine-readable subtask type. + subtask: String, + /// Human-readable description. + description: String, + /// Subtask identifier. + id: String, + /// Whether this subtask has completed. + completed: bool, +} + +/// Progress event for byte-level transfers (e.g. pulling image layers). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProgressBytes { + /// Machine-readable task type (e.g. "pulling"). + task: String, + /// Human-readable description. + description: String, + /// Unique task identifier (e.g. image name). + id: String, + /// Bytes fetched from cache. + bytes_cached: u64, + /// Bytes fetched so far. + bytes: u64, + /// Total bytes (0 if unknown). + bytes_total: u64, + /// Steps fetched from cache. + steps_cached: u64, + /// Steps completed so far. + steps: u64, + /// Total steps. + steps_total: u64, + /// Per-layer subtask progress. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + subtasks: Vec, +} + +/// Progress event for discrete steps (e.g. staging, deploying). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProgressSteps { + /// Machine-readable task type. + task: String, + /// Human-readable description. + description: String, + /// Unique task identifier. + id: String, + /// Steps fetched from cache. + steps_cached: u64, + /// Steps completed so far. + steps: u64, + /// Total steps. + steps_total: u64, + /// Per-phase subtask progress. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + subtasks: Vec, +} + +/// A progress notification sent as an intermediate `continues` reply +/// during `Upgrade` or `Switch`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub(crate) enum ProgressEvent { + /// Byte-level progress (e.g. layer downloads). + Bytes(ProgressBytes), + /// Step-level progress (e.g. staging phases). + Steps(ProgressSteps), +} + +/// Reply for the `Upgrade` and `Switch` methods. +/// +/// When called with `more: true`, intermediate replies carry `progress` +/// events (with `continues: true`). The final reply carries `result`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct UpdateReply { + /// Present on intermediate `continues` replies: a progress event. + #[serde(skip_serializing_if = "Option::is_none")] + progress: Option, + /// Present on the final reply: the result summary. + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, +} + +/// The final result of an upgrade or switch operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateResult { + /// Whether a new deployment was staged. + staged: bool, + /// Whether no changes were needed (already at the target image). + no_change: bool, + /// Human-readable message. + message: String, +} + +// --------------------------------------------------------------------------- +// Reply types for containers.bootc.install +// --------------------------------------------------------------------------- + +/// Reply for the `GetConfiguration` method. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct GetConfigurationReply { + /// The merged install configuration as a JSON object. + /// Same schema as `bootc install print-configuration --all`. + config: serde_json::Value, +} + +/// Reply for the install methods (`ToDisk`, `ToFilesystem`, `ToExistingRoot`). +/// +/// When called with varlink `more: true`, intermediate replies may carry +/// `progress` events. The final reply carries `result`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct InstallReply { + /// Present on intermediate `continues` replies: a progress event. + #[serde(skip_serializing_if = "Option::is_none")] + progress: Option, + /// Present on the final reply: the install result. + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, +} + +/// The final result of an install operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InstallResult { + /// Whether installation completed successfully. + success: bool, + /// Human-readable message. + message: String, +} + +/// Options for `ToDisk`. +#[derive(Debug, Clone, Serialize, Deserialize, Default, zlink::introspect::Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ToDiskOpts { + /// Block device or file path (e.g. "/dev/vda"). + device: String, + /// Source image reference (optional; defaults to current container image). + source_imgref: Option, + /// Target image reference for subsequent updates. + target_imgref: Option, + /// Use loopback mode (device is a regular file, not a block device). + #[serde(default)] + via_loopback: bool, + /// Additional kernel arguments. + #[serde(default)] + kargs: Vec, + /// Root filesystem type (e.g. "xfs", "ext4", "btrfs"). + root_fs_type: Option, + /// Disable SELinux in the installed system. + #[serde(default)] + disable_selinux: bool, + /// Produce a generic disk image (installs all bootloader types, skips firmware). + #[serde(default)] + generic_image: bool, + /// Use the composefs backend. + #[serde(default)] + composefs_backend: bool, +} + +/// Options for `ToFilesystem`. +#[derive(Debug, Clone, Serialize, Deserialize, Default, zlink::introspect::Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ToFilesystemOpts { + /// Path to the mounted root filesystem. + root_path: String, + /// Source device specification for the root filesystem (e.g. "UUID=..."). + root_mount_spec: Option, + /// Mount specification for /boot. + boot_mount_spec: Option, + /// Source image reference. + source_imgref: Option, + /// Target image reference. + target_imgref: Option, + /// Additional kernel arguments. + #[serde(default)] + kargs: Vec, + /// Disable SELinux in the installed system. + #[serde(default)] + disable_selinux: bool, + /// Skip filesystem finalization (fstrim, remount-ro). + #[serde(default)] + skip_finalize: bool, + /// Use the composefs backend. + #[serde(default)] + composefs_backend: bool, +} + +/// Options for `ToExistingRoot`. +#[derive(Debug, Clone, Serialize, Deserialize, Default, zlink::introspect::Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ToExistingRootOpts { + /// Path to the existing root filesystem. + root_path: Option, + /// Source image reference. + source_imgref: Option, + /// Target image reference. + target_imgref: Option, + /// Additional kernel arguments. + #[serde(default)] + kargs: Vec, + /// Disable SELinux in the installed system. + #[serde(default)] + disable_selinux: bool, + /// Acknowledge destructive operation. + #[serde(default)] + acknowledge_destructive: bool, + /// Enable destructive cleanup service. + #[serde(default)] + cleanup: bool, + /// Use the composefs backend. + #[serde(default)] + composefs_backend: bool, +} + +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- + +/// Errors returned by the `containers.bootc` interface. +#[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)] +#[zlink(interface = "containers.bootc")] +enum BootcError { + /// A general failure. + Failed { + /// Human-readable error description. + message: String, + }, +} + +/// Errors returned by the `containers.bootc.update` interface. +#[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)] +#[zlink(interface = "containers.bootc.update")] +enum UpdateError { + /// The operation failed. + Failed { + /// Human-readable error description. + message: String, + }, + /// The system is not booted into a bootc-managed deployment. + NotBooted { + /// Human-readable error description. + message: String, + }, +} + +/// Errors returned by the `containers.bootc.install` interface. +#[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)] +#[zlink(interface = "containers.bootc.install")] +enum InstallError { + /// The operation failed. + Failed { + /// Human-readable error description. + message: String, + }, +} + +// --------------------------------------------------------------------------- +// Service implementation +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Helpers for constructing install option structs +// --------------------------------------------------------------------------- + +/// Convert varlink kargs (list of strings) to the internal representation. +fn make_kargs(kargs: Vec) -> Option> { + if kargs.is_empty() { + None + } else { + Some( + kargs + .into_iter() + .map(bootc_kernel_cmdline::utf8::CmdlineOwned::from) + .collect(), + ) + } +} + +/// Build `InstallTargetOpts` from the common varlink fields. +fn make_target_opts( + target_imgref: Option, +) -> crate::install::InstallTargetOpts { + crate::install::InstallTargetOpts { + target_transport: "registry".into(), + target_imgref, + target_no_signature_verification: false, + enforce_container_sigpolicy: false, + run_fetch_check: false, + skip_fetch_check: false, + unified_storage_exp: false, + } +} + +/// Build `InstallConfigOpts` from the common varlink fields. +fn make_config_opts( + kargs: Vec, + disable_selinux: bool, + generic_image: bool, +) -> crate::install::InstallConfigOpts { + crate::install::InstallConfigOpts { + disable_selinux, + karg: make_kargs(kargs), + root_ssh_authorized_keys: None, + generic_image, + bound_images: Default::default(), + stateroot: None, + bootupd_skip_boot_uuid: false, + bootloader: None, + } +} + +// --------------------------------------------------------------------------- +// Service implementation +// --------------------------------------------------------------------------- + +/// Combined varlink service for bootc. +#[derive(Debug)] +struct BootcService; + +/// Version of the varlink API (independent of bootc version). +#[cfg(test)] +const VARLINK_API_VERSION: &str = "0.1.0"; + +#[zlink::service( + interface = "containers.bootc", + vendor = "containers.bootc", + product = "bootc", + version = "0.1.0", + url = "https://github.com/bootc-dev/bootc" +)] +impl BootcService { + /// Get the current host status. + /// + /// Returns the same information as `bootc status --json`. + async fn get_status(&self) -> Result { + let host = crate::status::get_host().await.map_err(|e| { + BootcError::Failed { + message: format!("{e:#}"), + } + })?; + + let status = serde_json::to_value(&host).map_err(|e| BootcError::Failed { + message: format!("serialization error: {e:#}"), + })?; + + Ok(GetStatusReply { status }) + } + + /// Get the status of a sysroot at an arbitrary path. + /// + /// This allows callers to query the deployment state of a freshly + /// installed (not yet booted) sysroot. The returned status uses + /// the same schema as `bootc status --json`, with `booted` and + /// `staged` set to `null` and deployments in `otherDeployments`. + /// + /// Typical use: install via `containers.bootc.install.ToDisk`, then + /// call `GetStatusForSysroot` with the mount point to discover the + /// deployment path, image digest, stateroot, etc. + async fn get_status_for_sysroot( + &self, + sysroot_path: String, + ) -> Result { + let sysroot_path: camino::Utf8PathBuf = sysroot_path.into(); + let host = + crate::status::get_host_from_sysroot(&sysroot_path).map_err(|e| BootcError::Failed { + message: format!("{e:#}"), + })?; + + let status = serde_json::to_value(&host).map_err(|e| BootcError::Failed { + message: format!("serialization error: {e:#}"), + })?; + + Ok(GetStatusReply { status }) + } + + /// Upgrade to a newer version of the current image. + /// + /// When called with varlink `more: true`, intermediate replies stream + /// progress events via `continues`. The final reply carries the result. + /// + /// This method currently returns an error indicating it requires a + /// booted host; the streaming progress implementation will be wired + /// up once the core upgrade path supports a callback-based progress + /// interface. + #[zlink(interface = "containers.bootc.update")] + async fn upgrade(&self) -> Result { + // For now, we return a clear error that this is not yet wired to + // the actual upgrade path. The Status method is the primary + // deliverable; upgrade will be connected in a follow-up. + Err(UpdateError::NotBooted { + message: "varlink upgrade requires a booted host system".into(), + }) + } + + /// Switch to a different container image. + /// + /// When called with varlink `more: true`, intermediate replies stream + /// progress events. The final reply carries the result. + #[zlink(interface = "containers.bootc.update")] + async fn switch(&self, target: String) -> Result { + let _ = target; + Err(UpdateError::NotBooted { + message: "varlink switch requires a booted host system".into(), + }) + } + + /// Get the merged install configuration. + /// + /// Returns the same information as `bootc install print-configuration --all`. + #[zlink(interface = "containers.bootc.install")] + async fn get_configuration(&self) -> Result { + let config = crate::install::config::load_config() + .map_err(|e| InstallError::Failed { + message: format!("{e:#}"), + })? + .unwrap_or_default(); + + let config = serde_json::to_value(&config).map_err(|e| InstallError::Failed { + message: format!("serialization error: {e:#}"), + })?; + + Ok(GetConfigurationReply { config }) + } + + /// Install bootc to a block device or loopback file. + /// + /// This is the varlink equivalent of `bootc install to-disk`. It is a + /// long-running operation; when called with `more: true`, intermediate + /// replies may stream progress events. + #[zlink(interface = "containers.bootc.install")] + #[cfg(feature = "install-to-disk")] + async fn to_disk(&self, opts: ToDiskOpts) -> Result { + use crate::install::*; + + let filesystem = opts + .root_fs_type + .as_deref() + .map(config::Filesystem::try_from) + .transpose() + .map_err(|e| InstallError::Failed { + message: format!("{e:#}"), + })?; + + let install_opts = InstallToDiskOpts { + block_opts: baseline::InstallBlockDeviceOpts { + device: opts.device.into(), + wipe: false, + block_setup: None, + filesystem, + root_size: None, + }, + source_opts: InstallSourceOpts { + source_imgref: opts.source_imgref, + }, + target_opts: make_target_opts(opts.target_imgref), + config_opts: make_config_opts( + opts.kargs, + opts.disable_selinux, + opts.generic_image, + ), + via_loopback: opts.via_loopback, + composefs_opts: InstallComposefsOpts { + composefs_backend: opts.composefs_backend, + ..Default::default() + }, + }; + + install_to_disk(install_opts) + .await + .map_err(|e| InstallError::Failed { + message: format!("{e:#}"), + })?; + + Ok(InstallReply { + progress: None, + result: Some(InstallResult { + success: true, + message: "Installation complete".into(), + }), + }) + } + + /// Install bootc to a pre-mounted filesystem. + /// + /// This is the varlink equivalent of `bootc install to-filesystem`. + #[zlink(interface = "containers.bootc.install")] + async fn to_filesystem(&self, opts: ToFilesystemOpts) -> Result { + use crate::install::*; + + let install_opts = InstallToFilesystemOpts { + filesystem_opts: InstallTargetFilesystemOpts { + root_path: opts.root_path.into(), + root_mount_spec: opts.root_mount_spec, + boot_mount_spec: opts.boot_mount_spec, + replace: None, + acknowledge_destructive: false, + skip_finalize: opts.skip_finalize, + }, + source_opts: InstallSourceOpts { + source_imgref: opts.source_imgref, + }, + target_opts: make_target_opts(opts.target_imgref), + config_opts: make_config_opts(opts.kargs, opts.disable_selinux, false), + composefs_opts: InstallComposefsOpts { + composefs_backend: opts.composefs_backend, + ..Default::default() + }, + }; + + install_to_filesystem(install_opts, false, Cleanup::Skip) + .await + .map_err(|e| InstallError::Failed { + message: format!("{e:#}"), + })?; + + Ok(InstallReply { + progress: None, + result: Some(InstallResult { + success: true, + message: "Installation complete".into(), + }), + }) + } + + /// Install bootc to an existing root filesystem. + /// + /// This is the varlink equivalent of `bootc install to-existing-root`. + #[zlink(interface = "containers.bootc.install")] + async fn to_existing_root( + &self, + opts: ToExistingRootOpts, + ) -> Result { + use crate::install::*; + + let install_opts = InstallToExistingRootOpts { + replace: Some(ReplaceMode::Alongside), + source_opts: InstallSourceOpts { + source_imgref: opts.source_imgref, + }, + target_opts: make_target_opts(opts.target_imgref), + config_opts: make_config_opts(opts.kargs, opts.disable_selinux, false), + acknowledge_destructive: opts.acknowledge_destructive, + cleanup: opts.cleanup, + root_path: opts + .root_path + .unwrap_or_else(|| "/target".to_string()) + .into(), + composefs_opts: InstallComposefsOpts { + composefs_backend: opts.composefs_backend, + ..Default::default() + }, + }; + + install_to_existing_root(install_opts) + .await + .map_err(|e| InstallError::Failed { + message: format!("{e:#}"), + })?; + + Ok(InstallReply { + progress: None, + result: Some(InstallResult { + success: true, + message: "Installation complete".into(), + }), + }) + } +} + +// --------------------------------------------------------------------------- +// Client-side proxy traits +// --------------------------------------------------------------------------- + +/// Proxy for the `containers.bootc` interface (status queries). +#[allow(dead_code)] +#[zlink::proxy("containers.bootc")] +trait BootcProxy { + /// Get the current host status. + async fn get_status(&mut self) -> zlink::Result>; + + /// Get the status of a sysroot at an arbitrary path. + async fn get_status_for_sysroot( + &mut self, + sysroot_path: String, + ) -> zlink::Result>; +} + +/// Proxy for the `containers.bootc.update` interface (upgrade/switch). +#[allow(dead_code)] +#[zlink::proxy("containers.bootc.update")] +trait UpdateProxy { + /// Upgrade to a newer version of the current image. + async fn upgrade(&mut self) -> zlink::Result>; + + /// Switch to a different container image. + async fn switch( + &mut self, + target: String, + ) -> zlink::Result>; +} + +/// Proxy for the `containers.bootc.install` interface. +#[allow(dead_code)] +#[zlink::proxy("containers.bootc.install")] +trait InstallProxy { + /// Get the merged install configuration. + async fn get_configuration( + &mut self, + ) -> zlink::Result>; + + /// Install to a block device. + #[cfg(feature = "install-to-disk")] + async fn to_disk( + &mut self, + opts: ToDiskOpts, + ) -> zlink::Result>; + + /// Install to a pre-mounted filesystem. + async fn to_filesystem( + &mut self, + opts: ToFilesystemOpts, + ) -> zlink::Result>; + + /// Install to an existing root. + async fn to_existing_root( + &mut self, + opts: ToExistingRootOpts, + ) -> zlink::Result>; +} + +// --------------------------------------------------------------------------- +// Socket activation +// --------------------------------------------------------------------------- + +/// A `Listener` that yields a single pre-connected socket, then blocks forever. +/// +/// Used for `varlinkctl exec:` activation where a connected socket pair is +/// passed on fd 3. After the first `accept()` returns the connection, subsequent +/// calls pend indefinitely. +#[derive(Debug)] +struct ActivatedListener { + /// The connection to yield on the first accept(), consumed after use. + conn: Option>, +} + +impl zlink::Listener for ActivatedListener { + type Socket = zlink::unix::Stream; + + async fn accept(&mut self) -> zlink::Result> { + match self.conn.take() { + Some(conn) => Ok(conn), + None => std::future::pending().await, + } + } +} + +/// Try to build an [`ActivatedListener`] from a socket-activated fd. +/// +/// Uses `libsystemd` to receive file descriptors passed by the service +/// manager (checks `LISTEN_FDS`/`LISTEN_PID` and clears the env vars). +/// Returns `None` when the process was not socket-activated. +#[allow(unsafe_code)] +fn try_activated_listener() -> anyhow::Result> { + use std::os::fd::{FromRawFd as _, IntoRawFd as _}; + + let fds = libsystemd::activation::receive_descriptors(true) + .map_err(|e| anyhow::anyhow!("Failed to receive activation fds: {e}"))?; + + let fd = match fds.into_iter().next() { + Some(fd) => fd, + None => return Ok(None), + }; + + // SAFETY: `libsystemd::activation::receive_descriptors(true)` validated + // the fd and transferred ownership. `into_raw_fd()` consumes the + // `FileDescriptor` wrapper, giving us sole ownership of a valid fd. + let std_stream = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd.into_raw_fd()) }; + std_stream.set_nonblocking(true)?; + let tokio_stream = tokio::net::UnixStream::from_std(std_stream)?; + let zlink_stream = zlink::unix::Stream::from(tokio_stream); + let conn = zlink::Connection::from(zlink_stream); + Ok(Some(ActivatedListener { conn: Some(conn) })) +} + +/// If the process was socket-activated, serve varlink and return `true`. +/// +/// This follows the systemd/varlink activation pattern: if the process was +/// invoked with an activated socket (e.g. via `varlinkctl exec:`), it serves +/// varlink on that socket and returns `true` so the caller can exit. +/// Otherwise returns `false` and the process continues with normal CLI +/// handling. +pub(crate) async fn try_serve_varlink() -> anyhow::Result { + let listener = match try_activated_listener()? { + Some(l) => l, + None => return Ok(false), + }; + + tracing::debug!("Socket activation detected, serving varlink"); + let server = zlink::Server::new(listener, BootcService); + tokio::select! { + result = server.run() => result?, + _ = tokio::signal::ctrl_c() => { + tracing::debug!("Shutting down varlink server (activated)"); + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::VARLINK_API_VERSION; + + #[test] + fn varlink_version_is_consistent() { + // The version in the #[zlink::service] attribute must match + // VARLINK_API_VERSION. zlink doesn't allow consts in attribute + // position, so this test catches drift. + assert_eq!( + VARLINK_API_VERSION, "0.1.0", + "VARLINK_API_VERSION must match the #[zlink::service] version attribute" + ); + } +} diff --git a/crates/tests-integration/Cargo.toml b/crates/tests-integration/Cargo.toml index 8709c71e6..74112c7d1 100644 --- a/crates/tests-integration/Cargo.toml +++ b/crates/tests-integration/Cargo.toml @@ -40,6 +40,9 @@ rexpect = "0.7" scopeguard = "1.2.0" tar = "0.4" tokio = { workspace = true, features = ["rt", "macros"] } +libc = { workspace = true } +which = "7.0" +zlink = { workspace = true } [lints] workspace = true diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index a7d8e89d6..7aa717a7c 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -333,7 +333,7 @@ pub(crate) fn test_compute_composefs_digest() -> Result<()> { /// Tests that should be run in a default container image. #[context("Container tests")] pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { - let tests = [ + let mut tests: Vec = vec![ new_test("variant-base-crosscheck", test_variant_base_crosscheck), new_test("bootc upgrade", test_bootc_upgrade), new_test("install config", test_bootc_install_config), @@ -344,6 +344,7 @@ pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { new_test("container export tar", test_container_export_tar), new_test("compute-composefs-digest", test_compute_composefs_digest), ]; + tests.extend(crate::varlink::tests()); - libtest_mimic::run(&testargs, tests.into()).exit() + libtest_mimic::run(&testargs, tests).exit() } diff --git a/crates/tests-integration/src/tests-integration.rs b/crates/tests-integration/src/tests-integration.rs index d5c2e2dd6..001614d06 100644 --- a/crates/tests-integration/src/tests-integration.rs +++ b/crates/tests-integration/src/tests-integration.rs @@ -11,6 +11,7 @@ mod install; mod runvm; mod selinux; mod system_reinstall; +mod varlink; #[derive(Debug, Parser)] #[clap(name = "bootc-integration-tests", version, rename_all = "kebab-case")] diff --git a/crates/tests-integration/src/varlink.rs b/crates/tests-integration/src/varlink.rs new file mode 100644 index 000000000..de6a6e471 --- /dev/null +++ b/crates/tests-integration/src/varlink.rs @@ -0,0 +1,474 @@ +//! Integration tests for the bootc varlink IPC interface. +//! +//! Tests spawn bootc as a child process with a connected socketpair, +//! simulating socket activation, then use zlink proxy traits to make +//! typed varlink calls. + +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::sync::Arc; + +use anyhow::Result; +use cap_std_ext::cmdext::CapStdExtCommandExt; +use libtest_mimic::Trial; +use serde::Deserialize; + +// --------------------------------------------------------------------------- +// Client-side response types (redefined to keep integration tests +// independent of the bootc library) +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct GetStatusReply { + status: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +struct UpdateReply { + progress: Option, + result: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct GetConfigurationReply { + config: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +struct InstallReply { + progress: Option, + result: Option, +} + +/// Options for `ToDisk` (client-side, kept in sync with server). +#[derive(Debug, Clone, serde::Serialize, Deserialize, Default, zlink::introspect::Type)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +struct ToDiskOpts { + device: String, + source_imgref: Option, + target_imgref: Option, + #[serde(default)] + via_loopback: bool, + #[serde(default)] + kargs: Vec, + root_fs_type: Option, + #[serde(default)] + disable_selinux: bool, + #[serde(default)] + generic_image: bool, + #[serde(default)] + composefs_backend: bool, +} + +// --------------------------------------------------------------------------- +// Error types (needed by proxy return types) +// --------------------------------------------------------------------------- + +#[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)] +#[zlink(interface = "containers.bootc")] +enum BootcError { + Failed { + #[allow(dead_code)] + message: String, + }, +} + +impl std::fmt::Display for BootcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Failed { message } => write!(f, "bootc error: {message}"), + } + } +} + +impl std::error::Error for BootcError {} + +#[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)] +#[zlink(interface = "containers.bootc.update")] +enum UpdateError { + Failed { + #[allow(dead_code)] + message: String, + }, + NotBooted { + #[allow(dead_code)] + message: String, + }, +} + +impl std::fmt::Display for UpdateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Failed { message } => write!(f, "update failed: {message}"), + Self::NotBooted { message } => write!(f, "not booted: {message}"), + } + } +} + +impl std::error::Error for UpdateError {} + +#[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)] +#[zlink(interface = "containers.bootc.install")] +enum InstallError { + Failed { + #[allow(dead_code)] + message: String, + }, +} + +impl std::fmt::Display for InstallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Failed { message } => write!(f, "install failed: {message}"), + } + } +} + +impl std::error::Error for InstallError {} + +// --------------------------------------------------------------------------- +// Proxy traits +// --------------------------------------------------------------------------- + +#[zlink::proxy("containers.bootc")] +trait BootcProxy { + async fn get_status(&mut self) -> zlink::Result>; + async fn get_status_for_sysroot( + &mut self, + sysroot_path: String, + ) -> zlink::Result>; +} + +#[zlink::proxy("containers.bootc.update")] +trait UpdateProxy { + async fn upgrade(&mut self) -> zlink::Result>; + async fn switch( + &mut self, + target: String, + ) -> zlink::Result>; +} + +#[zlink::proxy("containers.bootc.install")] +trait InstallProxy { + async fn get_configuration( + &mut self, + ) -> zlink::Result>; + + async fn to_disk( + &mut self, + opts: ToDiskOpts, + ) -> zlink::Result>; +} + +// --------------------------------------------------------------------------- +// Helper: spawn bootc with socket activation +// --------------------------------------------------------------------------- + +/// Wraps a zlink connection to a socket-activated bootc process. +struct ActivatedBootc { + conn: zlink::Connection, + rt: tokio::runtime::Runtime, + /// Held to keep the child process alive; dropped when the test completes. + _child: std::process::Child, +} + +/// Spawn bootc with socket activation and return a zlink connection. +/// +/// Creates a Unix socketpair and spawns bootc with socket-activation +/// env vars. `LISTEN_PID` must equal the child's actual PID (which is +/// only known after `fork()`), so it is set in a `pre_exec` hook where +/// `std::process::id()` returns the child PID. The other env vars are +/// static and set via `Command::env()`. +fn activated_connection() -> Result { + let bootc_path = which_bootc()?; + let (ours, theirs) = UnixStream::pair()?; + let theirs_fd: Arc = Arc::new(theirs.into()); + + let mut cmd = Command::new(&bootc_path); + cmd.take_fd_n(theirs_fd, 3) + .lifecycle_bind_to_parent_thread(); + // All socket activation env vars must be set via libc::setenv in the + // pre_exec hook, NOT via Command::env(). When Command::env() is used, + // Rust builds a custom envp array *before* fork that is passed to exec, + // which does not include anything set by pre_exec. Using libc::setenv + // modifies the actual process environ which *is* inherited by exec when + // no custom envp is provided. + // + // LISTEN_PID must equal the child's PID, which is only known post-fork. + #[allow(unsafe_code)] + unsafe { + cmd.pre_exec(|| { + let pid = std::process::id(); + let pid_str = std::ffi::CString::new(pid.to_string()).unwrap(); + libc::setenv(c"LISTEN_PID".as_ptr(), pid_str.as_ptr(), 1); + libc::setenv(c"LISTEN_FDS".as_ptr(), c"1".as_ptr(), 1); + libc::setenv(c"LISTEN_FDNAMES".as_ptr(), c"varlink".as_ptr(), 1); + Ok(()) + }); + } + let child = cmd.spawn()?; + + ours.set_nonblocking(true)?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let tokio_stream = rt.block_on(async { tokio::net::UnixStream::from_std(ours) })?; + let zlink_stream = zlink::unix::Stream::from(tokio_stream); + let conn = zlink::Connection::from(zlink_stream); + + Ok(ActivatedBootc { + conn, + rt, + _child: child, + }) +} + +/// Find the bootc binary. +fn which_bootc() -> Result { + // Prefer BOOTC_TEST_BINARY env var, fall back to resolving via PATH. + // We always return an absolute path because `varlinkctl exec:` requires one. + if let Ok(p) = std::env::var("BOOTC_TEST_BINARY") { + return Ok(p); + } + let p = which::which("bootc") + .map_err(|e| anyhow::anyhow!("bootc not found in PATH: {e}"))?; + Ok(p.to_string_lossy().into_owned()) +} + +// =========================================================================== +// Tests: containers.bootc (status) +// =========================================================================== + +/// Verify that `GetStatus` returns a JSON object with expected top-level keys. +fn test_varlink_get_status() -> Result<()> { + let mut bootc = activated_connection()?; + let reply = bootc + .rt + .block_on(async { bootc.conn.get_status().await })??; + + let status = &reply.status; + assert!(status.is_object(), "status should be a JSON object"); + // The Host type always has apiVersion and kind + assert!( + status.get("apiVersion").is_some(), + "status should have apiVersion" + ); + assert!(status.get("kind").is_some(), "status should have kind"); + assert!(status.get("status").is_some(), "status should have status"); + Ok(()) +} + +/// Verify that the status `kind` is `BootcHost`. +fn test_varlink_status_kind() -> Result<()> { + let mut bootc = activated_connection()?; + let reply = bootc + .rt + .block_on(async { bootc.conn.get_status().await })??; + + let kind = reply.status.get("kind").and_then(|v| v.as_str()); + assert_eq!(kind, Some("BootcHost"), "kind should be BootcHost"); + Ok(()) +} + +/// Verify that calling GetStatus twice returns consistent results. +fn test_varlink_status_consistent() -> Result<()> { + let mut bootc = activated_connection()?; + let reply1 = bootc + .rt + .block_on(async { bootc.conn.get_status().await })??; + let reply2 = bootc + .rt + .block_on(async { bootc.conn.get_status().await })??; + + assert_eq!( + reply1.status, reply2.status, + "two consecutive GetStatus calls should return identical results" + ); + Ok(()) +} + +// =========================================================================== +// Tests: containers.bootc (GetStatusForSysroot) +// =========================================================================== + +/// Verify that `GetStatusForSysroot` with a bad path returns an error. +fn test_varlink_status_for_sysroot_bad_path() -> Result<()> { + let mut bootc = activated_connection()?; + let result = bootc.rt.block_on(async { + bootc + .conn + .get_status_for_sysroot("/nonexistent-path-for-varlink-test".into()) + .await + })?; + match result { + Err(BootcError::Failed { .. }) => Ok(()), + Ok(_) => Err(anyhow::anyhow!( + "expected Failed error for nonexistent sysroot, got success" + )), + } +} + +// =========================================================================== +// Tests: containers.bootc.update (upgrade/switch) +// =========================================================================== + +/// Verify that `Upgrade` in a non-booted container returns `NotBooted`. +fn test_varlink_upgrade_not_booted() -> Result<()> { + let mut bootc = activated_connection()?; + let result = bootc + .rt + .block_on(async { bootc.conn.upgrade().await })?; + match result { + Err(UpdateError::NotBooted { .. }) => Ok(()), + Err(other) => Err(anyhow::anyhow!("expected NotBooted, got: {other}")), + Ok(_) => Err(anyhow::anyhow!( + "expected NotBooted error, got success" + )), + } +} + +/// Verify that `Switch` in a non-booted container returns `NotBooted`. +fn test_varlink_switch_not_booted() -> Result<()> { + let mut bootc = activated_connection()?; + let result = bootc.rt.block_on(async { + bootc + .conn + .switch("quay.io/example/test:latest".to_string()) + .await + })?; + match result { + Err(UpdateError::NotBooted { .. }) => Ok(()), + Err(other) => Err(anyhow::anyhow!("expected NotBooted, got: {other}")), + Ok(_) => Err(anyhow::anyhow!( + "expected NotBooted error, got success" + )), + } +} + +// =========================================================================== +// Tests: containers.bootc.install +// =========================================================================== + +/// Verify that `GetConfiguration` returns a JSON object. +fn test_varlink_get_configuration() -> Result<()> { + let mut bootc = activated_connection()?; + let reply = bootc + .rt + .block_on(async { bootc.conn.get_configuration().await })??; + + let config = &reply.config; + assert!( + config.is_object(), + "config should be a JSON object, got: {config}" + ); + Ok(()) +} + +/// Verify that `ToDisk` with a nonexistent device returns `Failed`. +fn test_varlink_to_disk_bad_device() -> Result<()> { + let mut bootc = activated_connection()?; + let result = bootc.rt.block_on(async { + bootc + .conn + .to_disk(ToDiskOpts { + device: "/dev/nonexistent-device-for-varlink-test".into(), + ..Default::default() + }) + .await + })?; + match result { + Err(InstallError::Failed { .. }) => Ok(()), + Ok(_) => Err(anyhow::anyhow!( + "expected Failed error for nonexistent device, got success" + )), + } +} + +// =========================================================================== +// Tests: varlinkctl exec: (if available) +// =========================================================================== + +/// Verify that `varlinkctl introspect exec:bootc` works for all interfaces. +/// +/// This validates that socket activation works end-to-end with varlinkctl. +/// We use `introspect` rather than `call` because systemd's varlinkctl +/// sends method calls with an empty `parameters: {}` key even for +/// zero-argument methods, which zlink's deserializer rejects. This is +/// a varlinkctl/zlink interop issue tracked upstream. The socketpair-based +/// tests above cover actual method calls comprehensively. +fn test_varlink_introspect_varlinkctl() -> Result<()> { + if which::which("varlinkctl").is_err() { + eprintln!("skipping varlinkctl introspect test: varlinkctl not found in PATH"); + return Ok(()); + } + + let bootc_path = which_bootc()?; + let sh = xshell::Shell::new()?; + + for iface in [ + "containers.bootc", + "containers.bootc.update", + "containers.bootc.install", + ] { + let output = xshell::cmd!( + sh, + "varlinkctl introspect exec:{bootc_path} {iface}" + ) + .read()?; + assert!( + output.contains(iface), + "introspect output missing '{iface}'" + ); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Test registration +// --------------------------------------------------------------------------- + +fn new_test(description: &'static str, f: fn() -> Result<()>) -> Trial { + Trial::test(description, move || f().map_err(Into::into)) +} + +/// All varlink integration tests, suitable for running in a container +/// environment (non-destructive). +pub(crate) fn tests() -> Vec { + vec![ + new_test("varlink get-status", test_varlink_get_status), + new_test("varlink status-kind", test_varlink_status_kind), + new_test("varlink status-consistent", test_varlink_status_consistent), + new_test( + "varlink status-for-sysroot-bad-path", + test_varlink_status_for_sysroot_bad_path, + ), + new_test( + "varlink upgrade-not-booted", + test_varlink_upgrade_not_booted, + ), + new_test( + "varlink switch-not-booted", + test_varlink_switch_not_booted, + ), + new_test( + "varlink get-configuration", + test_varlink_get_configuration, + ), + new_test( + "varlink to-disk-bad-device", + test_varlink_to_disk_bad_device, + ), + new_test( + "varlink introspect-varlinkctl", + test_varlink_introspect_varlinkctl, + ), + ] +} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index d4d046523..b30c2591b 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -65,6 +65,7 @@ - [fsck](experimental-fsck.md) - [install reset](experimental-install-reset.md) - [--progress-fd](experimental-progress-fd.md) +- [varlink IPC](experimental-varlink.md) - [container export](experimental-container-export.md) # More information diff --git a/docs/src/experimental-varlink.md b/docs/src/experimental-varlink.md new file mode 100644 index 000000000..63850b990 --- /dev/null +++ b/docs/src/experimental-varlink.md @@ -0,0 +1,247 @@ + +# Varlink IPC interface + +This is an experimental feature; tracking issue: + +bootc exposes a [varlink](https://varlink.org/) interface for programmatic +access. This is intended for building higher-level tooling (such as desktop +update managers or orchestration systems) on top of bootc without parsing +CLI output or relying on the `--progress-fd` pipe protocol. + +## Usage via subprocess + +bootc serves varlink via [socket activation](https://varlink.org/#activation). +The simplest way to use it is via `varlinkctl exec:`, which spawns bootc +as a subprocess, passes a connected socket on fd 3, and sends a single call: + +```bash +varlinkctl call exec:bootc containers.bootc.GetStatus +``` + +This returns the same JSON structure as `bootc status --json`. + +## Introspecting the interface + +To see the methods, types, and errors exposed by the running binary: + +```bash +varlinkctl introspect exec:bootc containers.bootc +varlinkctl introspect exec:bootc containers.bootc.update +varlinkctl introspect exec:bootc containers.bootc.install +``` + +## Interfaces + +### `containers.bootc` + +Read-only queries about the host. + +| Method | Description | +|--------|-------------| +| `GetStatus` | Returns the full host status (same schema as `bootc status --json`). | +| `GetStatusForSysroot(sysroot_path)` | Query the status of a sysroot at an arbitrary path (e.g. after install). | + +Example -- querying the running host: + +```bash +$ varlinkctl call exec:bootc containers.bootc.GetStatus +{ + "status": { + "apiVersion": "org.containers.bootc/v1", + "kind": "BootcHost", + "metadata": { "name": "host" }, + "spec": { ... }, + "status": { "staged": ..., "booted": ..., "rollback": ... } + } +} +``` + +Example -- querying a freshly installed sysroot: + +```bash +$ varlinkctl call exec:bootc containers.bootc.GetStatusForSysroot \ + '{"sysroot_path": "/mnt/installed-root"}' +``` + +This returns the same `Host` schema, with `booted` and `staged` set +to `null` and all deployments listed in `otherDeployments`. This is +intended for install API consumers (Anaconda, osbuild, custom tooling) +that need to discover deployment paths, image digests, stateroot names, +etc. immediately after installation without rebooting. + +### `containers.bootc.update` + +Mutating operations with streaming progress. + +| Method | Description | +|--------|-------------| +| `Upgrade` | Upgrade to the latest version of the current image. | +| `Switch(target)` | Switch to a different container image. | + +These methods are designed to use varlink's `more`/`continues` streaming +for progress notifications (see below). They currently require a booted +host system and will return a `NotBooted` error when run inside a +container. + +### `containers.bootc.install` + +Installation operations. These are the varlink equivalents of the +`bootc install` subcommands. + +| Method | Description | +|--------|-------------| +| `GetConfiguration` | Returns the merged install configuration (same as `bootc install print-configuration --all`). | +| `ToDisk(opts)` | Install to a block device or loopback file (`bootc install to-disk`). | +| `ToFilesystem(opts)` | Install to a pre-mounted filesystem (`bootc install to-filesystem`). | +| `ToExistingRoot(opts)` | Install alongside an existing root (`bootc install to-existing-root`). | + +Example -- querying the install configuration: + +```bash +$ varlinkctl call exec:bootc containers.bootc.install.GetConfiguration +{ + "config": { + "root-fs-type": null, + "filesystem": null, + "kargs": null, + ... + } +} +``` + +The install methods accept a structured `opts` object. Use +`varlinkctl introspect exec:bootc containers.bootc.install` to see the +full schema. For example, `ToDisk` accepts: + +```json +{ + "opts": { + "device": "/dev/vda", + "viaLoopback": false, + "genericImage": true, + "disableSelinux": false, + "composefsBackend": false, + "kargs": ["console=ttyS0,115200n8"] + } +} +``` + +These operations are destructive and require appropriate privileges +(typically running inside a privileged container with device access). + +## Progress via varlink streaming + +The `Upgrade`, `Switch`, and install methods support varlink's native +streaming protocol, which subsumes the +[`--progress-fd`](experimental-progress-fd.md) pipe-based API. + +When a client sends `{"more": true}` with a call, the server replies +multiple times: + +- **Intermediate replies** (`"continues": true`) carry a `progress` field + with byte-level or step-level progress events. +- **The final reply** (no `continues`) carries a `result` field with the + operation outcome. + +This maps to the same three deployment stages as `--progress-fd` (pulling, +importing, staging) but uses varlink's built-in framing instead of JSON +Lines over a raw pipe. + +### Progress event types + +**`ProgressBytes`** -- byte-level transfer progress (e.g. pulling layers): + +```json +{ + "progress": { + "type": "bytes", + "task": "pulling", + "description": "Pulling image", + "id": "quay.io/centos-bootc/centos-bootc:stream10", + "bytesCached": 0, + "bytes": 104857600, + "bytesTotal": 524288000, + "stepsCached": 0, + "steps": 3, + "stepsTotal": 7, + "subtasks": [ + { + "subtask": "ostree_derived", + "description": "Derived Layer:", + "id": "sha256:abc123...", + "bytesCached": 0, + "bytes": 52428800, + "bytesTotal": 104857600 + } + ] + } +} +``` + +**`ProgressSteps`** -- discrete operation phases (e.g. staging): + +```json +{ + "progress": { + "type": "steps", + "task": "staging", + "description": "Staging deployment", + "id": "staging", + "stepsCached": 0, + "steps": 1, + "stepsTotal": 4, + "subtasks": [ + { + "subtask": "deploying", + "description": "Deploying", + "id": "deploying", + "completed": false + } + ] + } +} +``` + +### Final result + +```json +{ + "result": { + "staged": true, + "noChange": false, + "message": "Queued for next boot: quay.io/centos-bootc/centos-bootc:stream10" + } +} +``` + +## Programmatic use from Rust + +The [zlink](https://docs.rs/zlink) crate provides typed proxy traits. +A client can connect via a Unix socketpair (the same pattern used by +`varlinkctl exec:`): + +```rust,ignore +use zlink::unix; + +// Connect to a running bootc varlink service +let mut conn = unix::connect("/run/bootc.varlink").await?; + +// Or spawn bootc with socket activation (socketpair on fd 3) +// and use the connection directly -- see the integration tests +// for the full pattern. +``` + +## Relationship with `--progress-fd` + +The varlink streaming progress is intended to eventually replace the +`--progress-fd` API. The progress event structure is intentionally +similar, but varlink provides several advantages: + +- **Framing**: varlink handles message framing (NUL-delimited JSON) + instead of requiring newline-delimited JSON Lines. +- **Bidirectional**: clients can cancel or query state mid-operation. +- **Typed**: the interface is self-describing via `varlinkctl introspect`. +- **Composable**: the same socket carries both the request and all + progress replies, rather than needing a separate file descriptor. + +Both APIs will coexist during the experimental period. diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 0a9d4368d..75ca4e8b2 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -231,4 +231,12 @@ execute: how: fmf test: - /tmt/tests/tests/test-39-upgrade-tag + +/plan-40-varlink-install-status: + summary: Varlink install-to-disk then query sysroot status + discover: + how: fmf + test: + - /tmt/tests/tests/test-40-varlink-install-status + extra-fixme_skip_if_composefs: true # END GENERATED PLANS diff --git a/tmt/tests/booted/test-varlink-install-status.nu b/tmt/tests/booted/test-varlink-install-status.nu new file mode 100644 index 000000000..1e26494ef --- /dev/null +++ b/tmt/tests/booted/test-varlink-install-status.nu @@ -0,0 +1,93 @@ +# number: 40 +# extra: +# fixme_skip_if_composefs: true +# tmt: +# summary: Varlink install-to-disk then query sysroot status +# duration: 30m +# +# Perform a loopback install, then use the varlink IPC interface to +# query the installed sysroot status via GetStatusForSysroot. +# This verifies the end-to-end flow: install via CLI, then query +# deployment metadata (image digest, stateroot, ostree commit) via +# the varlink API. + +use std assert +use tap.nu + +let target_image = (tap get_target_image) + +def main [] { + tap begin "varlink: install and query sysroot status" + + # --- Phase 1: Loopback install --- + truncate -s 10G disk.img + setenforce 0 + + let base_args = $"bootc install to-disk --disable-selinux --via-loopback --source-imgref ($target_image)" + + let install_cmd = if (tap is_composefs) { + let st = bootc status --json | from json + let bootloader = ($st.status.booted.composefs.bootloader | str downcase) + $"($base_args) --composefs-backend --bootloader=($bootloader) --filesystem ext4 ./disk.img" + } else { + $"($base_args) --filesystem xfs ./disk.img" + } + + tap run_install $install_cmd + + # --- Phase 2: Mount the installed sysroot --- + let mnt = "/var/mnt/installed" + mkdir $mnt + + # Find the root partition in the loopback image + let lodev = (losetup --find --show --partscan ./disk.img | str trim) + # Give the kernel a moment to create partition devices + udevadm settle + # Use lsblk to find the largest partition (the root fs) + let root_part = (lsblk -ln -o NAME,SIZE,TYPE $lodev + | lines + | where {|l| $l | str contains "part" } + | last + | split row " " + | first + | str trim) + mount $"/dev/($root_part)" $mnt + + # --- Phase 3: Query the installed sysroot status --- + let status = (bootc status --json --sysroot $mnt | from json) + assert equal ($status.apiVersion) "org.containers.bootc/v1" "apiVersion should match" + assert equal ($status.kind) "BootcHost" "kind should be BootcHost" + + # A non-booted sysroot has no staged/booted; deployments are in otherDeployments + assert ($status.status.staged? == null) "staged should be null for non-booted sysroot" + assert ($status.status.booted? == null) "booted should be null for non-booted sysroot" + let deployments = $status.status.otherDeployments + assert (($deployments | length) > 0) "should have at least one deployment" + + let primary = ($deployments | first) + + # Verify the primary deployment has image metadata + assert ($primary.image? != null) "deployment should have image info" + assert ($primary.image.image? != null) "deployment should have image reference" + assert ($primary.image.imageDigest? != null) "deployment should have image digest" + let digest = $primary.image.imageDigest + assert ($digest | str starts-with "sha256:") $"digest should start with sha256:, got ($digest)" + + # Verify ostree metadata is present + assert ($primary.ostree? != null) "deployment should have ostree info" + assert ($primary.ostree.stateroot? != null) "deployment should have stateroot" + assert ($primary.ostree.checksum? != null) "deployment should have ostree checksum" + + print $"Verified: stateroot=($primary.ostree.stateroot) digest=($digest)" + + # --- Phase 4: Also verify plain status still works --- + let plain = (bootc status --json | from json) + assert equal ($plain.kind) "BootcHost" "plain bootc status should also work" + + # --- Cleanup --- + umount $mnt + losetup -d $lodev + rm -f disk.img + + tap ok +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 0b66f4e89..81d53f79e 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -143,3 +143,8 @@ check: summary: Test bootc upgrade --tag functionality with containers-storage duration: 30m test: nu booted/test-upgrade-tag.nu + +/test-40-varlink-install-status: + summary: Varlink install-to-disk then query sysroot status + duration: 30m + test: nu booted/test-varlink-install-status.nu