From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [PATCH 4/7] client: job: add job management CLI subcommands
Date: Wed, 14 Oct 2026 12:51:33 +0200 [thread overview]
Message-ID: <20261014105208.2747208-5-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261014105208.2747208-1-t.lamprecht@proxmox.com>
Add `job create`, `update`, `remove`, `enable`, `disable`, `list`
and `show`.
When a change leaves a job that should run but no scheduler is running,
the commands print how to start one, as nothing runs without it. A
running scheduler picks up changes within a minute.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
proxmox-backup-client/src/job/mod.rs | 358 ++++++++++++++++++++-
proxmox-backup-client/src/job/scheduler.rs | 15 +
2 files changed, 366 insertions(+), 7 deletions(-)
diff --git a/proxmox-backup-client/src/job/mod.rs b/proxmox-backup-client/src/job/mod.rs
index ffde90ad1..858dc2250 100644
--- a/proxmox-backup-client/src/job/mod.rs
+++ b/proxmox-backup-client/src/job/mod.rs
@@ -1,20 +1,364 @@
//! Scheduled backup jobs: the `job` subcommands, the [`scheduler`] daemon and the [`runner`].
+use anyhow::{Error, bail, format_err};
+use serde_json::Value;
+
use proxmox_router::cli::*;
+use proxmox_schema::{ApiType, ReturnType, api};
-use pbs_client::client_job::complete_job_id;
+use pbs_api_types::JOB_ID_SCHEMA;
+use pbs_client::client_job::{
+ self, ClientBackupJobConfig, ClientBackupJobConfigUpdater, ClientBackupJobStatus,
+ DeletableProperty, complete_job_id,
+};
mod runner;
mod scheduler;
+/// Refuse a schedule without any event left. It is valid syntax, but the job would never run.
+fn check_schedule_ahead(schedule: &str) -> Result<(), Error> {
+ let event: proxmox_time::CalendarEvent = schedule.parse()?;
+ if event
+ .compute_next_event(proxmox_time::epoch_i64())?
+ .is_none()
+ {
+ bail!("schedule '{schedule}' has no future event, the job would never run");
+ }
+ Ok(())
+}
+
+/// Render the backup target for the overview: a repository URL as configured, or the server and
+/// datastore of the atom form. Deliberately lossy, `job show` has the exact values.
+fn render_target(_value: &Value, record: &Value) -> Result<String, Error> {
+ if let Some(repository) = record.get("repository").and_then(Value::as_str) {
+ return Ok(repository.to_string());
+ }
+ let server = record
+ .get("server")
+ .and_then(Value::as_str)
+ .unwrap_or("localhost");
+ let store = record
+ .get("datastore")
+ .and_then(Value::as_str)
+ .unwrap_or("");
+ Ok(format!("{server}:{store}"))
+}
+
+/// Render when a job runs next, or why it does not.
+fn render_next_run(value: &Value, record: &Value) -> Result<String, Error> {
+ if record["disable"].as_bool().unwrap_or(false) {
+ return Ok("disabled".to_string());
+ }
+ if value.is_null() {
+ // an enabled job without a next run has a schedule that ran out
+ return Ok("never".to_string());
+ }
+ pbs_tools::format::render_epoch(value, record)
+}
+
+#[api(
+ input: {
+ properties: {
+ "output-format": {
+ schema: OUTPUT_FORMAT,
+ optional: true,
+ },
+ },
+ },
+ returns: {
+ description: "List of configured backup jobs.",
+ type: Array,
+ items: { type: ClientBackupJobStatus },
+ },
+)]
+/// List the backup jobs, with their last and next run.
+fn list_jobs(param: Value) -> Result<Value, Error> {
+ let output_format = get_output_format(¶m);
+ let (config, _digest) = client_job::config()?;
+
+ let mut jobs = Vec::with_capacity(config.sections.len());
+ for id in client_job::job_ids(&config) {
+ let job = client_job::lookup_job(&config, id)?;
+ jobs.push(ClientBackupJobStatus::with_state(job));
+ }
+
+ let options = default_table_format_options()
+ .column(ColumnConfig::new("id"))
+ .column(ColumnConfig::new("schedule"))
+ .column(
+ ColumnConfig::new("repository")
+ .header("target")
+ .renderer(render_target),
+ )
+ .column(ColumnConfig::new("last-run").renderer(pbs_tools::format::render_epoch))
+ .column(ColumnConfig::new("last-result"))
+ .column(ColumnConfig::new("next-run").renderer(render_next_run));
+
+ let mut data = serde_json::to_value(jobs)?;
+ format_and_print_result_full(
+ &mut data,
+ &API_METHOD_LIST_JOBS.returns,
+ &output_format,
+ &options,
+ );
+
+ Ok(Value::Null)
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ schema: JOB_ID_SCHEMA,
+ },
+ "output-format": {
+ schema: OUTPUT_FORMAT,
+ optional: true,
+ },
+ },
+ },
+)]
+/// Show a backup job, with its last and next run.
+fn show_job(id: String, param: Value) -> Result<Value, Error> {
+ let output_format = get_output_format(¶m);
+ let (config, _digest) = client_job::config()?;
+ let job = client_job::lookup_job(&config, &id)?;
+ let mut data = serde_json::to_value(ClientBackupJobStatus::with_state(job))?;
+
+ let return_type = ReturnType::new(false, &ClientBackupJobStatus::API_SCHEMA);
+ format_and_print_result_full(&mut data, &return_type, &output_format, &show_options());
+
+ Ok(Value::Null)
+}
+
+fn status_schema() -> &'static dyn proxmox_schema::ObjectSchemaType {
+ ClientBackupJobStatus::API_SCHEMA
+ .any_object()
+ .expect("job status schema is an object schema")
+}
+
+/// Columns for `job show`: every status property, with the job's identity first and its runs last.
+fn show_options() -> TableFormatOptions {
+ const LEADING: [&str; 2] = ["id", "schedule"];
+ const TRAILING: [&str; 4] = ["last-run", "last-result", "message", "next-run"];
+
+ let mut names: Vec<&str> = LEADING.to_vec();
+ names.extend(
+ status_schema()
+ .properties()
+ .map(|(name, _optional, _schema)| *name)
+ .filter(|name| !LEADING.contains(name) && !TRAILING.contains(name)),
+ );
+ names.extend(TRAILING);
+
+ let mut options = default_table_format_options();
+ for name in names {
+ let column = ColumnConfig::new(name);
+ options = match name {
+ "last-run" | "next-run" => {
+ options.column(column.renderer(pbs_tools::format::render_epoch))
+ }
+ "backupspec" | "exclude" | "include-dev" => {
+ options.column(column.renderer(render_string_list))
+ }
+ _ => options.column(column),
+ };
+ }
+ options
+}
+
+/// Render a list of strings one per line, rather than as a JSON array.
+fn render_string_list(value: &Value, _record: &Value) -> Result<String, Error> {
+ let Some(items) = value.as_array() else {
+ return Ok(String::new());
+ };
+ Ok(items
+ .iter()
+ .map(|item| item.as_str().unwrap_or_default())
+ .collect::<Vec<&str>>()
+ .join("\n"))
+}
+
+#[api(
+ input: {
+ properties: {
+ config: {
+ type: ClientBackupJobConfig,
+ flatten: true,
+ },
+ },
+ },
+)]
+/// Create a backup job.
+fn create_job(mut config: ClientBackupJobConfig) -> Result<(), Error> {
+ config.normalize();
+ config.validate()?;
+ check_schedule_ahead(&config.schedule)?;
+
+ let _lock = client_job::lock_config()?;
+ let (mut section_config, _digest) = client_job::config()?;
+
+ if section_config.sections.contains_key(&config.id) {
+ bail!("job '{}' already exists", config.id);
+ }
+
+ // state first, as a job without state is due at once
+ client_job::create_state(&config.id)?;
+
+ section_config.set_data(&config.id, client_job::JOB_SECTION_TYPE, &config)?;
+ client_job::save_config(§ion_config)?;
+
+ if !config.disable.unwrap_or(false) {
+ scheduler::hint_if_not_running();
+ }
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ schema: JOB_ID_SCHEMA,
+ },
+ update: {
+ type: ClientBackupJobConfigUpdater,
+ flatten: true,
+ },
+ delete: {
+ description: "List of properties to delete.",
+ type: Array,
+ optional: true,
+ items: {
+ type: DeletableProperty,
+ },
+ },
+ },
+ },
+)]
+/// Update a backup job. Options that are not given keep their value.
+fn update_job(
+ id: String,
+ update: ClientBackupJobConfigUpdater,
+ delete: Option<Vec<DeletableProperty>>,
+) -> Result<(), Error> {
+ let _lock = client_job::lock_config()?;
+ let (mut section_config, _digest) = client_job::config()?;
+
+ let mut job = client_job::lookup_job(§ion_config, &id)?;
+ // only a new schedule, so that a one-off job that already ran stays editable
+ let new_schedule = update.schedule.is_some();
+ job.apply_update(update, &delete.unwrap_or_default());
+ job.validate()?;
+ if new_schedule {
+ check_schedule_ahead(&job.schedule)?;
+ }
+
+ section_config.set_data(&id, client_job::JOB_SECTION_TYPE, &job)?;
+ client_job::save_config(§ion_config)?;
+
+ if !job.disable.unwrap_or(false) {
+ scheduler::hint_if_not_running();
+ }
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: { schema: JOB_ID_SCHEMA },
+ },
+ },
+)]
+/// Enable a disabled backup job.
+fn enable_job(id: String) -> Result<(), Error> {
+ set_disable(&id, None)
+}
+
+#[api(
+ input: {
+ properties: {
+ id: { schema: JOB_ID_SCHEMA },
+ },
+ },
+)]
+/// Disable a backup job. The scheduler no longer starts it, but 'job run' still does.
+fn disable_job(id: String) -> Result<(), Error> {
+ set_disable(&id, Some(true))
+}
+
+/// Enabling drops the property instead of storing `false`, so that an enabled job looks the same
+/// however it got there.
+fn set_disable(id: &str, value: Option<bool>) -> Result<(), Error> {
+ let _lock = client_job::lock_config()?;
+ let (mut section_config, _digest) = client_job::config()?;
+ let mut job = client_job::lookup_job(§ion_config, id)?;
+ job.disable = value;
+ section_config.set_data(id, client_job::JOB_SECTION_TYPE, &job)?;
+ client_job::save_config(§ion_config)?;
+ if value.is_none() {
+ scheduler::hint_if_not_running();
+ }
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ schema: JOB_ID_SCHEMA,
+ },
+ },
+ },
+)]
+/// Remove a backup job.
+fn remove_job(id: String) -> Result<(), Error> {
+ let _lock = client_job::lock_config()?;
+ let (mut section_config, _digest) = client_job::config()?;
+ client_job::lookup_job(§ion_config, &id)?;
+ if client_job::is_running(&id) {
+ bail!("cannot remove job '{id}' while it is running");
+ }
+
+ // the held job lock makes it safe to drop the job's files
+ let job_lock =
+ client_job::ClientJob::new(&id).map_err(|err| format_err!("cannot remove job: {err}"))?;
+
+ section_config.sections.remove(&id);
+ client_job::save_config(§ion_config)?;
+ // only now, as a job without state would be due at once
+ client_job::remove_state(&job_lock)?;
+
+ Ok(())
+}
+
pub fn cli() -> CliCommandMap {
+ let with_id = |info| {
+ CliCommand::new(info)
+ .arg_param(&["id"])
+ .completion_cb("id", complete_job_id)
+ };
+ let with_files = |command: CliCommand| {
+ command
+ .completion_cb("keyfile", complete_file_name)
+ .completion_cb("master-pubkey-file", complete_file_name)
+ .completion_cb("password-file", complete_file_name)
+ .completion_cb("encryption-password-file", complete_file_name)
+ .completion_cb("pre-run-hook", complete_file_name)
+ };
+ // no ID completion for `create`, which needs a new one
+ let create = with_files(CliCommand::new(&API_METHOD_CREATE_JOB).arg_param(&["id"]));
+
CliCommandMap::new()
- .insert(
- "run",
- CliCommand::new(&runner::API_METHOD_RUN_JOB)
- .arg_param(&["id"])
- .completion_cb("id", complete_job_id),
- )
+ .insert("create", create)
+ .insert("update", with_files(with_id(&API_METHOD_UPDATE_JOB)))
+ .insert("remove", with_id(&API_METHOD_REMOVE_JOB))
+ .insert("enable", with_id(&API_METHOD_ENABLE_JOB))
+ .insert("disable", with_id(&API_METHOD_DISABLE_JOB))
+ .insert("list", CliCommand::new(&API_METHOD_LIST_JOBS))
+ .insert("show", with_id(&API_METHOD_SHOW_JOB))
+ .insert("run", with_id(&runner::API_METHOD_RUN_JOB))
.insert(
"scheduler",
CliCommand::new(&scheduler::API_METHOD_RUN_SCHEDULER),
diff --git a/proxmox-backup-client/src/job/scheduler.rs b/proxmox-backup-client/src/job/scheduler.rs
index e8eaa7d0f..1c539bf52 100644
--- a/proxmox-backup-client/src/job/scheduler.rs
+++ b/proxmox-backup-client/src/job/scheduler.rs
@@ -325,3 +325,18 @@ fn acquire_scheduler_pidfile(path: &Path) -> Result<Flock<std::fs::File>, Error>
write!(locked, "{}", std::process::id())?;
Ok(locked)
}
+
+/// Name of the systemd user service shipped for the scheduler.
+const SCHEDULER_UNIT: &str = "proxmox-backup-client-scheduler";
+
+/// Tell the user how to start a scheduler if none is running, as no job runs without one.
+pub(super) fn hint_if_not_running() {
+ if scheduler_pid_path().is_ok_and(|path| client_job::is_locked(&path)) {
+ return;
+ }
+ log::info!(
+ "note: no backup job scheduler is running, so jobs will not run. Enable it with\n \
+ systemctl --user enable --now {SCHEDULER_UNIT}\n\
+ or run 'proxmox-backup-client job scheduler' in the foreground."
+ );
+}
--
2.47.3
next prev parent reply other threads:[~2026-10-21 20:55 UTC|newest]
Thread overview: 9+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-14 10:51 [PATCH 0/7] client: add scheduled backup jobs Thomas Lamprecht
2026-10-14 10:51 ` [PATCH 1/7] client: backup specification: widen BackupDetectionMode derives Thomas Lamprecht
2026-10-14 10:51 ` [PATCH 2/7] client: job: add backup job config and state types Thomas Lamprecht
2026-10-14 10:51 ` [PATCH 3/7] client: job: add job runner and scheduled backup daemon Thomas Lamprecht
2026-10-14 10:51 ` Thomas Lamprecht [this message]
2026-10-14 10:51 ` [PATCH 5/7] client: job: ship systemd user unit for the scheduler Thomas Lamprecht
2026-10-14 10:51 ` [PATCH 6/7] client: job: add battery-check example pre-run hook Thomas Lamprecht
2026-10-14 10:51 ` [PATCH 7/7] docs: client: document scheduled backup jobs Thomas Lamprecht
2026-10-22 12:58 ` [PATCH 0/7] client: add " Christian Ebner
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261014105208.2747208-5-t.lamprecht@proxmox.com \
--to=t.lamprecht@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox