From 574b8e2c74f3174b15d3d80a21d86cbb10268b15 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sun, 24 Oct 2021 09:59:45 +0300 Subject: feat! store schema version of generation database in the db Add a new mandatory database table "meta" to the SQLite database the stores information about the files in a backup generation. The idea is for future versions of the Obnam client to be able to be able to restore from backups made by older -- or newer -- versions of Obnam, as far as is reasonable. Add the `obnam gen-info` command to show information about the generation metadata. Sponsored-by: author --- obnam.md | 28 +++++++++++++ src/bin/obnam.rs | 3 ++ src/cmd/gen_info.rs | 36 ++++++++++++++++ src/cmd/mod.rs | 1 + src/generation.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 183 insertions(+) create mode 100644 src/cmd/gen_info.rs diff --git a/obnam.md b/obnam.md index 02634f0..d363069 100644 --- a/obnam.md +++ b/obnam.md @@ -1768,6 +1768,34 @@ exclude_cache_tag_directories: false ~~~ +## Generation information + +This scenario verifies that the Obnam client can show metadata about a +backup generation. + +~~~scenario +given an installed obnam +given a running chunk server +given a client config based on smoke.yaml +given a file live/data.dat containing some random data +given a manifest of the directory live in live.yaml +given file geninfo.json +when I run obnam backup +when I run obnam gen-info latest +then stdout, as JSON, has all the values in file geninfo.json +~~~ + +~~~{#geninfo.json .file .json} +{ + "schema_version": { + "major": 0, + "minor": 0 + }, + "extras": {} +} +~~~ + + # Acceptance criteria for backup encryption This chapter outlines scenarios, to be implemented later, for diff --git a/src/bin/obnam.rs b/src/bin/obnam.rs index b2d5683..21add9c 100644 --- a/src/bin/obnam.rs +++ b/src/bin/obnam.rs @@ -5,6 +5,7 @@ use log4rs::config::{Appender, Logger, Root}; use obnam::cmd::backup::Backup; use obnam::cmd::chunk::{DecryptChunk, EncryptChunk}; use obnam::cmd::chunkify::Chunkify; +use obnam::cmd::gen_info::GenInfo; use obnam::cmd::get_chunk::GetChunk; use obnam::cmd::init::Init; use obnam::cmd::list::List; @@ -47,6 +48,7 @@ fn main_program() -> anyhow::Result<()> { Command::ListFiles(x) => x.run(&config), Command::Resolve(x) => x.run(&config), Command::Restore(x) => x.run(&config), + Command::GenInfo(x) => x.run(&config), Command::GetChunk(x) => x.run(&config), Command::Config(x) => x.run(&config), Command::EncryptChunk(x) => x.run(&config), @@ -103,6 +105,7 @@ enum Command { List(List), ListFiles(ListFiles), Restore(Restore), + GenInfo(GenInfo), ShowGeneration(ShowGeneration), Resolve(Resolve), GetChunk(GetChunk), diff --git a/src/cmd/gen_info.rs b/src/cmd/gen_info.rs new file mode 100644 index 0000000..6d12bd8 --- /dev/null +++ b/src/cmd/gen_info.rs @@ -0,0 +1,36 @@ +use crate::client::AsyncBackupClient; +use crate::config::ClientConfig; +use crate::error::ObnamError; +use log::info; +use structopt::StructOpt; +use tempfile::NamedTempFile; +use tokio::runtime::Runtime; + +#[derive(Debug, StructOpt)] +pub struct GenInfo { + #[structopt()] + gen_ref: String, +} + +impl GenInfo { + pub fn run(&self, config: &ClientConfig) -> Result<(), ObnamError> { + let rt = Runtime::new()?; + rt.block_on(self.run_async(config)) + } + + async fn run_async(&self, config: &ClientConfig) -> Result<(), ObnamError> { + let temp = NamedTempFile::new()?; + + let client = AsyncBackupClient::new(config)?; + + let genlist = client.list_generations().await?; + let gen_id = genlist.resolve(&self.gen_ref)?; + info!("generation id is {}", gen_id.as_chunk_id()); + + let gen = client.fetch_generation(&gen_id, temp.path()).await?; + let meta = gen.meta()?; + println!("{}", serde_json::to_string_pretty(&meta)?); + + Ok(()) + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 502ec5d..ee5efd9 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,6 +1,7 @@ pub mod backup; pub mod chunk; pub mod chunkify; +pub mod gen_info; pub mod get_chunk; pub mod init; pub mod list; diff --git a/src/generation.rs b/src/generation.rs index bd36a19..de2ea10 100644 --- a/src/generation.rs +++ b/src/generation.rs @@ -2,9 +2,17 @@ use crate::backup_reason::Reason; use crate::chunkid::ChunkId; use crate::fsentry::FilesystemEntry; use rusqlite::Connection; +use serde::Serialize; +use std::collections::HashMap; use std::fmt; use std::path::{Path, PathBuf}; +/// Current generation database schema major version. +const SCHEMA_MAJOR: u32 = 0; + +/// Current generation database schema minor version. +const SCHEMA_MINOR: u32 = 0; + /// An identifier for a file in a generation. type FileId = i64; @@ -126,6 +134,15 @@ pub enum LocalGenerationError { #[error("Generation has more than one file with the name {0}")] TooManyFiles(PathBuf), + #[error("Generation does not have a 'meta' table")] + NoMeta, + + #[error("Generation 'meta' table does not have a row {0}")] + NoMetaKey(String), + + #[error("Generation 'meta' row {0} has badly formed integer: {1}")] + BadMetaInteger(String, std::num::ParseIntError), + #[error(transparent)] RusqliteError(#[from] rusqlite::Error), @@ -174,6 +191,11 @@ impl LocalGeneration { Ok(Self { conn }) } + pub fn meta(&self) -> Result { + let map = sql::meta(&self.conn)?; + GenMeta::from(map) + } + pub fn file_count(&self) -> Result { sql::file_count(&self.conn) } @@ -205,6 +227,65 @@ impl LocalGeneration { } } +/// Metadata about the generation. +#[derive(Debug, Serialize)] +pub struct GenMeta { + schema_version: SchemaVersion, + extras: HashMap, +} + +impl GenMeta { + fn from(mut map: HashMap) -> Result { + let major: u32 = metaint(&mut map, "schema_version_major")?; + let minor: u32 = metaint(&mut map, "schema_version_minor")?; + Ok(Self { + schema_version: SchemaVersion::new(major, minor), + extras: map, + }) + } + + pub fn schema_version(&self) -> SchemaVersion { + self.schema_version + } +} + +fn metastr(map: &mut HashMap, key: &str) -> Result { + if let Some(v) = map.remove(key) { + Ok(v) + } else { + Err(LocalGenerationError::NoMetaKey(key.to_string())) + } +} + +fn metaint(map: &mut HashMap, key: &str) -> Result { + let v = metastr(map, key)?; + let v = v + .parse() + .map_err(|err| LocalGenerationError::BadMetaInteger(key.to_string(), err))?; + Ok(v) +} + +/// Schema version of the database storing the generation. +/// +/// An Obnam client can restore a generation using schema version +/// (x,y), if the client supports a schema version (x,z). If z < y, +/// the client knows it may not be able to the generation faithfully, +/// and should warn the user about this. If z >= y, the client knows +/// it can restore the generation faithfully. If the client does not +/// support any schema version x, it knows it can't restore the backup +/// at all. +#[derive(Debug, Clone, Copy, Serialize)] +pub struct SchemaVersion { + pub major: u32, + pub minor: u32, +} + +impl SchemaVersion { + fn new(major: u32, minor: u32) -> Self { + Self { major, minor } + } +} + mod sql { use super::BackedUpFile; use super::FileId; @@ -212,14 +293,19 @@ mod sql { use crate::backup_reason::Reason; use crate::chunkid::ChunkId; use crate::fsentry::FilesystemEntry; + use crate::generation::SCHEMA_MAJOR; + use crate::generation::SCHEMA_MINOR; use log::debug; use rusqlite::{params, Connection, OpenFlags, Row, Statement, Transaction}; + use std::collections::HashMap; use std::os::unix::ffi::OsStrExt; use std::path::Path; pub fn create_db(filename: &Path) -> Result { let flags = OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_READ_WRITE; let conn = Connection::open_with_flags(filename, flags)?; + conn.execute("CREATE TABLE meta (key TEXT, value TEXT)", params![])?; + init_meta(&conn)?; conn.execute( "CREATE TABLE files (fileno INTEGER PRIMARY KEY, filename BLOB, json TEXT, reason TEXT, is_cachedir_tag BOOLEAN)", params![], @@ -234,6 +320,18 @@ mod sql { Ok(conn) } + fn init_meta(conn: &Connection) -> Result<(), LocalGenerationError> { + conn.execute( + "INSERT INTO meta (key, value) VALUES (?1, ?2)", + params!["schema_version_major", SCHEMA_MAJOR], + )?; + conn.execute( + "INSERT INTO meta (key, value) VALUES (?1, ?2)", + params!["schema_version_minor", SCHEMA_MINOR], + )?; + Ok(()) + } + pub fn open_db(filename: &Path) -> Result { let flags = OpenFlags::SQLITE_OPEN_READ_WRITE; let conn = Connection::open_with_flags(filename, flags)?; @@ -241,6 +339,23 @@ mod sql { Ok(conn) } + pub fn meta(conn: &Connection) -> Result, LocalGenerationError> { + let mut stmt = conn.prepare("SELECT key, value FROM meta")?; + let iter = stmt.query_map(params![], |row| row_to_key_value(row))?; + let mut map = HashMap::new(); + for r in iter { + let (key, value) = r?; + map.insert(key, value); + } + Ok(map) + } + + fn row_to_key_value(row: &Row) -> rusqlite::Result<(String, String)> { + let key: String = row.get(row.column_index("key")?)?; + let value: String = row.get(row.column_index("value")?)?; + Ok((key, value)) + } + pub fn insert_one( t: &Transaction, e: FilesystemEntry, -- cgit v1.2.1