diff --git a/src/api2/config.rs b/src/api2/config.rs index 3e3f4927..23465a52 100644 --- a/src/api2/config.rs +++ b/src/api2/config.rs @@ -12,6 +12,7 @@ pub mod drive; pub mod changer; pub mod media_pool; pub mod tape_encryption_keys; +pub mod tape_backup_job; const SUBDIRS: SubdirMap = &[ ("access", &access::ROUTER), @@ -22,6 +23,7 @@ const SUBDIRS: SubdirMap = &[ ("remote", &remote::ROUTER), ("sync", &sync::ROUTER), ("tape-encryption-keys", &tape_encryption_keys::ROUTER), + ("tape-backup-job", &tape_backup_job::ROUTER), ("verify", &verify::ROUTER), ]; diff --git a/src/api2/config/tape_backup_job.rs b/src/api2/config/tape_backup_job.rs new file mode 100644 index 00000000..7441ebde --- /dev/null +++ b/src/api2/config/tape_backup_job.rs @@ -0,0 +1,219 @@ +use anyhow::{bail, Error}; +use serde_json::Value; +use ::serde::{Deserialize, Serialize}; + +use proxmox::api::{api, Router, RpcEnvironment, schema::Updatable}; +use proxmox::tools::fs::open_file_locked; + +use crate::{ + api2::types::{ + JOB_ID_SCHEMA, + PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + config::{ + self, + tape_job::{ + TAPE_JOB_CFG_LOCKFILE, + TapeBackupJobConfig, + TapeBackupJobConfigUpdater, + } + }, +}; + +#[api( + input: { + properties: {}, + }, + returns: { + description: "List configured jobs.", + type: Array, + items: { type: TapeBackupJobConfig }, + }, +)] +/// List all tape backup jobs +pub fn list_tape_backup_jobs( + _param: Value, + mut rpcenv: &mut dyn RpcEnvironment, +) -> Result, Error> { + + let (config, digest) = config::tape_job::config()?; + + let list = config.convert_to_typed_array("backup")?; + + rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into(); + + Ok(list) +} + +#[api( + protected: true, + input: { + properties: { + job: { + type: TapeBackupJobConfig, + flatten: true, + }, + }, + }, +)] +/// Create a new tape backup job. +pub fn create_tape_backup_job( + job: TapeBackupJobConfig, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + + let _lock = open_file_locked(TAPE_JOB_CFG_LOCKFILE, std::time::Duration::new(10, 0), true)?; + + let (mut config, _digest) = config::tape_job::config()?; + + if config.sections.get(&job.id).is_some() { + bail!("job '{}' already exists.", job.id); + } + + config.set_data(&job.id, "backup", &job)?; + + config::tape_job::save_config(&config)?; + + Ok(()) +} + +#[api( + input: { + properties: { + id: { + schema: JOB_ID_SCHEMA, + }, + }, + }, + returns: { type: TapeBackupJobConfig }, +)] +/// Read a tape backup job configuration. +pub fn read_tape_backup_job( + id: String, + mut rpcenv: &mut dyn RpcEnvironment, +) -> Result { + + let (config, digest) = config::tape_job::config()?; + + let job = config.lookup("backup", &id)?; + + rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into(); + + Ok(job) +} + +#[api()] +#[derive(Serialize, Deserialize)] +#[serde(rename_all="kebab-case")] +#[allow(non_camel_case_types)] +/// Deletable property name +pub enum DeletableProperty { + /// Delete the comment property. + comment, + /// Delete the job schedule. + schedule, +} + +#[api( + protected: true, + input: { + properties: { + id: { + schema: JOB_ID_SCHEMA, + }, + update: { + flatten: true, + type: TapeBackupJobConfigUpdater, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeletableProperty, + } + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, +)] +/// Update the tape backup job +pub fn update_tape_backup_job( + id: String, + update: TapeBackupJobConfigUpdater, + delete: Option>, + digest: Option, +) -> Result<(), Error> { + let _lock = open_file_locked(TAPE_JOB_CFG_LOCKFILE, std::time::Duration::new(10, 0), true)?; + + let (mut config, expected_digest) = config::tape_job::config()?; + + let mut job: TapeBackupJobConfig = config.lookup("backup", &id)?; + + if let Some(ref digest) = digest { + let digest = proxmox::tools::hex_to_digest(digest)?; + crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?; + } + + job.update_from(update, &delete.unwrap_or(Vec::new()))?; + + config.set_data(&job.id, "backup", &job)?; + + config::tape_job::save_config(&config)?; + + Ok(()) +} + +#[api( + protected: true, + input: { + properties: { + id: { + schema: JOB_ID_SCHEMA, + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, +)] +/// Remove a tape backup job configuration +pub fn delete_tape_backup_job( + id: String, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = open_file_locked(TAPE_JOB_CFG_LOCKFILE, std::time::Duration::new(10, 0), true)?; + + let (mut config, expected_digest) = config::tape_job::config()?; + + if let Some(ref digest) = digest { + let digest = proxmox::tools::hex_to_digest(digest)?; + crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?; + } + + match config.lookup::("backup", &id) { + Ok(_job) => { + config.sections.remove(&id); + }, + Err(_) => { bail!("job '{}' does not exist.", id) }, + }; + + config::tape_job::save_config(&config)?; + + Ok(()) +} + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_READ_TAPE_BACKUP_JOB) +//.put(&API_METHOD_UPDATE_TAPE_BACKUP_JOB) + .delete(&API_METHOD_DELETE_TAPE_BACKUP_JOB); + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_TAPE_BACKUP_JOBS) + .post(&API_METHOD_CREATE_TAPE_BACKUP_JOB) + .match_all("id", &ITEM_ROUTER); diff --git a/src/bin/proxmox-tape.rs b/src/bin/proxmox-tape.rs index 5a6ee06f..9de976f6 100644 --- a/src/bin/proxmox-tape.rs +++ b/src/bin/proxmox-tape.rs @@ -995,6 +995,7 @@ fn main() { .insert("pool", pool_commands()) .insert("media", media_commands()) .insert("key", encryption_key_commands()) + .insert("backup-job", backup_job_commands()) .insert( "load-media", CliCommand::new(&API_METHOD_LOAD_MEDIA) diff --git a/src/bin/proxmox_tape/backup_job.rs b/src/bin/proxmox_tape/backup_job.rs new file mode 100644 index 00000000..0bcfdc11 --- /dev/null +++ b/src/bin/proxmox_tape/backup_job.rs @@ -0,0 +1,112 @@ +use anyhow::Error; +use serde_json::Value; + +use proxmox::api::{api, cli::*, RpcEnvironment, ApiHandler}; + +use proxmox_backup::{ + config, + api2::{ + self, + types::*, + }, +}; + +#[api( + input: { + properties: { + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + } +)] +/// Tape backup job list. +fn list_tape_backup_jobs(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result { + + let output_format = get_output_format(¶m); + + let info = &api2::config::tape_backup_job::API_METHOD_LIST_TAPE_BACKUP_JOBS; + let mut data = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + let options = default_table_format_options() + .column(ColumnConfig::new("id")) + .column(ColumnConfig::new("store")) + .column(ColumnConfig::new("pool")) + .column(ColumnConfig::new("drive")) + .column(ColumnConfig::new("schedule")) + .column(ColumnConfig::new("comment")); + + format_and_print_result_full(&mut data, &info.returns, &output_format, &options); + + Ok(Value::Null) +} + +#[api( + input: { + properties: { + id: { + schema: JOB_ID_SCHEMA, + }, + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + } +)] +/// Show tape backup job configuration +fn show_tape_backup_job(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result { + + let output_format = get_output_format(¶m); + + let info = &api2::config::tape_backup_job::API_METHOD_READ_TAPE_BACKUP_JOB; + let mut data = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + let options = default_table_format_options(); + format_and_print_result_full(&mut data, &info.returns, &output_format, &options); + + Ok(Value::Null) +} + +pub fn backup_job_commands() -> CommandLineInterface { + + let cmd_def = CliCommandMap::new() + .insert("list", CliCommand::new(&API_METHOD_LIST_TAPE_BACKUP_JOBS)) + .insert("show", + CliCommand::new(&API_METHOD_SHOW_TAPE_BACKUP_JOB) + .arg_param(&["id"]) + .completion_cb("id", config::tape_job::complete_tape_job_id) + ) + .insert("create", + CliCommand::new(&api2::config::tape_backup_job::API_METHOD_CREATE_TAPE_BACKUP_JOB) + .arg_param(&["id"]) + .completion_cb("id", config::tape_job::complete_tape_job_id) + .completion_cb("schedule", config::datastore::complete_calendar_event) + .completion_cb("store", config::datastore::complete_datastore_name) + .completion_cb("pool", config::media_pool::complete_pool_name) + .completion_cb("drive", crate::complete_drive_name) + ) + .insert("update", + CliCommand::new(&api2::config::tape_backup_job::API_METHOD_UPDATE_TAPE_BACKUP_JOB) + .arg_param(&["id"]) + .completion_cb("id", config::tape_job::complete_tape_job_id) + .completion_cb("schedule", config::datastore::complete_calendar_event) + .completion_cb("store", config::datastore::complete_datastore_name) + .completion_cb("pool", config::media_pool::complete_pool_name) + .completion_cb("drive", crate::complete_drive_name) + ) + .insert("remove", + CliCommand::new(&api2::config::tape_backup_job::API_METHOD_DELETE_TAPE_BACKUP_JOB) + .arg_param(&["id"]) + .completion_cb("id", config::tape_job::complete_tape_job_id) + ); + + cmd_def.into() +} diff --git a/src/bin/proxmox_tape/mod.rs b/src/bin/proxmox_tape/mod.rs index 5e04d901..c3f88b8b 100644 --- a/src/bin/proxmox_tape/mod.rs +++ b/src/bin/proxmox_tape/mod.rs @@ -12,3 +12,6 @@ pub use media::*; mod encryption_key; pub use encryption_key::*; + +mod backup_job; +pub use backup_job::*; diff --git a/src/config.rs b/src/config.rs index c1b91b39..1557e20a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,7 @@ pub mod verify; pub mod drive; pub mod media_pool; pub mod tape_encryption_keys; +pub mod tape_job; /// Check configuration directory permissions /// diff --git a/src/config/tape_job.rs b/src/config/tape_job.rs new file mode 100644 index 00000000..e623287a --- /dev/null +++ b/src/config/tape_job.rs @@ -0,0 +1,121 @@ +use anyhow::{Error}; +use lazy_static::lazy_static; +use std::collections::HashMap; +use serde::{Serialize, Deserialize}; + +use proxmox::api::{ + api, + schema::*, + section_config::{ + SectionConfig, + SectionConfigData, + SectionConfigPlugin, + } +}; + +use proxmox::tools::{fs::replace_file, fs::CreateOptions}; + +use crate::api2::types::{ + JOB_ID_SCHEMA, + DATASTORE_SCHEMA, + DRIVE_NAME_SCHEMA, + MEDIA_POOL_NAME_SCHEMA, + SINGLE_LINE_COMMENT_SCHEMA, + SYNC_SCHEDULE_SCHEMA, +}; + +lazy_static! { + pub static ref CONFIG: SectionConfig = init(); +} + +#[api( + properties: { + id: { + schema: JOB_ID_SCHEMA, + }, + store: { + schema: DATASTORE_SCHEMA, + }, + pool: { + schema: MEDIA_POOL_NAME_SCHEMA, + }, + drive: { + schema: DRIVE_NAME_SCHEMA, + }, + comment: { + optional: true, + schema: SINGLE_LINE_COMMENT_SCHEMA, + }, + schedule: { + optional: true, + schema: SYNC_SCHEDULE_SCHEMA, + }, + } +)] +#[serde(rename_all="kebab-case")] +#[derive(Updater,Serialize,Deserialize,Clone)] +/// Tape Backup Job +pub struct TapeBackupJobConfig { + #[updater(fixed)] + pub id: String, + pub store: String, + pub pool: String, + pub drive: String, + #[serde(skip_serializing_if="Option::is_none")] + pub comment: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub schedule: Option, +} + +fn init() -> SectionConfig { + let obj_schema = match TapeBackupJobConfig::API_SCHEMA { + Schema::Object(ref obj_schema) => obj_schema, + _ => unreachable!(), + }; + + let plugin = SectionConfigPlugin::new("backup".to_string(), Some(String::from("id")), obj_schema); + let mut config = SectionConfig::new(&JOB_ID_SCHEMA); + config.register_plugin(plugin); + + config +} + +pub const TAPE_JOB_CFG_FILENAME: &str = "/etc/proxmox-backup/tape-job.cfg"; +pub const TAPE_JOB_CFG_LOCKFILE: &str = "/etc/proxmox-backup/.tape-job.lck"; + +pub fn config() -> Result<(SectionConfigData, [u8;32]), Error> { + + let content = proxmox::tools::fs::file_read_optional_string(TAPE_JOB_CFG_FILENAME)? + .unwrap_or_else(|| "".to_string()); + + let digest = openssl::sha::sha256(content.as_bytes()); + let data = CONFIG.parse(TAPE_JOB_CFG_FILENAME, &content)?; + Ok((data, digest)) +} + +pub fn save_config(config: &SectionConfigData) -> Result<(), Error> { + let raw = CONFIG.write(TAPE_JOB_CFG_FILENAME, &config)?; + + let backup_user = crate::backup::backup_user()?; + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); + // set the correct owner/group/permissions while saving file + // owner(rw) = root, group(r)= backup + let options = CreateOptions::new() + .perm(mode) + .owner(nix::unistd::ROOT) + .group(backup_user.gid); + + replace_file(TAPE_JOB_CFG_FILENAME, raw.as_bytes(), options)?; + + Ok(()) +} + +// shell completion helper + +/// List all tape job IDs +pub fn complete_tape_job_id(_arg: &str, _param: &HashMap) -> Vec { + match config() { + Ok((data, _digest)) => data.sections.iter().map(|(id, _)| id.to_string()).collect(), + Err(_) => return vec![], + } +}