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 2/7] client: job: add backup job config and state types
Date: Wed, 14 Oct 2026 12:51:31 +0200	[thread overview]
Message-ID: <20261014105208.2747208-3-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20261014105208.2747208-1-t.lamprecht@proxmox.com>

Add a per-user jobs.cfg under $XDG_CONFIG_HOME/proxmox-backup/ for
scheduled backups, with the job state under $XDG_STATE_HOME. A job
holds a schedule, repository, sources and the options of a one-shot
backup, plus where its secrets come from and an optional pre-run hook.

Every write is validated, so that mistakes fail at once instead of at
03:00. Paths must be absolute, as a scheduled run does not share the
working directory or $PATH of an interactive shell.

Unknown section types are rejected, so that a misspelled header fails
the parse instead of silently dropping that job.

A per-job flock keeps a job from running twice, and tells a live run
from one that was killed.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
 pbs-client/Cargo.toml        |    3 +-
 pbs-client/src/client_job.rs | 1808 ++++++++++++++++++++++++++++++++++
 pbs-client/src/lib.rs        |    1 +
 3 files changed, 1811 insertions(+), 1 deletion(-)
 create mode 100644 pbs-client/src/client_job.rs

diff --git a/pbs-client/Cargo.toml b/pbs-client/Cargo.toml
index 233969f65..912c44956 100644
--- a/pbs-client/Cargo.toml
+++ b/pbs-client/Cargo.toml
@@ -43,7 +43,8 @@ proxmox-log = { workspace = true }
 proxmox-rate-limiter = { workspace = true, features = [ "rate-limiter" ] }
 proxmox-router = { workspace = true, features = [ "cli", "server" ] }
 proxmox-schema.workspace = true
-proxmox-sys.workspace = true
+proxmox-section-config.workspace = true
+proxmox-sys = { workspace = true, features = [ "timer" ] }
 proxmox-time.workspace = true
 
 pxar.workspace = true
