public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [PATCH 3/7] client: job: add job runner and scheduled backup daemon
Date: Wed, 14 Oct 2026 12:51:32 +0200	[thread overview]
Message-ID: <20261014105208.2747208-4-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261014105208.2747208-1-t.lamprecht@proxmox.com>

Add `job run <id>` to run one job, and `job scheduler`, a per-user
daemon that starts due jobs.

The daemon plans at least once a minute rather than sleeping until the
next event, as timers stop during suspend and wall-clock deadlines move
with NTP and DST. Each run is a separate `job run` process without
stdin, which keeps the secrets of jobs apart and avoids prompts on a
shared terminal. At most two backups run at once.

A run holds the config lock until it has the job lock, like
`job remove`, and a scheduled run then checks that the job is still
due. A run whose outcome cannot be recorded fails, as the job would
otherwise stay due and run again.

The pre-run hook proceeds on exit 0, skips on 1 and fails the run
otherwise, so that a broken hook is not mistaken for a skip. It gets a
minute, no secrets, and its own process group, so a timeout also stops
what it started.

If the config does not parse, the daemon refuses to start, and a
running one starts no new runs until it is fixed.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 proxmox-backup-client/Cargo.toml           |   3 +-
 proxmox-backup-client/src/job/mod.rs       |  22 +
 proxmox-backup-client/src/job/runner.rs    | 502 +++++++++++++++++++++
 proxmox-backup-client/src/job/scheduler.rs | 327 ++++++++++++++
 proxmox-backup-client/src/main.rs          |   4 +-
 proxmox-backup-client/tests/job.rs         | 263 +++++++++++
 6 files changed, 1119 insertions(+), 2 deletions(-)
 create mode 100644 proxmox-backup-client/src/job/mod.rs
 create mode 100644 proxmox-backup-client/src/job/runner.rs
 create mode 100644 proxmox-backup-client/src/job/scheduler.rs
 create mode 100644 proxmox-backup-client/tests/job.rs

diff --git a/proxmox-backup-client/Cargo.toml b/proxmox-backup-client/Cargo.toml
index 5f0140e78..e3a5474d9 100644
--- a/proxmox-backup-client/Cargo.toml
+++ b/proxmox-backup-client/Cargo.toml
@@ -13,7 +13,7 @@ nix.workspace = true
 openssl.workspace = true
 serde.workspace = true
 serde_json.workspace = true
-tokio = { workspace = true, features = [ "rt", "rt-multi-thread" ] }
+tokio = { workspace = true, features = [ "io-util", "macros", "process", "rt", "rt-multi-thread", "signal", "time" ] }
 tokio-stream.workspace = true
 tokio-util = { workspace = true, features = [ "codec" ] }
 xdg.workspace = true
@@ -29,6 +29,7 @@ proxmox-log.workspace = true
 proxmox-io.workspace = true
 proxmox-router = { workspace = true, features = [ "cli" ] }
 proxmox-schema = { workspace = true, features = [ "api-macro" ] }
+proxmox-section-config.workspace = true
 proxmox-sortable-macro.workspace = true
 proxmox-sys.workspace = true
 proxmox-systemd.workspace = true
