diff --git a/crates/driven-core/src/hooks.rs b/crates/driven-core/src/hooks.rs new file mode 100644 index 00000000..89494cbc --- /dev/null +++ b/crates/driven-core/src/hooks.rs @@ -0,0 +1,164 @@ +//! Pre/post backup hook seam (V2 pre/post backup shell hooks, DESIGN s17). +//! +//! `driven-core` stays free of direct process I/O, so the orchestrator runs a +//! user-configured shell command through this injected [`CommandRunner`] +//! trait. The app wires a real tokio-process implementation; tests inject a +//! fake. The default [`NoopCommandRunner`] reports success without running +//! anything, so the gate is inert until a real runner is attached. + +use std::time::Duration; + +use async_trait::async_trait; + +/// Which hook is being run, for env (`DRIVEN_HOOK`) and the activity row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookKind { + /// Runs before a backup cycle touches any source. + Pre, + /// Runs after a backup cycle's source loop, regardless of outcome. + Post, +} + +impl HookKind { + /// The lowercase discriminant used in env vars + the `hook.` + /// activity event type. + pub fn as_str(self) -> &'static str { + match self { + HookKind::Pre => "pre", + HookKind::Post => "post", + } + } +} + +/// The outcome of running a hook command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookOutcome { + /// The process exit code when it exited normally; `None` when it was + /// killed (timeout) or never produced an exit status. + pub exit_code: Option, + /// True when the command was killed for exceeding its timeout. + pub timed_out: bool, + /// A spawn / wait error (e.g. the shell or binary was not found) when the + /// runner could not run the command at all; `None` otherwise. + pub spawn_error: Option, +} + +impl HookOutcome { + /// A clean success: exit 0, not timed out, spawned fine. + pub fn success() -> Self { + Self { + exit_code: Some(0), + timed_out: false, + spawn_error: None, + } + } + + /// True only when the command ran to completion with a zero exit code. + pub fn succeeded(&self) -> bool { + !self.timed_out && self.spawn_error.is_none() && self.exit_code == Some(0) + } + + /// A short human description for the activity-log message. + pub fn describe(&self) -> String { + if let Some(err) = &self.spawn_error { + format!("failed to run ({err})") + } else if self.timed_out { + "timed out".to_string() + } else { + match self.exit_code { + Some(0) => "ok".to_string(), + Some(code) => format!("exited with code {code}"), + None => "killed".to_string(), + } + } + } +} + +/// Runs a user-configured shell command (the pre/post backup hooks). +/// +/// Implementations receive the raw command string, a set of `(key, value)` +/// environment variables to pass to it, and a timeout after which the command +/// must be killed (returning `timed_out: true`). They must never panic or +/// propagate an error: a command that cannot be spawned returns a +/// [`HookOutcome`] with `spawn_error` set. +#[async_trait] +pub trait CommandRunner: Send + Sync { + /// Run `command`, passing `env`, killing it after `timeout`. + async fn run(&self, command: &str, env: &[(String, String)], timeout: Duration) -> HookOutcome; +} + +/// The default runner: reports success without running anything. Used when no +/// real runner is injected (the orchestrator's `new` default), so a configured +/// hook is simply inert until the app wires a real [`CommandRunner`]. +#[derive(Debug, Default)] +pub struct NoopCommandRunner; + +#[async_trait] +impl CommandRunner for NoopCommandRunner { + async fn run( + &self, + _command: &str, + _env: &[(String, String)], + _timeout: Duration, + ) -> HookOutcome { + HookOutcome::success() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn succeeded_only_on_clean_zero_exit() { + assert!(HookOutcome::success().succeeded()); + assert!(!HookOutcome { + exit_code: Some(1), + timed_out: false, + spawn_error: None, + } + .succeeded()); + assert!(!HookOutcome { + exit_code: None, + timed_out: true, + spawn_error: None, + } + .succeeded()); + assert!(!HookOutcome { + exit_code: None, + timed_out: false, + spawn_error: Some("not found".into()), + } + .succeeded()); + } + + #[test] + fn describe_is_human_readable() { + assert_eq!(HookOutcome::success().describe(), "ok"); + assert_eq!( + HookOutcome { + exit_code: Some(2), + timed_out: false, + spawn_error: None + } + .describe(), + "exited with code 2" + ); + assert_eq!( + HookOutcome { + exit_code: None, + timed_out: true, + spawn_error: None + } + .describe(), + "timed out" + ); + } + + #[tokio::test] + async fn noop_runner_reports_success() { + let r = NoopCommandRunner; + let out = r.run("anything", &[], Duration::from_secs(1)).await; + assert!(out.succeeded()); + } +} diff --git a/crates/driven-core/src/lib.rs b/crates/driven-core/src/lib.rs index f8b54c4b..debcdb62 100644 --- a/crates/driven-core/src/lib.rs +++ b/crates/driven-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod crypto_provider; pub mod exclude; pub mod executor; +pub mod hooks; pub mod network; pub mod orchestrator; pub mod pacer; diff --git a/crates/driven-core/src/orchestrator.rs b/crates/driven-core/src/orchestrator.rs index 82bc5002..c5e3ebb7 100644 --- a/crates/driven-core/src/orchestrator.rs +++ b/crates/driven-core/src/orchestrator.rs @@ -55,6 +55,7 @@ use driven_power::PowerSource; use driven_vss::{VssMode, VssProvider}; use crate::executor::{Executor, OpOutcome}; +use crate::hooks::{CommandRunner, HookKind, NoopCommandRunner}; use crate::network::{NetworkProbe, NetworkState, ServiceHealth, ServiceName}; use crate::pacer::PacerCeilings; use crate::state::{ActivityLevel, NewActivity, SourceRow, StateRepo}; @@ -178,6 +179,16 @@ pub struct OrchestratorConfig { /// resumes automatically once the clock re-enters it. The /// [`Default`](ScheduleConfig::default) is disabled (V1 behaviour). pub schedule: ScheduleConfig, + /// Optional shell command run BEFORE a backup cycle touches any source + /// (V2 pre/post backup hooks, DESIGN s17). A non-zero / timed-out / + /// unspawnable pre-hook aborts that cycle's backup. `None` = no hook. + pub pre_backup_hook: Option, + /// Optional shell command run AFTER a backup cycle's source loop, + /// regardless of outcome (`DRIVEN_RESULT` is `ok`/`error`). A failure is a + /// warning only. `None` = no hook. + pub post_backup_hook: Option, + /// How long a hook command may run before it is killed, in seconds. + pub hook_timeout_secs: u32, } impl Default for OrchestratorConfig { @@ -194,6 +205,9 @@ impl Default for OrchestratorConfig { pacer_ceilings: PacerCeilings::default(), vss_mode: VssMode::Auto, schedule: ScheduleConfig::default(), + pre_backup_hook: None, + post_backup_hook: None, + hook_timeout_secs: 60, } } } @@ -354,6 +368,11 @@ pub struct SyncOrchestrator { /// path), and the executor (holding a CLONE of this same `Arc`) reads /// locked files from the snapshots in between. Set via [`Self::with_vss`]. vss: Option>, + /// Pre/post backup hook runner (V2, DESIGN s17). Defaults to the inert + /// [`NoopCommandRunner`]; the app injects a real process runner via + /// [`Self::with_command_runner`]. The hook COMMANDS come from + /// [`OrchestratorConfig`]; this is only the seam that runs them. + command_runner: Arc, /// Per-orchestrator record-at-create ledger (P1-A). The recorder hook wired /// into the provider by [`Self::with_vss`] pushes each freshly-created /// shadow GUID here synchronously; `record_vss_orphans` drains it into the @@ -418,6 +437,7 @@ impl SyncOrchestrator { shutdown_tx, shutdown_rx, vss: None, + command_runner: Arc::new(NoopCommandRunner), vss_create_ledger: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), orphan_cleanup_done: Mutex::new(false), suspended: std::sync::atomic::AtomicBool::new(false), @@ -446,6 +466,64 @@ impl SyncOrchestrator { self } + /// Attach a real pre/post backup hook runner (V2, DESIGN s17). Without + /// this the orchestrator keeps the inert [`NoopCommandRunner`], so a + /// configured hook is silently a no-op until the app wires this. + pub fn with_command_runner(mut self, runner: Arc) -> Self { + self.command_runner = runner; + self + } + + /// Run a configured pre/post backup hook command and record the outcome as + /// an activity row. Returns whether the command SUCCEEDED (clean zero + /// exit). Passes `DRIVEN_HOOK` (`pre`/`post`), `DRIVEN_ACCOUNT_ID`, and - + /// for the post hook - `DRIVEN_RESULT` (`ok`/`error`). + async fn run_backup_hook(&self, kind: HookKind, command: &str, result: Option<&str>) -> bool { + let timeout_secs = self.config.read().await.hook_timeout_secs.max(1); + let mut env = vec![ + ("DRIVEN_HOOK".to_string(), kind.as_str().to_string()), + ("DRIVEN_ACCOUNT_ID".to_string(), self.account_id.to_string()), + ]; + if let Some(r) = result { + env.push(("DRIVEN_RESULT".to_string(), r.to_string())); + } + let outcome = self + .command_runner + .run( + command, + &env, + std::time::Duration::from_secs(u64::from(timeout_secs)), + ) + .await; + let level = if outcome.succeeded() { + ActivityLevel::Info + } else { + ActivityLevel::Warn + }; + // Best-effort: a failure to record the activity row must not abort the + // backup decision the caller makes on the return value. + if let Err(e) = self + .state + .write_activity(NewActivity { + ts: self.clock.now_ms(), + source_id: None, + level, + event_type: format!("hook.{}", kind.as_str()), + file_count: None, + bytes: None, + message: Some(format!( + "{} backup hook: {}", + kind.as_str(), + outcome.describe() + )), + }) + .await + { + tracing::warn!(target: TARGET, account_id = %self.account_id, error = %e, "failed to record hook activity row"); + } + outcome.succeeded() + } + /// Release any Driven-created shadow copies older than one hour that an /// unclean shutdown (`kill -9`, power loss) stranded - the RAII [`Drop`] /// never ran for those (ROADMAP M3.5 acceptance). Runs at most once per @@ -1398,6 +1476,31 @@ impl SyncOrchestrator { // return that would otherwise leak the shadow copies until next // startup's orphan sweep. let sources = self.state.list_enabled_sources_for(self.account_id).await?; + + // Pre/post backup hooks (V2, DESIGN s17). Snapshot the configured + // commands once; the pre-hook gates the cycle, the post-hook runs after. + let (pre_hook, post_hook) = { + let c = self.config.read().await; + (c.pre_backup_hook.clone(), c.post_backup_hook.clone()) + }; + // Pre-backup hook: run BEFORE any source is touched. A failed pre-hook + // (non-zero / timed out / unspawnable) ABORTS this cycle's backup (no + // scan, no upload); the next cycle retries. Skipped when no source would + // run or no hook is configured. No VSS snapshot exists yet on this path, + // so the early return needs no snapshot cleanup. + if !sources.is_empty() { + if let Some(cmd) = pre_hook.as_deref() { + if !self.run_backup_hook(HookKind::Pre, cmd, None).await { + tracing::warn!(target: TARGET, account_id = %self.account_id, "pre-backup hook failed; skipping this cycle's backup"); + self.transition(OrchestratorState::Idle { + last_run_at: Some(self.clock.now_ms()), + }) + .await; + return Ok(()); + } + } + } + let loop_result: anyhow::Result<()> = async { for source in &sources { let deep_verify = self.deep_verify_due(source); @@ -1445,6 +1548,18 @@ impl SyncOrchestrator { let recorded = self.record_vss_orphans().await; self.end_vss_cycle(); self.forget_vss_orphans(&recorded).await; + + // Post-backup hook: run AFTER the source loop, regardless of outcome. + // A failure is a warning only - the backup already ran. `DRIVEN_RESULT` + // reflects whether the loop succeeded. + if !sources.is_empty() { + if let Some(cmd) = post_hook.as_deref() { + let result = if loop_result.is_ok() { "ok" } else { "error" }; + self.run_backup_hook(HookKind::Post, cmd, Some(result)) + .await; + } + } + loop_result?; self.transition(OrchestratorState::Idle { @@ -2413,6 +2528,122 @@ mod tests { (orch, clock) } + /// One recorded hook invocation: the command and its env vars. + type HookCall = (String, Vec<(String, String)>); + + /// Records every hook invocation (command + env) and optionally fails. + #[derive(Default)] + struct FakeCommandRunner { + calls: StdMutex>, + fail: std::sync::atomic::AtomicBool, + } + + #[async_trait] + impl CommandRunner for FakeCommandRunner { + async fn run( + &self, + command: &str, + env: &[(String, String)], + _timeout: std::time::Duration, + ) -> crate::hooks::HookOutcome { + self.calls + .lock() + .unwrap() + .push((command.to_string(), env.to_vec())); + if self.fail.load(Ordering::SeqCst) { + crate::hooks::HookOutcome { + exit_code: Some(1), + timed_out: false, + spawn_error: None, + } + } else { + crate::hooks::HookOutcome::success() + } + } + } + + #[tokio::test] + async fn pre_backup_hook_failure_aborts_the_cycle() { + // A failing pre-hook skips the backup: no execute, and the post-hook + // does NOT run (the backup never happened). + let account = AccountId::new_v4(); + let dir = tempfile::tempdir().unwrap(); + let src = source_in(account, dir.path()); + let exec = Arc::new(RecordingExecutor::default()); + let cfg = OrchestratorConfig { + pre_backup_hook: Some("run-pre".into()), + post_backup_hook: Some("run-post".into()), + ..OrchestratorConfig::default() + }; + let (orch, _clock) = build( + account, + vec![src], + exec.clone(), + power_on_ac(), + Arc::new(FakeNet::online()), + cfg, + ); + let runner = Arc::new(FakeCommandRunner::default()); + runner.fail.store(true, Ordering::SeqCst); + let orch = orch.with_command_runner(runner.clone()); + + orch.run_cycle(TickSource::Scheduled).await.unwrap(); + + assert_eq!( + exec.executes.load(Ordering::SeqCst), + 0, + "a failed pre-hook must skip the backup" + ); + let calls = runner.calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "only the pre-hook runs; post is skipped"); + assert_eq!(calls[0].0, "run-pre"); + assert!(calls[0] + .1 + .iter() + .any(|(k, v)| k == "DRIVEN_HOOK" && v == "pre")); + assert!(calls[0].1.iter().any(|(k, _)| k == "DRIVEN_ACCOUNT_ID")); + } + + #[tokio::test] + async fn post_backup_hook_runs_after_a_successful_pre_hook() { + // Pre succeeds -> the cycle proceeds and the post-hook runs with + // DRIVEN_RESULT=ok. + let account = AccountId::new_v4(); + let dir = tempfile::tempdir().unwrap(); + let src = source_in(account, dir.path()); + let exec = Arc::new(RecordingExecutor::default()); + let cfg = OrchestratorConfig { + pre_backup_hook: Some("run-pre".into()), + post_backup_hook: Some("run-post".into()), + ..OrchestratorConfig::default() + }; + let (orch, _clock) = build( + account, + vec![src], + exec.clone(), + power_on_ac(), + Arc::new(FakeNet::online()), + cfg, + ); + let runner = Arc::new(FakeCommandRunner::default()); + let orch = orch.with_command_runner(runner.clone()); + + orch.run_cycle(TickSource::Scheduled).await.unwrap(); + + let calls = runner.calls.lock().unwrap(); + assert_eq!(calls.len(), 2, "pre then post both run"); + assert_eq!(calls[0].0, "run-pre"); + assert_eq!(calls[1].0, "run-post"); + assert!(calls[1] + .1 + .iter() + .any(|(k, v)| k == "DRIVEN_HOOK" && v == "post")); + assert!(calls[1] + .1 + .iter() + .any(|(k, v)| k == "DRIVEN_RESULT" && v == "ok")); + } + #[tokio::test] async fn battery_gate_pauses_when_skip_on_battery() { // On battery with skip_on_battery => Paused{Battery}, no execute. diff --git a/src-tauri/src/assembly.rs b/src-tauri/src/assembly.rs index 919b6de1..818bf4d9 100644 --- a/src-tauri/src/assembly.rs +++ b/src-tauri/src/assembly.rs @@ -462,6 +462,10 @@ async fn build_account( // (DESIGN s5.3). orchestrator = orchestrator.with_vss(vss); } + // Real pre/post backup hook runner (V2, DESIGN s17): without this the + // orchestrator keeps the inert no-op runner and configured hooks never run. + orchestrator = + orchestrator.with_command_runner(Arc::new(crate::hook_runner::TokioCommandRunner)); let orchestrator = Arc::new(orchestrator); // R-P1-1: one shutdown signal both bridges select! on, so quit can stop the diff --git a/src-tauri/src/commands/dtos.rs b/src-tauri/src/commands/dtos.rs index 2e7ad129..8831c4cd 100644 --- a/src-tauri/src/commands/dtos.rs +++ b/src-tauri/src/commands/dtos.rs @@ -377,6 +377,12 @@ pub struct GlobalSettings { /// V2 schedule window (DESIGN s17): when enabled, sync is gated to the /// configured local-time window. pub schedule: ScheduleSettings, + /// V2 pre/post backup shell hooks (DESIGN s17). `null` = no hook. + pub pre_backup_hook: Option, + /// See [`Self::pre_backup_hook`]; runs after a backup cycle. + pub post_backup_hook: Option, + /// How long a hook command may run before it is killed, in seconds. + pub hook_timeout_secs: u32, } /// V2 schedule-window settings (DESIGN s17). Mirrors @@ -484,6 +490,12 @@ pub struct GlobalSettingsPatch { pub log_level: Option, /// See [`GlobalSettings::schedule`]. Present = replace the whole schedule. pub schedule: Option, + /// See [`GlobalSettings::pre_backup_hook`]. `Some(None)` clears it. + pub pre_backup_hook: Option>, + /// See [`GlobalSettings::post_backup_hook`]. `Some(None)` clears it. + pub post_backup_hook: Option>, + /// See [`GlobalSettings::hook_timeout_secs`]. + pub hook_timeout_secs: Option, } /// Partial SPEC s22 `telemetry` settings. diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 41e76018..e67cd302 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -269,6 +269,26 @@ pub async fn update_settings( cur.schedule = v; orchestrator_affecting = true; } + if let Some(v) = g.pre_backup_hook { + // A blank command clears the hook. + cur.pre_backup_hook = v.and_then(|s| { + let t = s.trim().to_string(); + (!t.is_empty()).then_some(t) + }); + orchestrator_affecting = true; + } + if let Some(v) = g.post_backup_hook { + cur.post_backup_hook = v.and_then(|s| { + let t = s.trim().to_string(); + (!t.is_empty()).then_some(t) + }); + orchestrator_affecting = true; + } + if let Some(v) = g.hook_timeout_secs { + check_range("hook_timeout_secs", v, 1, 86_400)?; + cur.hook_timeout_secs = v; + orchestrator_affecting = true; + } store_group(repo, KEY_GLOBAL, &storage::Global::from(cur)).await?; } @@ -580,6 +600,19 @@ mod storage { // default = V1 behaviour). #[serde(default)] pub schedule: Schedule, + // Added in V2 (pre/post backup hooks). `serde(default)` so a pre-V2 + // `global` blob still deserialises. + #[serde(default)] + pub pre_backup_hook: Option, + #[serde(default)] + pub post_backup_hook: Option, + #[serde(default = "default_hook_timeout_secs")] + pub hook_timeout_secs: u32, + } + + /// Default hook timeout (seconds) for a pre-V2 `global` blob missing it. + fn default_hook_timeout_secs() -> u32 { + 60 } impl From for GlobalSettings { @@ -595,6 +628,9 @@ mod storage { io_priority: s.io_priority, log_level: s.log_level, schedule: s.schedule.into(), + pre_backup_hook: s.pre_backup_hook, + post_backup_hook: s.post_backup_hook, + hook_timeout_secs: s.hook_timeout_secs, } } } @@ -612,6 +648,9 @@ mod storage { io_priority: d.io_priority, log_level: d.log_level, schedule: d.schedule.into(), + pre_backup_hook: d.pre_backup_hook, + post_backup_hook: d.post_backup_hook, + hook_timeout_secs: d.hook_timeout_secs, } } } @@ -825,6 +864,9 @@ pub async fn load_orchestrator_config(state: &dyn StateRepo) -> CommandResult GlobalSettings { io_priority: "low".to_string(), log_level: "info".to_string(), schedule: default_schedule(), + pre_backup_hook: None, + post_backup_hook: None, + hook_timeout_secs: 60, } } diff --git a/src-tauri/src/hook_runner.rs b/src-tauri/src/hook_runner.rs new file mode 100644 index 00000000..ebc2ac08 --- /dev/null +++ b/src-tauri/src/hook_runner.rs @@ -0,0 +1,132 @@ +//! Real pre/post backup hook runner (V2 pre/post backup hooks, DESIGN s17). +//! +//! `driven-core` stays free of process I/O, so the orchestrator runs hook +//! commands through this injected [`CommandRunner`]. This is the only place a +//! user-configured shell command is actually spawned. Commands run through the +//! platform shell (`sh -c` / `cmd /C`) with the orchestrator-supplied env vars +//! and a hard kill-on-timeout. + +use std::time::Duration; + +use async_trait::async_trait; +use driven_core::hooks::{CommandRunner, HookOutcome}; + +/// Runs hook commands through the platform shell, killing them on timeout. +#[derive(Debug, Default)] +pub struct TokioCommandRunner; + +#[async_trait] +impl CommandRunner for TokioCommandRunner { + async fn run(&self, command: &str, env: &[(String, String)], timeout: Duration) -> HookOutcome { + let mut cmd = build_command(command); + for (key, value) in env { + cmd.env(key, value); + } + // Kill the child if this future is dropped (e.g. app shutdown). + cmd.kill_on_drop(true); + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + return HookOutcome { + exit_code: None, + timed_out: false, + spawn_error: Some(e.to_string()), + } + } + }; + + match tokio::time::timeout(timeout, child.wait()).await { + Ok(Ok(status)) => HookOutcome { + exit_code: status.code(), + timed_out: false, + spawn_error: None, + }, + Ok(Err(e)) => HookOutcome { + exit_code: None, + timed_out: false, + spawn_error: Some(e.to_string()), + }, + Err(_elapsed) => { + // Exceeded the timeout: kill it and report a timeout. + let _ = child.kill().await; + HookOutcome { + exit_code: None, + timed_out: true, + spawn_error: None, + } + } + } + } +} + +#[cfg(not(windows))] +fn build_command(command: &str) -> tokio::process::Command { + let mut cmd = tokio::process::Command::new("sh"); + cmd.arg("-c").arg(command); + cmd +} + +#[cfg(windows)] +fn build_command(command: &str) -> tokio::process::Command { + let mut cmd = tokio::process::Command::new("cmd"); + cmd.arg("/C").arg(command); + cmd +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[tokio::test] + async fn zero_exit_succeeds() { + let out = TokioCommandRunner + .run("exit 0", &[], Duration::from_secs(5)) + .await; + assert!(out.succeeded()); + } + + #[tokio::test] + async fn nonzero_exit_is_reported() { + let out = TokioCommandRunner + .run("exit 3", &[], Duration::from_secs(5)) + .await; + assert_eq!(out.exit_code, Some(3)); + assert!(!out.succeeded()); + } + + #[tokio::test] + async fn env_vars_are_passed() { + let out = TokioCommandRunner + .run( + "test \"$DRIVEN_HOOK\" = pre", + &[("DRIVEN_HOOK".to_string(), "pre".to_string())], + Duration::from_secs(5), + ) + .await; + assert!(out.succeeded(), "the hook saw DRIVEN_HOOK=pre"); + } + + #[tokio::test] + async fn timeout_kills_a_long_command() { + let out = TokioCommandRunner + .run("sleep 10", &[], Duration::from_millis(100)) + .await; + assert!(out.timed_out); + assert!(!out.succeeded()); + } + + #[tokio::test] + async fn unspawnable_shell_is_a_spawn_error_not_a_panic() { + // A command that the shell cannot find still EXITS non-zero (the shell + // runs), so spawn succeeds; assert the non-zero is surfaced. + let out = TokioCommandRunner + .run( + "this-binary-does-not-exist-12345", + &[], + Duration::from_secs(5), + ) + .await; + assert!(!out.succeeded()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3b116269..caa256de 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ mod crypto_provider_impl; #[allow(dead_code)] mod elevation; mod events; +mod hook_runner; mod i18n; mod migrations; mod panic_hook; diff --git a/ui/src/__tests__/settings-components.test.ts b/ui/src/__tests__/settings-components.test.ts index 63609bec..89288004 100644 --- a/ui/src/__tests__/settings-components.test.ts +++ b/ui/src/__tests__/settings-components.test.ts @@ -79,6 +79,9 @@ function makeSettings(over: Partial = {}): SettingsDto { days: [true, true, true, true, true, true, true], utcOffsetMinutes: 0, }, + preBackupHook: null, + postBackupHook: null, + hookTimeoutSecs: 60, }, telemetry: { enabled: true, @@ -644,6 +647,46 @@ describe("Settings Rules tab", () => { expect(lastSchedule()?.days[0]).toBe(false); }); + it("backup hooks: setting a command patches it, clearing patches null", async () => { + invokeMock.mockImplementation((cmd: string, args: unknown) => { + if (cmd === "get_settings") return Promise.resolve(makeSettings()); + if (cmd === "update_settings") { + const patch = (args as { patch: Record }).patch; + return Promise.resolve(makeSettings(patch as Partial)); + } + return Promise.resolve(undefined); + }); + + const wrapper = mount(Settings, { + props: { tab: "rules" }, + global: globalMountOptions, + }); + await flushPromises(); + + type GlobalPatch = { patch?: { global?: Record } }; + const lastGlobalPatch = (key: string): unknown => + invokeMock.mock.calls + .filter( + (c) => c[0] === "update_settings" && key in ((c[1] as GlobalPatch).patch?.global ?? {}) + ) + .map((c) => (c[1] as GlobalPatch).patch!.global![key]) + .pop(); + + // Set a pre-backup hook command. + const pre = wrapper.get('[data-testid="pre-hook"]'); + await pre.setValue("./backup-pre.sh"); + await pre.trigger("change"); + await flushPromises(); + expect(lastGlobalPatch("preBackupHook")).toBe("./backup-pre.sh"); + + // Clearing the post-hook patches null (no hook). + const post = wrapper.get('[data-testid="post-hook"]'); + await post.setValue(" "); + await post.trigger("change"); + await flushPromises(); + expect(lastGlobalPatch("postBackupHook")).toBeNull(); + }); + it("an empty bandwidth cap patches null (unlimited)", async () => { invokeMock.mockImplementation((cmd: string, args: unknown) => { if (cmd === "get_settings") diff --git a/ui/src/__tests__/settings-stores.test.ts b/ui/src/__tests__/settings-stores.test.ts index 6f3c02bb..6fcfff12 100644 --- a/ui/src/__tests__/settings-stores.test.ts +++ b/ui/src/__tests__/settings-stores.test.ts @@ -74,6 +74,9 @@ function makeSettings(over: Partial = {}): SettingsDto { days: [true, true, true, true, true, true, true], utcOffsetMinutes: 0, }, + preBackupHook: null, + postBackupHook: null, + hookTimeoutSecs: 60, }, telemetry: { enabled: true, diff --git a/ui/src/ipc/types.ts b/ui/src/ipc/types.ts index 6257691d..4ed95e46 100644 --- a/ui/src/ipc/types.ts +++ b/ui/src/ipc/types.ts @@ -175,6 +175,11 @@ export interface GlobalSettings { ioPriority: string; logLevel: string; schedule: ScheduleSettings; + /** V2 pre/post backup shell hooks (null = no hook). */ + preBackupHook: string | null; + postBackupHook: string | null; + /** How long a hook may run before it is killed, in seconds. */ + hookTimeoutSecs: number; } export interface TelemetrySettings { @@ -218,6 +223,10 @@ export interface GlobalSettingsPatch { logLevel?: string; /** Present = replace the whole schedule window. */ schedule?: ScheduleSettings; + /** Present = set; null clears the hook. */ + preBackupHook?: string | null; + postBackupHook?: string | null; + hookTimeoutSecs?: number; } export interface TelemetrySettingsPatch { diff --git a/ui/src/locales/en-US.json b/ui/src/locales/en-US.json index 395f7274..1de3b4c3 100644 --- a/ui/src/locales/en-US.json +++ b/ui/src/locales/en-US.json @@ -165,6 +165,14 @@ }, "telemetryLabel": "Send anonymous usage stats", "telemetryNote": "Anonymous usage stats only - no file names or contents are ever sent. One click to disable.", + "hooks": { + "title": "Backup hooks", + "preLabel": "Before each backup (shell command)", + "postLabel": "After each backup (shell command)", + "timeoutLabel": "Hook timeout (seconds)", + "placeholder": "(none)", + "note": "Commands run in your shell once per backup cycle. A non-zero exit from the before-hook skips that backup. DRIVEN_HOOK, DRIVEN_ACCOUNT_ID, and (for the after-hook) DRIVEN_RESULT are set." + }, "schedule": { "label": "Only back up during a schedule window", "startLabel": "From", diff --git a/ui/src/views/Settings.vue b/ui/src/views/Settings.vue index 0d3dcfe3..b5759865 100644 --- a/ui/src/views/Settings.vue +++ b/ui/src/views/Settings.vue @@ -45,6 +45,11 @@ const scheduleStart = ref("00:00"); const scheduleEnd = ref("00:00"); const scheduleDays = ref([true, true, true, true, true, true, true]); +// Pre/post backup hook local mirrors (DESIGN s17). +const preBackupHook = ref(""); +const postBackupHook = ref(""); +const hookTimeoutSecs = ref(60); + function minutesToHHMM(min: number): string { const m = ((Math.floor(min) % 1440) + 1440) % 1440; const hh = String(Math.floor(m / 60)).padStart(2, "0"); @@ -94,6 +99,9 @@ watch( // Coerce to exactly seven booleans regardless of what was stored. scheduleDays.value = dayIndices.map((i) => sched.days?.[i] ?? true); } + preBackupHook.value = s.global.preBackupHook ?? ""; + postBackupHook.value = s.global.postBackupHook ?? ""; + hookTimeoutSecs.value = s.global.hookTimeoutSecs ?? 60; }, { immediate: true } ); @@ -151,6 +159,23 @@ async function commitDeepVerifyInterval(event: Event): Promise { await settings.patch({ global: { deepVerifyIntervalSecs: value } }); } +// Backup hooks (DESIGN s17). A blank command clears the hook (sent as null). +async function commitPreHook(): Promise { + const cmd = preBackupHook.value.trim(); + await settings.patch({ global: { preBackupHook: cmd === "" ? null : cmd } }); +} + +async function commitPostHook(): Promise { + const cmd = postBackupHook.value.trim(); + await settings.patch({ global: { postBackupHook: cmd === "" ? null : cmd } }); +} + +async function commitHookTimeout(event: Event): Promise { + const current = settings.settings?.global.hookTimeoutSecs ?? 60; + const value = parsePositiveInt((event.target as HTMLInputElement).value, current); + await settings.patch({ global: { hookTimeoutSecs: value } }); +} + async function setIoPriority(event: Event): Promise { const value = (event.target as HTMLSelectElement).value; await settings.patch({ global: { ioPriority: value } }); @@ -405,6 +430,51 @@ async function setTelemetryEnabled(event: Event): Promise { +
+

{{ t("settings.rules.hooks.title") }}

+ + + +

+ {{ t("settings.rules.hooks.note") }} +

+
+