diff --git a/pbs-client/src/client_job.rs b/pbs-client/src/client_job.rs
new file mode 100644
index 000000000..0c89bd107
--- /dev/null
+++ b/pbs-client/src/client_job.rs
@@ -0,0 +1,1808 @@
+//! Client-side backup jobs: their config in `$XDG_CONFIG_HOME/proxmox-backup/jobs.cfg`, their
+//! state and locks in `$XDG_STATE_HOME/proxmox-backup/jobstates/`.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::LazyLock;
+
+use anyhow::{Error, bail, format_err};
+use nix::fcntl::{Flock, FlockArg};
+use nix::sys::stat::Mode;
+use serde::{Deserialize, Serialize};
+
+use proxmox_human_byte::HumanByte;
+use proxmox_schema::{ApiStringFormat, ApiType, Schema, StringSchema, Updater, api};
+use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
+use proxmox_sys::fs::{
+    CreateOptions, atomic_open_or_create_file, create_path, file_read_optional_string, lock_file,
+    open_file_locked, replace_file,
+};
+
+use pbs_api_types::{
+    Authid, BACKUP_ID_SCHEMA, BACKUP_TYPE_SCHEMA, BackupNamespace, CERT_FINGERPRINT_SHA256_SCHEMA,
+    CryptMode, DATASTORE_SCHEMA, JOB_ID_SCHEMA, SINGLE_LINE_COMMENT_SCHEMA,
+};
+
+use crate::tools::CHUNK_SIZE_SCHEMA;
+use crate::tools::key_source::{KEYFILE_SCHEMA, MASTER_PUBKEY_FILE_SCHEMA};
+use crate::{
+    BACKUP_REPO_PORT_SCHEMA, BACKUP_REPO_SERVER_SCHEMA, BACKUP_SOURCE_SCHEMA, BackupDetectionMode,
+    BackupRepository, BackupRepositoryArgs, REPO_URL_SCHEMA,
+};
+
+/// Config and state files name where secrets come from, so keep them private.
+const FILE_MODE: Mode = Mode::from_bits_truncate(0o600);
+const DIR_MODE: Mode = Mode::from_bits_truncate(0o700);
+
+fn file_create_options() -> CreateOptions {
+    CreateOptions::new().perm(FILE_MODE)
+}
+
+const JOB_RATE_SCHEMA: Schema = HumanByte::API_SCHEMA
+    .unwrap_string_schema_cloned()
+    .description("Rate limit in bytes per second, with optional unit, for example '10MB'.")
+    .schema();
+
+const JOB_BURST_SCHEMA: Schema = HumanByte::API_SCHEMA
+    .unwrap_string_schema_cloned()
+    .description("Largest burst the rate limit allows, in bytes with optional unit.")
+    .schema();
+
+/// An empty comment would be written back as a bare `comment` key, so drop it instead.
+fn normalize_comment(comment: &str) -> Option<String> {
+    let comment = comment.trim();
+    (!comment.is_empty()).then(|| comment.to_string())
+}
+
+/// The scheduler shares neither the working directory nor `$PATH` with an interactive shell.
+fn check_absolute(name: &str, path: &str) -> Result<(), Error> {
+    if !Path::new(path).is_absolute() {
+        bail!("{name} must be an absolute path: '{path}'");
+    }
+    Ok(())
+}
+
+/// The config format trims values and reads one per line, so reject what would not read back as is.
+fn check_round_trip(name: &str, value: &str) -> Result<(), Error> {
+    if value.trim() != value {
+        bail!("{name} must not start or end with whitespace: '{value}'");
+    }
+    if value.contains(['\n', '\r']) {
+        bail!("{name} must not contain a line break: '{value}'");
+    }
+    Ok(())
+}
+
+fn check_absolute_opt(name: &str, path: Option<&str>) -> Result<(), Error> {
+    match path {
+        Some(path) => check_absolute(name, path),
+        None => Ok(()),
+    }
+}
+
+const CLIENT_BACKUP_SCHEDULE_SCHEMA: Schema = StringSchema::new(
+    "Backup schedule as a calendar event, for example 'daily' or 'mon..fri 02:30'.",
+)
+.format(&ApiStringFormat::VerifyFn(
+    proxmox_time::verify_calendar_event,
+))
+.type_text("<calendar-event>")
+.schema();
+
+// The repository fields mirror `BackupRepositoryArgs` but are not flattened, so that the updater
+// and `DeletableProperty` can handle them like any other field.
+#[api(
+    properties: {
+        id: {
+            schema: JOB_ID_SCHEMA,
+        },
+        schedule: {
+            schema: CLIENT_BACKUP_SCHEDULE_SCHEMA,
+        },
+        repository: {
+            schema: REPO_URL_SCHEMA,
+            optional: true,
+        },
+        server: {
+            schema: BACKUP_REPO_SERVER_SCHEMA,
+            optional: true,
+        },
+        port: {
+            schema: BACKUP_REPO_PORT_SCHEMA,
+            optional: true,
+        },
+        datastore: {
+            schema: DATASTORE_SCHEMA,
+            optional: true,
+        },
+        "auth-id": {
+            type: Authid,
+            optional: true,
+        },
+        ns: {
+            type: BackupNamespace,
+            optional: true,
+        },
+        // Backup sources
+        backupspec: {
+            type: Array,
+            description: "Backup source as <archive-name>.<type>:<source-path>, for example \
+                'root.pxar:/'.",
+            items: {
+                schema: BACKUP_SOURCE_SCHEMA,
+            },
+        },
+        // Auth secrets and TLS pinning
+        "password-file": {
+            description: "File whose first line is the password or API token secret.",
+            optional: true,
+            type: String,
+            min_length: 1,
+        },
+        "password-cmd": {
+            description: "Command that prints the password or API token secret on its first \
+                line. It runs without a shell, so use '/bin/sh -c' for shell syntax.",
+            optional: true,
+            type: String,
+            min_length: 1,
+        },
+        "encryption-password-file": {
+            description: "File whose first line is the passphrase of the key file.",
+            optional: true,
+            type: String,
+            min_length: 1,
+        },
+        "encryption-password-cmd": {
+            description: "Command that prints the passphrase of the key file on its first \
+                line. It runs without a shell, so use '/bin/sh -c' for shell syntax.",
+            optional: true,
+            type: String,
+            min_length: 1,
+        },
+        fingerprint: {
+            schema: CERT_FINGERPRINT_SHA256_SCHEMA,
+            optional: true,
+        },
+        // Backup options
+        "backup-id": {
+            schema: BACKUP_ID_SCHEMA,
+            optional: true,
+        },
+        "backup-type": {
+            schema: BACKUP_TYPE_SCHEMA,
+            optional: true,
+        },
+        keyfile: {
+            schema: KEYFILE_SCHEMA,
+            optional: true,
+        },
+        "master-pubkey-file": {
+            schema: MASTER_PUBKEY_FILE_SCHEMA,
+            optional: true,
+        },
+        "crypt-mode": {
+            type: CryptMode,
+            optional: true,
+        },
+        exclude: {
+            type: Array,
+            description: "List of paths or patterns for matching files to exclude.",
+            optional: true,
+            items: {
+                type: String,
+                description: "Path or match pattern.",
+            },
+        },
+        "include-dev": {
+            type: Array,
+            description: "Include mountpoints with same st_dev number.",
+            optional: true,
+            items: {
+                type: String,
+                description: "Path to file.",
+            },
+        },
+        "all-file-systems": {
+            type: Boolean,
+            description: "Include all mounted subdirectories.",
+            optional: true,
+            default: false,
+        },
+        "skip-lost-and-found": {
+            type: Boolean,
+            description: "Skip lost+found directory.",
+            optional: true,
+            default: false,
+        },
+        "change-detection-mode": {
+            type: BackupDetectionMode,
+            optional: true,
+        },
+        "chunk-size": {
+            schema: CHUNK_SIZE_SCHEMA,
+            optional: true,
+        },
+        rate: {
+            schema: JOB_RATE_SCHEMA,
+            optional: true,
+        },
+        burst: {
+            schema: JOB_BURST_SCHEMA,
+            optional: true,
+        },
+        "entries-max": {
+            type: Integer,
+            description: "Max number of entries to hold in memory.",
+            optional: true,
+        },
+        "skip-e2big-xattr": {
+            type: Boolean,
+            description: "Ignore the E2BIG error when retrieving xattrs.",
+            optional: true,
+            default: false,
+        },
+        "no-cache": {
+            type: Boolean,
+            description: "Bypass local datastore cache for network storages.",
+            optional: true,
+            default: false,
+        },
+        // Job control
+        disable: {
+            type: Boolean,
+            description: "Disable this job.",
+            optional: true,
+            default: false,
+        },
+        comment: {
+            schema: SINGLE_LINE_COMMENT_SCHEMA,
+            optional: true,
+        },
+        "pre-run-hook": {
+            description: "Executable run before each backup. Exit status 0 proceeds, 1 skips \
+                the run, and anything else fails it.",
+            optional: true,
+            type: String,
+            min_length: 1,
+        },
+    },
+)]
+#[derive(Serialize, Deserialize, Clone, Updater)]
+#[serde(rename_all = "kebab-case")]
+/// Client backup job configuration.
+pub struct ClientBackupJobConfig {
+    #[updater(skip)]
+    pub id: String,
+    pub schedule: String,
+
+    // Repository location, either URL or atoms, see `repo_args`.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub repository: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub server: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub port: Option<u16>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub datastore: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub auth_id: Option<Authid>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub ns: Option<BackupNamespace>,
+
+    pub backupspec: Vec<String>,
+
+    // Auth secrets and TLS pinning
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub password_file: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub password_cmd: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub encryption_password_file: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub encryption_password_cmd: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub fingerprint: Option<String>,
+
+    // Backup options
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub backup_id: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub backup_type: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub keyfile: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub master_pubkey_file: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub crypt_mode: Option<CryptMode>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub exclude: Option<Vec<String>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub include_dev: Option<Vec<String>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub all_file_systems: Option<bool>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub skip_lost_and_found: Option<bool>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub change_detection_mode: Option<BackupDetectionMode>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub chunk_size: Option<u64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub rate: Option<HumanByte>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub burst: Option<HumanByte>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub entries_max: Option<u64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub skip_e2big_xattr: Option<bool>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub no_cache: Option<bool>,
+
+    // Job control
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub disable: Option<bool>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub pre_run_hook: Option<String>,
+}
+
+#[api]
+#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Optional properties of [`ClientBackupJobConfig`] that `job update --delete`
+/// can clear.
+pub enum DeletableProperty {
+    /// Clear an explicit repository URL.
+    Repository,
+    /// Unset the server atom (reverts to the URL form or default).
+    Server,
+    /// Unset the port atom.
+    Port,
+    /// Unset the datastore atom.
+    Datastore,
+    /// Unset the auth-id atom.
+    AuthId,
+    /// Reset the namespace to the root namespace.
+    Ns,
+    /// Clear the password-file path.
+    PasswordFile,
+    /// Clear the password-cmd string.
+    PasswordCmd,
+    /// Clear the encryption-password-file path.
+    EncryptionPasswordFile,
+    /// Clear the encryption-password-cmd string.
+    EncryptionPasswordCmd,
+    /// Clear the pinned server fingerprint.
+    Fingerprint,
+    /// Clear an explicit backup-id (reverts to the host name).
+    BackupId,
+    /// Clear an explicit backup-type (reverts to "host").
+    BackupType,
+    /// Clear the keyfile path.
+    Keyfile,
+    /// Clear the master public key path.
+    MasterPubkeyFile,
+    /// Reset the crypt-mode to the default.
+    CryptMode,
+    /// Drop any configured exclude patterns.
+    Exclude,
+    /// Drop any configured include-dev paths.
+    IncludeDev,
+    /// Reset all-file-systems to the default.
+    AllFileSystems,
+    /// Reset skip-lost-and-found to the default.
+    SkipLostAndFound,
+    /// Reset the change detection mode to the default.
+    ChangeDetectionMode,
+    /// Reset chunk size to the default.
+    ChunkSize,
+    /// Drop the upload rate limit.
+    Rate,
+    /// Drop the upload rate limit's burst size.
+    Burst,
+    /// Reset the entries-max limit to the default.
+    EntriesMax,
+    /// Reset skip-e2big-xattr to the default.
+    SkipE2bigXattr,
+    /// Reset no-cache to the default.
+    NoCache,
+    /// Reset the disable flag.
+    Disable,
+    /// Delete the comment.
+    Comment,
+    /// Clear the pre-run hook.
+    PreRunHook,
+}
+
+impl ClientBackupJobConfig {
+    /// Collect the repository fields into a [`BackupRepositoryArgs`].
+    fn repo_args(&self) -> BackupRepositoryArgs {
+        BackupRepositoryArgs {
+            repository: self.repository.clone(),
+            server: self.server.clone(),
+            port: self.port,
+            datastore: self.datastore.clone(),
+            auth_id: self.auth_id.clone(),
+        }
+    }
+
+    /// Resolve the job's repository, without falling back to `PBS_*` environment variables.
+    pub fn resolve_repository(&self) -> Result<BackupRepository, Error> {
+        BackupRepository::try_from(self.repo_args())
+    }
+
+    /// Store values that have more than one spelling in one form, as every write path must.
+    pub fn normalize(&mut self) {
+        if let Some(comment) = self.comment.take() {
+            self.comment = normalize_comment(&comment);
+        }
+        // the same as `job enable`, which drops the flag
+        if self.disable == Some(false) {
+            self.disable = None;
+        }
+    }
+
+    /// Check a job at create and update time, rather than letting it fail when it runs.
+    pub fn validate(&self) -> Result<(), Error> {
+        proxmox_time::verify_calendar_event(&self.schedule)?;
+        self.resolve_repository()?;
+
+        if matches!(self.crypt_mode, Some(CryptMode::None)) {
+            for (name, path) in [
+                ("keyfile", &self.keyfile),
+                ("master-pubkey-file", &self.master_pubkey_file),
+            ] {
+                if path.is_some() {
+                    bail!("{name} cannot be used with crypt-mode 'none'");
+                }
+            }
+        }
+
+        if self.password_file.is_some() && self.password_cmd.is_some() {
+            bail!("password-file and password-cmd are mutually exclusive");
+        }
+        if self.encryption_password_file.is_some() && self.encryption_password_cmd.is_some() {
+            bail!("encryption-password-file and encryption-password-cmd are mutually exclusive");
+        }
+
+        for (name, cmd) in [
+            ("password-cmd", &self.password_cmd),
+            ("encryption-password-cmd", &self.encryption_password_cmd),
+        ] {
+            let Some(cmd) = cmd else { continue };
+            let args = proxmox_router::cli::shellword_split(cmd)
+                .map_err(|err| format_err!("invalid {name} '{cmd}': {err}"))?;
+            // an empty command would panic in the shared secret lookup
+            let program = args
+                .first()
+                .ok_or_else(|| format_err!("{name} must not be empty"))?;
+            check_absolute(name, program)?;
+        }
+
+        check_absolute_opt("pre-run-hook", self.pre_run_hook.as_deref())?;
+        check_absolute_opt("keyfile", self.keyfile.as_deref())?;
+        check_absolute_opt("master-pubkey-file", self.master_pubkey_file.as_deref())?;
+        check_absolute_opt("password-file", self.password_file.as_deref())?;
+        check_absolute_opt(
+            "encryption-password-file",
+            self.encryption_password_file.as_deref(),
+        )?;
+        for path in self.include_dev.iter().flatten() {
+            check_absolute("include-dev", path)?;
+        }
+        let mut archives = std::collections::HashSet::new();
+        for spec in &self.backupspec {
+            let spec = crate::parse_backup_specification(spec)
+                .map_err(|err| format_err!("invalid backup source '{spec}': {err}"))?;
+            check_absolute("backup source", &spec.config_string)?;
+            if !archives.insert(spec.archive_name.clone()) {
+                bail!(
+                    "duplicate backup source archive name '{}'",
+                    spec.archive_name
+                );
+            }
+        }
+        if self.all_file_systems.unwrap_or(false) && self.include_dev.is_some() {
+            bail!("all-file-systems and include-dev are mutually exclusive");
+        }
+
+        if let Some(size) = self.chunk_size {
+            if !size.is_power_of_two() {
+                bail!("chunk-size must be a power of 2, got {size}");
+            }
+        }
+        if self.burst.is_some() && self.rate.is_none() {
+            bail!("burst requires rate to be set");
+        }
+
+        // Last, so that more specific checks report first. The serialized form covers new fields.
+        let serialized = serde_json::to_value(self)?;
+        for (name, value) in serialized.as_object().into_iter().flatten() {
+            match value {
+                serde_json::Value::String(text) => check_round_trip(name, text)?,
+                serde_json::Value::Array(items) => {
+                    for text in items.iter().filter_map(serde_json::Value::as_str) {
+                        check_round_trip(name, text)?;
+                    }
+                }
+                _ => (),
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Apply deletions, then updates. Setting the repository URL clears the separate repository
+    /// fields and the other way around, so a job can switch forms in one call.
+    pub fn apply_update(
+        &mut self,
+        update: ClientBackupJobConfigUpdater,
+        delete: &[DeletableProperty],
+    ) {
+        for prop in delete {
+            match prop {
+                DeletableProperty::Repository => self.repository = None,
+                DeletableProperty::Server => self.server = None,
+                DeletableProperty::Port => self.port = None,
+                DeletableProperty::Datastore => self.datastore = None,
+                DeletableProperty::AuthId => self.auth_id = None,
+                DeletableProperty::Ns => self.ns = None,
+                DeletableProperty::PasswordFile => self.password_file = None,
+                DeletableProperty::PasswordCmd => self.password_cmd = None,
+                DeletableProperty::EncryptionPasswordFile => self.encryption_password_file = None,
+                DeletableProperty::EncryptionPasswordCmd => self.encryption_password_cmd = None,
+                DeletableProperty::Fingerprint => self.fingerprint = None,
+                DeletableProperty::BackupId => self.backup_id = None,
+                DeletableProperty::BackupType => self.backup_type = None,
+                DeletableProperty::Keyfile => self.keyfile = None,
+                DeletableProperty::MasterPubkeyFile => self.master_pubkey_file = None,
+                DeletableProperty::CryptMode => self.crypt_mode = None,
+                DeletableProperty::Exclude => self.exclude = None,
+                DeletableProperty::IncludeDev => self.include_dev = None,
+                DeletableProperty::AllFileSystems => self.all_file_systems = None,
+                DeletableProperty::SkipLostAndFound => self.skip_lost_and_found = None,
+                DeletableProperty::ChangeDetectionMode => self.change_detection_mode = None,
+                DeletableProperty::ChunkSize => self.chunk_size = None,
+                DeletableProperty::Rate => self.rate = None,
+                DeletableProperty::Burst => self.burst = None,
+                DeletableProperty::EntriesMax => self.entries_max = None,
+                DeletableProperty::SkipE2bigXattr => self.skip_e2big_xattr = None,
+                DeletableProperty::NoCache => self.no_cache = None,
+                DeletableProperty::Disable => self.disable = None,
+                DeletableProperty::Comment => self.comment = None,
+                DeletableProperty::PreRunHook => self.pre_run_hook = None,
+            }
+        }
+
+        let switching_to_url = update.repository.is_some();
+        let switching_to_atoms = update.server.is_some()
+            || update.port.is_some()
+            || update.datastore.is_some()
+            || update.auth_id.is_some();
+        if switching_to_url {
+            self.server = None;
+            self.port = None;
+            self.datastore = None;
+            self.auth_id = None;
+        } else if switching_to_atoms {
+            self.repository = None;
+        }
+
+        if let Some(schedule) = update.schedule {
+            self.schedule = schedule;
+        }
+        if let Some(backupspec) = update.backupspec {
+            self.backupspec = backupspec;
+        }
+        if let Some(repository) = update.repository {
+            self.repository = Some(repository);
+        }
+        if let Some(server) = update.server {
+            self.server = Some(server);
+        }
+        if let Some(port) = update.port {
+            self.port = Some(port);
+        }
+        if let Some(datastore) = update.datastore {
+            self.datastore = Some(datastore);
+        }
+        if let Some(auth_id) = update.auth_id {
+            self.auth_id = Some(auth_id);
+        }
+        if let Some(ns) = update.ns {
+            self.ns = Some(ns);
+        }
+        if let Some(password_file) = update.password_file {
+            self.password_file = Some(password_file);
+        }
+        if let Some(password_cmd) = update.password_cmd {
+            self.password_cmd = Some(password_cmd);
+        }
+        if let Some(encryption_password_file) = update.encryption_password_file {
+            self.encryption_password_file = Some(encryption_password_file);
+        }
+        if let Some(encryption_password_cmd) = update.encryption_password_cmd {
+            self.encryption_password_cmd = Some(encryption_password_cmd);
+        }
+        if let Some(fingerprint) = update.fingerprint {
+            self.fingerprint = Some(fingerprint);
+        }
+        if let Some(backup_id) = update.backup_id {
+            self.backup_id = Some(backup_id);
+        }
+        if let Some(backup_type) = update.backup_type {
+            self.backup_type = Some(backup_type);
+        }
+        if let Some(keyfile) = update.keyfile {
+            self.keyfile = Some(keyfile);
+        }
+        if let Some(master_pubkey_file) = update.master_pubkey_file {
+            self.master_pubkey_file = Some(master_pubkey_file);
+        }
+        if let Some(crypt_mode) = update.crypt_mode {
+            self.crypt_mode = Some(crypt_mode);
+        }
+        if let Some(exclude) = update.exclude {
+            self.exclude = Some(exclude);
+        }
+        if let Some(include_dev) = update.include_dev {
+            self.include_dev = Some(include_dev);
+        }
+        if let Some(all_file_systems) = update.all_file_systems {
+            self.all_file_systems = Some(all_file_systems);
+        }
+        if let Some(skip_lost_and_found) = update.skip_lost_and_found {
+            self.skip_lost_and_found = Some(skip_lost_and_found);
+        }
+        if let Some(change_detection_mode) = update.change_detection_mode {
+            self.change_detection_mode = Some(change_detection_mode);
+        }
+        if let Some(chunk_size) = update.chunk_size {
+            self.chunk_size = Some(chunk_size);
+        }
+        if let Some(rate) = update.rate {
+            self.rate = Some(rate);
+        }
+        if let Some(burst) = update.burst {
+            self.burst = Some(burst);
+        }
+        if let Some(entries_max) = update.entries_max {
+            self.entries_max = Some(entries_max);
+        }
+        if let Some(skip_e2big_xattr) = update.skip_e2big_xattr {
+            self.skip_e2big_xattr = Some(skip_e2big_xattr);
+        }
+        if let Some(no_cache) = update.no_cache {
+            self.no_cache = Some(no_cache);
+        }
+        if let Some(disable) = update.disable {
+            self.disable = Some(disable);
+        }
+        if let Some(comment) = update.comment {
+            self.comment = Some(comment);
+        }
+        if let Some(pre_run_hook) = update.pre_run_hook {
+            self.pre_run_hook = Some(pre_run_hook);
+        }
+
+        self.normalize();
+    }
+}
+
+pub static CONFIG: LazyLock<SectionConfig> = LazyLock::new(init);
+
+/// Section type of a backup job, and the type key used in `jobs.cfg`.
+pub const JOB_SECTION_TYPE: &str = "backup-job";
+
+fn init() -> SectionConfig {
+    let obj_schema = ClientBackupJobConfig::API_SCHEMA.unwrap_object_schema();
+
+    let plugin = SectionConfigPlugin::new(
+        JOB_SECTION_TYPE.to_string(),
+        Some(String::from("id")),
+        obj_schema,
+    );
+    // no unknown section types, so a misspelled header fails instead of dropping that job
+    let mut config = SectionConfig::new(&JOB_ID_SCHEMA);
+    config.register_plugin(plugin);
+    config
+}
+
+/// The job IDs of a config, sorted for a stable `job list` and a deterministic scheduler.
+pub fn job_ids(data: &SectionConfigData) -> impl Iterator<Item = &String> {
+    let mut ids: Vec<&String> = data.sections.keys().collect();
+    ids.sort_unstable();
+    ids.into_iter()
+}
+
+/// Find a backup job by ID, if the config has one.
+pub fn find_job(
+    config: &SectionConfigData,
+    id: &str,
+) -> Result<Option<ClientBackupJobConfig>, Error> {
+    if !config.sections.contains_key(id) {
+        return Ok(None);
+    }
+    config.lookup(JOB_SECTION_TYPE, id).map(Some)
+}
+
+/// Look up a backup job by ID, failing if there is none.
+pub fn lookup_job(config: &SectionConfigData, id: &str) -> Result<ClientBackupJobConfig, Error> {
+    find_job(config, id)?.ok_or_else(|| format_err!("job '{id}' does not exist"))
+}
+
+const JOBS_CFG_FILENAME: &str = "jobs.cfg";
+const JOBS_CFG_LOCKFILE: &str = ".jobs.lck";
+
+fn config_path() -> Result<PathBuf, Error> {
+    crate::tools::place_xdg_file(JOBS_CFG_FILENAME, "backup job config")
+}
+
+fn lockfile_path() -> Result<PathBuf, Error> {
+    crate::tools::place_xdg_file(JOBS_CFG_LOCKFILE, "backup job lock")
+}
+
+/// Acquire an exclusive lock on the job config file.
+pub fn lock_config() -> Result<std::fs::File, Error> {
+    let path = lockfile_path()?;
+    open_file_locked(
+        &path,
+        std::time::Duration::from_secs(10),
+        true,
+        file_create_options(),
+    )
+    .map_err(|err| format_err!("cannot lock the job config: {err}"))
+}
+
+/// Read the job config, with a digest for change detection.
+pub fn config() -> Result<(SectionConfigData, [u8; 32]), Error> {
+    let path = config_path()?;
+    let content = file_read_optional_string(&path)?.unwrap_or_default();
+    let digest = openssl::sha::sha256(content.as_bytes());
+    let data = CONFIG.parse(&path, &content)?;
+    Ok((data, digest))
+}
+
+/// Write the job config file.
+pub fn save_config(data: &SectionConfigData) -> Result<(), Error> {
+    let path = config_path()?;
+    let raw = CONFIG.write(&path, data)?;
+    replace_file(&path, raw.as_bytes(), file_create_options(), true)
+}
+
+/// Shell completion helper for job IDs.
+pub fn complete_job_id(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
+    match config() {
+        Ok((data, _)) => job_ids(&data).map(|id| id.to_string()).collect(),
+        Err(_) => Vec::new(),
+    }
+}
+
+const JOBSTATE_DIRNAME: &str = "jobstates";
+
+fn state_dir() -> Result<PathBuf, Error> {
+    Ok(crate::tools::base_directories()?
+        .get_state_home()
+        .join(JOBSTATE_DIRNAME))
+}
+
+/// Create the state directory, only when about to write, so that `job list` creates nothing.
+fn create_state_dir() -> Result<PathBuf, Error> {
+    let dir = state_dir()?;
+    create_path(&dir, None, Some(CreateOptions::new().perm(DIR_MODE)))?;
+    Ok(dir)
+}
+
+fn state_path(id: &str) -> Result<PathBuf, Error> {
+    Ok(state_dir()?.join(format!("backup-job-{id}.json")))
+}
+
+fn state_lock_path(id: &str) -> Result<PathBuf, Error> {
+    Ok(state_dir()?.join(format!(".backup-job-{id}.lck")))
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// On-disk state of a client backup job, as written to its state file.
+enum ClientJobState {
+    /// Job was created but never started.
+    Created { time: i64 },
+    /// Job is running. The PID is informational, liveness comes from the job lock.
+    Running { pid: u32, start_time: i64 },
+    /// Job finished (successfully or not).
+    Finished {
+        start_time: i64,
+        end_time: i64,
+        success: bool,
+        #[serde(skip_serializing_if = "Option::is_none")]
+        message: Option<String>,
+    },
+    /// The pre-run hook gated this run, so no backup was made.
+    Skipped {
+        start_time: i64,
+        end_time: i64,
+        code: i32,
+    },
+}
+
+impl ClientJobState {
+    /// Timestamp of this state transition, used for schedule computation.
+    fn time(&self) -> i64 {
+        match *self {
+            ClientJobState::Created { time } => time,
+            ClientJobState::Running { start_time, .. } => start_time,
+            ClientJobState::Finished { end_time, .. }
+            | ClientJobState::Skipped { end_time, .. } => end_time,
+        }
+    }
+}
+
+/// Read a job's state. A broken state file counts as none, so that it cannot wedge the job.
+fn read_state(id: &str) -> Option<ClientJobState> {
+    let path = state_path(id).ok()?;
+    let raw = match file_read_optional_string(&path) {
+        Ok(Some(raw)) => raw,
+        Ok(None) => return None,
+        Err(err) => {
+            proxmox_log::warn!("job '{id}': cannot read state file: {err}");
+            return None;
+        }
+    };
+    match serde_json::from_str(&raw) {
+        Ok(state) => Some(state),
+        Err(err) => {
+            proxmox_log::warn!("job '{id}': ignoring unparsable state file: {err}");
+            None
+        }
+    }
+}
+
+#[api]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Outcome of a backup job's most recent run.
+pub enum JobResult {
+    /// The job was created but has not run yet.
+    Created,
+    /// A run is currently in progress.
+    Running,
+    /// The last run completed successfully.
+    Ok,
+    /// The last run failed.
+    Failed,
+    /// The last run was skipped by the pre-run hook.
+    Skipped,
+    /// A run was started but never completed, for example because it was killed.
+    Interrupted,
+}
+
+/// When `event` next fires after the job's last run. A job without state is due at once.
+pub fn next_run_after(event: &proxmox_time::CalendarEvent, id: &str) -> Result<Option<i64>, Error> {
+    event.compute_next_event(read_state(id).map_or(0, |state| state.time()))
+}
+
+#[api(
+    properties: {
+        config: {
+            type: ClientBackupJobConfig,
+            flatten: true,
+        },
+        "last-run": {
+            description: "Time of the last run, in seconds since the epoch.",
+            type: Integer,
+            optional: true,
+        },
+        "last-result": {
+            type: JobResult,
+            optional: true,
+        },
+        message: {
+            description: "Error of the last run, or why it was skipped.",
+            type: String,
+            optional: true,
+        },
+        "next-run": {
+            description: "When the job is due next, in seconds since the epoch. Unset if the \
+                job is disabled or its schedule has no future event.",
+            type: Integer,
+            optional: true,
+        },
+    },
+)]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// A configured backup job together with the summary of its most recent run.
+pub struct ClientBackupJobStatus {
+    #[serde(flatten)]
+    pub config: ClientBackupJobConfig,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub last_run: Option<i64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub last_result: Option<JobResult>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub message: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub next_run: Option<i64>,
+}
+
+/// When a job is due next, counting from `last`. An overdue job is due now, not in the past.
+fn next_run(config: &ClientBackupJobConfig, last: i64) -> Option<i64> {
+    if config.disable.unwrap_or(false) {
+        return None;
+    }
+    let event: proxmox_time::CalendarEvent = config.schedule.parse().ok()?;
+    let next = event.compute_next_event(last).ok()??;
+    Some(next.max(proxmox_time::epoch_i64()))
+}
+
+impl ClientBackupJobStatus {
+    /// Pair a job config with its state. A `Running` state without a lock holder is interrupted.
+    pub fn with_state(config: ClientBackupJobConfig) -> Self {
+        let state = read_state(&config.id);
+        let next_run = next_run(&config, state.as_ref().map_or(0, ClientJobState::time));
+        let Some(state) = state else {
+            return Self {
+                config,
+                last_run: None,
+                last_result: None,
+                message: None,
+                next_run,
+            };
+        };
+
+        let last_run = match state {
+            ClientJobState::Created { .. } => None,
+            _ => Some(state.time()),
+        };
+        let (last_result, message) = match state {
+            ClientJobState::Created { .. } => (JobResult::Created, None),
+            ClientJobState::Running { .. } if is_running(&config.id) => (JobResult::Running, None),
+            ClientJobState::Running { .. } => (JobResult::Interrupted, None),
+            ClientJobState::Finished {
+                success: true,
+                message,
+                ..
+            } => (JobResult::Ok, message),
+            ClientJobState::Finished { message, .. } => (JobResult::Failed, message),
+            ClientJobState::Skipped { code, .. } => (
+                JobResult::Skipped,
+                Some(format!("pre-run hook exited with status {code}")),
+            ),
+        };
+
+        Self {
+            config,
+            last_run,
+            last_result: Some(last_result),
+            message,
+            next_run,
+        }
+    }
+}
+
+/// Not zero, so that a liveness probe holding the lock for a moment cannot fail a run.
+const JOB_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
+
+/// Whether someone holds an exclusive lock on `path`.
+///
+/// The probe takes a shared lock, so that two probes do not see each other as the holder.
+pub fn is_locked(path: &Path) -> bool {
+    let Ok(file) = std::fs::File::open(path) else {
+        return false;
+    };
+    matches!(
+        Flock::lock(file, FlockArg::LockSharedNonblock),
+        Err((_, nix::errno::Errno::EWOULDBLOCK))
+    )
+}
+
+/// Whether a run of the job holds its lock.
+pub fn is_running(id: &str) -> bool {
+    state_lock_path(id).is_ok_and(|path| is_locked(&path))
+}
+
+/// A running job, holding the lock that keeps it from running twice.
+pub struct ClientJob {
+    id: String,
+    start_time: i64,
+    _lock: std::fs::File,
+}
+
+impl ClientJob {
+    /// Take the job lock, failing if the job is already running. Callers must hold the config lock,
+    /// so that `job remove` cannot unlink the lock file under a run waiting for it.
+    pub fn new(id: &str) -> Result<Self, Error> {
+        create_state_dir()?;
+        let lock_path = state_lock_path(id)?;
+        // opened separately, so that an open error is not reported as a running job
+        let mut lock = atomic_open_or_create_file(
+            &lock_path,
+            nix::fcntl::OFlag::O_RDWR | nix::fcntl::OFlag::O_CLOEXEC | nix::fcntl::OFlag::O_APPEND,
+            &[],
+            file_create_options(),
+            false,
+        )
+        .map_err(|err| format_err!("cannot open the lock file of job '{id}': {err}"))?;
+        lock_file(&mut lock, true, Some(JOB_LOCK_TIMEOUT)).map_err(|err| {
+            // the timeout interrupts the wait
+            if err.kind() == std::io::ErrorKind::Interrupted {
+                format_err!("job '{id}' is already running")
+            } else {
+                format_err!("cannot lock job '{id}': {err}")
+            }
+        })?;
+
+        Ok(Self {
+            id: id.to_string(),
+            start_time: 0,
+            _lock: lock,
+        })
+    }
+
+    /// Mark the job as running.
+    pub fn start(&mut self) -> Result<(), Error> {
+        self.start_time = proxmox_time::epoch_i64();
+        let state = ClientJobState::Running {
+            pid: std::process::id(),
+            start_time: self.start_time,
+        };
+        self.write_state(&state)
+    }
+
+    /// Mark the job as finished.
+    pub fn finish(&self, success: bool, message: Option<String>) -> Result<(), Error> {
+        let state = ClientJobState::Finished {
+            start_time: self.start_time,
+            end_time: proxmox_time::epoch_i64(),
+            success,
+            message,
+        };
+        self.write_state(&state)
+    }
+
+    /// Record that the pre-run hook gated this run, so no backup was made.
+    pub fn skip(&self, code: i32) -> Result<(), Error> {
+        let state = ClientJobState::Skipped {
+            start_time: self.start_time,
+            end_time: proxmox_time::epoch_i64(),
+            code,
+        };
+        self.write_state(&state)
+    }
+
+    fn write_state(&self, state: &ClientJobState) -> Result<(), Error> {
+        let path = state_path(&self.id)?;
+        let data = serde_json::to_string_pretty(state)?;
+        replace_file(&path, data.as_bytes(), file_create_options(), true)
+    }
+}
+
+/// Record a new job as created, so that it does not count as overdue.
+pub fn create_state(id: &str) -> Result<(), Error> {
+    create_state_dir()?;
+    let state = ClientJobState::Created {
+        time: proxmox_time::epoch_i64(),
+    };
+    let path = state_path(id)?;
+    let data = serde_json::to_string_pretty(&state)?;
+    replace_file(&path, data.as_bytes(), file_create_options(), true)
+}
+
+/// Remove a job's state and lock file. Takes the held lock, as unlinking it is only safe then.
+pub fn remove_state(job: &ClientJob) -> Result<(), Error> {
+    for path in [state_path(&job.id)?, state_lock_path(&job.id)?] {
+        match std::fs::remove_file(&path) {
+            Ok(()) => (),
+            Err(err) if err.kind() == std::io::ErrorKind::NotFound => (),
+            Err(err) => return Err(err.into()),
+        }
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use pbs_api_types::Authid;
+    use proxmox_section_config::SectionConfigData;
+
+    fn minimal_job(id: &str) -> ClientBackupJobConfig {
+        ClientBackupJobConfig {
+            id: id.to_string(),
+            schedule: "daily".to_string(),
+            repository: None,
+            server: None,
+            port: None,
+            datastore: Some("store1".to_string()),
+            auth_id: None,
+            ns: None,
+            backupspec: vec!["root.pxar:/".to_string()],
+            password_file: None,
+            password_cmd: None,
+            encryption_password_file: None,
+            encryption_password_cmd: None,
+            fingerprint: None,
+            backup_id: None,
+            backup_type: None,
+            keyfile: None,
+            master_pubkey_file: None,
+            crypt_mode: None,
+            exclude: None,
+            include_dev: None,
+            all_file_systems: None,
+            skip_lost_and_found: None,
+            change_detection_mode: None,
+            chunk_size: None,
+            rate: None,
+            burst: None,
+            entries_max: None,
+            skip_e2big_xattr: None,
+            no_cache: None,
+            disable: None,
+            comment: None,
+            pre_run_hook: None,
+        }
+    }
+
+    fn round_trip(job: &ClientBackupJobConfig) -> ClientBackupJobConfig {
+        let mut data = SectionConfigData::new();
+        data.set_data(&job.id, JOB_SECTION_TYPE, job).unwrap();
+        let raw = CONFIG.write("<test>", &data).unwrap();
+        let parsed = CONFIG.parse("<test>", &raw).unwrap();
+        parsed.lookup(JOB_SECTION_TYPE, &job.id).unwrap()
+    }
+
+    #[test]
+    fn section_config_round_trip_minimal_atoms() {
+        let job = minimal_job("minimal-atoms");
+        let got = round_trip(&job);
+        assert_eq!(got.schedule, "daily");
+        assert_eq!(got.datastore.as_deref(), Some("store1"));
+        assert!(got.repository.is_none());
+        assert!(got.server.is_none());
+        assert_eq!(got.backupspec, vec!["root.pxar:/"]);
+    }
+
+    /// A job can also be configured with a plain repository URL instead of atoms.
+    #[test]
+    fn section_config_round_trip_repository_url() {
+        let mut job = minimal_job("url-job");
+        job.datastore = None;
+        job.repository = Some("user@pbs@backup.example.com:8008:store1".to_string());
+        let got = round_trip(&job);
+        assert_eq!(
+            got.repository.as_deref(),
+            Some("user@pbs@backup.example.com:8008:store1"),
+        );
+        assert!(got.datastore.is_none());
+        let repo = got.resolve_repository().unwrap();
+        assert_eq!(repo.host(), "backup.example.com");
+        assert_eq!(repo.port(), 8008);
+        assert_eq!(repo.store(), "store1");
+    }
+
+    #[test]
+    fn section_config_round_trip_full() {
+        let auth_id: Authid = "testuser@pam".parse().unwrap();
+        let ns: BackupNamespace = "prod/db".parse().unwrap();
+
+        let job = ClientBackupJobConfig {
+            id: "full-job".to_string(),
+            schedule: "mon..fri 02:00".to_string(),
+            repository: None,
+            server: Some("backup.example.com".to_string()),
+            port: Some(8008),
+            datastore: Some("bigstore".to_string()),
+            auth_id: Some(auth_id.clone()),
+            ns: Some(ns.clone()),
+            backupspec: vec!["root.pxar:/".to_string()],
+            password_file: Some("/run/secrets/pw".to_string()),
+            password_cmd: None,
+            encryption_password_file: Some("/run/secrets/enc-pw".to_string()),
+            encryption_password_cmd: None,
+            fingerprint: Some(
+                "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:\
+                 aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99"
+                    .to_string(),
+            ),
+            backup_id: Some("myhost".to_string()),
+            backup_type: Some("host".to_string()),
+            keyfile: Some("/etc/pbs/key.json".to_string()),
+            master_pubkey_file: Some("/etc/pbs/master.pem".to_string()),
+            crypt_mode: Some(CryptMode::Encrypt),
+            exclude: Some(vec!["/tmp".to_string(), "/proc".to_string()]),
+            include_dev: Some(vec!["/mnt/data".to_string()]),
+            all_file_systems: Some(true),
+            skip_lost_and_found: Some(true),
+            change_detection_mode: Some(BackupDetectionMode::Metadata),
+            chunk_size: Some(4096),
+            rate: Some(HumanByte::from(10 * 1024 * 1024_usize)),
+            burst: Some(HumanByte::from(20 * 1024 * 1024_usize)),
+            entries_max: Some(65536),
+            skip_e2big_xattr: Some(true),
+            no_cache: Some(true),
+            disable: Some(false),
+            comment: Some("nightly backup".to_string()),
+            pre_run_hook: Some("/usr/local/bin/pre-backup.sh".to_string()),
+        };
+
+        let got = round_trip(&job);
+        assert_eq!(got.schedule, "mon..fri 02:00");
+        assert_eq!(got.server.as_deref(), Some("backup.example.com"));
+        assert_eq!(got.port, Some(8008));
+        assert_eq!(got.datastore.as_deref(), Some("bigstore"));
+        assert_eq!(
+            got.auth_id.as_ref().unwrap().to_string(),
+            auth_id.to_string(),
+        );
+        assert_eq!(got.ns.as_ref().unwrap().to_string(), ns.to_string());
+        assert_eq!(got.password_file.as_deref(), Some("/run/secrets/pw"));
+        assert_eq!(
+            got.encryption_password_file.as_deref(),
+            Some("/run/secrets/enc-pw"),
+        );
+        assert_eq!(
+            got.fingerprint.as_deref(),
+            Some(
+                "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:\
+                 aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
+            ),
+        );
+        assert_eq!(got.backup_id.as_deref(), Some("myhost"));
+        assert_eq!(got.backup_type.as_deref(), Some("host"));
+        assert_eq!(got.keyfile.as_deref(), Some("/etc/pbs/key.json"));
+        assert_eq!(got.crypt_mode, Some(CryptMode::Encrypt));
+        assert_eq!(got.exclude.as_ref().unwrap(), &["/tmp", "/proc"]);
+        assert_eq!(got.include_dev.as_ref().unwrap(), &["/mnt/data"]);
+        assert_eq!(got.all_file_systems, Some(true));
+        assert_eq!(got.skip_lost_and_found, Some(true));
+        assert_eq!(
+            got.change_detection_mode,
+            Some(BackupDetectionMode::Metadata)
+        );
+        assert_eq!(got.chunk_size, Some(4096));
+        assert_eq!(got.entries_max, Some(65536));
+        assert_eq!(got.skip_e2big_xattr, Some(true));
+        assert_eq!(got.disable, Some(false));
+        assert_eq!(got.comment.as_deref(), Some("nightly backup"));
+        assert_eq!(
+            got.pre_run_hook.as_deref(),
+            Some("/usr/local/bin/pre-backup.sh"),
+        );
+    }
+
+    #[test]
+    fn section_config_round_trip_repeated_keys() {
+        let mut job = minimal_job("multi-spec");
+        job.schedule = "hourly".to_string();
+        job.backupspec = vec![
+            "root.pxar:/".to_string(),
+            "home.pxar:/home".to_string(),
+            "var.pxar:/var".to_string(),
+        ];
+        job.exclude = Some(vec![
+            "/tmp".to_string(),
+            "/dev".to_string(),
+            "/sys".to_string(),
+        ]);
+
+        let got = round_trip(&job);
+        assert_eq!(
+            got.backupspec,
+            vec!["root.pxar:/", "home.pxar:/home", "var.pxar:/var"]
+        );
+        assert_eq!(got.exclude.as_ref().unwrap(), &["/tmp", "/dev", "/sys"]);
+    }
+
+    #[test]
+    fn resolve_repository_requires_datastore() {
+        let mut job = minimal_job("bad");
+        job.datastore = None;
+        job.server = Some("pbs.local".to_string());
+        let err = job.resolve_repository().unwrap_err().to_string();
+        assert!(err.contains("--datastore is required"), "got: {err}");
+    }
+
+    #[test]
+    fn validate_rejects_bad_schedule() {
+        let mut job = minimal_job("bad-schedule");
+        job.schedule = "definitely not a calendar event".to_string();
+        assert!(job.validate().is_err());
+    }
+
+    #[test]
+    fn validate_rejects_relative_pre_run_hook() {
+        let mut job = minimal_job("rel-hook");
+        job.pre_run_hook = Some("./local-hook.sh".into());
+        let err = job.validate().unwrap_err().to_string();
+        assert!(
+            err.contains("pre-run-hook must be an absolute path"),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn validate_accepts_absolute_pre_run_hook() {
+        let mut job = minimal_job("abs-hook");
+        job.pre_run_hook = Some("/usr/local/bin/hook.sh".into());
+        job.validate().unwrap();
+    }
+
+    #[test]
+    fn validate_rejects_unparseable_password_cmd() {
+        let mut job = minimal_job("bad-cmd");
+        // unmatched quote
+        job.password_cmd = Some("pass show 'pbs/nightly".to_string());
+        let err = job.validate().unwrap_err().to_string();
+        assert!(err.contains("password-cmd"), "got: {err}");
+    }
+
+    /// An empty command has no program to execute and used to panic in the shared secret lookup.
+    #[test]
+    fn validate_rejects_empty_password_cmd() {
+        for cmd in ["", "   ", "\t"] {
+            let mut job = minimal_job("empty-cmd");
+            job.password_cmd = Some(cmd.to_string());
+            let err = job.validate().unwrap_err().to_string();
+            assert!(err.contains("password-cmd must not be empty"), "got: {err}");
+        }
+    }
+
+    #[test]
+    fn validate_rejects_relative_secret_cmd() {
+        let mut job = minimal_job("rel-cmd");
+        job.encryption_password_cmd = Some("get-pw --job nightly".to_string());
+        let err = job.validate().unwrap_err().to_string();
+        assert!(
+            err.contains("encryption-password-cmd must be an absolute path"),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn validate_rejects_conflicting_secret_sources() {
+        let mut job = minimal_job("two-sources");
+        job.password_file = Some("/run/secrets/pw".to_string());
+        job.password_cmd = Some("/usr/bin/pass show pbs".to_string());
+        let err = job.validate().unwrap_err().to_string();
+        assert!(err.contains("mutually exclusive"), "got: {err}");
+    }
+
+    /// The name a relative path is reported under, plus a setter that puts one into a config.
+    type RelativePathCase = (&'static str, fn(&mut ClientBackupJobConfig));
+
+    /// The scheduler and `job run` resolve relative paths differently, so none are accepted.
+    #[test]
+    fn validate_rejects_relative_paths() {
+        let cases: &[RelativePathCase] = &[
+            ("keyfile", |j| j.keyfile = Some("key.json".into())),
+            ("password-file", |j| j.password_file = Some("pw".into())),
+            ("encryption-password-file", |j| {
+                j.encryption_password_file = Some("enc-pw".into())
+            }),
+            ("include-dev", |j| {
+                j.include_dev = Some(vec!["mnt/data".into()])
+            }),
+            ("backup source", |j| {
+                j.backupspec = vec!["root.pxar:Documents".into()]
+            }),
+        ];
+
+        for (name, setup) in cases {
+            let mut job = minimal_job("rel");
+            setup(&mut job);
+            let err = job.validate().unwrap_err().to_string();
+            assert!(
+                err.contains(name) && err.contains("must be an absolute path"),
+                "{name}: got: {err}"
+            );
+        }
+    }
+
+    #[test]
+    fn validate_rejects_unparseable_backupspec() {
+        let mut job = minimal_job("bad-spec");
+        job.backupspec = vec!["not-a-spec".to_string()];
+        let err = job.validate().unwrap_err().to_string();
+        assert!(err.contains("invalid backup source"), "got: {err}");
+    }
+
+    /// The backup code enforces the power of 2 only deep inside, so `validate` checks it up front.
+    #[test]
+    fn validate_rejects_non_power_of_two_chunk_size() {
+        let mut job = minimal_job("chunk");
+        job.chunk_size = Some(100);
+        let err = job.validate().unwrap_err().to_string();
+        assert!(err.contains("power of 2"), "got: {err}");
+
+        job.chunk_size = Some(1024);
+        job.validate().unwrap();
+    }
+
+    #[test]
+    fn validate_rejects_burst_without_rate() {
+        let mut job = minimal_job("burst");
+        job.burst = Some(HumanByte::from(1024_usize));
+        let err = job.validate().unwrap_err().to_string();
+        assert!(err.contains("burst requires rate"), "got: {err}");
+
+        job.rate = Some(HumanByte::from(1024_usize));
+        job.validate().unwrap();
+    }
+
+    // -- apply_update / DeletableProperty tests ----------------------------
+
+    fn updater() -> ClientBackupJobConfigUpdater {
+        // all fields unset, a no-op update
+        serde_json::from_value(serde_json::json!({})).unwrap()
+    }
+
+    #[test]
+    fn apply_update_patches_optional_field() {
+        let mut job = minimal_job("p");
+        job.comment = None;
+        let mut up = updater();
+        up.comment = Some("hello".to_string());
+        job.apply_update(up, &[]);
+        assert_eq!(job.comment.as_deref(), Some("hello"));
+    }
+
+    /// Both write paths have to store the same comment.
+    #[test]
+    fn comment_is_trimmed_and_empty_becomes_absent() {
+        for (input, expected) in [
+            ("  spaced  ", Some("spaced")),
+            ("   ", None),
+            ("", None),
+            ("kept", Some("kept")),
+        ] {
+            let mut job = minimal_job("cmt");
+            job.comment = Some(input.to_string());
+            job.normalize();
+            assert_eq!(job.comment.as_deref(), expected, "normalize({input:?})");
+
+            let mut job = minimal_job("cmt");
+            let mut up = updater();
+            up.comment = Some(input.to_string());
+            job.apply_update(up, &[]);
+            assert_eq!(job.comment.as_deref(), expected, "apply_update({input:?})");
+        }
+    }
+
+    #[test]
+    fn apply_update_no_op_for_missing_fields() {
+        let mut job = minimal_job("p");
+        job.comment = Some("keep me".to_string());
+        job.apply_update(updater(), &[]);
+        assert_eq!(job.comment.as_deref(), Some("keep me"));
+    }
+
+    #[test]
+    fn apply_update_setting_repository_clears_atoms() {
+        let mut job = minimal_job("switch-to-url");
+        // start with atoms set
+        assert!(job.datastore.is_some());
+        let mut up = updater();
+        up.repository = Some("user@pbs@host:store".to_string());
+        job.apply_update(up, &[]);
+        assert_eq!(job.repository.as_deref(), Some("user@pbs@host:store"));
+        assert!(job.server.is_none());
+        assert!(job.port.is_none());
+        assert!(job.datastore.is_none());
+        assert!(job.auth_id.is_none());
+        // and the resulting job validates
+        job.validate().unwrap();
+    }
+
+    #[test]
+    fn apply_update_setting_atom_clears_repository() {
+        let mut job = minimal_job("switch-to-atoms");
+        job.datastore = None;
+        job.repository = Some("user@pbs@host:store".to_string());
+        let mut up = updater();
+        up.server = Some("new.example.com".to_string());
+        up.datastore = Some("new-store".to_string());
+        job.apply_update(up, &[]);
+        assert!(job.repository.is_none());
+        assert_eq!(job.server.as_deref(), Some("new.example.com"));
+        assert_eq!(job.datastore.as_deref(), Some("new-store"));
+        job.validate().unwrap();
+    }
+
+    #[test]
+    fn apply_update_delete_then_set_in_one_call() {
+        let mut job = minimal_job("combined");
+        job.comment = Some("old".to_string());
+        let mut up = updater();
+        up.comment = Some("new".to_string());
+        // deletions apply first, so the new value wins
+        job.apply_update(up, &[DeletableProperty::Comment]);
+        assert_eq!(job.comment.as_deref(), Some("new"));
+    }
+
+    /// One `DeletableProperty` plus a predicate that confirms the field it names is cleared.
+    type DeleteCase = (DeletableProperty, fn(&ClientBackupJobConfig) -> bool);
+
+    /// Every `DeletableProperty` must clear the field it names.
+    #[test]
+    fn delete_each_property_clears_field() {
+        let auth_id: Authid = "u@pam".parse().unwrap();
+        let mut job = minimal_job("all-set");
+        job.repository = Some("host:store".to_string());
+        job.server = Some("h".to_string());
+        job.port = Some(8007);
+        job.datastore = Some("s".to_string());
+        job.auth_id = Some(auth_id);
+        job.ns = Some(BackupNamespace::root());
+        job.password_file = Some("pf".into());
+        job.password_cmd = Some("pc".into());
+        job.encryption_password_file = Some("epf".into());
+        job.encryption_password_cmd = Some("epc".into());
+        job.fingerprint = Some("fp".into());
+        job.backup_id = Some("bi".into());
+        job.backup_type = Some("bt".into());
+        job.keyfile = Some("kf".into());
+        job.master_pubkey_file = Some("mpk".into());
+        job.crypt_mode = Some(CryptMode::Encrypt);
+        job.exclude = Some(vec!["x".into()]);
+        job.include_dev = Some(vec!["i".into()]);
+        job.all_file_systems = Some(true);
+        job.skip_lost_and_found = Some(true);
+        job.change_detection_mode = Some(BackupDetectionMode::Metadata);
+        job.chunk_size = Some(1024);
+        job.rate = Some(HumanByte::from(1024_usize));
+        job.burst = Some(HumanByte::from(2048_usize));
+        job.entries_max = Some(10);
+        job.skip_e2big_xattr = Some(true);
+        job.no_cache = Some(true);
+        job.disable = Some(true);
+        job.comment = Some("c".into());
+        job.pre_run_hook = Some("h".into());
+
+        let cases: &[DeleteCase] = &[
+            (DeletableProperty::Repository, |j| j.repository.is_none()),
+            (DeletableProperty::Server, |j| j.server.is_none()),
+            (DeletableProperty::Port, |j| j.port.is_none()),
+            (DeletableProperty::Datastore, |j| j.datastore.is_none()),
+            (DeletableProperty::AuthId, |j| j.auth_id.is_none()),
+            (DeletableProperty::Ns, |j| j.ns.is_none()),
+            (DeletableProperty::PasswordFile, |j| {
+                j.password_file.is_none()
+            }),
+            (DeletableProperty::PasswordCmd, |j| j.password_cmd.is_none()),
+            (DeletableProperty::EncryptionPasswordFile, |j| {
+                j.encryption_password_file.is_none()
+            }),
+            (DeletableProperty::EncryptionPasswordCmd, |j| {
+                j.encryption_password_cmd.is_none()
+            }),
+            (DeletableProperty::Fingerprint, |j| j.fingerprint.is_none()),
+            (DeletableProperty::BackupId, |j| j.backup_id.is_none()),
+            (DeletableProperty::BackupType, |j| j.backup_type.is_none()),
+            (DeletableProperty::Keyfile, |j| j.keyfile.is_none()),
+            (DeletableProperty::MasterPubkeyFile, |j| {
+                j.master_pubkey_file.is_none()
+            }),
+            (DeletableProperty::CryptMode, |j| j.crypt_mode.is_none()),
+            (DeletableProperty::Exclude, |j| j.exclude.is_none()),
+            (DeletableProperty::IncludeDev, |j| j.include_dev.is_none()),
+            (DeletableProperty::AllFileSystems, |j| {
+                j.all_file_systems.is_none()
+            }),
+            (DeletableProperty::SkipLostAndFound, |j| {
+                j.skip_lost_and_found.is_none()
+            }),
+            (DeletableProperty::ChangeDetectionMode, |j| {
+                j.change_detection_mode.is_none()
+            }),
+            (DeletableProperty::ChunkSize, |j| j.chunk_size.is_none()),
+            (DeletableProperty::Rate, |j| j.rate.is_none()),
+            (DeletableProperty::Burst, |j| j.burst.is_none()),
+            (DeletableProperty::EntriesMax, |j| j.entries_max.is_none()),
+            (DeletableProperty::SkipE2bigXattr, |j| {
+                j.skip_e2big_xattr.is_none()
+            }),
+            (DeletableProperty::NoCache, |j| j.no_cache.is_none()),
+            (DeletableProperty::Disable, |j| j.disable.is_none()),
+            (DeletableProperty::Comment, |j| j.comment.is_none()),
+            (DeletableProperty::PreRunHook, |j| j.pre_run_hook.is_none()),
+        ];
+
+        for (prop, check) in cases {
+            let mut copy = job.clone();
+            copy.apply_update(updater(), std::slice::from_ref(prop));
+            assert!(
+                check(&copy),
+                "DeletableProperty::{prop:?} did not clear its field"
+            );
+        }
+    }
+
+    /// Hand-editing mistakes must fail the parse, rather than quietly drop a job or an option.
+    #[test]
+    fn hand_editing_mistakes_fail_the_parse() {
+        let job = "\
+backup-job: laptop
+\tbackupspec home.pxar:/home
+\tdatastore store1
+\tschedule daily
+";
+        CONFIG.parse("<test>", job).expect("the job itself is fine");
+        for broken in [
+            format!("{job}\n\texclude /home/me/secrets\n"),
+            format!("{job}\nkeyfile: /etc/pbs/key.json\n"),
+            job.replace("backup-job:", "backup-jbo:"),
+        ] {
+            assert!(
+                CONFIG.parse("<test>", &broken).is_err(),
+                "parsed:\n{broken}"
+            );
+        }
+    }
+
+    /// The config format trims a property, so a value that needs its whitespace cannot be stored.
+    #[test]
+    fn validate_rejects_values_the_format_cannot_store() {
+        let base = minimal_job("roundtrip");
+
+        let mut job = base.clone();
+        job.backupspec = vec!["files.pxar:/srv/data ".to_string()];
+        let err = job
+            .validate()
+            .expect_err("a trailing space changes the path");
+        assert!(err.to_string().contains("whitespace"), "got: {err}");
+
+        let mut job = base.clone();
+        job.keyfile = Some(" /etc/pbs/key.json".to_string());
+        assert!(job.validate().is_err(), "a leading space changes the path");
+
+        let mut job = base.clone();
+        job.comment = Some("one\ntwo".to_string());
+        let err = job
+            .validate()
+            .expect_err("a line break would read back as a property");
+        assert!(err.to_string().contains("line break"), "got: {err}");
+
+        let mut job = base;
+        job.password_cmd = Some("/usr/bin/pass show my/secret".to_string());
+        job.validate()
+            .expect("whitespace inside a value survives the round trip");
+    }
+
+    /// A job that the backup itself always refuses must not be storable.
+    #[test]
+    fn validate_rejects_a_key_together_with_crypt_mode_none() {
+        let mut job = minimal_job("cryptmode");
+        job.crypt_mode = Some(CryptMode::None);
+
+        job.keyfile = Some("/etc/pbs/key.json".to_string());
+        let err = job
+            .validate()
+            .expect_err("crypt-mode none cannot use a key file");
+        assert!(err.to_string().contains("crypt-mode 'none'"), "got: {err}");
+
+        job.keyfile = None;
+        job.master_pubkey_file = Some("/etc/pbs/master.pem".to_string());
+        assert!(
+            job.validate().is_err(),
+            "crypt-mode none cannot use a master public key"
+        );
+
+        job.master_pubkey_file = None;
+        job.validate().expect("crypt-mode none on its own is fine");
+    }
+
+    /// The next run shown for a job must match what the scheduler does.
+    #[test]
+    fn next_run_follows_the_schedule() {
+        let now = proxmox_time::epoch_i64();
+        let mut job = minimal_job("next");
+
+        // counted from the last run
+        let next = next_run(&job, now).expect("daily always has a next event");
+        assert!(next > now && next <= now + 86400, "{next} vs {now}");
+
+        // a job that never ran is due now, not in 1970
+        let next = next_run(&job, 0).unwrap();
+        assert!(next >= now, "{next} vs {now}");
+
+        // nothing for a schedule that has run out
+        job.schedule = "2020-01-01 00:00".to_string();
+        assert_eq!(next_run(&job, now), None);
+
+        // nor for a disabled job
+        job.schedule = "daily".to_string();
+        job.disable = Some(true);
+        assert_eq!(next_run(&job, now), None);
+    }
+
+    // -- ClientJobState serde round-trip tests -------------------------------
+
+    #[test]
+    fn job_state_created_round_trip() {
+        let state = ClientJobState::Created { time: 1700000000 };
+        let json = serde_json::to_string(&state).unwrap();
+        let parsed: ClientJobState = serde_json::from_str(&json).unwrap();
+        match parsed {
+            ClientJobState::Created { time } => assert_eq!(time, 1700000000),
+            other => panic!(
+                "expected Created, got {}",
+                serde_json::to_string(&other).unwrap()
+            ),
+        }
+    }
+
+    #[test]
+    fn job_state_running_round_trip() {
+        let state = ClientJobState::Running {
+            pid: 12345,
+            start_time: 1700001000,
+        };
+        let json = serde_json::to_string(&state).unwrap();
+        let parsed: ClientJobState = serde_json::from_str(&json).unwrap();
+        match parsed {
+            ClientJobState::Running { pid, start_time } => {
+                assert_eq!(pid, 12345);
+                assert_eq!(start_time, 1700001000);
+            }
+            other => panic!(
+                "expected Running, got {}",
+                serde_json::to_string(&other).unwrap()
+            ),
+        }
+    }
+
+    #[test]
+    fn job_state_finished_round_trip() {
+        let state = ClientJobState::Finished {
+            start_time: 1700001000,
+            end_time: 1700002000,
+            success: true,
+            message: Some("all good".to_string()),
+        };
+        let json = serde_json::to_string(&state).unwrap();
+        let parsed: ClientJobState = serde_json::from_str(&json).unwrap();
+        match parsed {
+            ClientJobState::Finished {
+                start_time,
+                end_time,
+                success,
+                message,
+            } => {
+                assert_eq!(start_time, 1700001000);
+                assert_eq!(end_time, 1700002000);
+                assert!(success);
+                assert_eq!(message.as_deref(), Some("all good"));
+            }
+            other => panic!(
+                "expected Finished, got {}",
+                serde_json::to_string(&other).unwrap()
+            ),
+        }
+    }
+
+    #[test]
+    fn job_state_finished_without_message_round_trip() {
+        let state = ClientJobState::Finished {
+            start_time: 1700001000,
+            end_time: 1700002000,
+            success: false,
+            message: None,
+        };
+        let json = serde_json::to_string(&state).unwrap();
+        assert!(!json.contains("message"));
+        let parsed: ClientJobState = serde_json::from_str(&json).unwrap();
+        match parsed {
+            ClientJobState::Finished {
+                success, message, ..
+            } => {
+                assert!(!success);
+                assert!(message.is_none());
+            }
+            other => panic!(
+                "expected Finished, got {}",
+                serde_json::to_string(&other).unwrap()
+            ),
+        }
+    }
+}
diff --git a/pbs-client/src/lib.rs b/pbs-client/src/lib.rs
index 139f4f4b5..453b0be00 100644
--- a/pbs-client/src/lib.rs
+++ b/pbs-client/src/lib.rs
@@ -4,6 +4,7 @@
 //! server using https.
 
 pub mod catalog_shell;
+pub mod client_job;
 pub mod pxar;
 pub mod tools;
 
-- 
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 ` Thomas Lamprecht [this message]
2026-10-14 10:51 ` [PATCH 3/7] client: job: add job runner and scheduled backup daemon Thomas Lamprecht
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-3-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