diff --git a/proxmox-backup-client/src/job/mod.rs b/proxmox-backup-client/src/job/mod.rs
new file mode 100644
index 000000000..ffde90ad1
--- /dev/null
+++ b/proxmox-backup-client/src/job/mod.rs
@@ -0,0 +1,22 @@
+//! Scheduled backup jobs: the `job` subcommands, the [`scheduler`] daemon and the [`runner`].
+
+use proxmox_router::cli::*;
+
+use pbs_client::client_job::complete_job_id;
+
+mod runner;
+mod scheduler;
+
+pub fn cli() -> CliCommandMap {
+    CliCommandMap::new()
+        .insert(
+            "run",
+            CliCommand::new(&runner::API_METHOD_RUN_JOB)
+                .arg_param(&["id"])
+                .completion_cb("id", complete_job_id),
+        )
+        .insert(
+            "scheduler",
+            CliCommand::new(&scheduler::API_METHOD_RUN_SCHEDULER),
+        )
+}
diff --git a/proxmox-backup-client/src/job/runner.rs b/proxmox-backup-client/src/job/runner.rs
new file mode 100644
index 000000000..a3bcefeed
--- /dev/null
+++ b/proxmox-backup-client/src/job/runner.rs
@@ -0,0 +1,502 @@
+//! Executing a single backup job, as `proxmox-backup-client job run <id>` does.
+
+use anyhow::{Error, bail, format_err};
+use serde_json::Value;
+
+use proxmox_router::cli::*;
+use proxmox_schema::api;
+
+use pbs_api_types::{BackupNamespace, ClientRateLimitConfig, JOB_ID_SCHEMA};
+use pbs_client::client_job::{self, ClientBackupJobConfig};
+
+#[api(
+    input: {
+        properties: {
+            id: {
+                schema: JOB_ID_SCHEMA,
+            },
+            "dry-run": {
+                type: Boolean,
+                description: "Only show what the job would back up, without uploading \
+                    anything or recording the run.",
+                optional: true,
+                default: false,
+            },
+            scheduled: {
+                type: Boolean,
+                description: "Only run the job if it is due, as the scheduler does.",
+                optional: true,
+                default: false,
+            },
+        },
+    },
+)]
+/// Run a backup job now.
+pub(super) async fn run_job(id: String, dry_run: bool, scheduled: bool) -> Result<(), Error> {
+    // Hold the config lock until the job lock is taken, like `job remove`, so that a removal
+    // cannot slip in between.
+    let config_lock = client_job::lock_config()?;
+
+    let (config, _digest) = client_job::config()?;
+    let job_config = match client_job::find_job(&config, &id)? {
+        Some(job_config) => job_config,
+        None if scheduled => {
+            log::info!("backup job '{id}' no longer exists, not running it");
+            return Ok(());
+        }
+        None => bail!("job '{id}' does not exist"),
+    };
+
+    // even a dry run, so that it cannot overlap with a real one
+    let job = client_job::ClientJob::new(&id)?;
+    drop(config_lock);
+
+    // the daemon decided before the lock was taken, and things may have changed since
+    if scheduled && !still_due(&job_config)? {
+        return Ok(());
+    }
+
+    run_backup_job_once(job, job_config, dry_run).await
+}
+
+/// Whether a job the scheduler picked is still due.
+fn still_due(job_config: &ClientBackupJobConfig) -> Result<bool, Error> {
+    let id = &job_config.id;
+
+    if job_config.disable.unwrap_or(false) {
+        log::info!("backup job '{id}' is disabled, not running it");
+        return Ok(false);
+    }
+
+    let event: proxmox_time::CalendarEvent = job_config.schedule.parse()?;
+    match client_job::next_run_after(&event, id)? {
+        Some(next) if next <= proxmox_time::epoch_i64() => Ok(true),
+        _ => {
+            log::info!("backup job '{id}' is no longer due, not running it");
+            Ok(false)
+        }
+    }
+}
+
+/// Run a backup job and record its outcome.
+async fn run_backup_job_once(
+    mut job: client_job::ClientJob,
+    job_config: ClientBackupJobConfig,
+    dry_run: bool,
+) -> Result<(), Error> {
+    let id = &job_config.id;
+    if !dry_run {
+        job.start()?;
+    }
+
+    let result = run_backup_job_inner(&job_config, dry_run).await;
+
+    // Persist the outcome without replacing a backup error with a state-write error.
+    let record = match &result {
+        Ok(JobOutcome::Completed) if dry_run => {
+            log::info!("dry run of backup job '{id}' finished");
+            Ok(())
+        }
+        // log success only once it is recorded
+        Ok(JobOutcome::Completed) => job.finish(true, None).inspect(|()| {
+            log::info!("backup job '{id}' finished successfully");
+        }),
+        Ok(JobOutcome::SkippedByHook { code }) => {
+            if dry_run {
+                log::info!("backup job '{id}' was skipped by its pre-run hook");
+                Ok(())
+            } else {
+                job.skip(*code).inspect(|()| {
+                    log::info!("backup job '{id}' was skipped by its pre-run hook");
+                })
+            }
+        }
+        Err(_) if dry_run => Ok(()),
+        Err(err) => job.finish(false, Some(truncated_message(err))),
+    };
+    // An unrecorded outcome fails the run, as the job would otherwise stay due and run again.
+    match (result, record) {
+        (Err(err), record) => {
+            if let Err(record_err) = record {
+                log::error!("backup job '{id}': cannot record its outcome: {record_err}");
+            }
+            bail!("backup job '{id}' failed: {err:#}")
+        }
+        (Ok(_), Err(err)) => {
+            bail!("backup job '{id}' ran, but its outcome was not recorded: {err}")
+        }
+        (Ok(_), Ok(())) => Ok(()),
+    }
+}
+
+/// The error for the state file, capped, as it can carry a password helper's output.
+fn truncated_message(err: &Error) -> String {
+    const MAX_LEN: usize = 1024;
+
+    let mut text = format!("{err:#}");
+    if text.len() > MAX_LEN {
+        let end = (0..=MAX_LEN)
+            .rev()
+            .find(|&idx| text.is_char_boundary(idx))
+            .unwrap_or(0);
+        text.truncate(end);
+        text.push_str("[...]");
+    }
+    text
+}
+
+#[derive(Debug)]
+enum JobOutcome {
+    Completed,
+    SkippedByHook { code: i32 },
+}
+
+/// Everything that can fail once the run is recorded as started.
+async fn run_backup_job_inner(
+    job_config: &ClientBackupJobConfig,
+    dry_run: bool,
+) -> Result<JobOutcome, Error> {
+    // a hand-edited job never went through `job create` or `job update`
+    job_config.validate()?;
+
+    if let Some(hook) = &job_config.pre_run_hook {
+        if let Some(outcome) = run_pre_run_hook(&job_config.id, hook).await? {
+            return Ok(outcome);
+        }
+    }
+
+    populate_secret_env(job_config);
+
+    log::info!("starting backup job '{}'", job_config.id);
+    do_create_backup_from_job(job_config, dry_run).await?;
+    Ok(JobOutcome::Completed)
+}
+
+/// Environment variables that can carry a secret into the client.
+const SECRET_ENV_VARS: [&str; 8] = [
+    "PBS_PASSWORD",
+    "PBS_PASSWORD_FD",
+    "PBS_PASSWORD_FILE",
+    "PBS_PASSWORD_CMD",
+    "PBS_ENCRYPTION_PASSWORD",
+    "PBS_ENCRYPTION_PASSWORD_FD",
+    "PBS_ENCRYPTION_PASSWORD_FILE",
+    "PBS_ENCRYPTION_PASSWORD_CMD",
+];
+
+/// A hook that never returns would keep the job locked, so it gets a deadline.
+const PRE_RUN_HOOK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
+
+/// Any other non-zero exit status means a broken hook, not a skip.
+const PRE_RUN_HOOK_SKIP_CODE: i32 = 1;
+
+/// Run a job's pre-run hook: `Ok(None)` proceeds, `Ok(Some(..))` skips, an error fails the run.
+async fn run_pre_run_hook(job_id: &str, hook: &str) -> Result<Option<JobOutcome>, Error> {
+    run_pre_run_hook_timeout(job_id, hook, PRE_RUN_HOOK_TIMEOUT).await
+}
+
+async fn run_pre_run_hook_timeout(
+    job_id: &str,
+    hook: &str,
+    timeout: std::time::Duration,
+) -> Result<Option<JobOutcome>, Error> {
+    use std::os::unix::process::ExitStatusExt;
+
+    log::info!("running pre-run hook for job '{job_id}'");
+    let mut command = tokio::process::Command::new(hook);
+    command
+        .env("PBS_JOB_ID", job_id)
+        .current_dir("/")
+        .stdin(std::process::Stdio::null())
+        // its own process group, so that a timeout also stops what it started
+        .process_group(0)
+        .kill_on_drop(true);
+    // third-party code has no business seeing the backup's secrets
+    for var in SECRET_ENV_VARS {
+        command.env_remove(var);
+    }
+    command.env_remove("CREDENTIALS_DIRECTORY");
+
+    let mut child = command
+        .spawn()
+        .map_err(|err| format_err!("cannot run pre-run hook '{hook}': {err}"))?;
+
+    let status = match tokio::time::timeout(timeout, child.wait()).await {
+        Ok(status) => status.map_err(|err| format_err!("pre-run hook '{hook}' failed: {err}"))?,
+        Err(_) => {
+            kill_hook_process_group(&mut child).await;
+            bail!(
+                "pre-run hook '{hook}' did not finish within {} seconds",
+                timeout.as_secs(),
+            );
+        }
+    };
+
+    if let Some(signal) = status.signal() {
+        bail!("pre-run hook '{hook}' was killed by signal {signal}");
+    }
+    match status.code() {
+        Some(0) => Ok(None),
+        Some(PRE_RUN_HOOK_SKIP_CODE) => Ok(Some(JobOutcome::SkippedByHook {
+            code: PRE_RUN_HOOK_SKIP_CODE,
+        })),
+        Some(code) => bail!(
+            "pre-run hook '{hook}' exited with status {code}, expected 0 to proceed or \
+            {PRE_RUN_HOOK_SKIP_CODE} to skip the run"
+        ),
+        None => bail!("pre-run hook '{hook}' did not exit normally"),
+    }
+}
+
+/// Stop a hook that ran out of time, together with the processes it started.
+async fn kill_hook_process_group(child: &mut tokio::process::Child) {
+    use nix::sys::signal::{Signal, killpg};
+    use nix::unistd::Pid;
+
+    if let Some(pid) = child.id() {
+        // the hook leads its own group, so the group ID is its PID
+        if let Err(err) = killpg(Pid::from_raw(pid as i32), Signal::SIGKILL) {
+            log::warn!("cannot stop the pre-run hook's process group: {err}");
+        }
+    }
+    let _ = child.wait().await;
+}
+
+/// Hand the job's secret sources and fingerprint to the client through the usual `PBS_*` variables.
+///
+/// Inherited variables for a secret the job configures are cleared first, as they would win.
+fn populate_secret_env(job: &pbs_client::client_job::ClientBackupJobConfig) {
+    let set = |name: &str, value: Option<&String>| {
+        if let Some(value) = value {
+            std::env::set_var(name, value);
+        }
+    };
+
+    if job.password_file.is_some() || job.password_cmd.is_some() {
+        for var in SECRET_ENV_VARS
+            .iter()
+            .filter(|var| !var.contains("ENCRYPTION"))
+        {
+            std::env::remove_var(var);
+        }
+    }
+    if job.encryption_password_file.is_some() || job.encryption_password_cmd.is_some() {
+        for var in SECRET_ENV_VARS
+            .iter()
+            .filter(|var| var.contains("ENCRYPTION"))
+        {
+            std::env::remove_var(var);
+        }
+    }
+
+    set("PBS_PASSWORD_FILE", job.password_file.as_ref());
+    set("PBS_PASSWORD_CMD", job.password_cmd.as_ref());
+    set(
+        "PBS_ENCRYPTION_PASSWORD_FILE",
+        job.encryption_password_file.as_ref(),
+    );
+    set(
+        "PBS_ENCRYPTION_PASSWORD_CMD",
+        job.encryption_password_cmd.as_ref(),
+    );
+    set("PBS_FINGERPRINT", job.fingerprint.as_ref());
+}
+
+/// Insert a job option if set, so that `create_backup` applies its own default otherwise.
+fn insert_opt<T: serde::Serialize>(
+    param: &mut serde_json::Map<String, Value>,
+    key: &str,
+    value: Option<&T>,
+) -> Result<(), Error> {
+    if let Some(value) = value {
+        param.insert(key.to_string(), serde_json::to_value(value)?);
+    }
+    Ok(())
+}
+
+/// Run the backup of a job through the regular `create_backup` command.
+async fn do_create_backup_from_job(
+    job: &ClientBackupJobConfig,
+    dry_run: bool,
+) -> Result<(), Error> {
+    let repo = job.resolve_repository()?;
+
+    let mut param = serde_json::Map::new();
+    param.insert("repository".into(), repo.to_string().into());
+    // always set, or `PBS_NAMESPACE` from the environment would apply
+    let ns = job.ns.clone().unwrap_or_else(BackupNamespace::root);
+    param.insert("ns".into(), serde_json::to_value(ns)?);
+    param.insert("backupspec".into(), serde_json::to_value(&job.backupspec)?);
+
+    insert_opt(&mut param, "backup-id", job.backup_id.as_ref())?;
+    insert_opt(&mut param, "backup-type", job.backup_type.as_ref())?;
+    insert_opt(&mut param, "keyfile", job.keyfile.as_ref())?;
+    insert_opt(
+        &mut param,
+        "master-pubkey-file",
+        job.master_pubkey_file.as_ref(),
+    )?;
+    insert_opt(&mut param, "crypt-mode", job.crypt_mode.as_ref())?;
+    insert_opt(&mut param, "exclude", job.exclude.as_ref())?;
+    insert_opt(&mut param, "include-dev", job.include_dev.as_ref())?;
+    insert_opt(
+        &mut param,
+        "all-file-systems",
+        job.all_file_systems.as_ref(),
+    )?;
+    insert_opt(
+        &mut param,
+        "skip-lost-and-found",
+        job.skip_lost_and_found.as_ref(),
+    )?;
+    insert_opt(
+        &mut param,
+        "change-detection-mode",
+        job.change_detection_mode.as_ref(),
+    )?;
+    insert_opt(&mut param, "chunk-size", job.chunk_size.as_ref())?;
+    insert_opt(&mut param, "entries-max", job.entries_max.as_ref())?;
+    insert_opt(
+        &mut param,
+        "skip-e2big-xattr",
+        job.skip_e2big_xattr.as_ref(),
+    )?;
+
+    let param = Value::Object(param);
+    let all_file_systems = job.all_file_systems.unwrap_or(false);
+    let skip_lost_and_found = job.skip_lost_and_found.unwrap_or(false);
+    let skip_e2big_xattr = job.skip_e2big_xattr.unwrap_or(false);
+    let change_detection_mode = job.change_detection_mode.clone();
+    // `ClientRateLimitConfig` has private fields, so build it through serde like the CLI does
+    let mut limit = serde_json::Map::new();
+    insert_opt(&mut limit, "rate", job.rate.as_ref())?;
+    insert_opt(&mut limit, "burst", job.burst.as_ref())?;
+    let limit: ClientRateLimitConfig = serde_json::from_value(Value::Object(limit))?;
+
+    let rpcenv = &mut CliEnvironment::new() as &mut dyn proxmox_router::RpcEnvironment;
+
+    crate::create_backup(
+        param,
+        all_file_systems,
+        skip_lost_and_found,
+        change_detection_mode,
+        dry_run,
+        skip_e2big_xattr,
+        job.no_cache.unwrap_or(false),
+        limit,
+        &crate::API_METHOD_CREATE_BACKUP,
+        rpcenv,
+    )
+    .await?;
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use proxmox_schema::{ApiType, ObjectSchemaType};
+
+    use pbs_client::client_job::ClientBackupJobConfig;
+
+    use super::*;
+
+    /// Write an executable hook script into a fresh directory and return its path.
+    fn write_hook(name: &str, body: &str) -> std::path::PathBuf {
+        use std::os::unix::fs::PermissionsExt;
+
+        let dir = std::env::temp_dir().join(format!("pbs-hook-test-{}-{name}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join(format!("{name}.sh"));
+        std::fs::write(&path, body).unwrap();
+        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
+        path
+    }
+
+    /// A hook that runs out of time is killed together with the processes it started.
+    #[tokio::test]
+    async fn a_hook_that_times_out_takes_its_process_group_with_it() {
+        let dir = std::env::temp_dir().join(format!("pbs-hook-test-{}-group", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let pidfile = dir.join("grandchild.pid");
+        let _ = std::fs::remove_file(&pidfile);
+
+        let hook = write_hook(
+            "hangs",
+            &format!(
+                "#!/bin/sh\nsh -c 'echo $$ > {}; exec sleep 60' &\nsleep 60\n",
+                pidfile.display(),
+            ),
+        );
+        let hook = hook.to_str().unwrap();
+
+        // long enough for the helper to record itself, even on a busy machine
+        let err = run_pre_run_hook_timeout("group", hook, std::time::Duration::from_secs(2))
+            .await
+            .expect_err("a hook that sleeps past its timeout must fail the run");
+        assert!(err.to_string().contains("did not finish within"), "{err}");
+
+        let mut recorded = None;
+        for _ in 0..50 {
+            if let Some(pid) = std::fs::read_to_string(&pidfile)
+                .ok()
+                .and_then(|text| text.trim().parse::<i32>().ok())
+            {
+                recorded = Some(pid);
+                break;
+            }
+            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+        }
+        let grandchild = recorded.expect("the hook records the process it started");
+
+        // the group may take a moment to go away
+        let mut alive = true;
+        for _ in 0..50 {
+            if !std::path::Path::new(&format!("/proc/{grandchild}")).exists() {
+                alive = false;
+                break;
+            }
+            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+        }
+        assert!(
+            !alive,
+            "process {grandchild} outlived the hook that started it"
+        );
+
+        let _ = std::fs::remove_file(&pidfile);
+    }
+
+    /// Backup options a job deliberately lacks, each with the reason.
+    const NOT_IN_JOBS: [(&str, &str); 4] = [
+        (
+            "keyfd",
+            "a file descriptor cannot be stored and reopened later",
+        ),
+        ("master-pubkey-fd", "same, the job uses master-pubkey-file"),
+        ("backup-time", "each run picks its own time"),
+        ("dry-run", "per invocation, `job run` has its own flag"),
+    ];
+
+    /// A job must cover every option of a one-shot backup, or list it in `NOT_IN_JOBS`.
+    #[test]
+    fn job_config_covers_the_backup_options() {
+        let job = ClientBackupJobConfig::API_SCHEMA
+            .any_object()
+            .expect("job config schema is an object schema");
+
+        let mut missing = Vec::new();
+        for (name, _optional, _schema) in crate::API_METHOD_CREATE_BACKUP.parameters.properties() {
+            if job.lookup(name).is_some() {
+                continue;
+            }
+            if NOT_IN_JOBS.iter().any(|(excluded, _)| excluded == name) {
+                continue;
+            }
+            missing.push(*name);
+        }
+        assert!(
+            missing.is_empty(),
+            "backup options missing from the job config: {missing:?}. Add them, or list them in \
+            NOT_IN_JOBS with the reason they cannot be stored.",
+        );
+    }
+}
diff --git a/proxmox-backup-client/src/job/scheduler.rs b/proxmox-backup-client/src/job/scheduler.rs
new file mode 100644
index 000000000..e8eaa7d0f
--- /dev/null
+++ b/proxmox-backup-client/src/job/scheduler.rs
@@ -0,0 +1,327 @@
+//! The per-user scheduler daemon: it watches the job config and starts due jobs, each as its own
+//! `proxmox-backup-client job run <id>` process.
+
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+
+use anyhow::{Error, bail, format_err};
+
+use proxmox_schema::api;
+use proxmox_section_config::SectionConfigData;
+
+use nix::fcntl::{Flock, FlockArg};
+use pbs_client::client_job::{self, ClientBackupJobConfig};
+
+/// Argv prefix passed to a re-exec of this binary to run a single job.
+/// Kept in sync with the CLI command tree wired up in [`cli`].
+const JOB_RUN_ARGV: [&str; 2] = ["job", "run"];
+
+#[api(
+    input: {
+        properties: {},
+    },
+)]
+/// Run the scheduler daemon that starts due backup jobs.
+pub(super) async fn run_scheduler() -> Result<(), Error> {
+    use tokio::signal::unix::{SignalKind, signal};
+
+    // before the PID file exists, as SIGHUP terminates a process without a handler
+    let mut sighup = signal(SignalKind::hangup())?;
+    let mut sigterm = signal(SignalKind::terminate())?;
+
+    // held for the daemon's lifetime, which keeps a second scheduler from starting
+    let _pid_lock = acquire_scheduler_pidfile(&scheduler_pid_path()?)?;
+    // a broken config at startup would look healthy to systemd
+    client_job::config()?;
+
+    let self_exe = std::env::current_exe()?;
+    let mut children: Vec<(String, tokio::process::Child)> = Vec::new();
+    let mut last_started: HashMap<String, i64> = HashMap::new();
+    // jobs that found no free slot, so that each wait is logged once
+    let mut waiting: HashSet<String> = HashSet::new();
+    // config problems are logged once per config, not on every pass
+    let mut reported_digest = None;
+    let mut reported_load_error = false;
+
+    log::info!("backup job scheduler started (PID {})", std::process::id());
+
+    loop {
+        reap_finished(&mut children);
+        let in_flight: HashSet<String> = children.iter().map(|(id, _)| id.clone()).collect();
+
+        // every run reads the config itself, so a broken one starts no runs at all
+        let plan = match client_job::config() {
+            Ok((config, digest)) => {
+                reported_load_error = false;
+                let report = reported_digest.replace(digest) != Some(digest);
+                plan_jobs(&config, &in_flight, &last_started, report)
+            }
+            Err(err) => {
+                if !std::mem::replace(&mut reported_load_error, true) {
+                    log::error!(
+                        "cannot read the job config, starting no runs until it is fixed: {err}"
+                    );
+                }
+                Plan {
+                    due: Vec::new(),
+                    next_wake: now_plus(MAX_SLEEP),
+                }
+            }
+        };
+        let deferred = start_due_jobs(&self_exe, plan.due, &mut children, &mut last_started);
+        for job_id in deferred.difference(&waiting) {
+            log::info!(
+                "backup job '{job_id}' is queued, {MAX_CONCURRENT_RUNS} backups are already \
+                running"
+            );
+        }
+        waiting = deferred;
+
+        let delay = (plan.next_wake - proxmox_time::epoch_i64()).clamp(0, MAX_SLEEP);
+        tokio::select! {
+            _ = tokio::time::sleep(std::time::Duration::from_secs(delay as u64)) => {}
+            // a finished run frees a slot; `Child::wait` is cancel safe
+            _ = any_child_finished(&mut children) => {}
+            _ = sighup.recv() => log::info!("received SIGHUP, reloading configuration"),
+            _ = sigterm.recv() => {
+                log::info!("received SIGTERM, shutting down");
+                break;
+            }
+        }
+    }
+
+    reap_finished(&mut children);
+    if !children.is_empty() {
+        log::info!("waiting for {} running backup(s) to finish", children.len());
+        let wait_all = async {
+            for (job_id, child) in children.iter_mut() {
+                log_child_result(job_id, child.wait().await);
+            }
+        };
+        // a second SIGTERM stops the wait
+        tokio::select! {
+            _ = wait_all => {}
+            _ = sigterm.recv() => {
+                log::warn!("received second SIGTERM, exiting without waiting for running backups");
+            }
+        }
+    }
+
+    log::info!("backup job scheduler stopped");
+    Ok(())
+}
+
+/// What one planning pass concluded.
+struct Plan {
+    /// IDs of the jobs to start now, the longest-waiting one first.
+    due: Vec<String>,
+    /// Wall-clock time by which the loop has to look again.
+    next_wake: i64,
+}
+
+/// Work out which jobs are due, skipping disabled and running ones. `report` enables the log
+/// messages about the config itself, so that they do not repeat on every pass.
+fn plan_jobs(
+    config: &SectionConfigData,
+    in_flight: &HashSet<String>,
+    last_started: &HashMap<String, i64>,
+    report: bool,
+) -> Plan {
+    use proxmox_time::CalendarEvent;
+
+    let now = proxmox_time::epoch_i64();
+    let mut next_wake = now_plus(MAX_SLEEP);
+    let mut due = Vec::new();
+
+    for job_id in client_job::job_ids(config) {
+        let job: ClientBackupJobConfig = match config.lookup(client_job::JOB_SECTION_TYPE, job_id) {
+            Ok(job) => job,
+            Err(err) => {
+                if report {
+                    log::error!("cannot parse job '{job_id}': {err}");
+                }
+                continue;
+            }
+        };
+
+        if job.disable.unwrap_or(false) {
+            continue;
+        }
+
+        let event: CalendarEvent = match job.schedule.parse() {
+            Ok(event) => event,
+            Err(err) => {
+                if report {
+                    log::error!("job '{job_id}': invalid schedule '{}': {err}", job.schedule);
+                }
+                continue;
+            }
+        };
+
+        let next = match client_job::next_run_after(&event, job_id) {
+            Ok(Some(next)) => next,
+            Ok(None) => {
+                if report {
+                    log::warn!(
+                        "job '{job_id}': schedule '{}' has no future event, the job will not run \
+                        again",
+                        job.schedule,
+                    );
+                }
+                continue;
+            }
+            Err(err) => {
+                if report {
+                    log::error!("job '{job_id}': cannot compute next run: {err}");
+                }
+                continue;
+            }
+        };
+
+        if next > now {
+            next_wake = next_wake.min(next);
+            continue;
+        }
+        if in_flight.contains(job_id) || client_job::is_running(job_id) {
+            continue;
+        }
+        // a run that records nothing leaves its job due, so do not restart it on every wakeup
+        if let Some(started) = last_started.get(job_id) {
+            let earliest = started + MIN_RESTART_INTERVAL;
+            if earliest > now {
+                next_wake = next_wake.min(earliest);
+                continue;
+            }
+        }
+        due.push((next, job_id.clone()));
+    }
+
+    // longest waiting first, rather than in config order
+    due.sort_unstable();
+
+    Plan {
+        due: due.into_iter().map(|(_next, job_id)| job_id).collect(),
+        next_wake,
+    }
+}
+
+/// Start the due jobs, up to the concurrency limit, and return the ones that have to wait.
+fn start_due_jobs(
+    self_exe: &Path,
+    due: Vec<String>,
+    children: &mut Vec<(String, tokio::process::Child)>,
+    last_started: &mut HashMap<String, i64>,
+) -> HashSet<String> {
+    let mut deferred = HashSet::new();
+    for job_id in due {
+        if children.len() >= MAX_CONCURRENT_RUNS {
+            deferred.insert(job_id);
+            continue;
+        }
+        match tokio::process::Command::new(self_exe)
+            .args(JOB_RUN_ARGV)
+            .arg(&job_id)
+            // the run checks again that the job is still due
+            .arg("--scheduled")
+            // never prompt on a terminal, the same as under systemd
+            .stdin(std::process::Stdio::null())
+            .spawn()
+        {
+            Ok(child) => {
+                match child.id() {
+                    Some(pid) => log::info!("spawned backup job '{job_id}' (PID {pid})"),
+                    None => log::info!("spawned backup job '{job_id}'"),
+                }
+                last_started.insert(job_id.clone(), proxmox_time::epoch_i64());
+                children.push((job_id, child));
+            }
+            Err(err) => log::error!("cannot spawn backup job '{job_id}': {err}"),
+        }
+    }
+    deferred
+}
+
+/// Upper bound for one sleep. Timers stop during suspend and wall-clock deadlines move with NTP
+/// and DST, so look again at least once a minute, like the proxy does.
+const MAX_SLEEP: i64 = 60;
+
+/// Shortest time between two starts of the same job, for runs that record no outcome.
+const MIN_RESTART_INTERVAL: i64 = MAX_SLEEP;
+
+/// Backups running at the same time, so that due jobs do not all compete for disk and uplink.
+const MAX_CONCURRENT_RUNS: usize = 2;
+
+fn now_plus(secs: i64) -> i64 {
+    proxmox_time::epoch_i64().saturating_add(secs)
+}
+
+/// Wait until any one of the running jobs finishes, or forever if none is running.
+async fn any_child_finished(children: &mut [(String, tokio::process::Child)]) {
+    if children.is_empty() {
+        std::future::pending::<()>().await;
+        return;
+    }
+    let waits = children
+        .iter_mut()
+        .map(|(_, child)| Box::pin(child.wait()))
+        .collect::<Vec<_>>();
+    let _ = futures::future::select_all(waits).await;
+}
+
+/// Reap the runs that exited, so that they no longer count as in flight.
+fn reap_finished(children: &mut Vec<(String, tokio::process::Child)>) {
+    children.retain_mut(|(job_id, child)| match child.try_wait() {
+        Ok(None) => true,
+        Ok(Some(status)) => {
+            log_child_result(job_id, Ok(status));
+            false
+        }
+        Err(err) => {
+            log_child_result(job_id, Err(err));
+            false
+        }
+    });
+}
+
+/// Log a failed run's exit status. Runs log their own outcome, but a killed one cannot.
+fn log_child_result(job_id: &str, result: std::io::Result<std::process::ExitStatus>) {
+    match result {
+        Ok(status) if status.success() => (),
+        Ok(status) => log::warn!("backup job '{job_id}' failed ({status})"),
+        Err(err) => log::error!("backup job '{job_id}': cannot determine outcome: {err}"),
+    }
+}
+
+fn scheduler_pid_path() -> Result<PathBuf, Error> {
+    Ok(pbs_client::tools::base_directories()?
+        .get_state_home()
+        .join("scheduler.pid"))
+}
+
+/// Lock the PID file and write our PID to it. The kernel drops the lock when the process exits.
+fn acquire_scheduler_pidfile(path: &Path) -> Result<Flock<std::fs::File>, Error> {
+    use std::io::Write;
+
+    if let Some(dir) = path.parent() {
+        proxmox_sys::fs::create_path(dir, None, None)?;
+    }
+    // not truncated before the lock is held, the file may belong to a running scheduler
+    let file = std::fs::OpenOptions::new()
+        .read(true)
+        .write(true)
+        .create(true)
+        .truncate(false)
+        .open(path)
+        .map_err(|err| format_err!("cannot open {}: {err}", path.display()))?;
+    let mut locked = match Flock::lock(file, FlockArg::LockExclusiveNonblock) {
+        Ok(locked) => locked,
+        Err((_, nix::errno::Errno::EWOULDBLOCK)) => bail!(
+            "another backup job scheduler is already running ({} is locked)",
+            path.display(),
+        ),
+        Err((_, errno)) => bail!("cannot lock {}: {errno}", path.display()),
+    };
+    locked.set_len(0)?;
+    write!(locked, "{}", std::process::id())?;
+    Ok(locked)
+}
diff --git a/proxmox-backup-client/src/main.rs b/proxmox-backup-client/src/main.rs
index 5238a75ea..5979377a6 100644
--- a/proxmox-backup-client/src/main.rs
+++ b/proxmox-backup-client/src/main.rs
@@ -65,6 +65,7 @@ use pbs_key_config::{KeyConfig, decrypt_key, rsa_encrypt_key_config};
 use pbs_tools::crypt_config::CryptConfig;
 use pbs_tools::json;
 
+pub mod job;
 pub mod key;
 pub mod namespace;
 
@@ -799,7 +800,7 @@ fn spawn_catalog_upload(
  )]
 /// Create (host) backup.
 #[allow(clippy::too_many_arguments)]
-async fn create_backup(
+pub(crate) async fn create_backup(
     param: Value,
     all_file_systems: bool,
     skip_lost_and_found: bool,
@@ -2294,6 +2295,7 @@ fn main() {
         .insert("benchmark", benchmark_cmd_def)
         .insert("change-owner", change_owner_cmd_def)
         .insert("namespace", namespace::cli_map())
+        .insert("job", job::cli())
         .insert("group", group_mgmt_cli())
         .alias(&["files"], &["snapshot", "files"])
         .alias(&["forget"], &["snapshot", "forget"])
diff --git a/proxmox-backup-client/tests/job.rs b/proxmox-backup-client/tests/job.rs
new file mode 100644
index 000000000..bb165434a
--- /dev/null
+++ b/proxmox-backup-client/tests/job.rs
@@ -0,0 +1,263 @@
+//! End-to-end checks of `job run` against the built binary, for the locks, the job state and the
+//! config on disk.
+
+use std::fs::File;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use nix::errno::Errno;
+use nix::fcntl::{Flock, FlockArg};
+
+/// A private home directory, so that the jobs of whoever runs the tests stay untouched.
+struct Home {
+    dir: PathBuf,
+}
+
+impl Home {
+    fn new(name: &str) -> Self {
+        let dir = Path::new(env!("CARGO_TARGET_TMPDIR"))
+            .join(format!("job-{name}-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&dir);
+        std::fs::create_dir_all(&dir).unwrap();
+        Self { dir }
+    }
+
+    fn config_dir(&self) -> PathBuf {
+        self.dir.join("config/proxmox-backup")
+    }
+
+    fn state_dir(&self) -> PathBuf {
+        self.dir.join("state/proxmox-backup/jobstates")
+    }
+
+    fn state_file(&self, id: &str) -> PathBuf {
+        self.state_dir().join(format!("backup-job-{id}.json"))
+    }
+
+    /// Write a job config holding a single job, with `extra` appended to its section.
+    fn write_job(&self, id: &str, extra: &str) {
+        std::fs::create_dir_all(self.config_dir()).unwrap();
+        std::fs::write(
+            self.config_dir().join("jobs.cfg"),
+            format!(
+                "backup-job: {id}\n\tbackupspec etc.pxar:/etc\n\tdatastore store1\n\
+                 \tschedule daily\n{extra}"
+            ),
+        )
+        .unwrap();
+    }
+
+    /// Record that the job was created just now, so that its next event lies ahead.
+    fn write_created_now(&self, id: &str) {
+        let now = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_secs();
+        std::fs::create_dir_all(self.state_dir()).unwrap();
+        std::fs::write(
+            self.state_file(id),
+            format!("{{\"created\":{{\"time\":{now}}}}}"),
+        )
+        .unwrap();
+    }
+
+    /// Write an executable hook script and return its path.
+    fn hook(&self, name: &str, body: &str) -> PathBuf {
+        use std::os::unix::fs::PermissionsExt;
+
+        let path = self.dir.join(name);
+        std::fs::write(&path, body).unwrap();
+        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
+        path
+    }
+
+    fn client(&self) -> Command {
+        let mut command = Command::new(env!("CARGO_BIN_EXE_proxmox-backup-client"));
+        // no stray PBS_* variable from the caller
+        command
+            .env_clear()
+            .env("PATH", "/usr/bin:/bin")
+            .env("HOME", &self.dir)
+            .env("XDG_CONFIG_HOME", self.dir.join("config"))
+            .env("XDG_STATE_HOME", self.dir.join("state"));
+        command
+    }
+
+    fn run(&self, args: &[&str]) -> Output {
+        self.client()
+            .arg("job")
+            .arg("run")
+            .args(args)
+            .output()
+            .unwrap()
+    }
+}
+
+impl Drop for Home {
+    fn drop(&mut self) {
+        let _ = std::fs::remove_dir_all(&self.dir);
+    }
+}
+
+fn stderr(output: &Output) -> String {
+    String::from_utf8_lossy(&output.stderr).into_owned()
+}
+
+/// A run the scheduler starts must check again that the job is due, a manual one must not.
+#[test]
+fn a_scheduled_run_starts_only_a_job_that_is_still_due() {
+    let home = Home::new("scheduled");
+    let marker = home.dir.join("hook-ran");
+    let hook = home.hook(
+        "mark.sh",
+        &format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()),
+    );
+    let hook_line = format!("\tpre-run-hook {}\n", hook.display());
+
+    // just created, so its next event lies ahead
+    home.write_job("nightly", &hook_line);
+    home.write_created_now("nightly");
+    let out = home.run(&["nightly", "--scheduled"]);
+    assert!(out.status.success(), "{}", stderr(&out));
+    assert!(!marker.exists(), "ran a job that is not due");
+    assert!(stderr(&out).contains("no longer due"), "{}", stderr(&out));
+
+    // due, having never run, but disabled
+    std::fs::remove_file(home.state_file("nightly")).unwrap();
+    home.write_job("nightly", &format!("{hook_line}\tdisable true\n"));
+    let out = home.run(&["nightly", "--scheduled"]);
+    assert!(out.status.success(), "{}", stderr(&out));
+    assert!(!marker.exists(), "ran a disabled job");
+    assert!(stderr(&out).contains("disabled"), "{}", stderr(&out));
+
+    // removed after the daemon picked it
+    let out = home.run(&["removed", "--scheduled"]);
+    assert!(out.status.success(), "{}", stderr(&out));
+    assert!(
+        stderr(&out).contains("no longer exists"),
+        "{}",
+        stderr(&out)
+    );
+
+    // and a job that is due does run, or the checks above would prove nothing
+    home.write_job("nightly", &hook_line);
+    let out = home.run(&["nightly", "--scheduled"]);
+    assert!(out.status.success(), "{}", stderr(&out));
+    assert!(marker.exists(), "did not run a job that is due");
+
+    // a manual run is not bound by the schedule
+    std::fs::remove_file(&marker).unwrap();
+    home.write_created_now("nightly");
+    let out = home.run(&["nightly"]);
+    assert!(out.status.success(), "{}", stderr(&out));
+    assert!(marker.exists(), "a manual run did not run the job");
+}
+
+/// A run whose outcome cannot be recorded must not report success.
+#[test]
+fn a_run_whose_outcome_cannot_be_recorded_fails() {
+    let home = Home::new("unrecorded");
+    let state = home.state_file("nightly");
+
+    // the hook puts a directory where the outcome goes, then asks to skip the run
+    let hook = home.hook(
+        "block.sh",
+        &format!(
+            "#!/bin/sh\nrm -f '{0}'\nmkdir '{0}'\nexit 1\n",
+            state.display()
+        ),
+    );
+    home.write_job("nightly", &format!("\tpre-run-hook {}\n", hook.display()));
+    let out = home.run(&["nightly"]);
+    assert!(!out.status.success(), "reported a skip it did not record");
+    assert!(
+        stderr(&out).contains("outcome was not recorded"),
+        "{}",
+        stderr(&out)
+    );
+    assert!(
+        !stderr(&out).contains("was skipped by its pre-run hook"),
+        "{}",
+        stderr(&out)
+    );
+
+    // a failed run keeps its own error, and also says that it could not record it
+    std::fs::remove_dir(&state).unwrap();
+    let hook = home.hook(
+        "block-and-fail.sh",
+        &format!(
+            "#!/bin/sh\nrm -f '{0}'\nmkdir '{0}'\nexit 2\n",
+            state.display()
+        ),
+    );
+    home.write_job("nightly", &format!("\tpre-run-hook {}\n", hook.display()));
+    let out = home.run(&["nightly"]);
+    assert!(!out.status.success(), "{}", stderr(&out));
+    assert!(
+        stderr(&out).contains("exited with status 2"),
+        "{}",
+        stderr(&out)
+    );
+    assert!(
+        stderr(&out).contains("cannot record its outcome"),
+        "{}",
+        stderr(&out)
+    );
+}
+
+/// Try to take a lock that someone else might hold, reporting whether it was free.
+fn lock_is_free(path: &Path) -> bool {
+    let file = File::options().read(true).write(true).open(path).unwrap();
+    match Flock::lock(file, FlockArg::LockExclusiveNonblock) {
+        Ok(_lock) => true,
+        Err((_, Errno::EWOULDBLOCK)) => false,
+        Err((_, errno)) => panic!("cannot probe {}: {errno}", path.display()),
+    }
+}
+
+/// A run holds the config lock until it has the job lock, so that `job remove` cannot slip in.
+#[test]
+fn a_run_holds_the_config_lock_while_it_waits_for_the_job() {
+    let home = Home::new("lock-order");
+    home.write_job("nightly", "");
+
+    // hold the job lock, as a run of the same job would
+    std::fs::create_dir_all(home.state_dir()).unwrap();
+    let job_lock_path = home.state_dir().join(".backup-job-nightly.lck");
+    let job_lock = Flock::lock(
+        File::create(&job_lock_path).unwrap(),
+        FlockArg::LockExclusive,
+    )
+    .map_err(|(_, errno)| errno)
+    .unwrap();
+
+    let config_lock = home.config_dir().join(".jobs.lck");
+    let run = home
+        .client()
+        .args(["job", "run", "nightly"])
+        .stderr(std::process::Stdio::piped())
+        .spawn()
+        .unwrap();
+
+    // still held a second later, well into the wait, not just while reading the config
+    let started = Instant::now();
+    while !config_lock.exists() || lock_is_free(&config_lock) {
+        assert!(
+            started.elapsed() < Duration::from_secs(2),
+            "the run never took the config lock"
+        );
+        std::thread::sleep(Duration::from_millis(20));
+    }
+    std::thread::sleep(Duration::from_secs(1));
+    assert!(
+        !lock_is_free(&config_lock),
+        "the run let go of the config lock while it waited for the job"
+    );
+
+    // keep the job locked until the run gives up
+    let out = run.wait_with_output().unwrap();
+    drop(job_lock);
+    assert!(!out.status.success());
+    assert!(stderr(&out).contains("already running"), "{}", stderr(&out));
+}
-- 
2.47.3





  parent reply	other threads:[~2026-10-21 20:56 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 ` Thomas Lamprecht [this message]
2026-10-14 10:51 ` [PATCH 4/7] client: job: add job management CLI subcommands Thomas Lamprecht
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-4-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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal