diff --git a/rust/cbindgen.toml b/rust/cbindgen.toml index c81ae401ea..9fdaa9fae5 100644 --- a/rust/cbindgen.toml +++ b/rust/cbindgen.toml @@ -3,6 +3,8 @@ language = "C" header = "#pragma once\n#include \ntypedef GError RORGError;\ntypedef GHashTable RORGHashTable;\ntypedef GPtrArray RORGPtrArray;" trailer = """ G_DEFINE_AUTOPTR_CLEANUP_FUNC(RORTreefile, ror_treefile_free) +G_DEFINE_AUTOPTR_CLEANUP_FUNC(RORHistoryCtx, ror_history_ctx_free) +G_DEFINE_AUTO_CLEANUP_CLEAR_FUNC(RORHistoryEntry, ror_history_entry_clear) """ diff --git a/rust/src/history.rs b/rust/src/history.rs new file mode 100644 index 0000000000..fb00afb114 --- /dev/null +++ b/rust/src/history.rs @@ -0,0 +1,781 @@ +/* + * Copyright (C) 2019 Jonathan Lebon + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + */ + +//! High-level interface to retrieve host RPM-OSTree history. The two main C +//! APIs are `ror_history_ctx_new()` which creates a context object +//! (`HistoryCtx`), and `ror_history_ctx_next()`, which iterates through history +//! entries (`HistoryEntry`). +//! +//! The basic idea is that at deployment creation time, the upgrader does two +//! things: (1) it writes a GVariant file describing the deployment to +//! `/var/lib/rpm-ostree/history` and (2) it logs a journal message. These two +//! pieces are tied together through the deployment root timestamp (which is +//! used as the filename for the GVariant and is included in the message under +//! `DEPLOYMENT_TIMESTAMP`). Thus, we can retrieve the GVariant corresponding to +//! a specific journal message. See the upgrader code for more details. +//! +//! This journal message also includes the deployment path in `DEPLOYMENT_PATH`. +//! At boot time, `ostree-prepare-root` logs the resolved deployment path +//! in *its* message's `DEPLOYMENT_PATH` too. Thus, we can tie together boot +//! messages with their corresponding deployment messages. To do this, we do +//! something akin to the following: +//! +//! - starting from the most recent journal entry, go backwards searching for +//! OSTree boot messages +//! - when a boot message is found, keep going backwards to find its matching +//! RPM-OSTree deploy message by comparing the two messages' deployment path +//! fields +//! - when a match is found, return a `HistoryEntry` +//! - start up the search again for the next boot message +//! +//! There's some added complexity to deal with ordering between boot events and +//! deployment events, and some "reboot" squashing to yield a single +//! `HistoryEntry` if the system booted into the same deployment multiple times +//! in a row. +//! +//! The algorithm is streaming, i.e. it yields entries as it finds them, rather +//! than scanning the whole journal upfront. This can then be e.g. piped through +//! a pager, stopped after N entries, etc... + +use failure::{bail, Fallible}; +use openat::{self, Dir, SimpleType}; +use std::collections::VecDeque; +use std::ffi::CString; +use std::ops::Deref; +use std::path::Path; +use std::{fs, ptr}; +use systemd::journal::JournalRecord; + +use openat_ext::OpenatDirExt; + +#[cfg(test)] +use self::mock_journal as journal; +#[cfg(not(test))] +use systemd::journal; + +// msg ostree-prepare-root emits at boot time when it resolved the deployment */ +static OSTREE_BOOT_MSG: &'static str = "7170336a73ba4601bad31af888aa0df7"; +// msg rpm-ostree emits when it creates the deployment */ +static RPMOSTREE_DEPLOY_MSG: &'static str = "9bddbda177cd44d891b1b561a8a0ce9e"; + +static RPMOSTREE_HISTORY_DIR: &'static str = "/var/lib/rpm-ostree/history"; + +/// Context object used to iterate through `HistoryEntry` events. +pub struct HistoryCtx { + journal: journal::Journal, + marker_queue: VecDeque, + current_entry: Option, + search_mode: Option, + reached_eof: bool, +} + +// Markers are essentially deserialized journal messages, where all the +// interesting bits have been parsed out. + +/// Marker for OSTree boot messages. +struct BootMarker { + timestamp: u64, + path: String, + node: DevIno, +} + +/// Marker for RPM-OSTree deployment messages. +#[derive(Clone)] +struct DeploymentMarker { + timestamp: u64, + path: String, + node: DevIno, + cmdline: Option, +} + +enum Marker { + Boot(BootMarker), + Deployment(DeploymentMarker), +} + +#[derive(Clone, PartialEq)] +struct DevIno { + device: u64, + inode: u64, +} + +/// A history entry in the journal. It may represent multiple consecutive boots +/// into the same deployment. This struct is exposed directly via FFI to C. +#[repr(C)] +#[derive(PartialEq, Debug)] +pub struct HistoryEntry { + /// The deployment root timestamp. + deploy_timestamp: u64, + /// The command-line that was used to create the deployment, if any. + deploy_cmdline: *mut libc::c_char, + /// The number of consecutive times the deployment was booted. + boot_count: u64, + /// The first time the deployment was booted if multiple consecutive times. + first_boot_timestamp: u64, + /// The last time the deployment was booted if multiple consecutive times. + last_boot_timestamp: u64, + /// `true` if there are no more entries. + eof: bool, +} + +impl HistoryEntry { + /// Create a new `HistoryEntry` from a boot marker and a deployment marker. + fn new_from_markers(boot: BootMarker, deploy: DeploymentMarker) -> HistoryEntry { + HistoryEntry { + first_boot_timestamp: boot.timestamp, + last_boot_timestamp: boot.timestamp, + deploy_timestamp: deploy.timestamp, + deploy_cmdline: deploy + .cmdline + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()), + boot_count: 1, + eof: false, + } + } + + fn eof() -> HistoryEntry { + HistoryEntry { + eof: true, + first_boot_timestamp: 0, + last_boot_timestamp: 0, + deploy_timestamp: 0, + deploy_cmdline: ptr::null_mut(), + boot_count: 0, + } + } +} + +#[derive(PartialEq)] +enum JournalSearchMode { + BootMsgs, + BootAndDeploymentMsgs, +} + +#[cfg(not(test))] +fn journal_record_timestamp(journal: &journal::Journal) -> Fallible { + Ok(journal + .timestamp()? + .duration_since(std::time::UNIX_EPOCH)? + .as_secs()) +} + +#[cfg(test)] +fn journal_record_timestamp(journal: &journal::Journal) -> Fallible { + Ok(journal.current_timestamp.unwrap()) +} + +fn map_to_u64(s: Option<&T>) -> Option +where + T: Deref, +{ + s.and_then(|s| s.parse::().ok()) +} + +fn history_get_oldest_deployment_msg_timestamp() -> Fallible> { + let mut journal = journal::Journal::open(journal::JournalFiles::System, false, true)?; + journal.seek(journal::JournalSeek::Head)?; + journal.match_add("MESSAGE_ID", RPMOSTREE_DEPLOY_MSG)?; + while let Some(rec) = journal.next_record()? { + if let Some(ts) = map_to_u64(rec.get("DEPLOYMENT_TIMESTAMP")) { + return Ok(Some(ts)); + } + } + Ok(None) +} + +/// Gets the oldest deployment message in the journal, and nuke all the GVariant data files +/// that correspond to deployments older than that one. Essentially, this binds pruning to +/// journal pruning. Called from C through `ror_history_prune()`. +fn history_prune() -> Fallible<()> { + let oldest_timestamp = history_get_oldest_deployment_msg_timestamp()?; + + // Cleanup any entry older than the oldest entry in the journal. Also nuke anything else that + // doesn't belong here; we own this dir. + let dir = Dir::open(RPMOSTREE_HISTORY_DIR)?; + for entry in dir.list_dir(".")? { + let entry = entry?; + let ftype = dir.get_file_type(&entry)?; + + let fname = entry.file_name(); + if let Some(oldest_ts) = oldest_timestamp { + if ftype == SimpleType::File { + if let Some(ts) = map_to_u64(fname.to_str().as_ref()) { + if ts >= oldest_ts { + continue; + } + } + } + } + + if ftype == SimpleType::Dir { + fs::remove_dir_all(Path::new(RPMOSTREE_HISTORY_DIR).join(fname))?; + } else { + dir.remove_file(fname)?; + } + } + + Ok(()) +} + +impl HistoryCtx { + /// Create a new context object. Called from C through `ror_history_ctx_new()`. + fn new_boxed() -> Fallible> { + let mut journal = journal::Journal::open(journal::JournalFiles::System, false, true)?; + journal.seek(journal::JournalSeek::Tail)?; + + Ok(Box::new(HistoryCtx { + journal: journal, + marker_queue: VecDeque::new(), + current_entry: None, + search_mode: None, + reached_eof: false, + })) + } + + /// Ensures the journal filters are set up for the messages we're interested in. + fn set_search_mode(&mut self, mode: JournalSearchMode) -> Fallible<()> { + if Some(&mode) != self.search_mode.as_ref() { + self.journal.match_flush()?; + self.journal.match_add("MESSAGE_ID", OSTREE_BOOT_MSG)?; + if mode == JournalSearchMode::BootAndDeploymentMsgs { + self.journal.match_add("MESSAGE_ID", RPMOSTREE_DEPLOY_MSG)?; + } + self.search_mode = Some(mode); + } + Ok(()) + } + + /// Creates a marker from an OSTree boot message. Uses the timestamp of the message + /// itself as the boot time. Returns None if record is incomplete. + fn boot_record_to_marker(&self, record: &JournalRecord) -> Fallible> { + if let (Some(path), Some(device), Some(inode)) = ( + record.get("DEPLOYMENT_PATH"), + map_to_u64(record.get("DEPLOYMENT_DEVICE")), + map_to_u64(record.get("DEPLOYMENT_INODE")), + ) { + return Ok(Some(Marker::Boot(BootMarker { + timestamp: journal_record_timestamp(&self.journal)?, + path: path.clone(), + node: DevIno { device, inode }, + }))); + } + Ok(None) + } + + /// Creates a marker from an RPM-OSTree deploy message. Uses the `DEPLOYMENT_TIMESTAMP` + /// in the message as the deploy time. This matches the history gv filename for that + /// deployment. Returns None if record is incomplete. + fn deployment_record_to_marker(&self, record: &JournalRecord) -> Fallible> { + if let (Some(timestamp), Some(device), Some(inode), Some(path)) = ( + map_to_u64(record.get("DEPLOYMENT_TIMESTAMP")), + map_to_u64(record.get("DEPLOYMENT_DEVICE")), + map_to_u64(record.get("DEPLOYMENT_INODE")), + record.get("DEPLOYMENT_PATH"), + ) { + return Ok(Some(Marker::Deployment(DeploymentMarker { + timestamp, + node: DevIno { device, inode }, + path: path.clone(), + cmdline: record + .get("COMMAND_LINE") + .and_then(|s| CString::new(s.as_str()).ok()), + }))); + } + Ok(None) + } + + /// Goes to the next OSTree boot msg in the journal and returns its marker. + fn find_next_boot_marker(&mut self) -> Fallible> { + self.set_search_mode(JournalSearchMode::BootMsgs)?; + while let Some(rec) = self.journal.previous_record()? { + if let Some(Marker::Boot(m)) = self.boot_record_to_marker(&rec)? { + return Ok(Some(m)); + } + } + Ok(None) + } + + /// Returns a marker of the appropriate kind for a given journal message. + fn record_to_marker(&self, record: &JournalRecord) -> Fallible> { + Ok(match record.get("MESSAGE_ID").unwrap() { + m if m == OSTREE_BOOT_MSG => self.boot_record_to_marker(&record)?, + m if m == RPMOSTREE_DEPLOY_MSG => self.deployment_record_to_marker(&record)?, + m => panic!("matched an unwanted message: {:?}", m), + }) + } + + /// Goes to the next OSTree boot or RPM-OSTree deploy msg in the journal, creates a + /// marker for it, and returns it. + fn find_next_marker(&mut self) -> Fallible> { + self.set_search_mode(JournalSearchMode::BootAndDeploymentMsgs)?; + while let Some(rec) = self.journal.previous_record()? { + if let Some(marker) = self.record_to_marker(&rec)? { + return Ok(Some(marker)); + } + } + Ok(None) + } + + /// Finds the matching deployment marker for the next boot marker in the queue. + fn scan_until_path_match(&mut self) -> Fallible> { + // keep popping & scanning until we get to the next boot marker + let boot_marker = loop { + match self.marker_queue.pop_front() { + Some(Marker::Boot(m)) => break m, + Some(Marker::Deployment(_)) => continue, + None => match self.find_next_boot_marker()? { + Some(m) => break m, + None => return Ok(None), + }, + } + }; + + // check if its corresponding deployment is already in the queue + for marker in self.marker_queue.iter() { + if let Marker::Deployment(m) = marker { + if m.path == boot_marker.path { + return Ok(Some((boot_marker, m.clone()))); + } + } + } + + // keep collecting until we get a matching path + while let Some(marker) = self.find_next_marker()? { + self.marker_queue.push_back(marker); + // ...and now borrow it back; might be a cleaner way to do this + let marker = self.marker_queue.back().unwrap(); + + if let Marker::Deployment(m) = marker { + if m.path == boot_marker.path { + return Ok(Some((boot_marker, m.clone()))); + } + } + } + + Ok(None) + } + + /// Returns the next history entry, which consists of a boot timestamp and its matching + /// deploy timestamp. + fn scan_until_next_entry(&mut self) -> Fallible> { + while let Some((boot_marker, deployment_marker)) = self.scan_until_path_match()? { + if boot_marker.node != deployment_marker.node { + // This is a non-foolproof safety valve to ensure that the boot is definitely + // referring to the matched up deployment. E.g. if the correct, more recent, + // matching deployment somehow had its journal entry lost, we don't want to report + // this boot with the wrong match. For now, just silently skip over that boot. No + // history is better than wrong history. In the future, we could consider printing + // this somehow too. + continue; + } + return Ok(Some(HistoryEntry::new_from_markers( + boot_marker, + deployment_marker, + ))); + } + Ok(None) + } + + /// Returns the next *new* entry. This essentially collapses multiple subsequent boots + /// of the same deployment into a single entry. The `boot_count` field represents the + /// number of boots squashed, and `*_boot_timestamp` fields provide the timestamp of the + /// first and last boots. + fn scan_until_next_new_entry(&mut self) -> Fallible> { + while let Some(entry) = self.scan_until_next_entry()? { + if self.current_entry.is_none() { + /* first scan ever; prime with first entry */ + self.current_entry.replace(entry); + continue; + } + + let current_deploy_timestamp = self.current_entry.as_ref().unwrap().deploy_timestamp; + if entry.deploy_timestamp == current_deploy_timestamp { + /* we found an older boot for the same deployment: update first boot */ + let current_entry = &mut self.current_entry.as_mut().unwrap(); + current_entry.first_boot_timestamp = entry.first_boot_timestamp; + current_entry.boot_count += 1; + } else { + /* found a new boot for a different deployment; flush out current one */ + return Ok(self.current_entry.replace(entry)); + } + } + + /* flush out final entry if any */ + Ok(self.current_entry.take()) + } + + /// Returns the next entry. This is a thin wrapper around `scan_until_next_new_entry` + /// that mostly just handles the `Option` -> EOF conversion for the C side. Called from + /// C through `ror_history_ctx_next()`. + fn next_entry(&mut self) -> Fallible { + if self.reached_eof { + bail!("next_entry() called after having reached EOF!") + } + + match self.scan_until_next_new_entry()? { + Some(e) => Ok(e), + None => { + self.reached_eof = true; + Ok(HistoryEntry::eof()) + } + } + } +} + +/// A minimal mock journal interface so we can unit test various code paths without adding +/// stuff in the host journal; in fact without needing any system journal access at all. +#[cfg(test)] +mod mock_journal { + use super::Fallible; + pub use systemd::journal::{JournalFiles, JournalRecord, JournalSeek}; + + pub struct Journal { + pub entries: Vec<(u64, JournalRecord)>, + pub current_timestamp: Option, + msg_ids: Vec, + } + + impl Journal { + pub fn open(_: JournalFiles, _: bool, _: bool) -> Fallible { + Ok(Journal { + entries: Vec::new(), + current_timestamp: None, + msg_ids: Vec::new(), + }) + } + pub fn seek(&mut self, _: JournalSeek) -> Fallible<()> { + Ok(()) + } + pub fn match_flush(&mut self) -> Fallible<()> { + self.msg_ids.clear(); + Ok(()) + } + pub fn match_add(&mut self, _: &str, msg_id: &str) -> Fallible<()> { + self.msg_ids.push(msg_id.into()); + Ok(()) + } + pub fn previous_record(&mut self) -> Fallible> { + while let Some((timestamp, record)) = self.entries.pop() { + if self.msg_ids.contains(record.get("MESSAGE_ID").unwrap()) { + self.current_timestamp = Some(timestamp); + return Ok(Some(record)); + } + } + Ok(None) + } + // This is only used by the prune path, which we're not unit testing. + pub fn next_record(&mut self) -> Fallible> { + unimplemented!(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl HistoryCtx { + fn add_boot_record_inode(&mut self, ts: u64, path: &str, inode: u64) { + if let Some(entry) = self.journal.entries.last() { + assert!(ts > entry.0); + } + let mut record = JournalRecord::new(); + record.insert("MESSAGE_ID".into(), OSTREE_BOOT_MSG.into()); + record.insert("DEPLOYMENT_PATH".into(), path.into()); + record.insert("DEPLOYMENT_DEVICE".into(), inode.to_string()); + record.insert("DEPLOYMENT_INODE".into(), inode.to_string()); + self.journal.entries.push((ts, record)); + } + + fn add_boot_record(&mut self, ts: u64, path: &str) { + self.add_boot_record_inode(ts, path, 0); + } + + fn add_deployment_record_inode(&mut self, ts: u64, path: &str, inode: u64) { + if let Some(entry) = self.journal.entries.last() { + assert!(ts > entry.0); + } + let mut record = JournalRecord::new(); + record.insert("MESSAGE_ID".into(), RPMOSTREE_DEPLOY_MSG.into()); + record.insert("DEPLOYMENT_TIMESTAMP".into(), ts.to_string()); + record.insert("DEPLOYMENT_PATH".into(), path.into()); + record.insert("DEPLOYMENT_DEVICE".into(), inode.to_string()); + record.insert("DEPLOYMENT_INODE".into(), inode.to_string()); + self.journal.entries.push((ts, record)); + } + + fn add_deployment_record(&mut self, ts: u64, path: &str) { + self.add_deployment_record_inode(ts, path, 0); + } + + fn assert_next_entry( + &mut self, + first_boot_timestamp: u64, + last_boot_timestamp: u64, + deploy_timestamp: u64, + boot_count: u64, + ) { + assert!( + self.next_entry().unwrap() + == HistoryEntry { + first_boot_timestamp: first_boot_timestamp, + last_boot_timestamp: last_boot_timestamp, + deploy_timestamp: deploy_timestamp, + deploy_cmdline: ptr::null_mut(), + boot_count: boot_count, + eof: false, + } + ); + } + + fn assert_eof(&mut self) { + assert!(self.next_entry().unwrap().eof); + } + } + + #[test] + fn basic() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + assert!(ctx.next_entry().unwrap().eof); + assert!(ctx.next_entry().is_err()); + } + + #[test] + fn basic_deploy() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.assert_eof(); + } + + #[test] + fn basic_boot() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_boot_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.assert_eof(); + } + + #[test] + fn basic_match() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(1, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.assert_next_entry(1, 1, 0, 1); + ctx.assert_eof(); + } + + #[test] + fn multi_boot() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_boot_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(1, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_boot_record(3, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(4, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(5, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(6, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(7, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.assert_next_entry(5, 7, 4, 3); + ctx.assert_eof(); + } + + #[test] + fn multi_deployment() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(1, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(2, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(3, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_boot_record(4, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(5, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(6, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.assert_next_entry(4, 4, 2, 1); + ctx.assert_eof(); + } + + #[test] + fn multi1() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(1, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(2, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(3, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(4, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_boot_record(5, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_boot_record(6, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.assert_next_entry(5, 6, 4, 2); + ctx.assert_next_entry(1, 3, 0, 3); + ctx.assert_eof(); + } + + #[test] + fn multi2() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(1, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_deployment_record(2, "/ostree/deploy/fedora/deploy/deadcafe.2"); + ctx.add_deployment_record(3, "/ostree/deploy/fedora/deploy/deadcafe.3"); + ctx.add_deployment_record(4, "/ostree/deploy/fedora/deploy/deadcafe.4"); + ctx.add_boot_record(5, "/ostree/deploy/fedora/deploy/deadcafe.4"); + ctx.add_boot_record(6, "/ostree/deploy/fedora/deploy/deadcafe.3"); + ctx.add_boot_record(7, "/ostree/deploy/fedora/deploy/deadcafe.2"); + ctx.add_boot_record(8, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_boot_record(9, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.assert_next_entry(9, 9, 0, 1); + ctx.assert_next_entry(8, 8, 1, 1); + ctx.assert_next_entry(7, 7, 2, 1); + ctx.assert_next_entry(6, 6, 3, 1); + ctx.assert_next_entry(5, 5, 4, 1); + ctx.assert_eof(); + } + + #[test] + fn multi3() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(1, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_deployment_record(2, "/ostree/deploy/fedora/deploy/deadcafe.2"); + ctx.add_deployment_record(3, "/ostree/deploy/fedora/deploy/deadcafe.3"); + ctx.add_deployment_record(4, "/ostree/deploy/fedora/deploy/deadcafe.4"); + ctx.add_boot_record(5, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(6, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_boot_record(7, "/ostree/deploy/fedora/deploy/deadcafe.2"); + ctx.add_boot_record(8, "/ostree/deploy/fedora/deploy/deadcafe.3"); + ctx.add_boot_record(9, "/ostree/deploy/fedora/deploy/deadcafe.4"); + ctx.assert_next_entry(9, 9, 4, 1); + ctx.assert_next_entry(8, 8, 3, 1); + ctx.assert_next_entry(7, 7, 2, 1); + ctx.assert_next_entry(6, 6, 1, 1); + ctx.assert_next_entry(5, 5, 0, 1); + ctx.assert_eof(); + } + + #[test] + fn multi4() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(1, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(2, "/ostree/deploy/fedora/deploy/deadcafe.2"); + ctx.add_boot_record(3, "/ostree/deploy/fedora/deploy/deadcafe.2"); + ctx.add_deployment_record(4, "/ostree/deploy/fedora/deploy/deadcafe.4"); + ctx.add_boot_record(5, "/ostree/deploy/fedora/deploy/deadcafe.4"); + ctx.add_deployment_record(6, "/ostree/deploy/fedora/deploy/deadcafe.6"); + ctx.add_boot_record(7, "/ostree/deploy/fedora/deploy/deadcafe.6"); + ctx.add_deployment_record(8, "/ostree/deploy/fedora/deploy/deadcafe.8"); + ctx.add_boot_record(9, "/ostree/deploy/fedora/deploy/deadcafe.8"); + ctx.assert_next_entry(9, 9, 8, 1); + ctx.assert_next_entry(7, 7, 6, 1); + ctx.assert_next_entry(5, 5, 4, 1); + ctx.assert_next_entry(3, 3, 2, 1); + ctx.assert_next_entry(1, 1, 0, 1); + ctx.assert_eof(); + } + + #[test] + fn multi5() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record(0, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_deployment_record(1, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_boot_record(2, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(3, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(4, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(5, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_boot_record(6, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.add_boot_record(7, "/ostree/deploy/fedora/deploy/deadcafe.0"); + ctx.add_boot_record(8, "/ostree/deploy/fedora/deploy/deadcafe.1"); + ctx.assert_next_entry(8, 8, 1, 1); + ctx.assert_next_entry(7, 7, 0, 1); + ctx.assert_next_entry(5, 6, 1, 2); + ctx.assert_next_entry(2, 4, 0, 3); + ctx.assert_eof(); + } + + #[test] + fn inode1() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record_inode(0, "/ostree/deploy/fedora/deploy/deadcafe.0", 1000); + ctx.add_deployment_record_inode(1, "/ostree/deploy/fedora/deploy/deadcafe.1", 2000); + ctx.add_boot_record_inode(2, "/ostree/deploy/fedora/deploy/deadcafe.0", 1000); + ctx.add_boot_record_inode(3, "/ostree/deploy/fedora/deploy/deadcafe.1", 2000); + ctx.assert_next_entry(3, 3, 1, 1); + ctx.assert_next_entry(2, 2, 0, 1); + ctx.assert_eof(); + } + + #[test] + fn inode2() { + let mut ctx = HistoryCtx::new_boxed().unwrap(); + ctx.add_deployment_record_inode(0, "/ostree/deploy/fedora/deploy/deadcafe.0", 1000); + ctx.add_deployment_record_inode(1, "/ostree/deploy/fedora/deploy/deadcafe.1", 2000); + ctx.add_boot_record_inode(2, "/ostree/deploy/fedora/deploy/deadcafe.1", 2000); + ctx.add_boot_record_inode(3, "/ostree/deploy/fedora/deploy/deadcafe.0", 1000); + ctx.add_boot_record_inode(4, "/ostree/deploy/fedora/deploy/deadcafe.0", 1001); + ctx.assert_next_entry(3, 3, 0, 1); + ctx.assert_next_entry(2, 2, 1, 1); + ctx.assert_eof(); + } +} + +mod ffi { + use super::*; + use glib_sys; + use libc; + + use crate::ffiutil::*; + + #[no_mangle] + pub extern "C" fn ror_history_ctx_new(gerror: *mut *mut glib_sys::GError) -> *mut HistoryCtx { + ptr_glib_error(HistoryCtx::new_boxed(), gerror) + } + + #[no_mangle] + pub extern "C" fn ror_history_ctx_next( + hist: *mut HistoryCtx, + entry: *mut HistoryEntry, + gerror: *mut *mut glib_sys::GError, + ) -> libc::c_int { + let hist = ref_from_raw_ptr(hist); + let entry = ref_from_raw_ptr(entry); + int_glib_error(hist.next_entry().map(|e| *entry = e), gerror) + } + + #[no_mangle] + pub extern "C" fn ror_history_ctx_free(hist: *mut HistoryCtx) { + if hist.is_null() { + return; + } + unsafe { + Box::from_raw(hist); + } + } + + #[no_mangle] + pub extern "C" fn ror_history_entry_clear(entry: *mut HistoryEntry) { + let entry = ref_from_raw_ptr(entry); + if !entry.deploy_cmdline.is_null() { + unsafe { + CString::from_raw(entry.deploy_cmdline); + } + } + } + + #[no_mangle] + pub extern "C" fn ror_history_prune(gerror: *mut *mut glib_sys::GError) -> libc::c_int { + int_glib_error(history_prune(), gerror) + } +} +pub use self::ffi::*; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index fe669b7212..d91e7fe150 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -10,6 +10,8 @@ mod libdnf_sys; mod composepost; pub use self::composepost::*; +mod history; +pub use self::history::*; mod journal; pub use self::journal::*; mod progress; diff --git a/src/app/rpmostree-builtin-ex.c b/src/app/rpmostree-builtin-ex.c index 70d95d597a..2b8fe7a9c7 100644 --- a/src/app/rpmostree-builtin-ex.c +++ b/src/app/rpmostree-builtin-ex.c @@ -32,6 +32,8 @@ static RpmOstreeCommand ex_subcommands[] = { "Convert an OSTree commit into an rpm-ostree rojig", rpmostree_ex_builtin_commit2rojig }, { "rojig2commit", RPM_OSTREE_BUILTIN_FLAG_LOCAL_CMD, "Convert an rpm-ostree rojig into an OSTree commit", rpmostree_ex_builtin_rojig2commit }, + { "history", RPM_OSTREE_BUILTIN_FLAG_LOCAL_CMD, + "Inspect RPM-OSTree history of the system", rpmostree_ex_builtin_history }, /* temporary aliases; nuke in next version */ { "reset", RPM_OSTREE_BUILTIN_FLAG_SUPPORTS_PKG_INSTALLS | RPM_OSTREE_BUILTIN_FLAG_HIDDEN, NULL, rpmostree_builtin_reset }, diff --git a/src/app/rpmostree-builtin-status.c b/src/app/rpmostree-builtin-status.c index ae636cf28c..2bc2c4b59a 100644 --- a/src/app/rpmostree-builtin-status.c +++ b/src/app/rpmostree-builtin-status.c @@ -29,6 +29,7 @@ #include #include "rpmostree-builtins.h" +#include "rpmostree-ex-builtins.h" #include "rpmostree-libbuiltin.h" #include "rpmostree-dbus-helpers.h" #include "rpmostree-util.h" @@ -1097,3 +1098,174 @@ rpmostree_builtin_status (int argc, return TRUE; } + +/* XXX +static gboolean opt_no_pager; +static gboolean opt_deployments; +*/ + +static GOptionEntry history_option_entries[] = { + { "verbose", 'v', 0, G_OPTION_ARG_NONE, &opt_verbose, "Print additional fields (e.g. StateRoot)", NULL }, + { "json", 0, 0, G_OPTION_ARG_NONE, &opt_json, "Output JSON", NULL }, + /* XXX { "deployments", 0, 0, G_OPTION_ARG_NONE, &opt_deployments, "Print all deployments, not just those booted into", NULL }, */ + /* XXX { "no-pager", 0, 0, G_OPTION_ARG_NONE, &opt_no_pager, "Don't use a pager to display output", NULL }, */ + { NULL } +}; + +/* Read from history db, sets @out_deployment to NULL on ENOENT. */ +static gboolean +fetch_history_deployment_gvariant (RORHistoryEntry *entry, + GVariant **out_deployment, + GError **error) +{ + g_autofree char *fn = + g_strdup_printf ("%s/%lu", RPMOSTREE_HISTORY_DIR, entry->deploy_timestamp); + + *out_deployment = NULL; + + glnx_autofd int fd = -1; + g_autoptr(GError) local_error = NULL; + if (!glnx_openat_rdonly (AT_FDCWD, fn, TRUE, &fd, &local_error)) + { + if (!g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND)) + return g_propagate_error (error, g_steal_pointer (&local_error)), FALSE; + return TRUE; /* Note early return */ + } + + g_autoptr(GBytes) data = glnx_fd_readall_bytes (fd, NULL, error); + if (!data) + return FALSE; + + *out_deployment = + g_variant_ref_sink (g_variant_new_from_bytes (G_VARIANT_TYPE_VARDICT, data, FALSE)); + return TRUE; +} + +static void +print_timestamp_and_relative (const char* key, guint64 t) +{ + g_autofree char *ts = rpmostree_timestamp_str_from_unix_utc (t); + char time_rel[FORMAT_TIMESTAMP_RELATIVE_MAX] = ""; + libsd_format_timestamp_relative (time_rel, sizeof(time_rel), t * USEC_PER_SEC); + if (key) + g_print ("%s: ", key); + g_print ("%s (%s)\n", ts, time_rel); +} + +static gboolean +print_history_entry (RORHistoryEntry *entry, + GError **error) +{ + g_autoptr(GVariant) deployment = NULL; + if (!fetch_history_deployment_gvariant (entry, &deployment, error)) + return FALSE; + + if (!opt_json) + { + print_timestamp_and_relative ("BootTimestamp", entry->last_boot_timestamp); + if (entry->boot_count > 1) + { + g_print ("%s BootCount: %lu; first booted on ", + libsd_special_glyph (TREE_RIGHT), entry->boot_count); + print_timestamp_and_relative (NULL, entry->first_boot_timestamp); + } + + print_timestamp_and_relative ("CreateTimestamp", entry->deploy_timestamp); + if (entry->deploy_cmdline) + g_print ("CreateCommand: %s%s%s\n", + get_bold_start (), entry->deploy_cmdline, get_bold_end ()); + if (!deployment) + /* somehow we're missing an entry? XXX: just fallback to checksum, version, refspec + * from journal entry in this case */ + g_print (" << Missing history information >>\n"); + + /* XXX: factor out interesting bits from print_one_deployment() */ + else if (!print_one_deployment (NULL, deployment, TRUE, FALSE, FALSE, + NULL, NULL, NULL, NULL, error)) + return FALSE; + } + else + { + /* NB: notice we implicitly print as a stream of objects rather than an array */ + + glnx_unref_object JsonBuilder *builder = json_builder_new (); + json_builder_begin_object (builder); + + if (deployment) + { + json_builder_set_member_name (builder, "deployment"); + json_builder_add_value (builder, json_gvariant_serialize (deployment)); + } + + json_builder_set_member_name (builder, "deployment-create-timestamp"); + json_builder_add_int_value (builder, entry->deploy_timestamp); + json_builder_set_member_name (builder, "deployment-create-command-line"); + json_builder_add_string_value (builder, entry->deploy_cmdline); + json_builder_set_member_name (builder, "boot-count"); + json_builder_add_int_value (builder, entry->boot_count); + json_builder_set_member_name (builder, "first-boot-timestamp"); + json_builder_add_int_value (builder, entry->first_boot_timestamp); + json_builder_set_member_name (builder, "last-boot-timestamp"); + json_builder_add_int_value (builder, entry->last_boot_timestamp); + json_builder_end_object (builder); + + glnx_unref_object JsonGenerator *generator = json_generator_new (); + json_generator_set_pretty (generator, TRUE); + json_generator_set_root (generator, json_builder_get_root (builder)); + glnx_unref_object GOutputStream *stdout_gio = g_unix_output_stream_new (1, FALSE); + /* NB: watch out for the misleading API docs */ + if (json_generator_to_stream (generator, stdout_gio, NULL, error) <= 0 + || (error != NULL && *error != NULL)) + return FALSE; + } + + g_print ("\n"); + return TRUE; +} + +/* The `history` also lives here since the printing bits re-use a lot of the `status` + * machinery. */ +gboolean +rpmostree_ex_builtin_history (int argc, + char **argv, + RpmOstreeCommandInvocation *invocation, + GCancellable *cancellable, + GError **error) +{ + g_autoptr(GOptionContext) context = g_option_context_new (""); + if (!rpmostree_option_context_parse (context, + history_option_entries, + &argc, &argv, + invocation, + cancellable, + NULL, NULL, NULL, NULL, NULL, + error)) + return FALSE; + + /* initiate a history context, then iterate over each (boot time, deploy time), then print */ + + /* XXX: enhance with option for going in reverse (oldest first) */ + g_autoptr(RORHistoryCtx) history_ctx = ror_history_ctx_new (error); + if (!history_ctx) + return FALSE; + + /* XXX: use pager here */ + + gboolean at_least_one = FALSE; + while (TRUE) + { + g_auto(RORHistoryEntry) entry = { 0, }; + if (!ror_history_ctx_next (history_ctx, &entry, error)) + return FALSE; + if (entry.eof) + break; + if (!print_history_entry (&entry, error)) + return FALSE; + at_least_one = TRUE; + } + + if (!at_least_one) + g_print ("<< No entries found >>\n"); + + return TRUE; +} diff --git a/src/app/rpmostree-ex-builtins.h b/src/app/rpmostree-ex-builtins.h index 99b6adb75b..da6271eb12 100644 --- a/src/app/rpmostree-ex-builtins.h +++ b/src/app/rpmostree-ex-builtins.h @@ -34,6 +34,7 @@ BUILTINPROTO(unpack); BUILTINPROTO(livefs); BUILTINPROTO(commit2rojig); BUILTINPROTO(rojig2commit); +BUILTINPROTO(history); #undef BUILTINPROTO diff --git a/src/daemon/rpmostree-sysroot-core.c b/src/daemon/rpmostree-sysroot-core.c index 2da9d6b037..974ec50eaa 100644 --- a/src/daemon/rpmostree-sysroot-core.c +++ b/src/daemon/rpmostree-sysroot-core.c @@ -33,6 +33,7 @@ #include "rpmostree-rpm-util.h" #include "rpmostree-postprocess.h" #include "rpmostree-output.h" +#include "rpmostree-rust.h" #include "ostree-repo.h" @@ -295,6 +296,9 @@ rpmostree_syscore_cleanup (OstreeSysroot *sysroot, if (!glnx_shutil_rm_rf_at (repo_dfd, RPMOSTREE_TMP_ROOTFS_DIR, cancellable, error)) return FALSE; + /* also delete extra history entries */ + if (!ror_history_prune (error)) + return FALSE; /* Regenerate all refs */ guint n_pkgcache_freed = 0; diff --git a/src/daemon/rpmostree-sysroot-upgrader.c b/src/daemon/rpmostree-sysroot-upgrader.c index fc25e1fac7..e3f5acef90 100644 --- a/src/daemon/rpmostree-sysroot-upgrader.c +++ b/src/daemon/rpmostree-sysroot-upgrader.c @@ -30,14 +30,18 @@ #include "rpmostree-origin.h" #include "rpmostree-kernel.h" #include "rpmostreed-daemon.h" +#include "rpmostreed-deployment-utils.h" #include "rpmostree-kernel.h" #include "rpmostree-rpm-util.h" #include "rpmostree-postprocess.h" #include "rpmostree-output.h" #include "rpmostree-scripts.h" +#include "rpmostree-rust.h" #include "ostree-repo.h" +#define RPMOSTREE_NEW_DEPLOYMENT_MSG SD_ID128_MAKE(9b,dd,bd,a1,77,cd,44,d8,91,b1,b5,61,a8,a0,ce,9e) + /** * SECTION:rpmostree-sysroot-upgrader * @title: Simple upgrade class @@ -1226,6 +1230,72 @@ rpmostree_sysroot_upgrader_set_kargs (RpmOstreeSysrootUpgrader *self, self->kargs_strv = g_strdupv (kernel_args); } +static gboolean +write_history (RpmOstreeSysrootUpgrader *self, + OstreeDeployment *new_deployment, + const char *initiating_command_line, + GCancellable *cancellable, + GError **error) +{ + g_autoptr(GVariant) deployment_variant = + rpmostreed_deployment_generate_variant (self->sysroot, new_deployment, NULL, + self->repo, FALSE, error); + if (!deployment_variant) + return FALSE; + + g_autofree char *deployment_dirpath = + ostree_sysroot_get_deployment_dirpath (self->sysroot, new_deployment); + struct stat stbuf; + if (!glnx_fstatat (ostree_sysroot_get_fd (self->sysroot), + deployment_dirpath, &stbuf, 0, error)) + return FALSE; + + g_autofree char *fn = + g_strdup_printf ("%s/%ld", RPMOSTREE_HISTORY_DIR, stbuf.st_ctime); + if (!glnx_shutil_mkdir_p_at (AT_FDCWD, RPMOSTREE_HISTORY_DIR, + 0775, cancellable, error)) + return FALSE; + + /* Write out GVariant to a file. One obvious question here is: why not keep this in the + * journal itself since it supports binary data? We *could* do this, and it would simplify + * querying and pruning, but IMO I find binary data in journal messages not appealing and + * it breaks the expectation that journal messages should be somewhat easily + * introspectable. We could also serialize it to JSON first, though we wouldn't be able to + * re-use the printing code in `status.c` as is. Note also the GVariant can be large (e.g. + * we include the full `rpmostree.rpmdb.pkglist` in there). */ + + if (!glnx_file_replace_contents_at (AT_FDCWD, fn, + g_variant_get_data (deployment_variant), + g_variant_get_size (deployment_variant), + 0, cancellable, error)) + return FALSE; + + g_autofree char *version = NULL; + { g_autoptr(GVariant) commit = NULL; + if (!ostree_repo_load_commit (self->repo, ostree_deployment_get_csum (new_deployment), + &commit, NULL, error)) + return FALSE; + version = rpmostree_checksum_version (commit); + } + + sd_journal_send ("MESSAGE_ID=" SD_ID128_FORMAT_STR, + SD_ID128_FORMAT_VAL(RPMOSTREE_NEW_DEPLOYMENT_MSG), + "MESSAGE=Created new deployment /%s", deployment_dirpath, + "DEPLOYMENT_PATH=/%s", deployment_dirpath, + "DEPLOYMENT_TIMESTAMP=%ld", stbuf.st_ctime, + "DEPLOYMENT_DEVICE=%u", stbuf.st_dev, + "DEPLOYMENT_INODE=%u", stbuf.st_ino, + "DEPLOYMENT_CHECKSUM=%s", ostree_deployment_get_csum (new_deployment), + "DEPLOYMENT_REFSPEC=%s", rpmostree_origin_get_refspec (self->origin), + /* we could use iovecs here and sd_journal_sendv to make these truly + * conditional, but meh, empty field works fine too */ + "DEPLOYMENT_VERSION=%s", version ?: "", + "COMMAND_LINE=%s", initiating_command_line ?: "", + NULL); + + return TRUE; +} + /** * rpmostree_sysroot_upgrader_deploy: * @self: Self @@ -1329,6 +1399,9 @@ rpmostree_sysroot_upgrader_deploy (RpmOstreeSysrootUpgrader *self, return FALSE; } + if (!write_history (self, new_deployment, initiating_command_line, cancellable, error)) + return FALSE; + /* Also do a sanitycheck even if there's no local mutation; it's basically free * and might save someone in the future. The RPMOSTREE_SKIP_SANITYCHECK * environment variable is just used by test-basic.sh currently. diff --git a/src/libpriv/rpmostree-core.h b/src/libpriv/rpmostree-core.h index 38ee62b377..6a00944c36 100644 --- a/src/libpriv/rpmostree-core.h +++ b/src/libpriv/rpmostree-core.h @@ -40,6 +40,9 @@ /* put it in cache dir so it gets destroyed naturally with a `cleanup -m` */ #define RPMOSTREE_AUTOUPDATES_CACHE_FILE RPMOSTREE_CORE_CACHEDIR "cached-update.gv" +#define RPMOSTREE_STATE_DIR "/var/lib/rpm-ostree/" +#define RPMOSTREE_HISTORY_DIR RPMOSTREE_STATE_DIR "history" + #define RPMOSTREE_TYPE_CONTEXT (rpmostree_context_get_type ()) G_DECLARE_FINAL_TYPE (RpmOstreeContext, rpmostree_context, RPMOSTREE, CONTEXT, GObject) diff --git a/tests/vmcheck/test-history.sh b/tests/vmcheck/test-history.sh new file mode 100755 index 0000000000..9a696f35f5 --- /dev/null +++ b/tests/vmcheck/test-history.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# +# Copyright (C) 2019 Jonathan Lebon +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place - Suite 330, +# Boston, MA 02111-1307, USA. + +set -euo pipefail + +. ${commondir}/libtest.sh +. ${commondir}/libvm.sh + +set -x + +# Simple e2e test for history; note there are many more extensive corner-case +# style unit tests in history.rs. + +# XXX: hack around the latest ostree in f29 not having +# https://github.com/ostreedev/ostree/pull/1842 +vm_rpmostree initramfs --enable + +vm_build_rpm foo +vm_rpmostree install foo +vm_reboot +echo "ok setup" + +vm_rpmostree ex history > out.txt +assert_file_has_content out.txt "CreateCommand: install foo" +assert_file_has_content out.txt "LayeredPackages: foo" +vm_rpmostree ex history --json | jq --slurp > out.json +assert_jq out.json \ + '.[0]["deployment-create-command-line"] == "install foo"' \ + '.[0]["deployment-create-timestamp"] != null' \ + '.[0]["deployment"]["packages"][0] == "foo"' \ + '.[0]["first-boot-timestamp"] != null' \ + '.[0]["last-boot-timestamp"] != null' \ + '.[0]["boot-count"] == 1' +echo "ok install first boot" + +vm_reboot + +vm_rpmostree ex history > out.txt +assert_file_has_content out.txt "BootCount: 2" +vm_rpmostree ex history --json | jq --slurp > out.json +assert_jq out.json \ + '.[0]["deployment-create-command-line"] == "install foo"' \ + '.[0]["deployment-create-timestamp"] != null' \ + '.[0]["deployment"]["packages"][0] == "foo"' \ + '.[0]["first-boot-timestamp"] != null' \ + '.[0]["last-boot-timestamp"] != null' \ + '.[0]["boot-count"] == 2' +echo "ok install second boot" + +vm_rpmostree uninstall foo +vm_reboot + +vm_rpmostree ex history > out.txt +assert_file_has_content out.txt "CreateCommand: uninstall foo" +vm_rpmostree ex history --json | jq --slurp > out.json +assert_jq out.json \ + '.[0]["deployment-create-command-line"] == "uninstall foo"' \ + '.[0]["deployment-create-timestamp"] != null' \ + '.[0]["first-boot-timestamp"] != null' \ + '.[0]["last-boot-timestamp"] != null' \ + '.[0]["boot-count"] == 1' +assert_jq out.json \ + '.[1]["deployment-create-command-line"] == "install foo"' \ + '.[1]["deployment-create-timestamp"] != null' \ + '.[1]["deployment"]["packages"][0] == "foo"' \ + '.[1]["first-boot-timestamp"] != null' \ + '.[1]["last-boot-timestamp"] != null' \ + '.[1]["boot-count"] == 2' +echo "ok uninstall" + +# and check history pruning since that's one bit we can't really test from the +# unit tests + +vm_cmd find /var/lib/rpm-ostree/history | xargs -n 1 basename | sort -g > entries.txt +if [ ! $(wc -l entries.txt) -gt 1 ]; then + assert_not_reached "Expected more than 1 entry, got $(cat entries.txt)" +fi + +# get the most recent entry +entry=$(tail -n 1 entries.txt) +# And now nuke all the journal entries except the latest, but we don't want to +# actually lose everything since e.g. some of the previous vmcheck tests that +# ran on this machine may have failed and we would've rendered the journal +# useless for debugging. So hack around that... yeah, would be cleaner if we +# could just spawn individual VMs per test. +vm_cmd systemctl stop systemd-journald.service +vm_cmd cp -r /var/log/journal{,.bak} +vm_cmd journalctl --vacuum-time=$((entry - 1))s +vm_rpmostree cleanup -b +vm_cmd systemctl stop systemd-journald.service +vm_cmd rm -rf /var/log/journal +vm_cmd mv /var/log/journal{.bak,} + +vm_cmd ls -l /var/lib/rpm-ostree/history > entries.txt +if [ $(wc -l entries.txt) != 1 ]; then + assert_not_reached "Expected only 1 entry, got $(cat entries.txt)" +fi +echo "ok prune"