From 8ec108ba83c79f9d41695b6bef0831f12f50e897 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 3 Apr 2026 00:55:10 +0000 Subject: [PATCH 1/3] deps: Add zlink varlink crate, relax unused_must_use to deny Prep for adding a varlink IPC interface. The zlink crate provides a Rust implementation of the varlink protocol. The unused_must_use lint is relaxed from "forbid" to "deny" so that zlink's proc-macro-generated #[allow(unused)] does not conflict; the practical enforcement is identical. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- Cargo.lock | 202 +++++++++++++++++++++++++++- Cargo.toml | 5 +- crates/lib/Cargo.toml | 1 + crates/tests-integration/Cargo.toml | 2 + 4 files changed, 208 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82619359c..2340e66ad 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]] @@ -3021,7 +3139,9 @@ dependencies = [ "tar", "tempfile", "tokio", + "which 7.0.3", "xshell", + "zlink", ] [[package]] @@ -3092,6 +3212,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -3517,6 +3638,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 +4098,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/tests-integration/Cargo.toml b/crates/tests-integration/Cargo.toml index 8709c71e6..312568500 100644 --- a/crates/tests-integration/Cargo.toml +++ b/crates/tests-integration/Cargo.toml @@ -40,6 +40,8 @@ rexpect = "0.7" scopeguard = "1.2.0" tar = "0.4" tokio = { workspace = true, features = ["rt", "macros"] } +which = "7.0" +zlink = { workspace = true } [lints] workspace = true From f74a06b2b72adf27a4a53457ba47f893b94555bb Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 3 Apr 2026 13:00:51 +0000 Subject: [PATCH 2/3] status: Add --sysroot flag to query non-booted sysroots Add `bootc status --sysroot ` to query the deployment state of an ostree sysroot at an arbitrary path. This is designed for install tooling (Anaconda, osbuild, custom installers) that needs to discover deployment metadata immediately after installation 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` are null -- all deployments appear in `otherDeployments`, ordered by ostree priority (first entry is what will boot next). The underlying `get_host_from_sysroot()` function opens the sysroot read-only, loads its deployments, and builds the standard Host/BootEntry structures via `boot_entry_from_deployment()`. Assisted-by: OpenCode (Claude Opus 4) --- crates/lib/src/cli.rs | 10 +++++++- crates/lib/src/status.rs | 51 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 1c5224f53..faed79ca1 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 @@ -2228,7 +2235,8 @@ mod tests { format: None, format_version: None, booted: false, - verbose: false + verbose: false, + sysroot: None, }) )); assert!(matches!( 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. From 4ff9997cad4e626233b56219a135206270d95285 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 3 Apr 2026 13:01:03 +0000 Subject: [PATCH 3/3] varlink: Add experimental IPC interface for status, update, and install Add a varlink IPC interface as an experimental/hidden feature, following the pattern established in bcvk (bootc-dev/bcvk@daa5d06a6e). Three interfaces are served: - `containers.bootc` with `GetStatus` and `GetStatusForSysroot` methods - `containers.bootc.update` with `Upgrade` and `Switch` methods designed for streaming progress via varlink `more`/`continues` - `containers.bootc.install` with `GetConfiguration`, `ToDisk`, `ToFilesystem`, and `ToExistingRoot` methods The server uses socket activation (LISTEN_FDS) and is transparent: if the process is not socket-activated, CLI behavior is unchanged. Integration tests follow the bcvk pattern: spawn the binary with a Unix socketpair simulating socket activation, then use zlink proxy traits for typed calls. A TMT booted test (test-40) does a real loopback install then queries the installed sysroot via `bootc status --sysroot`. Assisted-by: OpenCode (Claude Opus 4) --- Cargo.lock | 1 + crates/lib/src/cli.rs | 5 + crates/lib/src/install.rs | 2 +- crates/lib/src/lib.rs | 1 + crates/lib/src/varlink.rs | 784 ++++++++++++++++++ crates/tests-integration/Cargo.toml | 1 + crates/tests-integration/src/container.rs | 5 +- .../src/tests-integration.rs | 1 + crates/tests-integration/src/varlink.rs | 474 +++++++++++ docs/src/SUMMARY.md | 1 + docs/src/experimental-varlink.md | 247 ++++++ tmt/plans/integration.fmf | 8 + .../booted/test-varlink-install-status.nu | 93 +++ tmt/tests/tests.fmf | 5 + 14 files changed, 1625 insertions(+), 3 deletions(-) create mode 100644 crates/lib/src/varlink.rs create mode 100644 crates/tests-integration/src/varlink.rs create mode 100644 docs/src/experimental-varlink.md create mode 100644 tmt/tests/booted/test-varlink-install-status.nu diff --git a/Cargo.lock b/Cargo.lock index 2340e66ad..824325556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3128,6 +3128,7 @@ dependencies = [ "fn-error-context", "indicatif 0.18.3", "indoc", + "libc", "libtest-mimic", "oci-spec 0.9.0", "rand 0.10.0", diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index faed79ca1..14b9d59e2 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1605,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 } 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/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 312568500..74112c7d1 100644 --- a/crates/tests-integration/Cargo.toml +++ b/crates/tests-integration/Cargo.toml @@ -40,6 +40,7 @@ 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 } 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